Payments 3/8: Idempotency and duplicate-payment-submission prevention - #148
Merged
wumibals merged 1 commit intoAug 22, 2026
Merged
Conversation
|
@soundsng is attempting to deploy a commit to the wumibals' projects Team on Vercel. A member of the Team first needs to authorize it. |
Adds a two-layer idempotency/dedup guard so a single user-intended payment action (deposit, withdraw, early-exit) can't execute more than once across UI double-clicks, network retries, a page reload mid-flight, or two tabs racing the same wallet: - SDK (sdks/typescript): TransactionPipeline.invoke() dedups concurrent calls sharing an idempotencyKey by returning the same in-flight promise instead of building/submitting twice. deposit/withdraw/earlyExit derive a default key from account+tier+asset+amount automatically, with an override available for callers (e.g. the app) that want their own per-intent key. - App (app/src/lib/paymentIntent.ts, new): a persisted per-intent state machine (idle -> building -> awaiting_signature -> submitted -> confirmed | failed) plus a coarser operation lock (kind+address+tier, no nonce) in localStorage. The lock is what stops two browser tabs racing the same deposit even though each tab independently generates its own nonce- suffixed intent key; the per-intent record is what lets a reload mid-flight rehydrate instead of allowing a fresh resubmission. A failed or rejected signing attempt releases the lock so retry isn't blocked forever; a genuinely new deliberate action gets a fresh key. - Wires the guard into deposit/page.tsx's confirm step and gives EarlyExitModal's previously-dead "Exit Early" button a real, idempotency-guarded submission path. - Adds a minimal vitest setup to the app (mirroring the SDK's existing one) plus a CI test step, since the app had no test runner at all before this issue's acceptance criteria required automated double-click/reload coverage. Closes LadderMine#140
yusuftomilola
force-pushed
the
feature/140-payment-idempotency-guard
branch
from
August 22, 2026 09:21
f01ea7b to
7a980e7
Compare
wumibals
approved these changes
Aug 22, 2026
wumibals
left a comment
Contributor
There was a problem hiding this comment.
Reviewed the idempotency/duplicate-submission-prevention PR (closes #140, depends on already-merged #139).
- The SDK-level dedup in
TransactionPipeline.invoke()— a second call sharing an in-flightidempotencyKeyreturns the same promise instead of building a second transaction — is the right primitive to build on, and releasing the key the moment the call settles (not after some fixed window) means it never blocks a genuinely later, separate call. - The two-layer approach on the app side is well thought out: a coarse
kind+address+tieroperation lock (deliberately excluding the nonce) is what actually stops two browser tabs from racing the same deposit, since each tab would otherwise generate its own nonce-suffixed key and dedup would never trigger. The separate per-intent state machine is what enables a reload mid-flight to rehydrate and resume instead of allowing a fresh resubmission. These solve different problems and correctly aren't collapsed into one mechanism. - The bug caught during testing — the guard must re-check the persisted status by key rather than trust a possibly-stale in-memory object a double-click's second handler invocation still closes over — is exactly the kind of subtle race that's easy to miss and easy to silently ship broken. Good that it's called out explicitly and covered by a regression test rather than just fixed quietly.
- Releasing the lock on failure/rejection so a retry isn't blocked forever, while still giving a genuinely new deliberate re-entry a fresh nonce, correctly distinguishes "let the user retry" from "don't let an accidental double-click through."
- Wiring this into
EarlyExitModal's "Exit Early" button — which apparently had noonClickat all before this PR — turns a dead button into a real, guarded submission path as a useful side effect of this work. - Adding a test runner to the
apppackage from scratch (it had none) so the acceptance criteria's automated double-click/reload coverage is actually enforceable going forward, rather than just manually verified once, is good infrastructure investment beyond the immediate ask. - Test coverage is thorough on both sides: SDK tests cover dedup vs. explicit-differing-keys vs. sequential-after-settle vs. failure-releases-key; app tests cover distinct-intents-same-params, the double-click stale-reference bug, cross-tab lock contention, deliberate sequential repeats, and reload rehydration.
CI is green across Soroban contracts, Next.js dashboard, and TypeScript SDK — including the newly-added app test step. Vercel's FAILURE is the usual unauthorized deployment integration link, unrelated to the code.
Approving — careful, well-tested handling of a genuinely tricky concurrency problem.
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.
Summary
sdks/typescript):TransactionPipeline.invoke()now dedups concurrent calls sharing anidempotencyKey— a second call for the same key returns the SAME in-flight promise instead of building/simulating/submitting a second transaction. The key is released the moment the call settles (success or failure), so it never blocks a later, genuinely separate call.deposit/withdraw/earlyExitderive a default key automatically from account+tier+asset+amount, with an explicit override available for callers (like the app) that want their own per-intent key.app/src/lib/paymentIntent.ts, new): a persisted per-intent state machine (idle -> building -> awaiting_signature -> submitted -> confirmed | failed) plus a coarser operation lock (kind+address+tier, deliberately without the nonce) inlocalStorage:deposit/page.tsx's confirm step (intent generated once per arrival at step 3, regenerated on re-entry with changed params) and givesEarlyExitModal's previously entirely-dead "Exit Early" button (noonClickat all before this PR) a real, idempotency-guarded submission path.apppackage had no test runner at all before this PR — added a minimalvitestsetup (mirroring the SDK's existing one) plus aTestCI step, since the acceptance criteria explicitly require automated double-click/reload coverage.Test plan
pnpm typecheck,pnpm build,pnpm test— all pass, including 6 new tests inidempotency.test.ts(rapid double-invocation ofdeposit/withdrawresults in exactly onesendTransactioncall; explicit differing keys are NOT deduped; a later call with the same default key is allowed once the first settles; a failed call releases the key for retry; two concurrent calls sharing an explicit key dedup even across different tiers/amounts).pnpm typecheck,pnpm test,pnpm lint(next lint— only pre-existing warnings in untouched files),pnpm build(next build) — all pass, including 13 new tests inpaymentIntent.test.tscovering: distinct keys for independently-created intents with identical params, double-click refusal (including the stale-reference bug caught above), cross-tab lock contention, sequential deliberate repeats NOT conflated, lock release on failure/success, reload-mid-flight rehydration viafindActiveIntent, and per-tier/per-kind lock isolation.Depends on #139 (SDK transaction pipeline, already merged).
Closes #140