Mobile: withdraw screen (SEP-24 off-ramp + contract sweep) - #523
Conversation
…weep Add frontend/mobile/app/withdraw.tsx implementing the fiat off-ramp flow: asset/amount entry, anchor discovery, SEP-24 interactive withdrawal request, launching the anchor's interactive URL via expo-web-browser, and status polling until the anchor reports the transaction complete. Add frontend/mobile/lib/sep24.ts, ported from frontend/wallet/lib/sep24.ts with just the pieces the withdraw (and future deposit) flow needs: TOML discovery, SEP-10 JWT exchange, interactive withdraw/deposit requests, and transaction status polling. The wallet version signs the SEP-10 challenge with a browser WebAuthn passkey; the mobile port takes an injectable signChallenge callback instead, following the same pattern as executeBulkPayout's submitBatch parameter. Add frontend/mobile/lib/sweepContractBalance.ts, ported from frontend/wallet/lib/sweepContractBalance.ts. The RPC-only balance check (getContractBalance) is ported as-is; building/signing/submitting the transfer transaction is delegated to an injectable signAndSubmit callback since the wallet's Soroban-auth signing depends on crypto.subtle and WebAuthn, neither available on React Native and no mobile signing infra exists yet. The withdraw screen wires both stubbed signers as inline no-ops for now, matching the existing bulk-payout screen's convention of passing a stub submit function. Add expo-web-browser as a dependency (via npx expo install to match the installed Expo SDK).
|
@BigManly4 is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@BigManly4 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
The withdraw flow itself is well done — the form → auth → interactive → polling step machine is clear, and lib/sweepContractBalance.ts is a genuinely useful addition that nothing else in the mobile tree has. initiateWithdraw is the piece the on-ramp work is missing.
But I have to block on the SEP-10 implementation in this branch's lib/sep24.ts.
🚨 The challenge is signed without any validation
const { transaction: challengeXdr, network_passphrase } = await challengeRes.json()
const effectivePassphrase = network_passphrase ?? networkPassphrase
const signedXdr = await signChallenge(challengeXdr, effectivePassphrase)The anchor's response goes directly to the signer. Nothing checks what is being signed.
SEP-0010 requires the wallet to verify, before signing, that the challenge:
- has sequence number 0 — this is what makes it unsubmittable on-chain
- contains a
manage_dataoperation whose key is"<home_domain> auth" - has
timeBoundsand has not expired - has a source account equal to the anchor's
SIGNING_KEYfrom itsstellar.toml
None of those happen here. A malicious anchor — or anyone who can intercept the WEB_AUTH_ENDPOINT response — can return a real transaction with a real sequence number, such as a payment operation moving funds, and this code will sign it and hand back a broadcastable envelope. That's a blind signing oracle, and it's the exact attack SEP-10's validation rules exist to prevent.
The passphrase line makes it worse: taking network_passphrase from the anchor's own response means the anchor also chooses which network the signature is valid for.
The good news is you've already written the fix — in #522. That branch's signSep10Challenge implements parse / manage_data / home-domain / timeBounds / expiry checks with a typed Sep10ErrorCode. This branch has none of it.
Both PRs create lib/sep24.ts
#522 adds a 281-line version, this one adds a different 230-line version. They can't both land, and the two have diverged on exactly the security-relevant part.
Resolution — consolidate on #522's module:
- Move
initiateWithdrawfrom here into #522'slib/sep24.ts. It's a near-mirror ofinitiateDepositand slots straight in. - Delete
lib/sep24.tsfrom this PR entirely. Import from the shared module instead. - This PR then contains just
app/withdraw.tsxandlib/sweepContractBalance.ts— both unique, neither conflicting.
I've asked on #522 for two additional checks to be added there (sequence number 0, and source account vs SIGNING_KEY), since even that version is missing them. Once #522 has the complete validation, this PR gets it for free.
Smaller things
app.json — #508 replaces it with app.config.ts, so your 2+/1- will need to move. Hold off for now: I'm settling the canonical bundle identifier and domain across #508/#512/#514 first and will confirm.
app/withdraw.tsx vs #507 — #507's navigation shell creates a withdraw route stub. Yours is the real screen and supersedes it; just rebase once #507 lands.
Tests — same note as #522. The validation logic is pure XDR-string-in, decision-out, which is about as testable as code gets, and these are precisely the rules that must not silently regress.
To be clear about severity: the withdraw screen and the contract sweep are good work, and I want them. It's specifically the unvalidated challenge path that can't ship, and the fix is mostly deletion plus reusing what you already built in #522.
lib/sep24.ts landed with Miracle656#522, so take main's version (which has the SEP-10 challenge validator this branch lacks) and add initiateWithdraw plus the withdraw-only fields on Sep24TransactionStatus on top. Replace the two stubs in withdraw.tsx: - The SEP-10 signer returned the challenge XDR unsigned, so every anchor would reject the token exchange. It now signs with the device key through signSep10Challenge, matching buy.tsx. - The sweep signer returned `pending-sweep-<amount>-<ts>` — a fabricated hash for a transfer that never happened, which the screen then displayed as "sweep tx: …". It now throws with an explanation. The existing catch already treats a failed sweep as non-fatal and keeps polling the withdrawal, so the honest failure degrades exactly where the fabricated success used to lie. - Account resolves through walletStore.getWalletAddress() with the env value as a fallback, rather than EXPO_PUBLIC_FEE_PAYER_ADDRESS defaulting to ''. Also drop app.json (main uses app.config.ts) and an unused Alert import. tsc clean; jest 10 suites / 174 tests; expo lint clean.
|
Merging. The withdraw flow is well structured — polling until the anchor reports Three changes before merge:
The SEP-10 signer returned the challenge unsigned: async (challengeXdr) => challengeXdrEvery anchor would reject that at the token exchange, so the withdrawal could never start. Now signed with the device key through The sweep fabricated a transaction hash. This is the one that mattered: async ({ amountStroops }) => ({ hash: `pending-sweep-${amountStroops}-${Date.now().toString(36)}` })followed by Your own error path was already the right answer — the Also: account resolves through Verified: Worth stating plainly, since it came up across all three of your PRs in this batch: a placeholder that throws is fine and often the right call — a placeholder that returns a success value is not, because the UI has no way to tell it apart from the real thing. #522 got this right, this one and #521 didn't. #521 is closed for that reason; this one merged because the fix was contained. |
Summary
Implements the mobile off-ramp screen from issue #479: a Withdraw screen that lets a user cash out to fiat through a SEP-24 anchor, completing the anchor's interactive withdrawal step.
What was ported
frontend/mobile/app/withdraw.tsx — new screen. Steps: form (asset code, amount, anchor domain) -> auth (SEP-10 JWT + SEP-24 interactive withdraw request) -> interactive (opens the anchor's interactive URL via expo-web-browser, then polls transaction status) -> sweeping (moves the contract wallet's balance to the classic fee-payer account once the anchor asks for payment) -> polling (waits for the anchor to report completion) -> done/error. Plain React Native (
react-nativeprimitives +StyleSheet.create), dark theme matching the existing bulk-payout/swap screens (#0B0B0Fbackground,#1e293bcards,#6366f1accent,#9BA1A6/#94a3b8muted text,#f87171error).frontend/mobile/lib/sep24.ts — ported from frontend/wallet/lib/sep24.ts, scoped to what the withdraw flow needs: stellar.toml discovery (
discoverAnchorInfo), SEP-10 JWT exchange (getSep10Jwt), the interactive deposit/withdraw request (initiateDeposit/initiateWithdraw), status polling (getTransactionStatus), andisSep24Complete. Did not exist in this worktree yet (issue 50. Buy screen #478's buy screen also needs it), so this PR creates it and is self-contained regardless of merge order.frontend/mobile/lib/sweepContractBalance.ts — ported from frontend/wallet/lib/sweepContractBalance.ts.
getContractBalance(reads the contract wallet's native SAC balance via a throwaway simulation account) is ported essentially as-is since it only touches the Soroban RPC client (fetch-based, no browser APIs).sweepContractBalanceorchestrates: read balance, throw if zero, then delegate building/signing/submitting the transfer to an injectablesignAndSubmitcallback.How signing is stubbed
The wallet's SEP-10 challenge signing and Soroban-auth sweep signing both depend on browser-only APIs (
navigator.credentials,crypto.subtle, WebAuthn passkeys) that don't exist on React Native, and mobile has no signing infra ported yet. Rather than inventing one, bothgetSep10Jwt(sep24.ts) andsweepContractBalance(sweepContractBalance.ts) take an injectable async callback parameter for the signing step — the same pattern already used byexecuteBulkPayout(rows, submitBatch)in lib/bulkPayout.ts. The withdraw screen currently wires trivial stub callbacks (pass challenge through unsigned / return a placeholder tx hash) so the rest of the flow — discovery, interactive request, browser hand-off, status polling — is real and exercised end to end; only the two signing steps are stand-ins pending a real mobile signer.Dependency added
expo-web-browser(~57.0.2, installed vianpx expo install expo-web-browserto match the project's Expo SDK 57), used to open the anchor's interactive withdrawal URL and the "check status" deep link. Added tofrontend/mobile/package.json/package-lock.jsonand registered as a config plugin inapp.json(asexpo installdid automatically).Verified
npm run typecheck(tsc --noEmit) passes with no errors.npx expo export --platform websucceeds and includes a bundled/withdrawroute (20KB), confirming the screen renders without crashing. Did not verify against a live anchor or real Soroban RPC endpoint (no anchor/testnet credentials available in this environment) — the interactive request, sweep, and polling logic were verified by code review against the ported wallet implementations rather than a live run.npm run lint(expo lint) could not be exercised: no ESLint config existed in frontend/mobile prior to this PR, and the auto-scaffolded config failed to run in this sandbox (Cannot find module 'eslint'). Reverted the lint-scaffolding side effects so this PR only touches files relevant to the withdraw screen.closes #479