Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1767,6 +1767,11 @@ input LnNoAmountUsdInvoicePaymentInput
"""Amount to pay in USD cents."""
amount: FractionalCentAmount!

"""
Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.
"""
idempotencyKey: String

"""Optional memo to associate with the lightning invoice."""
memo: Memo

Expand Down Expand Up @@ -1809,6 +1814,11 @@ input LnurlPaymentSendInput
"""Amount to spend from the USD/USDT wallet, in USD cents."""
amount: FractionalCentAmount!

"""
Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.
"""
idempotencyKey: String

"""LNURL-pay value to decode and pay."""
lnurl: Lnurl!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cu
import Ibex from "@services/ibex/client"

import { IbexError } from "@services/ibex/errors"
import { withPaymentIdempotency } from "@app/payments/idempotency"
import { paymentSendStatusOrPending } from "@services/ibex/payment-status"

const LnNoAmountUsdInvoicePaymentInput = GT.Input({
Expand All @@ -38,6 +39,11 @@ const LnNoAmountUsdInvoicePaymentInput = GT.Input({
type: Memo,
description: "Optional memo to associate with the lightning invoice.",
},
idempotencyKey: {
type: GT.String,
description:
"Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.",
},
}),
})

Expand All @@ -50,6 +56,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field<
paymentRequest: string | InputValidationError
amount: FractionalCentAmount | InputValidationError
memo?: string | InputValidationError
idempotencyKey?: string | null
}
}
>({
Expand All @@ -64,7 +71,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field<
input: { type: GT.NonNull(LnNoAmountUsdInvoicePaymentInput) },
},
resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => {
const { walletId, paymentRequest, amount, memo } = args.input
const { walletId, paymentRequest, amount, memo, idempotencyKey } = args.input

if (walletId instanceof InputValidationError) {
return { errors: [{ message: walletId.message }] }
Expand Down Expand Up @@ -112,24 +119,39 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field<
errors: [mapAndParseErrorForGqlResponse(usCents)],
}
}
const PayLightningInvoice = await Ibex.payInvoice({
invoice: paymentRequest as Bolt11,
accountId: routedWalletId,
send: usCents,
// ENG-533: this resolver executes IBEX directly (FLASH FORK above), so the
// exactly-once wrapper the covered send functions get in @app/payments
// never ran here — a double-fire on the most common USD send path
// double-paid, exactly the 2026-07-23 incident class. Scoped to the ROUTED
// wallet (the one actually debited) so the same key behaves identically
// across the cash-wallet compat redirect. Only the money-moving call sits
// inside execute(); routing and amount conversion stay outside so a cached
// replay does no IBEX work at all.
const outcome = await withPaymentIdempotency({
idempotencyKey,
senderWalletId: routedWalletId,
requestFingerprint: `ln-noamount-usd|${paymentRequest}|${amount}`,
execute: async () => {
const PayLightningInvoice = await Ibex.payInvoice({
invoice: paymentRequest as Bolt11,
accountId: routedWalletId,
send: usCents,
})
if (PayLightningInvoice instanceof IbexError) return PayLightningInvoice
return paymentSendStatusOrPending(PayLightningInvoice)
},
})

if (PayLightningInvoice instanceof IbexError) {
if (outcome instanceof Error) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(PayLightningInvoice)],
errors: [mapAndParseErrorForGqlResponse(outcome)],
}
}

const status = paymentSendStatusOrPending(PayLightningInvoice)

return {
errors: [],
status: status.value,
status: outcome.value,
}
},
})
Expand Down
140 changes: 75 additions & 65 deletions src/graphql/public/root/mutation/lnurl-payment-send.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withPaymentIdempotency } from "@app/payments/idempotency"
import axios from "axios"
import dedent from "dedent"

Expand Down Expand Up @@ -48,6 +49,11 @@ const LnurlPaymentSendInput = GT.Input({
type: Memo,
description: "Optional memo for the Lightning payment.",
},
idempotencyKey: {
type: GT.String,
description:
"Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.",
},
}),
})

Expand Down Expand Up @@ -90,6 +96,7 @@ const LnurlPaymentSendMutation = GT.Field<
lnurl: Lnurl | InputValidationError
amount: FractionalCentAmount | InputValidationError
memo?: Memo | InputValidationError
idempotencyKey?: string | null
}
}
>({
Expand All @@ -103,7 +110,7 @@ const LnurlPaymentSendMutation = GT.Field<
input: { type: GT.NonNull(LnurlPaymentSendInput) },
},
resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => {
const { walletId, lnurl, amount, memo } = args.input
const { walletId, lnurl, amount, memo, idempotencyKey } = args.input

if (walletId instanceof InputValidationError) {
return { status: "failed", errors: [{ message: walletId.message }] }
Expand Down Expand Up @@ -132,79 +139,82 @@ const LnurlPaymentSendMutation = GT.Field<
}
}

const walletAmount = await usdWalletAmountFromWalletId({
walletId: routedWalletId,
amount: amount.toString(),
})
if (walletAmount instanceof Error) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(walletAmount)],
}
}

const decoded = await Ibex.decodeLnurl({ lnurl })
if (decoded instanceof IbexError) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(decoded)],
}
}
if (!decoded.decodedLnurl) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(new InvalidLnurlError())],
}
}

const metadataResponse = await axios.get(decoded.decodedLnurl)
const metadata = metadataResponse.data
if (!isLnurlPayMetadata(metadata)) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(new InvalidLnurlError())],
}
}

const dealer = DealerPriceService()
const amountMsat = await amountMsatFromUsdWalletAmount({
amount: walletAmount,
btcFromUsd: dealer.getSatsFromCentsForImmediateSell,
})
if (amountMsat instanceof Error) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(amountMsat)],
}
}

const validAmount = validateLnurlPayAmountMsat({
amountMsat,
minSendable: metadata.minSendable,
maxSendable: metadata.maxSendable,
// ENG-533: direct-IBEX execution, so the exactly-once wrapper never ran on
// this path. Scoped to the ROUTED wallet. EVERYTHING after routing —
// decode, metadata fetch, wallet-amount conversion, msat conversion,
// amount validation and the money-moving call — sits inside execute(), so
// a cached replay
// short-circuits before touching IBEX or the lnurl server. That matters
// precisely on the retry path this wrapper exists for: the flaky lnurl
// server (or a moved dealer rate) must not be able to mask a cached
// success as a failure. The fingerprint needs only the client's
// lnurl + amount (the request as sent) — not amountMsat, which moves with
// the dealer rate; a legitimate same-key retry must not be rejected as a
// different payment because the price ticked. Failure branches return
// ApplicationErrors, which the wrapper never caches, so first-attempt
// failures stay retryable.
const outcome = await withPaymentIdempotency({
idempotencyKey,
senderWalletId: routedWalletId,
requestFingerprint: `lnurl|${lnurl}|${amount}`,
execute: async () => {
const decoded = await Ibex.decodeLnurl({ lnurl })
if (decoded instanceof IbexError) return decoded
if (!decoded.decodedLnurl) return new InvalidLnurlError()

// A metadata-fetch rejection (non-2xx or network error) must become a
// typed error like every sibling branch — a bare throw here would
// propagate through the redlock callback as an unhandled GraphQL error
// instead of the failed payload.
let metadata: unknown
try {
const metadataResponse = await axios.get(decoded.decodedLnurl)
metadata = metadataResponse.data
} catch {
return new InvalidLnurlError()
}
if (!isLnurlPayMetadata(metadata)) return new InvalidLnurlError()

const walletAmount = await usdWalletAmountFromWalletId({
walletId: routedWalletId,
amount: amount.toString(),
})
if (walletAmount instanceof Error) return walletAmount

const dealer = DealerPriceService()
const amountMsat = await amountMsatFromUsdWalletAmount({
amount: walletAmount,
btcFromUsd: dealer.getSatsFromCentsForImmediateSell,
})
if (amountMsat instanceof Error) return amountMsat

const validAmount = validateLnurlPayAmountMsat({
amountMsat,
minSendable: metadata.minSendable,
maxSendable: metadata.maxSendable,
})
if (validAmount instanceof Error) return validAmount

const payment = await Ibex.payToLnurl({
accountId: routedWalletId,
amountMsat,
params: paramsFromMetadata(metadata),
})
if (payment instanceof IbexError) return payment
return lnurlPaymentSendStatusOrPending(payment)
},
})
if (validAmount instanceof Error) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(validAmount)],
}
}

const payment = await Ibex.payToLnurl({
accountId: routedWalletId,
amountMsat,
params: paramsFromMetadata(metadata),
})
if (payment instanceof IbexError) {
if (outcome instanceof Error) {
return {
status: "failed",
errors: [mapAndParseErrorForGqlResponse(payment)],
errors: [mapAndParseErrorForGqlResponse(outcome)],
}
}

return {
errors: [],
status: lnurlPaymentSendStatusOrPending(payment).value,
status: outcome.value,
}
},
})
Expand Down
10 changes: 10 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,11 @@
"""Amount to pay in USD cents."""
amount: FractionalCentAmount!

"""
Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.
"""
idempotencyKey: String

Check notice on line 1435 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'idempotencyKey' of type 'String' was added to input object type 'LnNoAmountUsdInvoicePaymentInput'

The field is nullable and no default is set.

Check notice on line 1435 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'LnNoAmountUsdInvoicePaymentInput.idempotencyKey' has description 'Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.'

Input field 'LnNoAmountUsdInvoicePaymentInput.idempotencyKey' has description 'Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.'

"""Optional memo to associate with the lightning invoice."""
memo: Memo

Expand Down Expand Up @@ -1499,6 +1504,11 @@
"""Amount to spend from the USD/USDT wallet, in USD cents."""
amount: FractionalCentAmount!

"""
Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.
"""
idempotencyKey: String

Check notice on line 1510 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'idempotencyKey' of type 'String' was added to input object type 'LnurlPaymentSendInput'

The field is nullable and no default is set.

Check notice on line 1510 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'LnurlPaymentSendInput.idempotencyKey' has description 'Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.'

Input field 'LnurlPaymentSendInput.idempotencyKey' has description 'Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again.'

"""LNURL-pay value to decode and pay."""
lnurl: Lnurl!

Expand Down
Loading
Loading