Skip to content

Commit dba2c63

Browse files
feat(ramp): surface headless buy errors as data (Phase 7) (#29612)
## **Description** This PR closes **Phase 7** of the incremental **Unified Buy (v2) headless buy** plan (`app/components/UI/Ramp/headless/PLAN.md`): headless consumers now receive structured `HeadlessBuyError` data for hard failures instead of depending on Ramp UI surfaces like banners, ErrorViews, or order toasts. **Reason** - Phase 6 ([#29340](#29340)) fired `onOrderCreated` and bypassed the order-details redirect on success, but several failure paths were still UI-coupled. Limit failures could be wrapped into generic display errors, Checkout/WebView failures rendered local UI, and one Transak success path could still show a toast before a headless consumer regained control. **What changed** - **`HeadlessBuyErrorCode` + `failSession`** — centralizes error normalization in `sessionRegistry`, preserving explicit error codes/details and closing the session with failed terminal semantics after `onError` fires. - **`HeadlessHost`** — uses `failSession` for auth errors, malformed asset ids, and `continueWithQuote` rejections so the consumer receives one structured `onError` callback and one terminal close. - **`useTransakRouting`** — preserves `LimitExceededError` as `LIMIT_EXCEEDED`, forwards checkout-processing failures through `onError`, and suppresses the manual-bank-transfer toast path when a live headless session owns the flow. - **`Checkout`** — routes callback-processing failures and primary WebView HTTP errors through `onError` for headless sessions, then unwinds the ramp stack instead of rendering the checkout ErrorView. - **`BuildQuote`** — keeps legacy headless params from falling back to banner-only error handling if they are encountered. - **`PLAN.md`** — marks Phase 7 complete. **References** - **Stacked on Phase 6**: [#29340](#29340) (`poc/headless-buy-phase-6`). **This PR's base is `poc/headless-buy-phase-6`** so the diff is Phase 7-only. - Continues from **Phase 5**: [#29338](#29338) (Headless Host + quote-first start). **Tests** - `yarn eslint app/components/UI/Ramp/headless/types.ts app/components/UI/Ramp/headless/sessionRegistry.ts app/components/UI/Ramp/headless/sessionRegistry.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/hooks/useTransakRouting.ts app/components/UI/Ramp/hooks/useTransakRouting.test.ts app/components/UI/Ramp/Views/Checkout/Checkout.tsx app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx` - `yarn jest --watchman=false app/components/UI/Ramp/headless/sessionRegistry.test.ts app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx app/components/UI/Ramp/hooks/useTransakRouting.test.ts app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx app/components/UI/Ramp/Views/BuildQuote/BuildQuote.test.tsx` - `yarn lint:tsc` was attempted, but the local run is blocked by unrelated existing type errors in SocialLeaderboard tests and controller messenger types. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: _No GitHub issue — incremental POC on branch `saustrie-consensys/headless-buy-phase-7`._ Continuity: [#29340](#29340) (Phase 6 — headless order success callback + stack unwind). [#29338](#29338) (Phase 5 — Headless Host + quote-first start). ## **Manual testing steps** ```gherkin Feature: Headless Buy Phase 7 (structured errors) Scenario: Native Transak limit failure surfaces as data Given the app is an internal build and I am signed in And I open Settings → Fiat on-ramp → Headless Buy playground When I start a headless native quote that exceeds the user's Transak limit Then the playground event log should show `onError({ code: "LIMIT_EXCEEDED" })` And the session should close without showing a Ramp-only toast or order-details redirect Scenario: Aggregator Checkout failure surfaces as data Given I start a headless aggregator quote from the playground When the Checkout callback or primary WebView request fails Then the consumer should receive `onError({ code: "UNKNOWN", message })` And the app should unwind out of the Ramp stack instead of rendering the Checkout ErrorView Scenario: Non-headless Buy flow is unchanged Given I open Wallet → Buy through the regular flow When a quote, checkout, or limit error occurs Then the existing Ramp UI surfaces should render as before ``` ## **Screenshots/Recordings** ### **Before** N/A — Phase 7 changes error/callback plumbing only. ### **After** N/A — no user-facing UI changes, but here's a video anyways. https://github.com/user-attachments/assets/2ce3a5a7-7205-4490-9174-8be7672ae464 ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR #### 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 ## **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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes error handling and navigation unwinding for headless Unified Buy flows across `HeadlessHost`, `Checkout`, and `useTransakRouting`, which could affect session lifecycle and user recovery paths if misrouted. Scope is contained to headless-mode branches with added test coverage, but touches core buy/checkout flow control. > > **Overview** > **Headless buy errors are now surfaced as structured data instead of Ramp UI.** A new `failSession` helper in `headless/sessionRegistry` normalizes thrown/native errors into `HeadlessBuyError` (including `LIMIT_EXCEEDED` mapping and optional `details`), fires `onError`, and closes the session with failed terminal semantics. > > Headless flows now consistently use this failure path: `HeadlessHost` forwards auth/asset/continue failures via `failSession`, `Checkout` sends callback-processing and primary WebView HTTP errors through `onError` and pops the ramp stack instead of rendering an ErrorView, and `useTransakRouting` preserves `LimitExceededError` details, suppresses toasts when a live headless session is present, and routes post-checkout processing failures through `failSession` + stack unwind. Tests are updated/added to cover these headless-specific error paths and regression guards. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7bdaa03. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: saustrie-consensys <270766059+saustrie-consensys@users.noreply.github.com>
1 parent 3de0bcc commit dba2c63

11 files changed

Lines changed: 469 additions & 64 deletions

File tree

app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969

7070
import TruncatedError from '../../components/TruncatedError';
7171
import { PROVIDER_LINKS } from '../../Aggregator/types';
72+
import { failSession } from '../../headless/sessionRegistry';
7273
const BAILED_ORDER_STATUSES = new Set<RampsOrderStatus>([
7374
RampsOrderStatus.Precreated,
7475
RampsOrderStatus.IdExpired,
@@ -159,10 +160,24 @@ function BuildQuote() {
159160

160161
useEffect(() => {
161162
if (params?.nativeFlowError) {
163+
if (
164+
params.headlessSessionId &&
165+
failSession(
166+
params.headlessSessionId,
167+
{
168+
code: 'AUTH_FAILED',
169+
message: params.nativeFlowError,
170+
},
171+
'AUTH_FAILED',
172+
)
173+
) {
174+
navigation.setParams({ nativeFlowError: undefined });
175+
return;
176+
}
162177
setRampsError(params.nativeFlowError);
163178
navigation.setParams({ nativeFlowError: undefined });
164179
}
165-
}, [params?.nativeFlowError, navigation]);
180+
}, [params?.headlessSessionId, params?.nativeFlowError, navigation]);
166181

167182
const {
168183
userRegion,
@@ -627,6 +642,9 @@ function BuildQuote() {
627642
assetId: selectedToken?.assetId ?? '',
628643
});
629644
} catch (err) {
645+
if (failSession(params?.headlessSessionId, err)) {
646+
return;
647+
}
630648
setRampsError((err as Error).message);
631649
} finally {
632650
setIsContinueLoading(false);
@@ -642,6 +660,7 @@ function BuildQuote() {
642660
selectedPaymentMethod?.id,
643661
rampRoutingDecision,
644662
userRegion?.regionCode,
663+
params?.headlessSessionId,
645664
trackEvent,
646665
createEventBuilder,
647666
continueWithQuote,

app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jest.mock('../../utils/v2OrderToast', () => ({
5858
jest.mock('../../headless/sessionRegistry', () => ({
5959
getSession: jest.fn(),
6060
closeSession: jest.fn(),
61+
failSession: jest.fn(),
6162
}));
6263

6364
jest.mock('../../../../../util/Logger', () => ({
@@ -618,6 +619,8 @@ describe('Checkout', () => {
618619
.getSession as jest.Mock;
619620
const mockCloseSession = jest.requireMock('../../headless/sessionRegistry')
620621
.closeSession as jest.Mock;
622+
const mockFailSession = jest.requireMock('../../headless/sessionRegistry')
623+
.failSession as jest.Mock;
621624
const showV2OrderToastMock = jest.requireMock('../../utils/v2OrderToast')
622625
.showV2OrderToast as jest.Mock;
623626

@@ -641,6 +644,7 @@ describe('Checkout', () => {
641644
beforeEach(() => {
642645
mockGetSession.mockReset();
643646
mockCloseSession.mockReset();
647+
mockFailSession.mockReset();
644648
mockParentPop = jest.fn();
645649
mockNavigation.getParent.mockReturnValue({ pop: mockParentPop });
646650
mockGetOrderFromCallback.mockResolvedValue(mockOrder);
@@ -740,6 +744,52 @@ describe('Checkout', () => {
740744
expect(mockParentPop).toHaveBeenCalled();
741745
});
742746

747+
it('surfaces callback processing failures through onError and skips the ErrorView', async () => {
748+
mockUseParams.mockReturnValue(callbackFlowParams);
749+
mockGetOrderFromCallback.mockRejectedValueOnce(
750+
new Error('callback failed'),
751+
);
752+
mockFailSession.mockReturnValue({
753+
code: 'UNKNOWN',
754+
message: 'callback failed',
755+
});
756+
757+
const { getByTestId, queryByText } = renderWithProvider(
758+
<Checkout />,
759+
{},
760+
true,
761+
false,
762+
);
763+
764+
await act(async () => {
765+
fireEvent.press(getByTestId('trigger-callback-navigation'));
766+
});
767+
768+
await waitFor(() => {
769+
expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
770+
});
771+
expect(mockParentPop).toHaveBeenCalled();
772+
expect(showV2OrderToastMock).not.toHaveBeenCalled();
773+
expect(queryByText('callback failed')).toBeNull();
774+
});
775+
776+
it('surfaces provider WebView HTTP errors through onError when headless', async () => {
777+
mockUseParams.mockReturnValue(callbackFlowParams);
778+
mockFailSession.mockReturnValue({
779+
code: 'UNKNOWN',
780+
message: 'fiat_on_ramp_aggregator.webview_received_error',
781+
});
782+
783+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
784+
785+
await act(async () => {
786+
fireEvent.press(getByTestId('trigger-http-error-main-uri'));
787+
});
788+
789+
expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
790+
expect(mockParentPop).toHaveBeenCalled();
791+
});
792+
743793
it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => {
744794
mockGetSession.mockReturnValue(undefined);
745795
mockUseParams.mockReturnValue(callbackFlowParams);

app/components/UI/Ramp/Views/Checkout/Checkout.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard';
2828
import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled';
2929
import { showV2OrderToast } from '../../utils/v2OrderToast';
30-
import { closeSession, getSession } from '../../headless/sessionRegistry';
30+
import {
31+
closeSession,
32+
failSession,
33+
getSession,
34+
} from '../../headless/sessionRegistry';
3135
import { useStyles } from '../../../../hooks/useStyles';
3236
import styleSheet from './Checkout.styles';
3337
import Device from '../../../../../util/device';
@@ -120,6 +124,18 @@ const Checkout = () => {
120124
}
121125
}, [uri, createEventBuilder, trackEvent, rampRoutingDecision]);
122126

127+
const failHeadlessCheckout = useCallback(
128+
(checkoutError: unknown) => {
129+
if (!failSession(headlessSessionId, checkoutError)) {
130+
return false;
131+
}
132+
// @ts-expect-error navigation prop mismatch
133+
navigation.getParent()?.pop();
134+
return true;
135+
},
136+
[headlessSessionId, navigation],
137+
);
138+
123139
useEffect(() => {
124140
// For external-browser flows (e.g. PayPal), addPrecreatedOrder is called in
125141
// BuildQuote; the user never reaches Checkout. For WebView flows,
@@ -234,6 +250,9 @@ const Checkout = () => {
234250
Logger.error(navError as Error, {
235251
message: 'UnifiedCheckout: error handling callback',
236252
});
253+
if (failHeadlessCheckout(navError)) {
254+
return;
255+
}
237256
setError((navError as Error)?.message);
238257
}
239258
},
@@ -248,6 +267,7 @@ const Checkout = () => {
248267
isV2Enabled,
249268
params?.cryptocurrency,
250269
headlessSessionId,
270+
failHeadlessCheckout,
251271
],
252272
);
253273

@@ -344,6 +364,9 @@ const Checkout = () => {
344364
'fiat_on_ramp_aggregator.webview_received_error',
345365
{ code: nativeEvent.statusCode },
346366
);
367+
if (failHeadlessCheckout(new Error(webviewHttpError))) {
368+
return;
369+
}
347370
setError(webviewHttpError);
348371
} else {
349372
Logger.log(

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,25 @@ describe('HeadlessHost', () => {
348348
expect(screen.getByText('quote expired')).toBeOnTheScreen();
349349
});
350350

351+
it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => {
352+
const limitError = new Error('Daily limit exceeded');
353+
limitError.name = 'LimitExceededError';
354+
mockContinueWithQuote.mockRejectedValueOnce(limitError);
355+
const quote = buildNativeQuote();
356+
const session = seedSession(quote);
357+
const callbacks = session.callbacks;
358+
renderHost({ headlessSessionId: session.id });
359+
await waitFor(() => {
360+
expect(callbacks.onError).toHaveBeenCalledWith({
361+
code: 'LIMIT_EXCEEDED',
362+
message: 'Daily limit exceeded',
363+
});
364+
});
365+
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
366+
expect(getSession(session.id)).toBeUndefined();
367+
expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen();
368+
});
369+
351370
it('does not run the continueWithQuote rejection path after unmount', async () => {
352371
let rejectDeferred: ((error: Error) => void) | undefined;
353372
mockContinueWithQuote.mockImplementation(

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx

Lines changed: 11 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import Logger from '../../../../../util/Logger';
2727
// Going through the barrel would leave the registry exports `undefined`
2828
// at evaluation time inside this module.
2929
import {
30-
closeSession,
30+
failSession,
3131
getSession,
3232
setStatus,
3333
} from '../../headless/sessionRegistry';
@@ -132,26 +132,17 @@ function HeadlessHost() {
132132
if (!nativeFlowError) {
133133
return;
134134
}
135-
const liveSession = getSession(headlessSessionId);
136-
if (!liveSession) {
137-
return;
138-
}
139-
setErrorMessage(nativeFlowError);
140-
try {
141-
liveSession.callbacks.onError({
142-
code: 'AUTH_FAILED',
143-
message: nativeFlowError,
144-
});
145-
} catch (e) {
146-
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
147-
}
148-
closeSession(
135+
const headlessError = failSession(
149136
headlessSessionId,
150-
{ reason: 'unknown' },
151137
{
152-
terminalStatus: 'failed',
138+
code: 'AUTH_FAILED',
139+
message: nativeFlowError,
153140
},
141+
'AUTH_FAILED',
154142
);
143+
if (headlessError) {
144+
setErrorMessage(headlessError.message ?? nativeFlowError);
145+
}
155146
}, [nativeFlowError, headlessSessionId]);
156147

157148
// Process the session. Uses `useEffect` (not `useFocusEffect`) so that
@@ -202,25 +193,11 @@ function HeadlessHost() {
202193
if (!chainId) {
203194
const message = `HeadlessHost: invalid assetId "${currentSession.params.assetId}"`;
204195
Logger.error(new Error(message));
205-
try {
206-
currentSession.callbacks.onError({
207-
code: 'UNKNOWN',
208-
message,
209-
});
210-
} catch (e) {
211-
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
212-
}
213196
// closeSession alone does not trigger a re-render; without setState the
214197
// render-time `session` ref stays truthy and the loader would spin
215198
// forever. Surface the same message in UI as other error paths.
216199
setErrorMessage(message);
217-
closeSession(
218-
headlessSessionId,
219-
{ reason: 'unknown' },
220-
{
221-
terminalStatus: 'failed',
222-
},
223-
);
200+
failSession(headlessSessionId, { code: 'UNKNOWN', message });
224201
return;
225202
}
226203
// Defer until walletAddress resolves — avoids calling continueWithQuote
@@ -270,22 +247,8 @@ function HeadlessHost() {
270247
if (!liveSession) {
271248
return;
272249
}
273-
setErrorMessage(message);
274-
try {
275-
liveSession.callbacks.onError({
276-
code: 'UNKNOWN',
277-
message,
278-
});
279-
} catch (e) {
280-
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
281-
}
282-
closeSession(
283-
headlessSessionId,
284-
{ reason: 'unknown' },
285-
{
286-
terminalStatus: 'failed',
287-
},
288-
);
250+
const headlessError = failSession(headlessSessionId, error);
251+
setErrorMessage(headlessError?.message ?? message);
289252
});
290253
return () => {
291254
cancelled = true;

app/components/UI/Ramp/headless/PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops
1515
- [ ] **Phase 5b (deferred)**`startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable
1616
- [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session
17-
- [ ] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
17+
- [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
1818
- [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection)
1919
- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground
2020
- [ ] **Phase 10** — Playground polish — event log, input persistence, aggregator/native presets

0 commit comments

Comments
 (0)