Skip to content

Payments 3/8: Idempotency and duplicate-payment-submission prevention - #148

Merged
wumibals merged 1 commit into
LadderMine:mainfrom
yusuftomilola:feature/140-payment-idempotency-guard
Aug 22, 2026
Merged

Payments 3/8: Idempotency and duplicate-payment-submission prevention#148
wumibals merged 1 commit into
LadderMine:mainfrom
yusuftomilola:feature/140-payment-idempotency-guard

Conversation

@yusuftomilola

Copy link
Copy Markdown
Contributor

Summary

  • SDK (sdks/typescript): TransactionPipeline.invoke() now dedups concurrent calls sharing an idempotencyKey — 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/earlyExit derive 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 (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) 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 and resume instead of allowing a fresh resubmission.
    • A failed/rejected-signing attempt releases the lock so a retry isn't blocked forever; a genuinely new deliberate action (re-entering the confirm step with different params) gets a fresh nonce, never conflated with an accidental double-click on the same visit.
    • Caught and fixed a real bug during testing: the guard must always re-check the persisted status by key, never the possibly-stale in-memory object a double-click's second handler invocation still closes over — otherwise the guard silently does nothing on a genuine double-click.
  • Wires the guard into deposit/page.tsx's confirm step (intent generated once per arrival at step 3, regenerated on re-entry with changed params) and gives EarlyExitModal's previously entirely-dead "Exit Early" button (no onClick at all before this PR) a real, idempotency-guarded submission path.
  • The app package had no test runner at all before this PR — added a minimal vitest setup (mirroring the SDK's existing one) plus a Test CI step, since the acceptance criteria explicitly require automated double-click/reload coverage.

Test plan

  • SDK: pnpm typecheck, pnpm build, pnpm test — all pass, including 6 new tests in idempotency.test.ts (rapid double-invocation of deposit/withdraw results in exactly one sendTransaction call; 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).
  • App: 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 in paymentIntent.test.ts covering: 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 via findActiveIntent, and per-tier/per-kind lock isolation.

Depends on #139 (SDK transaction pipeline, already merged).

Closes #140

@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

@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
yusuftomilola force-pushed the feature/140-payment-idempotency-guard branch from f01ea7b to 7a980e7 Compare August 22, 2026 09:21

@wumibals wumibals left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-flight idempotencyKey returns 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+tier operation 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 no onClick at 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 app package 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.

@wumibals
wumibals merged commit eeadf29 into LadderMine:main Aug 22, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payments 3/8: Idempotency and duplicate-payment-submission prevention

2 participants