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
- Start a deposit, sign the transaction in the wallet.
- Close the tab (or lose the connection) before the page's "Deposit successful" state renders.
- Confirm on-chain (via
get_commitments() or an explorer) that the deposit landed.
- 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
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
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, callssubmitTransaction(signedXdr), and only after that promise resolves callssaveNote(note)to persist the note to localStorage.submitTransaction(stellar.ts:192-210) submits the tx and then polls with an unbounded loop: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
get_commitments()or an explorer) that the deposit landed.localStoragefor that commitment — the funds are now permanently shielded with no known secret.Environment
Proposed fix
Persist the note to
localStorageaspendingimmediately after the secrets are generated and the commitment is computed — before building/signing the transaction — then reconcile its status (confirmed/failed) oncesubmitTransactionsettles. Separately, boundsubmitTransaction'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
submitTransaction's polling loop has a bounded retry count/timeoutjust test,pnpm test,cargo test, ornargo testas applicable) pass locally and in CIOpen your PR against the
devbranch, notmain. All active development merges intodev.Discuss this issue / coordinate work: join the DShield contributor Telegram group: https://t.me/+SiGHH24No9U2MDJk