Skip to content

feat(ramp): add Headless Host + quote-first startHeadlessBuy (Phase 5)#29338

Open
wachunei wants to merge 15 commits intomainfrom
poc/headless-buy-phase-5
Open

feat(ramp): add Headless Host + quote-first startHeadlessBuy (Phase 5)#29338
wachunei wants to merge 15 commits intomainfrom
poc/headless-buy-phase-5

Conversation

@wachunei
Copy link
Copy Markdown
Member

@wachunei wachunei commented Apr 24, 2026

Description

This PR closes Phase 5 (revised) of the incremental Unified Buy (v2) headless buy plan (app/components/UI/Ramp/headless/PLAN.md): external callers can start a headless session with a concrete Quote from getQuotes, without seeding the Ramps controller or visiting Build Quote.

Reason

  • Phase 4 introduced useContinueWithQuote and parameterized Transak routing with a baseRoute so auth loops could reset off Build Quote. Phase 5 makes the consumer contract quote-first and adds a Headless Host screen as the stable stack base so navigation.reset targets (Checkout, KYC, etc.) live in the same inner navigator as the host (fixing the “stuck on loader” issue when the host was mounted on the outer stack).

What changed

  • Routes.RAMP.HEADLESS_HOST — new screen registered on the Unified Buy v2 inner stack (app/components/UI/Ramp/routes.tsx), not on MainNavigator, so useTransakRouting resets resolve correctly.
  • useHeadlessBuy.startHeadlessBuy({ quote, amount, assetId, currency, paymentMethodId?, redirectUrl? }) — creates a session carrying the quote; navigates with nested params: TOKEN_SELECTION (outer TokenListRoutes mount) → inner TOKEN_SELECTIONHEADLESS_HOST (avoids routing through legacy Routes.RAMP.BUY / Aggregator stack).
  • HeadlessHost — on focus, resolves chain + wallet, builds ContinueWithQuoteContext from the session, calls continueWithQuote once (guarded by hasContinuedRef + session status), handles nativeFlowError from OTP as onError({ code: 'AUTH_FAILED' }).
  • sessionRegistrycloseSession / getActiveSessionId: starting a new session cancels the previous one with consumer_cancelled (playground UX).
  • Native flowheadlessSessionId threaded through EnterEmailOtpCodeVerifyIdentity; OtpCode passes baseRoute: HEADLESS_HOST into useTransakRouting when in headless mode.
  • Playground — per-quote “Start headless buy” (standalone start removed); strings + tests updated.
  • BuildQuoteheadlessSessionId param documented as deprecated (headless no longer enters via Build Quote).

References

  • Continues from Phase 4 merged to main: #29213 (refactor(ramp): extract useContinueWithQuote hook).
  • Earlier POC phases (playground, registry, hook facade) were on branches poc/headless-buy-base, poc/headless-buy-phase-1phase-3 if you need historical diffs.

Tests

  • yarn run jest on: useHeadlessBuy.test.ts, useContinueWithQuote.test.ts, useTransakRouting.test.ts, HeadlessHost.test.tsx, HeadlessPlayground.test.tsx.

Changelog

CHANGELOG entry: null

Related issues

Fixes: No GitHub issue — incremental POC on branch poc/headless-buy-phase-5.

Continuity: #29213 (Phase 4 — useContinueWithQuote + Transak baseRoute parameterization).

Manual testing steps

Feature: Headless Buy Phase 5 (quote-first + Headless Host)

  Scenario: Internal build — start headless buy from a quote
    Given the app is an internal build and I am signed in
    And I open Settings → Fiat on-ramp → Headless Buy playground
    When I fetch quotes and tap "Start headless buy" on a quote row
    Then I should leave the playground and land on Headless Host briefly
    And the flow should continue to the expected next screen (aggregator checkout webview, or native email/KYC/checkout per provider state)
    And I should not remain stuck on the Headless Host loading state after Transak routing completes

  Scenario: Replace active session
    Given I have already started a headless session from the playground
    When I tap "Start headless buy" on a different quote
    Then the previous session should be closed with consumer_cancelled semantics
    And a new session should start for the newly selected quote

Screenshots/Recordings

Before

After

headless_p5_aggregator.mp4
headless_p5_native.mp4

Pre-merge author checklist

Performance checks (if applicable)

  • I've tested on Android
    • Ideally on a mid-range device; emulator is acceptable
  • I've tested with a power user scenario
    • Use these power-user SRPs to import wallets with many accounts and tokens
  • I've instrumented key operations with Sentry traces for production performance metrics

For performance guidelines and tooling, see the Performance Guide.

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Note

High Risk
High risk because it changes Ramp navigation/reset behavior during the Transak auth/KYC loop and introduces new headless session lifecycle semantics (auto-cancel, terminal statuses), which could strand users or misfire consumer callbacks if orchestration is wrong.

Overview
Adds a new Routes.RAMP.HEADLESS_HOST screen that acts as the stable stack base for headless buy sessions, deriving a ContinueWithQuoteContext from the session and calling continueWithQuote once while handling OTP/auth-loop errors via nativeFlowError.

Switches useHeadlessBuy.startHeadlessBuy to a quote-first contract ({ quote, assetId, amount, ... }), seeds controller selections from the chosen quote, navigates directly into HEADLESS_HOST via nested Ramp stack params, and enforces a single-active-session policy by auto-closing any prior session.

Extends core headless/session and routing plumbing: sessionRegistry gains closeSession, getActiveSessionId, and a new terminal failed status; useContinueWithQuote adds override-capable context plus transakRouting options; useTransakRouting now supports a configurable reset base route/params and propagates headlessSessionId through native flow screens (VerifyIdentity/EnterEmail/OtpCode/BasicInfo/EnterAddress). The Headless Playground UI/test flow is updated to start sessions per-quote, and BuildQuote marks headlessSessionId as deprecated legacy.

Reviewed by Cursor Bugbot for commit 58278dd. Bugbot is set up for automated code reviews on this repo. Configure here.

Introduces the Headless Host screen as the stable stack base for the
Unified Buy v2 headless flow and switches `startHeadlessBuy` to a
quote-first signature so external consumers drive the flow without
seeding the RampsController.

- New `Routes.RAMP.HEADLESS_HOST` registered inside the Unified Buy v2
  inner stack (`TokenListRoutes`) so `useTransakRouting` resets land on
  the Host instead of BuildQuote.
- `useHeadlessBuy.startHeadlessBuy({ quote, ... })` derives the
  `ContinueWithQuoteContext` from the supplied quote, creates a session
  in the registry, and navigates into the v2 stack via the proper
  nested-screen descriptor (TOKEN_SELECTION → MainRoutes → HEADLESS_HOST).
- Single-active-session policy: starting a new session
  auto-`closeSession`s the previous one with `consumer_cancelled`.
- `HeadlessHost` orchestrates: on focus it picks up the session, calls
  `continueWithQuote` exactly once (guarded by `hasContinuedRef` +
  `session.status` so the OTP/auth loop's re-focuses don't re-trigger),
  and surfaces `nativeFlowError` from `OtpCode` as
  `onError({ code: 'AUTH_FAILED' })`.
- Native flow screens (`EnterEmail`, `OtpCode`, `VerifyIdentity`) now
  thread `headlessSessionId` so `useTransakRouting` can be parameterized
  with `baseRoute = HEADLESS_HOST`.
- Playground UI: per-quote "Start headless buy" buttons (standalone
  start button removed); tapping replaces any active session.
- BuildQuote `headlessSessionId` param marked `@deprecated` — the
  headless flow no longer routes through BuildQuote.

Tests: 64 passing across `useHeadlessBuy`, `useContinueWithQuote`,
`useTransakRouting`, `HeadlessHost`, and `HeadlessPlayground`.
@wachunei wachunei self-assigned this Apr 24, 2026
@metamaskbotv2 metamaskbotv2 Bot added the team-money-movement issues related to Money Movement features label Apr 24, 2026
@wachunei wachunei marked this pull request as ready for review April 24, 2026 16:11
@wachunei wachunei requested a review from a team as a code owner April 24, 2026 16:11
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx Outdated
Comment thread app/components/UI/Ramp/Views/NativeFlow/OtpCode.tsx

> Codifies the "headless start now requires a `Quote`" pivot agreed with product.

Originally Phase 5 had the Headless Host fetch quotes for the consumer; Phase 5b was a follow-up where the consumer hands in a pre-selected quote. We're flipping the order: **Phase 5 (revised) is now the quote-first start path** and the original Phase 5 ("Host fetches quotes and auto-picks") is renamed to Phase 5b and deferred until quote-first is stable.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do the consumers care about passing the quotes? Should they just not care about the token they want to buy and the amount they want to buy?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The quote has the amounts and fees the consumer is expecting to display (currently money account funding)

Stabilize baseRouteParams identity for HeadlessHost and OtpCode so
useTransakRouting callbacks do not churn every render. This keeps
continueWithQuote stable and avoids unnecessary useFocusEffect
listener teardown on HeadlessHost.
Comment thread app/components/UI/Ramp/hooks/useTransakRouting.ts
`startHeadlessBuy` now calls `setSelectedToken`, `setSelectedProvider`,
and `setSelectedPaymentMethod` before navigating to the Headless Host.
This mirrors what `BuildQuote` does before calling `continueWithQuote`.

Without seeding, the native auth loop (`OtpCode`, `useTransakRouting`)
reads `selectedToken.chainId`, `selectedPaymentMethod`, and
`walletAddress` from the controller and gets null/empty-string values,
causing the post-OTP Transak quote fetch to fail and the payment widget
URL to be generated with an empty wallet address.

The Phase 5 quote-first API makes the fix safe: catalogs are loaded by
the time the caller picks a quote from `getQuotes()`, so the
id→object lookups are reliable. Falls back to `null` for unresolved
lookups (auto-select in `useRampsPaymentMethods` corrects it on refetch).

PLAN.md Phase 3.1 is annotated to document the Phase 5 revision.
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx Outdated
…andle new sessions while focused

When a second headless buy starts while HeadlessHost is the active
screen, React Navigation merges the new headlessSessionId param without
unmounting or emitting a focus event. This caused two compounding bugs:

1. hasContinuedRef stayed `true` across sessions (it was never scoped to
   a specific session id), so the guard bailed immediately.
2. useFocusEffect never fired for param-only changes — no blur/focus
   transition occurs when the screen is already focused.

Fix: drop hasContinuedRef entirely and switch to a plain useEffect with
headlessSessionId in its dep array. useEffect fires whenever deps change
regardless of focus state, so a new session is picked up immediately.

Re-entry during the Transak auth loop is already prevented by
session.status: setStatus marks it 'continued' before the loop starts,
and since headlessSessionId is unchanged during the loop the effect does
not re-fire. Add a separate useEffect to reset errorMessage when
headlessSessionId changes so subsequent sessions start with a clean UI.
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx Outdated
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
…ects

- nativeFlowError effect: use getSession(headlessSessionId) instead of the
  render-time session reference; drop session from deps. If the processing
  effect's .catch already closed the session, skip onError (no double notify).
- Processing effect: same pattern — re-read currentSession at effect start;
  in continueWithQuote .catch, re-read liveSession before onError/closeSession.
  Drop session from deps to avoid fragile Object.is coupling to the in-map
  object reference.

Prevents duplicate onError when nativeFlowError and promise rejection race,
and avoids relying on session object identity for effect invalidation.
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
…ace label when session active

HeadlessHost: guard continueWithQuote call against a null walletAddress.
useRampAccountAddress resolves asynchronously; without the guard the first
effect run passes undefined to continueWithQuote, producing a stale address
in widget/order URLs. Returning early (while status remains 'pending') lets
the dep-change re-fire when walletAddress settles, ensuring the resolved
value is always used.

Playground: thread hasActiveSession (activeSession !== null) through
QuotesList → QuoteRow and switch the per-quote button label to
"Replace & start headless buy" when a session is already live, restoring
the visual signal that was present on the old standalone start button.
Adds the new string to en.json.
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
…ssHost

When chainId is null (malformed assetId), useRampAccountAddress is still
invoked with a falsy chain id and returns null. The walletAddress === null
early return ran first, so the UNKNOWN invalid-assetId path never ran and
the loader never cleared.

Reorder guards so !chainId runs first. Strengthen the malformed-assetId
test with mockUseRampAccountAddress(null) so ordering regressions fail.
Comment thread app/components/UI/Ramp/headless/useHeadlessBuy.ts Outdated
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
…nder Host on invalid assetId

startHeadlessBuy: run getActiveSessionId/closeSession before setSelectedToken,
setSelectedProvider, and setSelectedPaymentMethod so a replaced session's
onClose observes controller state from that session, not the new quote.

HeadlessHost: call setErrorMessage on the invalid-assetId path so React
re-renders after closeSession (registry-only change would otherwise leave
the loader visible).

Tests: assert onClose-before-seed ordering on replace; async malformed-asset
test waits for error text on screen.
Comment thread app/components/UI/Ramp/hooks/useTransakRouting.ts Outdated
…fo logout params

useTransakRouting: merge baseRouteParams onto VerifyIdentity and BasicInfo
when navigation.reset runs so headlessSessionId reaches EnterEmail/OtpCode.
On 401 after logout, navigate via createV2EnterEmailNavDetails when a
headless session id is configured.

BasicInfo: forward headlessSessionId to EnterAddress; logout uses
createV2EnterEmailNavDetails with quote.fiatCurrency (string) and
selectedToken.assetId (TransakBuyQuote has no nested fiat/crypto objects).

EnterAddress: use headless Transak routing config when headlessSessionId
is present.

Tests: headless merge cases for verify/BasicInfo resets; mock
useRampsController in BasicInfo tests.
Comment thread app/components/UI/Ramp/hooks/useTransakRouting.ts
routeAfterAuthentication: when handling 401 with a headless session,
navigate to EnterEmail with amount (route arg or quote.fiatAmount),
currency (quote.fiatCurrency or user region), and selectedToken.assetId
so OtpCode can re-fetch the Transak quote after re-login. Previously only
headlessSessionId was passed, so OtpCode skipped the quote path and
returned to HEADLESS_HOST while the session was already continued,
leaving the Host stuck on the loader.

Tests: extend selectedToken mock with assetId; add headless 401 case.
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx Outdated
- Add HeadlessSessionStatus 'failed' and CloseSessionOptions.terminalStatus
- closeSession: treat failed as terminal; log when onClose throws
- HeadlessHost: use terminalStatus 'failed' on error paths after continued
- Extend sessionRegistry tests for failed lifecycle and Logger on throw
Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e0b4f06. Configure here.

Comment thread app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx
- Track effect cancellation so .catch does not setState, onError, or closeSession
- Add unit test for deferred rejection after unmount
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokeRamps
  • Selected Performance tags: None (no tests recommended)
  • Risk Level: medium
  • AI Confidence: 90%
click to see 🤖 AI reasoning details

E2E Test Selection:
All 28 changed files are within the Ramp/on-ramp feature area (app/components/UI/Ramp/ and related constants/locales). The changes implement Phase 4b/4c/5 of the Headless Buy API:

  1. New HeadlessHost screen - A new route added to the Ramp navigation stack
  2. useContinueWithQuote extended - Backward-compatible additions for headless callers; BuildQuote behavior unchanged
  3. useTransakRouting parameterized - Navigation reset helpers now accept baseRoute/baseRouteParams; default behavior (BuildQuote as base) preserved
  4. sessionRegistry enhanced - New closeSession/getActiveSessionId helpers
  5. NativeFlow screens (OtpCode, EnterEmail, VerifyIdentity, BasicInfo) - Thread headlessSessionId through the auth loop
  6. Routes.ts - New HEADLESS_HOST constant added (no existing constants modified)
  7. Localization - New strings for HeadlessHost UI

The changes are scoped entirely to the Ramp feature. The modifications to useTransakRouting and useContinueWithQuote are backward-compatible (existing BuildQuote callers pass no config and get the same behavior). The NativeFlow screen changes only add optional headlessSessionId threading.

SmokeRamps is the appropriate tag as it covers the on-ramp buy flows including the unified buy flow (BuildQuote, KYC screens, OTP flow) that are directly modified. No other feature areas are impacted - there are no changes to shared navigation infrastructure, core controllers, Engine, or other feature areas.

No performance tests are needed as these changes are feature additions to the Ramp UI flow with no impact on rendering performance, data loading, or app startup.

Performance Test Selection:
Changes are confined to the Ramp feature's headless buy flow - new screen, navigation routing, and session management. No impact on rendering performance, data loading, account/network lists, app startup, or other performance-sensitive areas.

View GitHub Actions results

@sonarqubecloud
Copy link
Copy Markdown

@github-actions
Copy link
Copy Markdown
Contributor

E2E Fixture Validation — Schema is up to date
12 value mismatches detected (expected — fixture represents an existing user).
View details

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size-XL team-money-movement issues related to Money Movement features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants