Skip to content

bug(critical): deposit note secrets can be permanently lost if the tab closes before confirmation #63

Description

@tech-adrian

Area
frontend/src/app/deposit/page.tsx, frontend/src/lib/stellar.ts (submitTransaction)

Describe the bug
A successful deposit can result in permanent, unrecoverable loss of funds if the browser tab closes (crash, accidental close, navigation, phone lock) at the wrong moment — with nothing the user did wrong.

handleDeposit() (deposit/page.tsx) generates the note's secret material (nullifier/secret, held only in JS memory/React state), builds and signs the transaction, calls submitTransaction(signedXdr), and only after that promise resolves calls saveNote(note) to persist the note to localStorage. submitTransaction (stellar.ts:192-210) submits the tx and then polls with an unbounded loop:

let result = await server.getTransaction(response.hash);
while (result.status === "NOT_FOUND") {
  await new Promise((r) => setTimeout(r, 1000));
  result = await server.getTransaction(response.hash);
}

There is no timeout or retry cap here — unlike the faucet/relay-withdraw/register-kyc API routes, which all cap polling at 30 tries. So the window between "wallet signs" and "note saved" can be arbitrarily long, and if the tab closes anywhere in that window, the deposit can land on-chain (a real commitment, real USDC transferred into the pool) while the only copy of (nullifier, secret) is gone. Since the on-chain commitment is a one-way hash, there is no way to recover such funds afterward.

Steps to reproduce

  1. Start a deposit, sign the transaction in the wallet.
  2. Close the tab (or lose the connection) before the page's "Deposit successful" state renders.
  3. Confirm on-chain (via get_commitments() or an explorer) that the deposit landed.
  4. Confirm there is no note in localStorage for that commitment — the funds are now permanently shielded with no known secret.

Environment

  • Network: local or testnet
  • Any browser

Proposed fix
Persist the note to localStorage as pending immediately after the secrets are generated and the commitment is computed — before building/signing the transaction — then reconcile its status (confirmed/failed) once submitTransaction settles. Separately, bound submitTransaction's polling loop with a max retry count / timeout matching the pattern already used in the API routes, and surface a clear "still confirming, don't close this tab" state to the user in the meantime.

Acceptance Criteria

  • Note secrets are persisted (as pending) before the transaction is signed/submitted, not after confirmation
  • submitTransaction's polling loop has a bounded retry count/timeout
  • UI shows an explicit "don't close this tab" state while a deposit is confirming
  • A pending note that never confirms (or fails) is clearly reconciled/marked, not left in limbo
  • Relevant tests (just test, pnpm test, cargo test, or nargo test as applicable) pass locally and in CI
  • A screenshot or short screen recording is attached to the PR showing the fix working (a note visible in storage immediately after signing, before confirmation completes) — required before this can be merged.

Open your PR against the dev branch, not main. All active development merges into dev.

Discuss this issue / coordinate work: join the DShield contributor Telegram group: https://t.me/+SiGHH24No9U2MDJk

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions