feat(ramp): expose order observation API + invisible host (Phase 9 + 9.5)#29930
feat(ramp): expose order observation API + invisible host (Phase 9 + 9.5)#29930saustrie-consensys wants to merge 9 commits into
Conversation
Closes Phase 8 of the Headless Buy plan. When the user backs out of the
headless flow without producing an order, the consumer's onClose
callback now fires with { reason: 'user_dismissed' } so external
consumers (notably MetaMask Pay's TransactionPayController) can detect
dismissal and clean up.
- Add useHeadlessSessionDismissal hook that fires the dismissal close on
unmount when the session is still in the registry
- Call the hook from HeadlessHost (the only headless entry under
quote-first) and fire closeSession synchronously from handleBack so
the close happens at the moment of intent, with the unmount cleanup
as a defense-in-depth fallback for back-gesture / programmatic nav
- Idempotent: closeSession no-ops on terminal sessions, so Phase 6
(completed), Phase 7 (failSession), Phase 5 restart and consumer
cancel paths each fire onClose exactly once before unmount and the
dismissal cleanup that follows is a no-op
- PLAN.md: check off Phase 8, relocate BuildQuote-dismissal bullet
(deferred to Phase 10), add Phase 9 Update reflecting MetaMask Pay's
awaitOrderTerminalState requirement, restructure Phase 10 to absorb
deferred Phase 5b
… open question Captures decisions and open questions from the May 6 2026 design thread (https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499) on how MetaMask Pay consumes the headless flow. - New Phase 9.5: HeadlessHost visual treatment. Pedro confirmed the Host must stay mounted (routing landing pad + nativeFlowError surface) but doesn't have to be visible. Two shapes evaluated: transparent overlay with consumer-rendered spinner (Pedro's pick) or bottom-sheet with the Host's own spinner. Final shape pending Lucas's design rec (May 13). Phase 8's dismissal contract is unaffected — back-press still fires onClose(user_dismissed). - Phase 9 Update: append the timeout open question Barbara raised (reply 36). Two API shapes worth considering during Phase 9 implementation: timeoutMs on awaitOrderTerminalState, or a registry-side per-session timeout that fires onError(TIMED_OUT) + onClose. Not blocking for v1. - Resolve a naming conflict: the auto-select-best-provider utility was tentatively listed as "Phase 9.5"; renamed to "follow-up phase" so the new visual-treatment phase can own the 9.5 number.
Captures two cross-cutting API rules so future contributors know the constraints before extending `useHeadlessBuy`: 1. Callbacks-only, three terminal events. No intermediate progress callbacks (onAuthStarted / onKycRequired / etc.) — they would couple consumers to ramp internals and force them to update on every flow change. 2. The consumer renders all visible UI. No render-shape props (loadingText / spinnerComponent / etc.). Headless Ramps is a behavior provider, not a UI provider — Phase 9.5 implements this on the Host side; the API side must stay this shape. Both principles were implicit in the API as designed but undocumented; making them explicit makes them defensible in PR review and harder to erode by accretion. Section sits between "Architecture at a glance" and Phase 1 so it is visible to anyone reading PLAN.md top-down.
…9.5)
Phase 9 (MMPay TPC dependency)
- New imperative `awaitOrderTerminalState(orderId, { timeoutMs?, pollIntervalMs?, walletAddress? })`
in `headless/orderTerminalState.ts` resolves with the `RampsOrder` once
its status reaches `Completed | Failed | Cancelled | IdExpired`. Drives
a redux subscription as the fast path and self-polls
`RampsController.getOrder` as the slow path so it is not coupled to
the unified order processor's `<FiatOrders />` mount lifecycle.
- New imperative `getOrder` and `refreshOrder(stringOrOrder)` siblings
in the same module — controllers can call them without going through
React. `useHeadlessBuy()` exposes thin passthroughs on the same names
for React consumers; `getOrderById` is preserved (deprecated) for
back-compat.
- Two typed errors with Hermes-safe `Object.setPrototypeOf` fixes:
`OrderTerminalStateTimeoutError` and `RefreshOrderUnresolvableError`.
- Playground gets an Order tracking panel after `onOrderCreated` —
surfaces `orderId`, current status (live from `getOrder`), and
Refresh / Await terminal state actions wired to the new API.
Phase 9.5 (HeadlessHost goes invisible)
- Strip header, spinner, "no session" / error text, and Cancel button
from `HeadlessHost.tsx`. The Host renders a transparent `View` so it
keeps acting as the routing landing pad and `nativeFlowError` surface
while the consumer (TPC) renders the only user-visible loading UI.
- Replace the visible Cancel/Back-button handlers with a
`navigation.addListener('beforeRemove', ...)` so the synchronous
`closeSession({ reason: 'user_dismissed' })` still fires before
unmount on hardware back / iOS swipe-back.
`useHeadlessSessionDismissal` (Phase 8) remains as
defense-in-depth for paths that bypass `beforeRemove`.
- Remove orphaned `headless_host.*` i18n keys across all 14 locales.
CHANGELOG entry: null
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
SonarQube flagged the playground for cognitive complexity (36 > 30) and three nested ternaries in the await-status badge rendering. Extracting `OrderTrackingPanel` as its own component pushes the complexity back under the threshold and clears the nested ternaries by routing color/text selection through `getAwaitBadgeColor` / `getAwaitBadgeText` helpers. No behaviour changes.
…uild
Real-device verification of Phase 8 revealed that `useTransakRouting`
opens Checkout via `navigation.reset({ routes: [HEADLESS_HOST, CHECKOUT] })`,
which rebuilds the navigator with fresh route keys and unmounts the
original HeadlessHost instance — even though logically the user is
still in the headless flow. The first `useHeadlessSessionDismissal`
treated that unmount as a dismissal and fired
`closeSession({ reason: 'user_dismissed' })`. By the time the Transak
widget redirected back ~50s later, `getSession(id)` returned undefined
and the Phase 6 bypass fell through to `RAMPS_ORDER_DETAILS`, breaking
the headless contract on every successful buy.
Fix: the unmount cleanup now reads `navigation.getState()` and walks
its routes (recursively, for nested navigators). If HEADLESS_HOST is
still present, the unmount is a stack rebuild and the close is skipped.
If `getState()` throws (navigator torn down), the cleanup falls through
to close — treating a missing navigator as "user left" is the safe
default. The original Phase 8 termination paths (Phase 6 completed,
Phase 7 unknown, consumer cancellation, handleBack) keep working
unchanged because `getSession(id)` already returns undefined by the
time the cleanup runs.
Tests:
- 4 new tests under `stack-rebuild guard` in
useHeadlessSessionDismissal.test.ts covering: HEADLESS_HOST present
as direct route, HEADLESS_HOST in nested navigator state, absent
(true dismissal), and getState throwing. Suite is now 12 tests at
100% coverage.
- Existing HeadlessHost.test.tsx Dismissal block (23 tests) keeps
passing because the test mock's missing getState triggers the
defensive throw branch, which matches the existing "close on
unmount" assertions.
PLAN.md (Phase 10 polish surfaced during verification): added two
secondary goals so the next reviewer can sequence them.
- Goal 3 — Navigation/state cleanups: flatten the nested
RampTokenSelection descriptor in `startHeadlessBuy` (3-level
warning); move Checkout's `onNavigationStateChange` function out
of route params and into the session registry (state-restore
failure mode if the app is killed mid-Checkout).
- Goal 4 — Suppress the global order toast for headless orders
(Phase 7 follow-up): Phase 7 audited the in-flow toast call sites
but missed `processFiatOrder` in index.tsx, which fires
`showV2OrderToast` whenever a polled order's state transitions to
Completed. Fix shape: stamp `headless: true` on the order in the
three bypass paths; short-circuit the toast in processFiatOrder
while preserving Redux state + analytics parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection:
These changes directly affect the ramp/buy flow, which is covered by SmokeMoney tests (onramp-unified-buy.spec.ts, deeplink-to-buy-flow.spec.ts, etc.). The buy flow can also involve transaction confirmations, so SmokeConfirmations is included per the SmokeMoney tag description which states 'When selecting SmokeMoney for Card Add Funds or similar flows that execute swaps, also select SmokeSwap and SmokeConfirmations.' The changes don't affect swap flows directly, so SmokeSwap is not needed. No changes to core controllers, navigation infrastructure, or other shared components that would warrant broader test coverage. Performance Test Selection: |
|



Description
Lands Phase 9 (the MMPay-blocking imperative order-tracking API) and Phase 9.5 (HeadlessHost goes invisible) in one PR, in line with the Phase 9.5 spec sitting on top of the Phase 9 work in
PLAN.md.Phase 9 — order observation API
The headless surface previously returned the three terminal callbacks (
onOrderCreated / onError / onClose) but nothing for the post-onOrderCreatedsettlement window. MetaMask Pay'sTransactionPayController(TPC, MetaMask/core#8628) needs to know when the fiat order reaches a terminal state to fire step II of its two-step flow.app/components/UI/Ramp/headless/orderTerminalState.tsexports module-level imperativegetOrder,refreshOrder(idOrOrder, options?), andawaitOrderTerminalState(orderId, { timeoutMs?, pollIntervalMs?, walletAddress? }). Module-level so non-React consumers like TPC can call them directly without going through a hook.awaitOrderTerminalStateis self-sufficient — it does not assume the unified order processor's<FiatOrders />poller is mounted. Three layers run in parallel: a synchronous fast-path read on entry, a redux subscription for state writebacks, and a slow-path self-poll viaRampsController.getOrder(with an immediate first tick so callers don't waitpollIntervalMson entry).useHeadlessBuy()exposes thin passthroughs atgetOrder,refreshOrder,awaitOrderTerminalState.getOrderByIdis preserved (@deprecated) for back-compat with the existing playground.Object.setPrototypeOfsoinstanceofsurvives Hermes lowering:OrderTerminalStateTimeoutErrorandRefreshOrderUnresolvableError.orderId, current status (live fromgetOrder), and Refresh / Await terminal state actions wired to the new API.Open question (Barbara, May 6 design thread) about the right shape for the timeout — we shipped option (i) per-call
timeoutMsbecause TPC needs a per-call escape hatch and a registry-side per-session timeout would impose a global default on consumers that explicitly do not want one. Option (ii) can layer on later without breaking compat.The auto-select-best-provider utility surfaced in the Apr 28 progress sync is deferred to a follow-up phase — out of scope for the MMPay-blocking work.
Phase 9.5 — HeadlessHost visual treatment
Strip header, spinner, "no session" / error text, and Cancel button from
HeadlessHost.tsx. The Host renders a transparentViewso it keeps acting as the routing landing pad and thenativeFlowErrorcallback surface, while the consumer (TPC) renders the only user-visible loading UI for a headless buy.useHeadlessSessionDismissal's unmount cleanup still firesonClose({ reason: 'user_dismissed' }). We additionally register anavigation.addListener('beforeRemove', ...)so the synchronouscloseSessionfire that used to live on the visible Cancel/Back buttons still happens before unmount on hardware back / iOS swipe-back.headless_host.*i18n keys removed across all 14 locales.Changelog
CHANGELOG entry: null
(internal API addition + invisible host — no end-user-facing change in this PR; MMPay's user-visible loading UI lands in their downstream PRs.)
Related issues
Fixes: https://consensyssoftware.atlassian.net/jira/software/c/projects/TRAM/boards/1568?assignee=712020%3Afd12f7ea-d9e1-4a0a-8a26-36804c9e11c9&selectedIssue=TRAM-3530
Stacked on top of Phase 8 (#29919) — base will auto-retarget to
mainwhen Phase 8 merges.Manual testing steps
Screenshots/Recordings
Before
HeadlessHost rendered a header, spinner, loading text, and a Cancel button while the buy flow was in flight.
After
HeadlessHost is fully transparent — the consumer's loading UI fills the screen. Functional behaviour (orchestration, dismissal,
nativeFlowError) is unchanged.Pre-merge author checklist
team-money-movementmatches Phase 8)Performance checks (if applicable)
(N/A for this phase — internal API addition + invisible host. The next consumer-facing change lands in MMPay's downstream PR.)