Skip to content

feat(ramp): expose order observation API + invisible host (Phase 9 + 9.5)#29930

Closed
saustrie-consensys wants to merge 9 commits into
mainfrom
headless-buy-phase-9-and-9.5
Closed

feat(ramp): expose order observation API + invisible host (Phase 9 + 9.5)#29930
saustrie-consensys wants to merge 9 commits into
mainfrom
headless-buy-phase-9-and-9.5

Conversation

@saustrie-consensys
Copy link
Copy Markdown
Contributor

@saustrie-consensys saustrie-consensys commented May 8, 2026

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-onOrderCreated settlement window. MetaMask Pay's TransactionPayController (TPC, MetaMask/core#8628) needs to know when the fiat order reaches a terminal state to fire step II of its two-step flow.

  • New module app/components/UI/Ramp/headless/orderTerminalState.ts exports module-level imperative getOrder, refreshOrder(idOrOrder, options?), and awaitOrderTerminalState(orderId, { timeoutMs?, pollIntervalMs?, walletAddress? }). Module-level so non-React consumers like TPC can call them directly without going through a hook.
  • awaitOrderTerminalState is 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 via RampsController.getOrder (with an immediate first tick so callers don't wait pollIntervalMs on entry).
  • useHeadlessBuy() exposes thin passthroughs at getOrder, refreshOrder, awaitOrderTerminalState. getOrderById is preserved (@deprecated) for back-compat with the existing playground.
  • Two typed errors with Object.setPrototypeOf so instanceof survives Hermes lowering: OrderTerminalStateTimeoutError and RefreshOrderUnresolvableError.
  • Playground gets an Order tracking panel that surfaces orderId, current status (live from getOrder), 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 timeoutMs because 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 transparent View so it keeps acting as the routing landing pad and the nativeFlowError callback surface, while the consumer (TPC) renders the only user-visible loading UI for a headless buy.

  • Phase 8's dismissal contract is preserved: useHeadlessSessionDismissal's unmount cleanup still fires onClose({ reason: 'user_dismissed' }). We additionally register a navigation.addListener('beforeRemove', ...) so the synchronous closeSession fire that used to live on the visible Cancel/Back buttons still happens before unmount on hardware back / iOS swipe-back.
  • Orphaned 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 main when Phase 8 merges.

Manual testing steps

Feature: Headless buy order observation + invisible host

  Scenario: Consumer awaits fiat order settlement
    Given a developer build with the Headless playground enabled
    When the user opens Settings → Advanced → Fiat on-ramp → Headless playground
    And taps "Get quotes" with a valid amount
    And taps "Start headless buy" on a quote row
    Then the Headless Host renders no visible chrome (transparent overlay)
    And the consumer flow proceeds through Checkout / KYC / OTP as usual
    When the provider produces an orderId via `onOrderCreated`
    Then the playground "Order tracking" panel surfaces the orderId and current status
    When the user taps "Refresh order"
    Then `refreshOrder` is called and the event log records the fresh status
    When the user taps "Await terminal state"
    Then the badge cycles through Awaiting → Terminal once the order reaches a terminal status

  Scenario: User backs out of the headless flow mid-session
    Given an active headless session with the Host on top of the navigation stack
    When the user uses hardware back / iOS swipe-back to exit the Host
    Then the `beforeRemove` listener fires `onClose({ reason: 'user_dismissed' })` synchronously
    And the session is removed from the registry

  Scenario: OTP failure surfaces as AUTH_FAILED
    Given an in-flight headless session in the OTP loop
    When OtpCode resets back to the Host with `nativeFlowError`
    Then the consumer receives `onError({ code: 'AUTH_FAILED' })` followed by `onClose({ reason: 'unknown' })`
    And no visible error UI is rendered by the Host

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

  • I've followed MetaMask Contributor Docs and MetaMask Mobile Coding Standards.
  • I've completed the PR template to the best of my ability
  • I've included tests if applicable (175 unit tests pass — covers the new imperative module, hook passthroughs, and the playground panel; existing HeadlessHost suite rewritten for the Phase 9.5 chrome strip)
  • I've documented my code using JSDoc format if applicable
  • I've applied the right labels on the PR (team-money-movement matches Phase 8)

Performance checks (if applicable)

  • I've tested on Android
  • I've tested with a power user scenario
  • I've instrumented key operations with Sentry traces for production performance metrics

(N/A for this phase — internal API addition + invisible host. The next consumer-facing change lands in MMPay's downstream PR.)

saustrie-consensys and others added 4 commits May 8, 2026 16:56
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
@saustrie-consensys saustrie-consensys added the team-money-movement issues related to Money Movement features label May 8, 2026
@github-actions github-actions Bot added the pr-not-ready-for-e2e Skip E2E and block merging. Remove this label once the PR is ready to run the E2E tests. label May 8, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

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.

@saustrie-consensys saustrie-consensys removed the pr-not-ready-for-e2e Skip E2E and block merging. Remove this label once the PR is ready to run the E2E tests. label May 8, 2026
@metamaskbotv2 metamaskbotv2 Bot added the INVALID-PR-TEMPLATE PR's body doesn't match template label May 8, 2026
saustrie-consensys and others added 5 commits May 8, 2026 20:10
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>
@github-actions
Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

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

E2E Test Selection:
The changes are focused on the Ramp/fiat on-ramp headless buy flow:

  1. HeadlessHost.tsx underwent a significant Phase 9.5 refactor: the component is now intentionally invisible (renders an empty transparent View). All visible UI (header, loading spinner, error messages, cancel button) was removed. A beforeRemove navigation listener was added for dismissal handling. This is a behavioral change to the headless buy flow that needs E2E validation.

  2. orderTerminalState.ts is a new module implementing imperative order tracking with polling/subscription logic (getOrder, refreshOrder, awaitOrderTerminalState). This is new functionality for waiting on fiat order settlement.

  3. useHeadlessBuy.ts exposes the new order observation methods as hook methods.

  4. HeadlessPlayground.tsx adds a developer-facing OrderTrackingPanel for testing the new APIs.

  5. Locale files updated to remove old headless_host strings and add new order_tracking strings.

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:
The changes are focused on the headless buy flow logic and UI refactoring. The HeadlessHost component is now intentionally invisible (empty View), which actually reduces rendering complexity. The new orderTerminalState module adds polling logic but this is background/async and not in critical rendering paths. No changes to account lists, navigation infrastructure, app startup, or other performance-sensitive areas. Performance tests are not warranted.

View GitHub Actions results

Base automatically changed from headless-buy-phase-8-on-close to main May 11, 2026 15:21
@sonarqubecloud
Copy link
Copy Markdown

@github-actions github-actions Bot locked and limited conversation to collaborators May 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

INVALID-PR-TEMPLATE PR's body doesn't match template size-XL team-money-movement issues related to Money Movement features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant