feat(payments): server-side idempotency for payment-send mutations (ENG-530) - #460
Open
islandbitcoin wants to merge 1 commit into
Open
feat(payments): server-side idempotency for payment-send mutations (ENG-530)#460islandbitcoin wants to merge 1 commit into
islandbitcoin wants to merge 1 commit into
Conversation
…NG-530) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On 2026-07-23 a $140 USDT intraledger send fired twice ~1.5s apart (client double-fire; the second mutation began before the first settled) and the backend executed both — the sender was debited $280. Payment-send mutations had no idempotency protection: intraledger mints a fresh IBEX invoice and pays it with no dedupe key, so a double-tap / retry-on-timeout / replay double-pays. Fixes ENG-530.
What
Adds an optional client-supplied
idempotencyKeyto the payment-send mutations. A repeated request with the same key returns the original result instead of executing again — exactly-once settlement.withPaymentIdempotency()(src/app/payments/idempotency.ts) wraps the exported send functions. Dedupe scoped to(senderWalletId, idempotencyKey).RedisCacheServicefor the result store (24h TTL) + a newLockService().lockPaymentIdempotencyKey(redlock.using, distinct namespace) for the in-flight guard.cache.setcompletes before the lock releases, so a concurrent second request either replays the cached result or gets a busy error — never a second payment.IdempotencyKeyReuseError(never a silent wrong-success, never a fresh execution).@appin this fork, so the inlineIbex.payInvoiceis wrapped there too.idempotencyKeyis optional — existing clients are unaffected (mobile key-attachment is the follow-up, ENG-533).Safety properties (verified in adversarial security review)
execute); errors are not cached (retryable).cache.setnow records a Critical exception so ops can catch a persist failure that could otherwise enable a later re-pay.Documented residual (non-blocking)
Tests
37/37 payments specs (12 new: same-key replay, concurrent double-fire → single execution, fingerprint-mismatch conflict, no-key backwards-compat, no-double-ops-event, per-wallet scoping, failed-
setrecords Critical, JSON round-trip replay). graphql + payments: 149/149.yarn tsc-checkclean;make codegenregenerated the supergraph (4 cleanidempotencyKey: Stringadditions).Out of scope
Cashout-initiate, the Fygaro credit path (will reuse this helper — ENG-440), and mobile key-attachment (ENG-533).
🤖 Generated with Claude Code
https://claude.ai/code/session_01UXnQYUbKQe5wUCWEvdAau7