Skip to content

Mobile: withdraw screen (SEP-24 off-ramp + contract sweep) - #523

Merged
Miracle656 merged 4 commits into
Miracle656:mainfrom
BigManly4:mobile/withdraw-screen-479
Jul 30, 2026
Merged

Mobile: withdraw screen (SEP-24 off-ramp + contract sweep)#523
Miracle656 merged 4 commits into
Miracle656:mainfrom
BigManly4:mobile/withdraw-screen-479

Conversation

@BigManly4

Copy link
Copy Markdown
Contributor

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-native primitives + StyleSheet.create), dark theme matching the existing bulk-payout/swap screens (#0B0B0F background, #1e293b cards, #6366f1 accent, #9BA1A6/#94a3b8 muted text, #f87171 error).

  • 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), and isSep24Complete. 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). sweepContractBalance orchestrates: read balance, throw if zero, then delegate building/signing/submitting the transfer to an injectable signAndSubmit callback.

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, both getSep10Jwt (sep24.ts) and sweepContractBalance (sweepContractBalance.ts) take an injectable async callback parameter for the signing step — the same pattern already used by executeBulkPayout(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 via npx expo install expo-web-browser to match the project's Expo SDK 57), used to open the anchor's interactive withdrawal URL and the "check status" deep link. Added to frontend/mobile/package.json/package-lock.json and registered as a config plugin in app.json (as expo install did automatically).

Verified

  • npm run typecheck (tsc --noEmit) passes with no errors.
  • npx expo export --platform web succeeds and includes a bundled /withdraw route (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

…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
BigManly4 requested a review from Miracle656 as a code owner July 28, 2026 00:39
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

@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.

@drips-wave

drips-wave Bot commented Jul 28, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_data operation whose key is "<home_domain> auth"
  • has timeBounds and has not expired
  • has a source account equal to the anchor's SIGNING_KEY from its stellar.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 initiateWithdraw from here into #522's lib/sep24.ts. It's a near-mirror of initiateDeposit and slots straight in.
  • Delete lib/sep24.ts from this PR entirely. Import from the shared module instead.
  • This PR then contains just app/withdraw.tsx and lib/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.

BigManly4 and others added 3 commits July 28, 2026 22:06
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.
@Miracle656

Copy link
Copy Markdown
Owner

Merging. The withdraw flow is well structured — polling until the anchor reports pending_user_transfer_start, then reading withdraw_anchor_account / withdraw_memo off the transaction to know where to settle, is the correct SEP-24 off-ramp shape, and sweepContractBalance cleanly separates the RPC-only parts from the signing it can't do yet.

Three changes before merge:

lib/sep24.ts had landed already. #522 merged it while this was open, and main's copy is the superset — it has the signSep10Challenge validator (checking the manage_data op, the <home_domain> auth key convention and timeBounds expiry, with typed error codes) that this branch's version doesn't. I took main's and added your initiateWithdraw plus the withdraw-only fields on Sep24TransactionStatus on top, rather than the reverse.

The SEP-10 signer returned the challenge unsigned:

async (challengeXdr) => challengeXdr

Every anchor would reject that at the token exchange, so the withdrawal could never start. Now signed with the device key through signSep10Challenge, matching what buy.tsx does.

The sweep fabricated a transaction hash. This is the one that mattered:

async ({ amountStroops }) => ({ hash: `pending-sweep-${amountStroops}-${Date.now().toString(36)}` })

followed by setSweepHash(result.hash), which the UI renders as sweep tx: pending-sweep-…. No transfer occurred, so the user was shown a hash for a movement of their funds that didn't happen.

Your own error path was already the right answer — the catch around the sweep notes that a failure "doesn't necessarily abort the withdrawal" and keeps polling. So I made the signer throw with an explanation instead of returning a hash. The honest failure now degrades exactly where the fabricated success used to lie, and the user is told to fund the classic account directly. Wiring the real signer is a separate piece of work: lib/passkey.ts (#544) can produce the Soroban auth-entry assertion this needs.

Also: account resolves through walletStore.getWalletAddress() with the env value as fallback rather than EXPO_PUBLIC_FEE_PAYER_ADDRESS defaulting to ''; dropped the re-added app.json; removed an unused Alert import.

Verified: tsc --noEmit clean, jest 10 suites / 174 tests, expo lint clean, CI green.

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.

@Miracle656
Miracle656 merged commit 74b8e7a into Miracle656:main Jul 30, 2026
10 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

51. Withdraw screen

3 participants