Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,10 @@ describe('Checkout', () => {
});

await waitFor(() => {
expect(onOrderCreated).toHaveBeenCalledWith('headless-order-1');
expect(onOrderCreated).toHaveBeenCalledWith(
'headless-order-1',
mockOrder,
);
});
expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
reason: 'completed',
Expand Down
11 changes: 6 additions & 5 deletions app/components/UI/Ramp/Views/Checkout/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,15 @@ const Checkout = () => {
addOrder(rampsOrder);
dispatch(protectWalletModalVisible());

// Headless mode: hand the orderId to the consumer, close the
// session, and unwind out of the ramp stack so the caller regains
// foreground. Skip the toast + RAMPS_ORDER_DETAILS reset — both
// are user-facing UI the headless consumer didn't ask for.
// Headless mode returns foreground control to the consumer without
// showing the toast or order-details reset.
const session = getSession(headlessSessionId);
if (headlessSessionId && session) {
try {
session.callbacks.onOrderCreated(rampsOrder.providerOrderId);
session.callbacks.onOrderCreated(
rampsOrder.providerOrderId,
rampsOrder,
);
} catch (callbackError) {
Logger.error(
callbackError as Error,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ function HeadlessPlayground() {
currency: fiatCurrency,
};
const session = startHeadlessBuy(params, {
onOrderCreated: (orderId) => {
onOrderCreated: (orderId, _order) => {
appendEvent(
strings(
'app_settings.fiat_on_ramp.headless_playground.event_log_order_created',
Expand Down
24 changes: 13 additions & 11 deletions app/components/UI/Ramp/headless/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
- [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session
- [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
- [x] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection)
- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground (now an MVP requirement — see Phase 9 Update)
- [ ] **Phase 9** — Headless buy fixes from Goktug's May 12 testing thread (see Phase 9 section for what shipped and the dropped imperative-API design)
- [ ] **Phase 9.5** — HeadlessHost visual treatment (transparent or bottom-sheet) — driven by the May 6 design thread
- [ ] **Phase 10** — Implement deferred Phase 5b + playground polish + navigation/state cleanups + headless toast suppression

Expand Down Expand Up @@ -568,24 +568,26 @@ Deliverable: closing the buy flow from anywhere on the headless stack notifies t

---

## Phase 9 — Expose `getOrder` + polling helpers
## Phase 9 — Headless buy fixes (May 12 testing)

### Update (May 2026 — driven by MetaMask Pay)

Phase 9 is now an MVP requirement, not playground polish. MetaMask Pay's `TransactionPayController` ([MetaMask/core#8628](https://github.com/MetaMask/core/pull/8628)) needs to know when the fiat order reaches a terminal state to fire step II (intent transaction) of its two-step flow.
Phase 9 was originally framed as "expose `getOrder` + polling helpers" and, after [MetaMask/core#8628](https://github.com/MetaMask/core/pull/8628) elevated the priority, briefly grew an imperative `awaitOrderTerminalState(orderId)` Promise so MetaMask Pay's `TransactionPayController` could `await` settlement.

The original Phase 9 surface (`getOrder`, `refreshOrder` + a "Refresh order" playground button) doesn't fit a controller consumer. The Phase 9 API should add an **imperative `awaitOrderTerminalState(orderId)` Promise** so TPC can `await` settlement instead of polling itself. Polling and the playground button stay as additional surfaces; the Promise is the load-bearing API.
### What shipped

The Apr 28 progress sync also called out a missing **auto-select-best-provider utility** ("Need utility function to auto-select best provider rather than requiring explicit provider ID"). Either fold into Phase 9 alongside `getOrder` or split as a follow-up phase — to be decided during Phase 9 implementation.
The imperative observation surface was **not** shipped. During implementation the team determined that MetaMask Pay's existing approach — `useFiatOrderStatus` in [#28152](https://github.com/MetaMask/metamask-mobile/pull/28152) polling `RampsController.getOrder` directly via `setInterval` — made the imperative API unnecessary at N=1. `useHeadlessBuy` continues to expose `getOrderById` from Pedro's original Phase 2 facade ([#29144](https://github.com/MetaMask/metamask-mobile/pull/29144)) and nothing else changed on the order-observation surface.

**Open question** (Barbara, [May 6 design thread](https://consensys.slack.com/archives/C0AK3NXRM7W/p1778072992397499)): does ramps need an internal timeout — distinct from the registry's 1-hour GC — so the consumer isn't pinned to a "loading forever" state when a quote stalls? Two shapes worth considering during Phase 9 implementation: (i) `awaitOrderTerminalState(orderId, { timeoutMs })` rejects with a timed-out error so the consumer can decide what to surface; (ii) the registry grows a per-session timeout that fires `onError({ code: 'TIMED_OUT' })` + `onClose` if no terminal event arrives within N seconds. Pedro's reply 37 in the same thread noted Phase 3's `cancel()` is today's escape hatch; not blocking for v1 but worth resolving before MMPay launch.
Phase 9 landed instead as the bug-fix bundle from Goktug's May 12 testing thread, plus a static-bounds utility re-export for MMPay's typing-time validation:

Goal: complete the hook surface.
- Fix #2 — provider-rejection routing through `onError` (5 EUR scenario).
- Fix #3.1 — `onOrderCreated(orderId, order)` signature widening.
- Fix #3.2 — asymmetric-callback contract JSDoc + per-path freshness table.
- Suggestion #1 — `getProviderBuyLimit` re-exported from the public barrel.
- Suggestion #2 — pre-quote static bounds short-circuit inside `getQuotes`.
- Analytics + deeplink utilities; `sessionRegistry` Fix #2 routing.

- Add `getOrder(orderId)` using `useRampsOrders.getOrderById` (already available via [app/components/UI/Ramp/hooks/useRampsOrders.ts](../hooks/useRampsOrders.ts)).
- Add `refreshOrder(providerCode, providerOrderId)` passthrough for polling after a callback.
- Document the hook in a JSDoc at top of `useHeadlessBuy.ts` with a full example.
- Extend playground: after `onOrderCreated` fires, show the orderId and a "Refresh order" button using these helpers.
The Apr 28 progress sync called out a missing **auto-select-best-provider utility** ("Need utility function to auto-select best provider rather than requiring explicit provider ID"). Still a deferred item; no longer tied to Phase 9.

---

Expand Down
4 changes: 4 additions & 0 deletions app/components/UI/Ramp/headless/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ export {
setStatus,
} from './sessionRegistry';
export { useHeadlessSessionDismissal } from './useHeadlessSessionDismissal';
export {
getProviderBuyLimit,
type ProviderBuyLimit,
} from '../utils/providerLimits';
8 changes: 6 additions & 2 deletions app/components/UI/Ramp/headless/sessionRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,12 @@ describe('sessionRegistry', () => {
const session = createSession(baseParams, callbacks);
const stored = getSession(session.id);
expect(stored?.callbacks).toBe(callbacks);
stored?.callbacks.onOrderCreated('order-1');
expect(callbacks.onOrderCreated).toHaveBeenCalledWith('order-1');
const fakeOrder = {} as Parameters<typeof callbacks.onOrderCreated>[1];
stored?.callbacks.onOrderCreated('order-1', fakeOrder);
expect(callbacks.onOrderCreated).toHaveBeenCalledWith(
'order-1',
fakeOrder,
);
});
});

Expand Down
2 changes: 1 addition & 1 deletion app/components/UI/Ramp/headless/sessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export function createSession(
/**
* Looks up a live session by id. Returns `undefined` for unknown ids and for
* `undefined` itself, so consumers can write
* `getSession(route.params?.headlessSessionId)?.callbacks.onOrderCreated(id)`
* `getSession(route.params?.headlessSessionId)?.callbacks.onOrderCreated(id, order)`
* without any extra null-checking.
*/
export function getSession(
Expand Down
53 changes: 43 additions & 10 deletions app/components/UI/Ramp/headless/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type {
Country,
PaymentMethod,
Provider,
QuotesResponse,
RampsOrder,
TokensResponse,
UserRegion,
import {
RampsOrderStatus,
type Country,
type PaymentMethod,
type Provider,
type QuotesResponse,
type RampsOrder,
type TokensResponse,
type UserRegion,
} from '@metamask/ramps-controller';
import type { Quote } from '../types';

Expand Down Expand Up @@ -36,6 +37,18 @@ export interface HeadlessGetQuotesParams {
forceRefresh?: boolean;
/** Override the default redirect URL injected into provider quotes. */
redirectUrl?: string;
/**
* Fiat currency code (e.g. `'EUR'`) used **only** by the pre-quote static
* bounds check in {@link HeadlessBuyResult.getQuotes}: when omitted, the
* hook falls back to the active user region's currency. Same default as
* `HeadlessBuyParams.currency`.
*
* The fiat currency the network call itself uses is determined by the
* RampsController from the active user region — passing this field here
* does NOT override that. It only changes which bounds row is consulted
* for the pre-flight check (see Fix #2 follow-up / Suggestion #2).
*/
currency?: string;
}

/**
Expand Down Expand Up @@ -136,8 +149,27 @@ export interface HeadlessBuyParams {
* Stored in the session registry by id; never serialized through navigation.
*/
export interface HeadlessBuyCallbacks {
/** Fired once the provider produces an `orderId` (aggregator or native). */
onOrderCreated: (orderId: string) => void;
/**
* Fired once the provider produces an `orderId` (aggregator or native).
*
* **The headless API does NOT fire `onError` for a created-then-failed
* order.** After this callback fires, the headless *session* terminates
* via `onClose({ reason: 'completed' })` immediately — but the underlying
* *order* is independent of the session and its `status` may not yet be
* terminal. Subsequent failures (e.g. a 3-D Secure rejection on a card
* order) flip the order's `status` to `Failed` with no further callback
* (the session is already closed).
*
* The `order` argument is a **creation snapshot**, not authoritative
* final state. Its `status` varies by path:
*
* | Path | `status` on receipt |
* |---|---|
* | Aggregator widget (Transak WebView) | Almost always non-terminal. |
* | Native card (3-D Secure) | Fresh from `refreshOrder` — usually non-terminal. |
* | Native bank transfer | May already be terminal (some settle at creation). |
*/
onOrderCreated: (orderId: string, order: RampsOrder) => void;
/** Fired when the session terminates due to an error. */
onError: (error: HeadlessBuyError) => void;
/** Fired when the user dismisses or the consumer cancels the session. */
Expand Down Expand Up @@ -218,3 +250,4 @@ export type {
TokensResponse,
UserRegion,
};
export { RampsOrderStatus };
Loading
Loading