Skip to content

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

Description

@wumibals

Problem / Context

Nothing in the SDK or app currently prevents a duplicate payment submission. In app/src/app/deposit/page.tsx, the step-4 handleConfirm handler (line 48-53) has no submitting/disabled guard, so a double-click — or a retry after a dropped network call — can trigger two separate transactions. app/src/components/EarlyExitModal.tsx's "Exit Early" button (lines 88-98) currently has no onClick at all, meaning today it can't be duplicated only because it does nothing; once wired up (see the SDK pipeline work in #139), it needs the same guard from day one.

Soroban's ledger deduplicates identical transaction envelopes by hash, but a resigned or resubmitted transaction — e.g. after a client-side retry that rebuilds with a fresh sequence number or fee — is a distinct hash and will execute a second time, debiting the user twice. There is no application-level idempotency concept anywhere in this codebase today.

Objective

Guarantee that a single user-intended payment action (one deposit, one withdrawal, one early-exit) cannot execute more than once — across UI double-clicks, network retries, or a page reload mid-flight.

Technical approach

  • Introduce a client-side idempotency key per payment intent, generated once when the user reaches the confirm step (derived from user address + tier + asset + amount + a monotonic nonce/timestamp), and persist it (reusing the existing sessionStorage pattern in app/src/lib/wallet/session.ts) until the operation resolves.
  • Define an explicit state machine per pending intent: idle -> building -> awaiting_signature -> submitted -> confirmed | failed, and refuse re-entry into building while any state other than idle/failed is active for that key.
  • Disable submit controls for the duration of an in-flight submission: the deposit confirm button and EarlyExitModal's currently-unwired submit button both need this.
  • On page reload while an intent is in submitted (or later) state, rehydrate the persisted intent and resume status polling (built in the next issue) instead of allowing a fresh submission of the same intent.

Detailed scope

app/src/app/deposit/page.tsx, app/src/components/EarlyExitModal.tsx, a new shared module (e.g. app/src/lib/paymentIntent.ts) usable by both flows, and any SDK-level dedup guard in sdks/typescript that consumers should be able to rely on directly (not just the app).

Important edge cases and failure scenarios

  • User closes the tab and reopens before confirmation completes — the pending intent must be recoverable, not silently dropped (which invites a duplicate submission) or stuck forever.
  • Two browser tabs open to the same wallet, both attempting the same deposit concurrently.
  • Wallet extension rejects/cancels signing after the intent was marked awaiting_signature — must cleanly return to a resubmittable idle/failed state without leaving a stuck lock that blocks all future submissions for that key.
  • User changes the amount or tier after an intent key was generated but before submission — the key must be regenerated, not silently reused for a now-different payment.
  • Idempotency key collisions across genuinely different intents (e.g. same user depositing the same amount into the same tier twice, deliberately, in quick succession) must not be conflated — the key must distinguish deliberate repeat actions from accidental duplicates.

Dependencies

Depends on #139 (SDK transaction pipeline) — there is no real submission to guard until that exists. Also establishes the intent identifier that the status-tracking issue below will use to correlate a submission with its on-chain outcome.

Acceptance criteria

  • An automated test demonstrates that rapid double-invocation of deposit/withdraw/earlyExit for the same intent results in exactly one submitted transaction.
  • EarlyExitModal's submit button is wired to a real, idempotency-guarded submission path (no longer dead).
  • Reloading the page mid-submission does not allow a duplicate submission of the same in-flight intent.

Definition of done

PR merged with tests covering the double-click and reload-during-flight scenarios; no user-facing payment action in the app is submittable without idempotency guarding.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions