Skip to content

mobile: add SEP-24 buy (on-ramp) screen - #522

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

mobile: add SEP-24 buy (on-ramp) screen#522
Miracle656 merged 4 commits into
Miracle656:mainfrom
BigManly4:mobile/buy-screen-478

Conversation

@BigManly4

Copy link
Copy Markdown
Contributor

Summary

Adds the mobile on-ramp (buy) flow described in the issue: a screen that
initiates a SEP-24 interactive deposit with an anchor, launches the
anchor's hosted flow, and returns cleanly to the app.

  • frontend/mobile/lib/sep24.ts — ports the deposit-side protocol logic
    from frontend/wallet/lib/sep24.ts: anchor stellar.toml discovery
    (discoverAnchorInfo), SEP-10 challenge validation/signing
    (signSep10Challenge, Sep10ChallengeError), the SEP-10 web-auth
    exchange (getSep10Jwt), the /transactions/deposit/interactive
    request (initiateDeposit), and transaction status polling
    (getTransactionStatus, isSep24Complete). It is pure/testable and has
    no browser-only APIs (no window, document, localStorage,
    sessionStorage, WebAuthn) — the web version signs the SEP-10 challenge
    with a browser passkey stored in localStorage; this port instead takes
    an injectable Sep10ChallengeSigner async function parameter, the same
    pattern as executeBulkPayout(rows, submitBatch) in
    frontend/mobile/lib/bulkPayout.ts. Mobile has no wallet-signing infra
    ported yet, so this is left as an injectable seam rather than invented;
    the buy screen itself only needs the unauthenticated deposit path
    (no JWT), matching how frontend/wallet/app/buy/page.tsx calls
    initiateDeposit without a JWT for its generic SEP-24 flow.

  • frontend/mobile/app/buy.tsx — a self-contained React Native screen
    (plain react-native primitives + StyleSheet.create, dark theme
    matching the existing swap.tsx / bulk-payout.tsx screens: background
    #0B0B0F, card #1e293b, accent #6366f1, muted text #9BA1A6/#94a3b8,
    error #f87171). Flow:

    1. User enters the anchor domain, optional amount, and asset code.
    2. On submit, discovers the anchor's transfer server, calls
      initiateDeposit to get the interactive URL + transaction id.
    3. Launches the interactive URL with expo-web-browser's
      WebBrowser.openAuthSessionAsync, using an expo-linking redirect URL
      back into the app; when the browser session closes (completed or
      dismissed) control returns to the screen.
    4. Shows a pending state and polls getTransactionStatus every 5s,
      stopping once the anchor reports the transaction as complete/error/
      refunded/expired (isSep24Complete); also exposes a manual
      "Check status" button for an immediate refresh.
    5. Shows a success state with the deposited amount, or an error state
      with a retry affordance.
  • Adds expo-web-browser as a dependency, installed via
    npx expo install expo-web-browser so the version (~57.0.2) and the
    app.json config-plugin entry are correct for this project's Expo SDK
    57 toolchain.

What was verified

  • cd frontend/mobile && npm install && npm run typecheck passes cleanly
    with no errors.
  • Manually reviewed frontend/wallet/app/buy/page.tsx and
    frontend/wallet/lib/sep24.ts in full and cross-checked the ported
    logic against them (TOML regex parsing, SEP-10 challenge validation
    rules, deposit request/response shape, status polling cadence and
    terminal states).
  • Followed the existing mobile screen conventions from
    frontend/mobile/app/swap.tsx, frontend/mobile/app/bulk-payout.tsx,
    and frontend/mobile/lib/bulkPayout.ts for styling, stubbed/injectable
    signing, and state-machine shape.
  • Did not have a way to run the Expo dev client / simulator in this
    environment, so the screen was not visually rendered — typecheck and
    manual code review are the extent of verification here.

What's stubbed

  • SEP-10 authenticated flows (getSep10Jwt, signSep10Challenge) are
    ported and exported but not wired into the buy screen, since mobile has
    no wallet signing infra yet; the screen uses the unauthenticated deposit
    path, same as the web app's generic SEP-24 flow.
  • The fee payer / spending address is read from
    EXPO_PUBLIC_FEE_PAYER_ADDRESS, matching the existing stub used in
    swap.tsx, pending real wallet address wiring.

closes #478

Adds a mobile buy screen that lets users bring fiat into the wallet
through a SEP-24 anchor. lib/sep24.ts ports the deposit-side protocol
logic from frontend/wallet/lib/sep24.ts (anchor TOML discovery, SEP-10
challenge validation/signing, interactive deposit request, status
polling) as pure, injectable-signer functions with no browser-only
APIs. app/buy.tsx drives the flow: enter anchor domain/amount/asset,
kick off the interactive deposit, launch the returned URL with
expo-web-browser's openAuthSessionAsync, and poll for the resulting
transaction status once the user returns to the app.

Adds expo-web-browser as a dependency (installed via `expo install`
for SDK-compatible versioning).
@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 mentioned this pull request Jul 28, 2026

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

Good structure here. Making the SEP-10 signer injectable rather than reaching for browser storage — the same pattern as executeBulkPayout(rows, submitBatch) — is the right call for a port, and the module doc is explicit that no browser-only APIs are used. discoverAnchorInfo reading stellar.toml and the status polling loop both look right.

I want to focus on the SEP-10 challenge validation, because you've built most of it and it's worth finishing properly.

What you got right

signSep10Challenge already rejects:

  • unparseable XDR → MALFORMED
  • no manage_data operation → MISSING_MANAGE_DATA
  • a manage_data key not ending in " auth"INVALID_HOME_DOMAIN
  • missing timeBoundsMALFORMED
  • maxTime in the past → EXPIRED

That's more than most implementations bother with, and the typed Sep10ErrorCode union makes the failures actionable.

🚨 The missing check: sequence number must be 0

SEP-0010 requires the challenge transaction to have sequence number 0. That is the control that makes a challenge impossible to submit to the network — a transaction with sequence 0 can never be valid on-chain.

Without it, a malicious or compromised anchor can hand you a genuine transaction with a real sequence number — say a payment operation draining the account — and as long as it also carries a manage_data op ending in " auth" and valid timeBounds, signSep10Challenge will happily sign it and hand back a broadcastable envelope.

if (tx.sequence !== '0') {
  throw new Sep10ChallengeError(
    `SEP-10 challenge must have sequence number 0, got ${tx.sequence}`,
    'MALFORMED',
  );
}

Please add that. It's two lines and it's the difference between a validated challenge and a blind signing oracle.

Also worth adding: verify the source account

SEP-0010 also says the challenge's source account must be the anchor's SIGNING_KEY, as published in its stellar.toml. You already fetch the TOML in discoverAnchorInfo, so the value is in hand:

if (tx.source !== anchorInfo.signingKey) {
  throw new Sep10ChallengeError(
    'Challenge source account does not match the anchor SIGNING_KEY',
    'MALFORMED',
  );
}

This stops a network-level attacker who can intercept the WEB_AUTH_ENDPOINT response from substituting their own challenge.

Duplicate module with #523

Your #523 creates frontend/mobile/lib/sep24.ts too — a different 230-line version. They can't both land.

The important part: #523's copy has no challenge validation at all. Its getSep10Jwt fetches the challenge and passes it straight to the signer, and it takes the network passphrase from the anchor's own response (network_passphrase ?? networkPassphrase), letting the anchor choose which network you sign for.

So please consolidate on this PR's version, and move #523's unique piece — initiateWithdraw — into it. Concretely:

  • Keep this lib/sep24.ts as the single module.
  • Add initiateWithdraw here (it's a near-mirror of initiateDeposit).
  • Strip lib/sep24.ts out of #523 entirely so it only contains app/withdraw.tsx and lib/sweepContractBalance.ts.

I've left the same note on #523.

Smaller things

app.json#508 removes it in favour of app.config.ts. Your 2+/1- change will need to move there. Don't act on this yet; I'm also settling the bundle identifier across #508/#512/#514 and will confirm the canonical values first.

app/buy.tsx collides with #517, which adds a 34-line placeholder buy.tsx. Yours is the real implementation — I've asked #517 to drop its stubs.

The stub signer. FEE_PAYER_ADDRESS from env with no real signing is fine as a staged port, and you documented it. Worth a TODO referencing the issue that will wire up real signing, so it can't quietly ship in that state — a buy flow that authenticates with a stub is a thing someone will eventually assume works.

No tests. signSep10Challenge is a pure function over an XDR string — it's the single most test-worthy thing in this PR, and each rejection path is a two-line test. Once a runner lands (#524/#525 add jest.config.js), please cover at minimum: sequence ≠ 0, expired, missing manage_data, wrong source. Those tests are what stop the validation quietly regressing later.

BigManly4 and others added 3 commits July 28, 2026 22:04
Reconcile with main and wire the SEP-10 path the library already implements:

- Drop app.json; main uses app.config.ts, which already registers the veil
  scheme this flow returns through.
- buy.tsx never called getSep10Jwt, so deposits went out unauthenticated and
  any anchor requiring SEP-10 would reject them. It now authenticates whenever
  the anchor advertises WEB_AUTH_ENDPOINT, signing the challenge with the
  device signer via signSep10Challenge, and passes the JWT to initiateDeposit.
- The account came from EXPO_PUBLIC_FEE_PAYER_ADDRESS, defaulting to '', so out
  of the box the screen posted an empty account. It now resolves through
  walletStore.getWalletAddress() with the env value as a fallback.

tsc clean; jest 10 suites / 174 tests; expo lint clean.
@Miracle656

Copy link
Copy Markdown
Owner

Merging. lib/sep24.ts is the strongest thing I've reviewed from this batch — real TOML discovery, a proper SEP-10 challenge validator that checks the manage_data operation, the <home_domain> auth key convention and timeBounds expiry with typed error codes, and every fetch wrapped in AbortSignal.timeout with the anchor's HTTP status surfaced in the thrown message. Failures propagate honestly instead of being swallowed, which is what makes this safe to ship.

Two things I fixed before merging:

SEP-10 was implemented but never called. buy.tsx imported discoverAnchorInfo, initiateDeposit, getTransactionStatus and isSep24Complete — but not getSep10Jwt, so deposits went out with no Authorization header. Most anchors reject deposit/interactive without a JWT, so the flow would have failed against nearly anything real, including testanchor.stellar.org. The screen now authenticates whenever the anchor publishes a WEB_AUTH_ENDPOINT, signing the challenge with the device signer through your signSep10Challenge, and passes the token to initiateDeposit. Where an anchor advertises no endpoint it still attempts unauthenticated and surfaces the anchor's own error.

The account was empty by default. FEE_PAYER_ADDRESS came from EXPO_PUBLIC_FEE_PAYER_ADDRESS with || '', so without that env var the screen posted account= to the anchor. It now resolves through walletStore.getWalletAddress(), keeping the env value as a fallback so the screen stays exercisable on a device with no wallet yet.

Also dropped the app.json this branch re-added — main moved to app.config.ts in #508, and the veil scheme this flow returns through is already registered there, so the return leg works unchanged.

On the acceptance criterion — buy flow launches the anchor interactive URL and returns to appWebBrowser.openAuthSessionAsync(deposit.url, Linking.createURL('buy')) is exactly right, and pairing it with getTransactionStatus polling means a user who dismisses the browser early still converges on the real status rather than a guess.

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

Worth a follow-up: the SEP-10 signer uses the G-account secret from walletStore, which is the fee payer rather than the passkey-backed contract account. That's the same shape the rest of mobile currently uses, so it's consistent — but once #594 lands the SDK wiring it's worth revisiting whether the anchor should be authenticating the C… account instead.

Note I've closed your #521 (vault) separately — that one reported Transaction submitted: stub-deposit-… for deposits that never happened, which is the opposite of what this PR does well. The contrast is instructive: here a missing capability throws and shows an error; there it returned a fake hash.

@Miracle656
Miracle656 merged commit 80e3663 into Miracle656:main Jul 30, 2026
10 of 13 checks passed
Miracle656 pushed a commit to BigManly4/veil that referenced this pull request Jul 30, 2026
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.
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.

50. Buy screen

3 participants