From 9e30ab275bd65c2e0d2d59c5d2f71fc9e9efb860 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 24 Jul 2026 09:45:02 -0700 Subject: [PATCH] feat(payments): server-side idempotency for payment-send mutations (ENG-530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double-fired intraledger send (client double-tap; the second mutation started before the first settled) executed twice and double-debited the sender, because payment-send mutations had no dedupe key. This adds an optional client-supplied idempotencyKey so a repeated request returns the original result instead of minting a fresh IBEX invoice and paying again — exactly-once settlement. - New withPaymentIdempotency() helper (src/app/payments/idempotency.ts) keyed on (senderWalletId, idempotencyKey). Fast-path replays a cached result; otherwise acquires a short auto-releasing lock, executes, and persists the outcome (24h TTL). Reuses RedisCacheService for the store and a new LockService().lockPaymentIdempotencyKey — a redlock `.using` lock on a distinct resource namespace from the existing header-middleware lock. - Request-param binding: the cached result is bound to a sha256 fingerprint of the payment params (recipient/invoice + amount). Same key + same params -> replay; same key + different params -> IdempotencyKeyReuseError (new, mapped in error-map.ts), never a silent replay that drops the new payment. Checked on both the fast path and the in-lock re-check. - cache.set failure after a completed send is recorded via recordExceptionInCurrentSpan at Critical level (money moved but result not persisted -> a retry could double-pay; can't un-pay, so make it visible). - Wrap the exported send functions (intraledger BTC/USD, payInvoiceByWalletId, no-amount BTC/USD). The #458 ops-event notify lives inside the wrapped body, so a cached replay emits no duplicate ops event and mints no second invoice. - Thread idempotencyKey through the intraledger + ln-noamount resolvers; wrap the ln-invoice resolver's inline IBEX call (it bypasses @app in this fork). SDL + supergraph regenerated via `make codegen`. - Optional field: absent = unchanged behavior. Only definitive outcomes are cached, so validation/transient errors can retry; the lock guards the concurrent window regardless. - Tests: helper unit specs (replay, JSON round-trip, fingerprint-mismatch conflict, lock-busy, error-not-cached, set-fail records exception, per-wallet scoping) + integration specs on the wrapped intraledger send (same-key replay, concurrent double-fire, different keys, no key, no double ops event, scoping, conflict). Client contract (documented in idempotency.ts): a busy/lock error MUST be retried with the SAME key. Known residual gap (unchanged, documented): an IBEX debited-but-errored return is uncached, so a same-key retry after the lock releases can re-pay — mitigated once IBEX exposes request-level idempotency. Out of scope (separate tickets): cashout-initiate, the Fygaro credit path, and mobile key-attachment (ENG-533). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7 --- dev/apollo-federation/supergraph.graphql | 20 ++ src/app/payments/idempotency.ts | 149 ++++++++++ src/app/payments/index.ts | 1 + src/app/payments/send-intraledger.ts | 48 +++- src/app/payments/send-lightning.ts | 59 ++-- src/app/wallets/index.types.d.ts | 4 + src/domain/errors.ts | 6 + src/domain/lock/index.types.d.ts | 4 + src/graphql/error-map.ts | 8 + .../root/mutation/intraledger-payment-send.ts | 8 +- .../mutation/intraledger-usd-payment-send.ts | 8 +- .../root/mutation/ln-invoice-payment-send.ts | 67 +++-- .../ln-noamount-invoice-payment-send.ts | 9 +- src/graphql/public/schema.graphql | 20 ++ src/services/lock/index.ts | 16 ++ .../unit/app/payments/idempotency.spec.ts | 272 ++++++++++++++++++ .../app/payments/send-intraledger.spec.ts | 196 ++++++++++++- .../send-lightning-ops-events.spec.ts | 10 + 18 files changed, 848 insertions(+), 57 deletions(-) create mode 100644 src/app/payments/idempotency.ts create mode 100644 test/flash/unit/app/payments/idempotency.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 9c8d30139..46ce2d659 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -1184,6 +1184,11 @@ input IntraLedgerPaymentSendInput """Amount in satoshis.""" amount: SatAmount! + """ + Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again. + """ + idempotencyKey: String + """Optional memo to be attached to the payment.""" memo: Memo recipientWalletId: WalletId! @@ -1208,6 +1213,11 @@ input IntraLedgerUsdPaymentSendInput """Amount in 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 be attached to the payment.""" memo: Memo recipientWalletId: WalletId! @@ -1323,6 +1333,11 @@ type LnInvoicePayload input LnInvoicePaymentInput @join__type(graph: PUBLIC) { + """ + 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 @@ -1407,6 +1422,11 @@ input LnNoAmountInvoicePaymentInput """Amount to pay in satoshis.""" amount: SatAmount! + """ + 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 diff --git a/src/app/payments/idempotency.ts b/src/app/payments/idempotency.ts new file mode 100644 index 000000000..0a2ad8ff4 --- /dev/null +++ b/src/app/payments/idempotency.ts @@ -0,0 +1,149 @@ +import { createHash } from "crypto" + +import { IdempotencyKeyReuseError, InvalidIdempotencyKeyError } from "@domain/errors" +import { ErrorLevel } from "@domain/shared" + +import { RedisCacheService } from "@services/cache" +import { LockService } from "@services/lock" +import { recordExceptionInCurrentSpan } from "@services/tracing" + +// How long a completed payment-send result is replayable under its idempotency key. +const IDEMPOTENCY_TTL_SECS = (24 * 60 * 60) as Seconds // 24h +const MAX_KEY_LENGTH = 256 + +const cacheKeyFor = (scopedKey: string) => `payment-idempotency:${scopedKey}` +const fingerprintOf = (requestFingerprint: string) => + createHash("sha256").update(requestFingerprint).digest("hex") + +type PaymentSendResult = PaymentSendStatus | ApplicationError +// The cached envelope binds the stored result to the request that produced it, so a +// key reused for a *different* payment is rejected rather than silently replayed. +type CachedPaymentSend = { fingerprint: string; result: PaymentSendStatus } + +/** + * ENG-530: server-side idempotency for payment-send mutations. + * + * Wraps an exported send function so a repeated request with the same + * client-supplied `idempotencyKey` returns the original result instead of + * executing (and paying) again — exactly-once settlement. + * + * The dedupe is scoped to `(senderWalletId, idempotencyKey)`, and the cached result + * is bound to a `requestFingerprint` of the parameters that identify the payment + * (recipient/invoice + amount). The same key from different wallets never collides, + * and the same key reused for a *different* payment is rejected (see below). + * + * Behavior when a key is supplied: + * - Completed result cached, same fingerprint → returns the stored result. + * `execute()` is never called, so no new IBEX invoice is minted, no second + * payment, and (because the ops-event notify lives inside `execute`) no duplicate + * ops event fires. + * - Completed result cached, DIFFERENT fingerprint → returns `IdempotencyKeyReuseError` + * and does NOT execute. Replaying the original result here would silently drop the + * new payment while reporting the old one's success. This check runs on both the + * fast path and the in-lock re-check. + * - Concurrent in-flight (lock held) → returns the busy lock error rather than + * executing a second time. + * - First request → acquires the lock, executes, persists `{fingerprint, result}` + * under the key (TTL), releases the lock. + * + * No key (absent / blank) → runs `execute()` unchanged; existing clients and internal + * callers are unaffected. + * + * Only a definitive payment outcome (a `PaymentSendStatus`) is cached. An + * `ApplicationError` return (validation / transient failure) is left uncached so a + * fresh attempt with the same key can retry. The lock still guards the concurrent + * window regardless of caching. + * + * Reuses existing primitives: `RedisCacheService` for the result store and + * `LockService().lockPaymentIdempotencyKey` (a redlock `.using` lock that releases + * when `execute` finishes) for the in-flight guard. + * + * CLIENT CONTRACT: a client that receives the busy/lock error MUST retry with the + * SAME idempotency key. Retrying with a NEW key can double-pay (the original request + * may still be settling). Likewise, a genuinely new payment MUST use a new key. + * + * KNOWN GAP (unchanged from pre-idempotency behavior, not fixed here): if IBEX + * actually debited but returned an error to us, `execute` returns an ApplicationError + * that is not cached, so a same-key retry after the lock releases can re-pay. The lock + * covers the concurrent window; the residual retry-after-partial-success case is + * mitigated only once IBEX exposes request-level idempotency of its own. + */ +export const withPaymentIdempotency = async ({ + idempotencyKey, + senderWalletId, + requestFingerprint, + execute, +}: { + idempotencyKey: string | null | undefined + senderWalletId: WalletId + requestFingerprint: string + execute: () => Promise +}): Promise => { + // No key supplied → unchanged behavior. + if (!idempotencyKey) return execute() + + const trimmedKey = idempotencyKey.trim() + // A blank / whitespace-only key is treated as "no key" (unchanged behavior). + if (trimmedKey.length === 0) return execute() + if (trimmedKey.length > MAX_KEY_LENGTH) { + return new InvalidIdempotencyKeyError(idempotencyKey) + } + + const scopedKey = `${senderWalletId}:${trimmedKey}` as IdempotencyKey + const cacheKey = cacheKeyFor(scopedKey) + const fingerprint = fingerprintOf(requestFingerprint) + const cache = RedisCacheService() + + // Resolve a cache entry: replay on a fingerprint match, reject on a mismatch. + const resolveCached = (entry: CachedPaymentSend): PaymentSendResult => + entry.fingerprint === fingerprint ? entry.result : new IdempotencyKeyReuseError() + + // 1. Fast path — a completed result is already stored. + const cached = await cache.get({ key: cacheKey }) + if (!(cached instanceof Error)) return resolveCached(cached) + + // 2. Acquire a short lock on the scoped key, execute under it, persist the + // outcome, then release (the lock auto-releases when the callback returns). + return LockService().lockPaymentIdempotencyKey( + scopedKey, + async () => { + // Re-check inside the lock to close the check-then-act race with a + // concurrent request that completed between our fast-path miss and here. + const cachedInLock = await cache.get({ key: cacheKey }) + if (!(cachedInLock instanceof Error)) return resolveCached(cachedInLock) + + const outcome = await execute() + + // Persist only a definitive payment outcome. Errors stay uncached so a + // fresh attempt with the same key can retry. + if (!(outcome instanceof Error)) { + const setResult = await cache.set({ + key: cacheKey, + value: { fingerprint, result: outcome }, + ttlSecs: IDEMPOTENCY_TTL_SECS, + }) + + // The payment already executed. If persisting its result failed, a later + // retry with this key would re-execute and double-pay. We can't un-pay — + // make it loud so ops can intervene. + if (setResult instanceof Error) { + recordExceptionInCurrentSpan({ + error: setResult, + level: ErrorLevel.Critical, + fallbackMsg: + "Payment idempotency: cache.set failed after a completed send; a retry with this key could double-pay", + attributes: { + "idempotency.scopedKey": scopedKey, + "idempotency.status": JSON.stringify(outcome), + }, + }) + } + } + + return outcome + }, + ) + // If the lock could not be acquired, `lockPaymentIdempotencyKey` returns a + // LockServiceError (⊆ ApplicationError) — a concurrent same-key request is in + // flight. We surface that busy error and never execute a second time. +} diff --git a/src/app/payments/index.ts b/src/app/payments/index.ts index 82cc722b6..927cec8bc 100644 --- a/src/app/payments/index.ts +++ b/src/app/payments/index.ts @@ -1,4 +1,5 @@ export * from "./get-protocol-fee" +export * from "./idempotency" export * from "./send-lightning" export * from "./send-intraledger" export * from "./update-pending-payments" diff --git a/src/app/payments/send-intraledger.ts b/src/app/payments/send-intraledger.ts index 722ff354d..1e90f7fd9 100644 --- a/src/app/payments/send-intraledger.ts +++ b/src/app/payments/send-intraledger.ts @@ -13,6 +13,8 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose" import Ibex from "@services/ibex/client" import { UnexpectedIbexResponse } from "@services/ibex/errors" +import { withPaymentIdempotency } from "./idempotency" + // Wallet-id intraledger sends are USD/USDT denominated; the unchecked amount // is in cents (1 USDT = 1 USD). const usdCentsDisplay = (cents: number) => ({ @@ -131,23 +133,41 @@ const intraledgerPaymentSendWalletId = async ({ export const intraledgerPaymentSendWalletIdForBtcWallet = async ( args: IntraLedgerPaymentSendWalletIdArgs, -): Promise => { - const validated = await validateIsBtcWallet(args.senderWalletId) - const result = - validated instanceof Error ? validated : await intraledgerPaymentSendWalletId(args) - notifyIntraledgerSendResult(args, result) - return result -} +): Promise => + withPaymentIdempotency({ + idempotencyKey: args.idempotencyKey, + senderWalletId: args.senderWalletId, + requestFingerprint: `intraledger|${args.recipientWalletId}|${args.amount}`, + execute: async () => { + const validated = await validateIsBtcWallet(args.senderWalletId) + const result = + validated instanceof Error + ? validated + : await intraledgerPaymentSendWalletId(args) + notifyIntraledgerSendResult(args, result) + return result + }, + }) export const intraledgerPaymentSendWalletIdForUsdWallet = async ( args: IntraLedgerPaymentSendWalletIdArgs, -): Promise => { - const validated = await validateIsUsdWallet(args.senderWalletId, { includeUsdt: true }) - const result = - validated instanceof Error ? validated : await intraledgerPaymentSendWalletId(args) - notifyIntraledgerSendResult(args, result, usdCentsDisplay(args.amount)) - return result -} +): Promise => + withPaymentIdempotency({ + idempotencyKey: args.idempotencyKey, + senderWalletId: args.senderWalletId, + requestFingerprint: `intraledger|${args.recipientWalletId}|${args.amount}`, + execute: async () => { + const validated = await validateIsUsdWallet(args.senderWalletId, { + includeUsdt: true, + }) + const result = + validated instanceof Error + ? validated + : await intraledgerPaymentSendWalletId(args) + notifyIntraledgerSendResult(args, result, usdCentsDisplay(args.amount)) + return result + }, + }) const validateIntraledgerPaymentInputs = async ({ uncheckedSenderWalletId, diff --git a/src/app/payments/send-lightning.ts b/src/app/payments/send-lightning.ts index d93dd8dd3..2e41fbf8e 100644 --- a/src/app/payments/send-lightning.ts +++ b/src/app/payments/send-lightning.ts @@ -72,6 +72,7 @@ import { } from "./helpers" import { reimburseFee } from "./reimburse-fee" +import { withPaymentIdempotency } from "./idempotency" const dealer = DealerPriceService() const paymentFlowRepo = PaymentFlowStateRepository(defaultTimeToExpiryInSeconds) @@ -118,11 +119,17 @@ const notifyLightningSendResult = ( export const payInvoiceByWalletId = async ( args: PayInvoiceByWalletIdArgs, -): Promise => { - const result = await executePayInvoiceByWalletId(args) - notifyLightningSendResult(args, result) - return result -} +): Promise => + withPaymentIdempotency({ + idempotencyKey: args.idempotencyKey, + senderWalletId: args.senderWalletId, + requestFingerprint: `ln|${args.uncheckedPaymentRequest}`, + execute: async () => { + const result = await executePayInvoiceByWalletId(args) + notifyLightningSendResult(args, result) + return result + }, + }) const executePayInvoiceByWalletId = async ({ uncheckedPaymentRequest, @@ -213,23 +220,37 @@ const payNoAmountInvoiceByWalletId = async ({ export const payNoAmountInvoiceByWalletIdForBtcWallet = async ( args: PayNoAmountInvoiceByWalletIdArgs, -): Promise => { - const validated = await validateIsBtcWallet(args.senderWalletId) - const result = - validated instanceof Error ? validated : await payNoAmountInvoiceByWalletId(args) - notifyLightningSendResult(args, result, satsDisplay(args.amount)) - return result -} +): Promise => + withPaymentIdempotency({ + idempotencyKey: args.idempotencyKey, + senderWalletId: args.senderWalletId, + requestFingerprint: `ln-noamount|${args.uncheckedPaymentRequest}|${args.amount}`, + execute: async () => { + const validated = await validateIsBtcWallet(args.senderWalletId) + const result = + validated instanceof Error ? validated : await payNoAmountInvoiceByWalletId(args) + notifyLightningSendResult(args, result, satsDisplay(args.amount)) + return result + }, + }) export const payNoAmountInvoiceByWalletIdForUsdWallet = async ( args: PayNoAmountInvoiceByWalletIdArgs, -): Promise => { - const validated = await validateIsUsdWallet(args.senderWalletId, { includeUsdt: true }) - const result = - validated instanceof Error ? validated : await payNoAmountInvoiceByWalletId(args) - notifyLightningSendResult(args, result, usdCentsDisplay(args.amount)) - return result -} +): Promise => + withPaymentIdempotency({ + idempotencyKey: args.idempotencyKey, + senderWalletId: args.senderWalletId, + requestFingerprint: `ln-noamount|${args.uncheckedPaymentRequest}|${args.amount}`, + execute: async () => { + const validated = await validateIsUsdWallet(args.senderWalletId, { + includeUsdt: true, + }) + const result = + validated instanceof Error ? validated : await payNoAmountInvoiceByWalletId(args) + notifyLightningSendResult(args, result, usdCentsDisplay(args.amount)) + return result + }, + }) const validateInvoicePaymentInputs = async ({ uncheckedPaymentRequest, diff --git a/src/app/wallets/index.types.d.ts b/src/app/wallets/index.types.d.ts index 92fa412f6..fb329646a 100644 --- a/src/app/wallets/index.types.d.ts +++ b/src/app/wallets/index.types.d.ts @@ -84,6 +84,10 @@ type PaymentSendArgs = { senderWalletId: WalletId senderAccount?: Account memo: string | null + // Optional client-supplied idempotency key (ENG-530). When present, a repeated + // send with the same key returns the original result instead of executing again. + // Absent = unchanged behavior (existing/internal callers do not supply one). + idempotencyKey?: string | null } type PayInvoiceByWalletIdArgs = PaymentSendArgs & { diff --git a/src/domain/errors.ts b/src/domain/errors.ts index d4ea375bb..c350acf5c 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -157,5 +157,11 @@ export class MultipleCurrenciesForSingleCurrencyOperationError extends Validatio export class InvalidIdempotencyKeyError extends ValidationError {} +// Raised when an idempotency key is reused for a request whose parameters differ +// from the original (e.g. same key, different amount/recipient). Returning the +// original result would silently drop the new payment while reporting success, so +// we reject instead. (ENG-530) +export class IdempotencyKeyReuseError extends ValidationError {} + export class InvalidLnurlError extends ValidationError {} export class InvalidLnurlAmountError extends ValidationError {} diff --git a/src/domain/lock/index.types.d.ts b/src/domain/lock/index.types.d.ts index 720db6a8d..ec999cb8e 100644 --- a/src/domain/lock/index.types.d.ts +++ b/src/domain/lock/index.types.d.ts @@ -27,6 +27,10 @@ interface ILockService { f: (signal: OnChainTxAbortSignal) => Promise, ): Promise lockIdempotencyKey(idempotencyKey: IdempotencyKey): Promise + lockPaymentIdempotencyKey( + idempotencyKey: IdempotencyKey, + f: (signal: IdempotencyKeyAbortSignal) => Promise, + ): Promise } type RedlockArgs = { diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 452b2f9b8..fdaaf98d4 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -472,6 +472,14 @@ export const mapError = (error: ApplicationError): CustomApolloError => { logger: baseLogger, }) + case "IdempotencyKeyReuseError": + message = + "This idempotency key was already used for a different payment. Use a new key for a new payment." + return new ValidationInternalError({ + message, + logger: baseLogger, + }) + case "EmailUnverifiedError": return new EmailUnverifiedError({ logger: baseLogger }) diff --git a/src/graphql/public/root/mutation/intraledger-payment-send.ts b/src/graphql/public/root/mutation/intraledger-payment-send.ts index 0f23c44ca..4fc0beef2 100644 --- a/src/graphql/public/root/mutation/intraledger-payment-send.ts +++ b/src/graphql/public/root/mutation/intraledger-payment-send.ts @@ -15,6 +15,11 @@ const IntraLedgerPaymentSendInput = GT.Input({ recipientWalletId: { type: GT.NonNull(WalletId) }, amount: { type: GT.NonNull(SatAmount), description: "Amount in satoshis." }, memo: { type: Memo, description: "Optional memo to be attached to the 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.", + }, }), }) @@ -30,7 +35,7 @@ const IntraLedgerPaymentSendMutation = GT.Field( input: { type: GT.NonNull(IntraLedgerPaymentSendInput) }, }, resolve: async (_, args, { domainAccount }) => { - const { walletId, recipientWalletId, amount, memo } = args.input + const { walletId, recipientWalletId, amount, memo, idempotencyKey } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { return { errors: [{ message: input.message }] } @@ -61,6 +66,7 @@ const IntraLedgerPaymentSendMutation = GT.Field( amount, senderWalletId: walletId, senderAccount: domainAccount, + idempotencyKey, }) if (status instanceof Error) { return { status: "failed", errors: [mapAndParseErrorForGqlResponse(status)] } diff --git a/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts b/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts index cf663acc1..7e1e818b0 100644 --- a/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts @@ -18,6 +18,11 @@ const IntraLedgerUsdPaymentSendInput = GT.Input({ recipientWalletId: { type: GT.NonNull(WalletId) }, amount: { type: GT.NonNull(FractionalCentAmount), description: "Amount in cents." }, memo: { type: Memo, description: "Optional memo to be attached to the 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.", + }, }), }) @@ -39,7 +44,7 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field { - const { walletId, recipientWalletId, amount, memo } = args.input + const { walletId, recipientWalletId, amount, memo, idempotencyKey } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { return { errors: [{ message: input.message }] } @@ -74,6 +79,7 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field({ @@ -52,7 +60,7 @@ const LnInvoicePaymentSendMutation = GT.Field< input: { type: GT.NonNull(LnInvoicePaymentInput) }, }, resolve: async (_, args, { domainAccount }) => { - const { walletId, paymentRequest, memo } = args.input + const { walletId, paymentRequest, memo, idempotencyKey } = args.input if (walletId instanceof InputValidationError) { return { errors: [{ message: walletId.message }] } } @@ -73,13 +81,43 @@ const LnInvoicePaymentSendMutation = GT.Field< */ if (!domainAccount) throw new Error("Authentication required") - const PayLightningInvoice = await Ibex.payInvoice({ - invoice: paymentRequest as Bolt11, - accountId: walletId, + + // ENG-530: dedupe on (senderWalletId, idempotencyKey) when a key is supplied. + // This resolver pays IBEX directly (the app-layer path is stubbed above), so the + // idempotency wrapper goes around the inline call here rather than in @app. + const status = await withPaymentIdempotency({ + idempotencyKey, + senderWalletId: walletId, + requestFingerprint: `ln|${paymentRequest}`, + execute: async (): Promise => { + const PayLightningInvoice = await Ibex.payInvoice({ + invoice: paymentRequest as Bolt11, + accountId: walletId, + }) + + if (PayLightningInvoice instanceof IbexError) { + return PayLightningInvoice + } + + let ibexStatus: PaymentSendStatus = PaymentSendStatus.Pending + switch (PayLightningInvoice.transaction?.payment?.status?.id) { + case 1: + ibexStatus = PaymentSendStatus.Pending + break + case 2: + ibexStatus = PaymentSendStatus.Success + break + case 3: + ibexStatus = PaymentSendStatus.Failure + break + } + + return ibexStatus + }, }) // TODO: Reintroduce following code by adding to mapAndParseErrorForGqlResponse - // if (PayLightningInvoice instanceof IbexRateLimitError) { + // if (status instanceof IbexRateLimitError) { // return { // status: "failed", // errors: [ @@ -91,25 +129,18 @@ const LnInvoicePaymentSendMutation = GT.Field< // } // } - if (PayLightningInvoice instanceof IbexError) { + // Preserve the existing generic IBEX-failure message. + if (status instanceof IbexError) { return { status: "failed", errors: [{ message: "An unexpected error occurred. Please try again later." }], - // errors: [mapAndParseErrorForGqlResponse(PayLightningInvoice)] } + // errors: [mapAndParseErrorForGqlResponse(status)] } } } - let status: PaymentSendStatus = PaymentSendStatus.Pending - switch (PayLightningInvoice.transaction?.payment?.status?.id) { - case 1: - status = PaymentSendStatus.Pending - break - case 2: - status = PaymentSendStatus.Success - break - case 3: - status = PaymentSendStatus.Failure - break + // Non-IBEX errors: a concurrent same-key request in flight, or an invalid key. + if (status instanceof Error) { + return { status: "failed", errors: [mapAndParseErrorForGqlResponse(status)] } } return { diff --git a/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts b/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts index d7d1380cd..558dbee9e 100644 --- a/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts +++ b/src/graphql/public/root/mutation/ln-noamount-invoice-payment-send.ts @@ -29,6 +29,11 @@ const LnNoAmountInvoicePaymentInput = 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.", + }, }), }) @@ -41,6 +46,7 @@ const LnNoAmountInvoicePaymentSendMutation = GT.Field< paymentRequest: string | InputValidationError amount: Satoshis | InputValidationError memo?: string | InputValidationError + idempotencyKey?: string | null } } >({ @@ -55,7 +61,7 @@ const LnNoAmountInvoicePaymentSendMutation = GT.Field< input: { type: GT.NonNull(LnNoAmountInvoicePaymentInput) }, }, resolve: async (_, args, { domainAccount }) => { - const { walletId, paymentRequest, amount, memo } = args.input + const { walletId, paymentRequest, amount, memo, idempotencyKey } = args.input if (walletId instanceof InputValidationError) { return { errors: [{ message: walletId.message }] } @@ -76,6 +82,7 @@ const LnNoAmountInvoicePaymentSendMutation = GT.Field< memo: memo ?? null, amount, senderAccount: domainAccount, + idempotencyKey, }) if (status instanceof Error) { diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 5d74ff85b..d6b335d11 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -942,6 +942,11 @@ input IntraLedgerPaymentSendInput { """Amount in satoshis.""" amount: SatAmount! + """ + Optional client-supplied key; a repeated send with the same key returns the original result instead of paying again. + """ + idempotencyKey: String + """Optional memo to be attached to the payment.""" memo: Memo recipientWalletId: WalletId! @@ -962,6 +967,11 @@ input IntraLedgerUsdPaymentSendInput { """Amount in 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 be attached to the payment.""" memo: Memo recipientWalletId: WalletId! @@ -1037,6 +1047,11 @@ type LnInvoicePayload { } input LnInvoicePaymentInput { + """ + 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 @@ -1105,6 +1120,11 @@ input LnNoAmountInvoicePaymentInput { """Amount to pay in satoshis.""" amount: SatAmount! + """ + 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 diff --git a/src/services/lock/index.ts b/src/services/lock/index.ts index 9b72e9d41..ce81bf821 100644 --- a/src/services/lock/index.ts +++ b/src/services/lock/index.ts @@ -73,6 +73,12 @@ const getOnChainTxHashAndVoutLockResource = ({ }) => `locks:onchaintxhash:${txHash}:${vout}` const getIdempotencyKeyLockResource = (path: IdempotencyKey) => `locks:idempotencykey:${path}` +// Distinct namespace from the header-middleware `lockIdempotencyKey` (which holds a +// non-releasing timelock). This one is a `.using` lock that releases when the wrapped +// send finishes, so a concurrent same-key request is held off only for the in-flight +// window (ENG-530 payment-send idempotency). +const getPaymentIdempotencyKeyLockResource = (path: IdempotencyKey) => + `locks:payment-idempotency:${path}` // unlock after asyncFn is done export const redlock = async ({ @@ -163,6 +169,15 @@ export const LockService = (): ILockService => { } } + const lockPaymentIdempotencyKey = async ( + idempotencyKey: IdempotencyKey, + asyncFn: (signal: IdempotencyKeyAbortSignal) => Promise, + ): Promise => { + const path = getPaymentIdempotencyKeyLockResource(idempotencyKey) + + return redlock({ path, asyncFn }) + } + return wrapAsyncFunctionsToRunInSpan({ namespace: "services.lock", fns: { @@ -171,6 +186,7 @@ export const LockService = (): ILockService => { lockOnChainTxHash, lockOnChainTxHashAndVout, lockIdempotencyKey, + lockPaymentIdempotencyKey, }, }) } diff --git a/test/flash/unit/app/payments/idempotency.spec.ts b/test/flash/unit/app/payments/idempotency.spec.ts new file mode 100644 index 000000000..ad947e3fa --- /dev/null +++ b/test/flash/unit/app/payments/idempotency.spec.ts @@ -0,0 +1,272 @@ +// Unit tests for the ENG-530 payment-send idempotency helper in isolation. +// The wrapped-send integration behavior (Ibex call counts, ops events) is covered +// in send-intraledger.spec.ts; here we exercise the helper's own logic against +// in-memory cache + lock mocks. +// +// The cache mock stores JSON strings and parses them back on read, so these tests +// exercise the REAL serialization round-trip a Redis-backed store performs — a +// replayed { value: "success" } is a freshly-deserialized object, not the original +// reference. + +const mockCacheStore = new Map() +let mockLockHeld = false +let mockCacheSetShouldFail = false + +jest.mock("@services/cache", () => ({ + RedisCacheService: () => ({ + get: async ({ key }: { key: string }) => + mockCacheStore.has(key) + ? JSON.parse(mockCacheStore.get(key) as string) + : new Error("cache miss"), + set: async ({ key, value }: { key: string; value: unknown }) => { + if (mockCacheSetShouldFail) return new Error("cache set failed") + mockCacheStore.set(key, JSON.stringify(value)) + return value + }, + }), +})) + +jest.mock("@services/lock", () => { + const { ResourceAttemptsLockServiceError } = jest.requireActual("@domain/lock") + return { + LockService: () => ({ + // Models redlock `.using`: if the key is already held, fail (busy) without + // running the callback; otherwise run it under the lock and release after. + lockPaymentIdempotencyKey: async ( + _key: string, + asyncFn: (signal: unknown) => Promise, + ) => { + if (mockLockHeld) return new ResourceAttemptsLockServiceError() + return asyncFn({ aborted: false }) + }, + }), + } +}) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), + addAttributesToCurrentSpan: jest.fn(), +})) + +import { withPaymentIdempotency } from "@app/payments/idempotency" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { + IdempotencyKeyReuseError, + InvalidIdempotencyKeyError, + MismatchedCurrencyForWalletError, +} from "@domain/errors" +import { ResourceAttemptsLockServiceError } from "@domain/lock" +import { recordExceptionInCurrentSpan } from "@services/tracing" + +const walletA = "11111111-1111-4111-8111-111111111111" as WalletId +const walletB = "22222222-2222-4222-8222-222222222222" as WalletId +const fingerprint = "recipient-1|100" + +describe("withPaymentIdempotency", () => { + beforeEach(() => { + mockCacheStore.clear() + mockLockHeld = false + mockCacheSetShouldFail = false + ;(recordExceptionInCurrentSpan as jest.Mock).mockClear() + }) + + it("runs execute unchanged when no key is supplied", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const result = await withPaymentIdempotency({ + idempotencyKey: undefined, + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + expect(result).toBe(PaymentSendStatus.Success) + expect(execute).toHaveBeenCalledTimes(1) + // No key => nothing persisted. + expect(mockCacheStore.size).toBe(0) + }) + + it("treats a blank/whitespace key as no key (executes normally)", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const result = await withPaymentIdempotency({ + idempotencyKey: " ", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + expect(result).toBe(PaymentSendStatus.Success) + expect(execute).toHaveBeenCalledTimes(1) + expect(mockCacheStore.size).toBe(0) + }) + + it("rejects an oversized key without executing", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const result = await withPaymentIdempotency({ + idempotencyKey: "x".repeat(257), + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + expect(result).toBeInstanceOf(InvalidIdempotencyKeyError) + expect(execute).not.toHaveBeenCalled() + }) + + it("executes once for a key, then replays the cached result", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const first = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + const second = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + expect(first).toEqual(PaymentSendStatus.Success) + expect(second).toEqual(PaymentSendStatus.Success) + // Second call is served from cache — execute runs exactly once. + expect(execute).toHaveBeenCalledTimes(1) + }) + + it("replays a JSON round-tripped result (proves real serialization, not a reference)", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + const replay = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + // Deep-equal to the enum value... + expect(replay).toEqual({ value: "success" }) + // ...but a distinct object — it was serialized to Redis and parsed back. + expect(replay).not.toBe(PaymentSendStatus.Success) + expect(execute).toHaveBeenCalledTimes(1) + }) + + it("rejects a key reused for a different payment (fingerprint mismatch) without executing again", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const first = await withPaymentIdempotency({ + idempotencyKey: "shared-key", + senderWalletId: walletA, + requestFingerprint: "pay-to-alice|100", + execute, + }) + const second = await withPaymentIdempotency({ + idempotencyKey: "shared-key", + senderWalletId: walletA, + requestFingerprint: "pay-to-bob|100", + execute, + }) + + expect(first).toEqual(PaymentSendStatus.Success) + // Same key, different payment => conflict, not a silent replay of the first. + expect(second).toBeInstanceOf(IdempotencyKeyReuseError) + // And the conflicting request did NOT execute. + expect(execute).toHaveBeenCalledTimes(1) + }) + + it("returns the busy lock error and does not execute when the key is in flight", async () => { + mockLockHeld = true + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const result = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + expect(result).toBeInstanceOf(ResourceAttemptsLockServiceError) + expect(execute).not.toHaveBeenCalled() + }) + + it("does not cache an error result — a fresh attempt re-executes", async () => { + const execute = jest + .fn() + .mockResolvedValueOnce(new MismatchedCurrencyForWalletError()) + .mockResolvedValueOnce(PaymentSendStatus.Success) + + const first = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + const second = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + expect(first).toBeInstanceOf(MismatchedCurrencyForWalletError) + expect(second).toEqual(PaymentSendStatus.Success) + // The error return was not cached, so the second attempt executed again. + expect(execute).toHaveBeenCalledTimes(2) + }) + + it("records a critical exception when cache.set fails after a completed send", async () => { + mockCacheSetShouldFail = true + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + const result = await withPaymentIdempotency({ + idempotencyKey: "key-1", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + + // The payment already executed — we still return its outcome... + expect(result).toEqual(PaymentSendStatus.Success) + expect(execute).toHaveBeenCalledTimes(1) + // ...but the failed persist is surfaced loudly (money moved, result not stored). + expect(recordExceptionInCurrentSpan).toHaveBeenCalledTimes(1) + expect(recordExceptionInCurrentSpan).toHaveBeenCalledWith( + expect.objectContaining({ + fallbackMsg: expect.stringContaining("double-pay"), + attributes: expect.objectContaining({ + "idempotency.scopedKey": `${walletA}:key-1`, + }), + }), + ) + }) + + it("scopes the key per sender wallet — the same key from a different wallet does not collide", async () => { + const execute = jest.fn().mockResolvedValue(PaymentSendStatus.Success) + + await withPaymentIdempotency({ + idempotencyKey: "shared-key", + senderWalletId: walletA, + requestFingerprint: fingerprint, + execute, + }) + await withPaymentIdempotency({ + idempotencyKey: "shared-key", + senderWalletId: walletB, + requestFingerprint: fingerprint, + execute, + }) + + // Different wallet scope => distinct cache entries => both execute. + expect(execute).toHaveBeenCalledTimes(2) + expect(mockCacheStore.size).toBe(2) + }) +}) diff --git a/test/flash/unit/app/payments/send-intraledger.spec.ts b/test/flash/unit/app/payments/send-intraledger.spec.ts index 7f26be2fd..af104b716 100644 --- a/test/flash/unit/app/payments/send-intraledger.spec.ts +++ b/test/flash/unit/app/payments/send-intraledger.spec.ts @@ -3,6 +3,12 @@ const mockPayInvoice = jest.fn() const mockFindWalletById = jest.fn() const mockFindAccountById = jest.fn() +// In-memory backing stores for the ENG-530 idempotency helper (see the +// "intraledger idempotency" describe block below). Keyed on the scoped cache/lock +// key so different (wallet, key) pairs stay independent. +const mockCacheStore = new Map() +const mockHeldLocks = new Set() + jest.mock("@config", () => ({ getCallbackServiceConfig: jest.fn(() => ({})), getValuesToSkipProbe: jest.fn(() => []), @@ -67,8 +73,38 @@ jest.mock("@services/dealer-price", () => ({ DealerPriceService: jest.fn(() => ({})), })) -jest.mock("@services/lock", () => ({ - LockService: jest.fn(() => ({})), +jest.mock("@services/lock", () => { + const { ResourceAttemptsLockServiceError } = jest.requireActual("@domain/lock") + return { + LockService: jest.fn(() => ({ + // Models a redlock `.using` lock: if the scoped key is already held, fail + // (busy) without running the callback; otherwise run under the lock and + // release afterwards. + lockPaymentIdempotencyKey: async ( + key: string, + asyncFn: (signal: unknown) => Promise, + ) => { + if (mockHeldLocks.has(key)) return new ResourceAttemptsLockServiceError() + mockHeldLocks.add(key) + try { + return await asyncFn({ aborted: false }) + } finally { + mockHeldLocks.delete(key) + } + }, + })), + } +}) + +jest.mock("@services/cache", () => ({ + RedisCacheService: jest.fn(() => ({ + get: async ({ key }: { key: string }) => + mockCacheStore.has(key) ? mockCacheStore.get(key) : new Error("cache miss"), + set: async ({ key, value }: { key: string; value: unknown }) => { + mockCacheStore.set(key, value) + return value + }, + })), })) jest.mock("@services/ledger", () => ({ @@ -93,7 +129,10 @@ jest.mock("@app/payments/helpers", () => ({ })) import { intraledgerPaymentSendWalletIdForUsdWallet } from "@app/payments/send-intraledger" -import { MismatchedCurrencyForWalletError } from "@domain/errors" +import { + IdempotencyKeyReuseError, + MismatchedCurrencyForWalletError, +} from "@domain/errors" import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import { notifyOpsEvent } from "@services/alerts/ops-events" @@ -391,3 +430,154 @@ describe("intraledger send ops events", () => { ) }) }) + +describe("intraledger idempotency (ENG-530)", () => { + // The incident: a $140 USDT intraledger send fired twice ~1.5s apart and both + // executed. These tests prove that a client-supplied idempotency key makes the + // underlying IBEX send run at most once. + const sendArgs = { + senderWalletId: senderUsdWalletId, + recipientWalletId: recipientUsdWalletId, + amount: 14000, + memo: "idempotency test", + } + + beforeEach(() => { + jest.clearAllMocks() + mockCacheStore.clear() + mockHeldLocks.clear() + + mockFindAccountById.mockImplementation(async (accountId: AccountId) => + activeAccount(accountId as string), + ) + mockAddInvoice.mockResolvedValue({ invoice: { bolt11: "lnbc1recipient" } }) + mockPayInvoice.mockResolvedValue({ status: 2 }) + mockFindWalletById.mockImplementation(async (walletId: WalletId) => { + const isUsdt = walletId === senderUsdtWalletId || walletId === recipientUsdtWalletId + const isSender = walletId === senderUsdWalletId || walletId === senderUsdtWalletId + return wallet({ + id: walletId, + accountId: isSender ? "sender-account" : "recipient-account", + currency: isUsdt ? WalletCurrency.Usdt : WalletCurrency.Usd, + }) + }) + }) + + it("executes once and replays the cached result on a repeated send with the same key", async () => { + const first = await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "dup-key", + }) + const second = await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "dup-key", + }) + + expect(first).toEqual({ value: "success" }) + expect(second).toEqual({ value: "success" }) + // The underlying IBEX send ran exactly once (no second invoice, no double-pay). + expect(mockAddInvoice).toHaveBeenCalledTimes(1) + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + // And the ops event fired exactly once — no duplicate on the cached replay. + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + }) + + it("executes once for a concurrent double-fire with the same key", async () => { + // Hold the IBEX pay open so the first send is still in flight (lock held) when + // the second fire arrives — the exact race from the incident. + let releasePay: (v: unknown) => void = () => undefined + const payReached = new Promise((resolve) => { + mockPayInvoice.mockImplementation(() => { + resolve() + return new Promise((res) => { + releasePay = res + }) + }) + }) + + const firstPromise = intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "race-key", + }) + await payReached // first send is mid-flight, holding the lock + + const second = await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "race-key", + }) + + // The concurrent request was rejected as busy — it did NOT execute a second send. + expect(second).toBeInstanceOf(Error) + expect(mockAddInvoice).toHaveBeenCalledTimes(1) + + releasePay({ status: 2 }) + const first = await firstPromise + + expect(first).toEqual({ value: "success" }) + expect(mockAddInvoice).toHaveBeenCalledTimes(1) + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + }) + + it("executes separately for different keys", async () => { + await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "key-a", + }) + await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "key-b", + }) + + expect(mockAddInvoice).toHaveBeenCalledTimes(2) + expect(notifyOpsEvent).toHaveBeenCalledTimes(2) + }) + + it("executes every time when no key is supplied (backwards-compatible)", async () => { + await intraledgerPaymentSendWalletIdForUsdWallet(sendArgs) + await intraledgerPaymentSendWalletIdForUsdWallet(sendArgs) + + // No key => no dedupe, no cache writes. + expect(mockAddInvoice).toHaveBeenCalledTimes(2) + expect(mockCacheStore.size).toBe(0) + }) + + it("does not collide across sender wallets that reuse the same key", async () => { + await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdWalletId, + recipientWalletId: recipientUsdWalletId, + amount: 100, + memo: null, + idempotencyKey: "shared", + }) + await intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: senderUsdtWalletId, + recipientWalletId: recipientUsdtWalletId, + amount: 100, + memo: null, + idempotencyKey: "shared", + }) + + // Different sender wallet => different scope => both execute. + expect(mockAddInvoice).toHaveBeenCalledTimes(2) + }) + + it("rejects the same key reused for a different payment instead of replaying", async () => { + const first = await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + idempotencyKey: "conflict-key", + }) + // Same key, but a different amount — a genuinely different payment. + const second = await intraledgerPaymentSendWalletIdForUsdWallet({ + ...sendArgs, + amount: 999, + idempotencyKey: "conflict-key", + }) + + expect(first).toEqual({ value: "success" }) + // Not a silent replay of the first payment's success — a clear conflict. + expect(second).toBeInstanceOf(IdempotencyKeyReuseError) + // And the conflicting request did NOT send. + expect(mockAddInvoice).toHaveBeenCalledTimes(1) + expect(mockPayInvoice).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/flash/unit/app/payments/send-lightning-ops-events.spec.ts b/test/flash/unit/app/payments/send-lightning-ops-events.spec.ts index c715fbd4d..2fa4ff979 100644 --- a/test/flash/unit/app/payments/send-lightning-ops-events.spec.ts +++ b/test/flash/unit/app/payments/send-lightning-ops-events.spec.ts @@ -43,6 +43,16 @@ jest.mock("@services/lock", () => ({ LockService: jest.fn(() => ({})), })) +// These tests never supply an idempotency key, so the send functions never touch +// the cache. Mock it to avoid transitively opening a real Redis connection via the +// ENG-530 idempotency helper imported by send-lightning. +jest.mock("@services/cache", () => ({ + RedisCacheService: jest.fn(() => ({ + get: jest.fn(async () => new Error("cache miss")), + set: jest.fn(async () => undefined), + })), +})) + jest.mock("@services/notifications", () => ({ NotificationsService: jest.fn(() => ({})), }))