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.
Problem / Context
Nothing in the SDK or app currently prevents a duplicate payment submission. In
app/src/app/deposit/page.tsx, the step-4handleConfirmhandler (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 noonClickat 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
sessionStoragepattern inapp/src/lib/wallet/session.ts) until the operation resolves.idle -> building -> awaiting_signature -> submitted -> confirmed | failed, and refuse re-entry intobuildingwhile any state other thanidle/failedis active for that key.EarlyExitModal's currently-unwired submit button both need this.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 insdks/typescriptthat consumers should be able to rely on directly (not just the app).Important edge cases and failure scenarios
awaiting_signature— must cleanly return to a resubmittableidle/failedstate without leaving a stuck lock that blocks all future submissions for that key.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
EarlyExitModal's submit button is wired to a real, idempotency-guarded submission path (no longer dead).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.