Skip to content

Commit a75f9df

Browse files
OGPoyrazvinistevam
andauthored
feat: Add fiat payment confirmation flow with headless ramp integration placeholder (#28152)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> Add fiat payment confirmation flow with headless ramp integration placeholder ### Summary - When a user confirms a transaction with fiat payment selected and no order yet, the confirm button now triggers the ramp purchase flow instead of submitting the transaction directly (`startHeadlessBuy` call is placeholder — will be uncommented once the Ramps team delivers the headless API) - After ramp purchase completes and `orderCode` lands in state, `useTransactionPayAutoFiatSubmission` auto-submits the transaction so the `FiatStrategy` in `TransactionPayController` can poll the order and relay funds - Auto-submission is deduplicated per `orderCode` and retries on failure - `getStrategy` in controller init now routes transactions with a selected fiat payment method to `FiatStrategy` instead of the default `Relay` - Non-fiat confirmation flow is completely unchanged - Depends on MetaMask/core#8347 — `@metamask/transaction-pay-controller` dep bump required before merge ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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 - [X] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **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 the transaction confirmation path to branch into a new fiat purchase kickoff and auto-submission flow, plus adds polling against `RampsController.getOrder`; regressions could block confirmations or cause duplicate/failed submissions if edge cases are missed. > > **Overview** > Adds a **fiat payment confirmation flow**: when fiat is selected and no `orderId` exists, `useTransactionConfirm` now triggers `useFiatConfirm` (headless ramp start + persist `orderId` + error/in-progress state) instead of immediately submitting the transaction, and `useTransactionPayAutoFiatSubmission` later auto-calls `onConfirm({ existingOrderId })` once the `orderId` appears (deduped per order, retry on failure). > > Surfaces **fiat order UX and errors** by extending `ConfirmationContext` with `headlessBuyError`, adding a new blocking alert (`AlertKeys.HeadlessBuyError`) + metrics mapping + i18n strings, and wiring the error into `CustomAmountInfo`’s `AlertMessage`. > > Adds **fiat order status visibility** in activity details via `FiatOrderSummaryLine` and a new `useFiatOrderStatus` hook that polls `RampsController.getOrder` until terminal states; also updates engine messenger action allowlists to include missing `RampsController`/pay-related actions and bumps `@metamask/transaction-controller` and `@metamask/transaction-pay-controller` deps. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7662e33. 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: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com>
1 parent 04acd1a commit a75f9df

33 files changed

Lines changed: 1637 additions & 117 deletions

app/components/Views/confirmations/components/UI/highlighted-item/highlighted-item.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
Spinner,
1919
} from '@metamask/design-system-react-native';
2020
import { useTailwind } from '@metamask/design-system-twrnc-preset';
21-
21+
import { useTheme } from '../../../../../../util/theme';
2222
import PaymentMethodIcon from '../../../../../UI/Ramp/Aggregator/components/PaymentMethodIcon';
2323
import {
2424
HighlightedActionButton,
@@ -72,18 +72,29 @@ export function HighlightedItem({ item }: HighlightedItemProps) {
7272

7373
function HighlightedItemIcon({ item }: { item: HighlightedItemType }) {
7474
const tw = useTailwind();
75+
const { colors } = useTheme();
7576

7677
if (item.paymentType) {
7778
return (
7879
<Box
7980
style={tw.style(
8081
'w-10 h-10 rounded-full bg-background-section items-center justify-center',
82+
{
83+
backgroundColor: item.isSelected
84+
? colors.primary.muted
85+
: colors.background.muted,
86+
},
8187
)}
8288
testID="icon"
8389
>
8490
<PaymentMethodIcon
8591
paymentMethodType={item.paymentType as PaymentType}
8692
size={20}
93+
style={{
94+
color: item.isSelected
95+
? colors.primary.default
96+
: colors.icon.default,
97+
}}
8798
/>
8899
</Box>
89100
);
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import React from 'react';
2+
import {
3+
TransactionMeta,
4+
TransactionStatus,
5+
} from '@metamask/transaction-controller';
6+
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
7+
import { strings } from '../../../../../../../locales/i18n';
8+
import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridgeStatusController';
9+
import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useBridgeTxHistoryData';
10+
import { useTokenAmount } from '../../../hooks/useTokenAmount';
11+
import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails';
12+
import { useFiatOrderStatus } from '../../../hooks/activity/useFiatOrderStatus';
13+
import { FiatOrderSummaryLine } from './fiat-order-summary-line';
14+
15+
jest.mock('../../../../../../selectors/bridgeStatusController');
16+
jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData');
17+
jest.mock('../../../hooks/useTokenAmount');
18+
jest.mock('../../../hooks/activity/useTransactionDetails');
19+
jest.mock('../../../hooks/activity/useFiatOrderStatus');
20+
21+
const PARENT_TRANSACTION = {
22+
id: 'parent-id',
23+
chainId: '0x1',
24+
status: TransactionStatus.confirmed,
25+
time: 1755719285723,
26+
txParams: { from: '0xabc' },
27+
metamaskPay: {
28+
fiat: {
29+
orderId: '/providers/transak/orders/order-123',
30+
provider: 'transak',
31+
},
32+
},
33+
} as unknown as TransactionMeta;
34+
35+
function render(parentTransaction = PARENT_TRANSACTION) {
36+
return renderWithProvider(
37+
<FiatOrderSummaryLine parentTransaction={parentTransaction} />,
38+
{
39+
state: {
40+
engine: {
41+
backgroundState: {
42+
TransactionController: { transactions: [] },
43+
},
44+
},
45+
},
46+
},
47+
);
48+
}
49+
50+
describe('FiatOrderSummaryLine', () => {
51+
const useFiatOrderStatusMock = jest.mocked(useFiatOrderStatus);
52+
53+
beforeEach(() => {
54+
jest.resetAllMocks();
55+
56+
useFiatOrderStatusMock.mockReturnValue({
57+
severity: 'success',
58+
statusText: 'Completed',
59+
cryptoSymbol: 'POL',
60+
paymentMethodName: 'Debit Card',
61+
});
62+
63+
jest.mocked(selectBridgeHistoryForAccount).mockReturnValue({});
64+
jest.mocked(useBridgeTxHistoryData).mockReturnValue({
65+
bridgeTxHistoryItem: undefined,
66+
isBridgeComplete: null,
67+
});
68+
jest
69+
.mocked(useTokenAmount)
70+
.mockReturnValue({} as ReturnType<typeof useTokenAmount>);
71+
jest.mocked(useTransactionDetails).mockReturnValue({
72+
transactionMeta: {} as TransactionMeta,
73+
});
74+
});
75+
76+
it('renders title with token and payment method', () => {
77+
const { getByText } = render();
78+
79+
expect(getByText('Buy POL with Debit Card')).toBeDefined();
80+
});
81+
82+
it('renders placeholder title before order data loads', () => {
83+
useFiatOrderStatusMock.mockReturnValue({
84+
severity: 'warning',
85+
statusText: 'Pending',
86+
cryptoSymbol: undefined,
87+
paymentMethodName: undefined,
88+
});
89+
90+
const { getByText } = render();
91+
92+
expect(getByText('Buy ... with ...')).toBeDefined();
93+
});
94+
95+
it('passes fiat order metadata to useFiatOrderStatus', () => {
96+
render();
97+
98+
expect(useFiatOrderStatusMock).toHaveBeenCalledWith(
99+
'/providers/transak/orders/order-123',
100+
'transak',
101+
'0xabc',
102+
TransactionStatus.confirmed,
103+
);
104+
});
105+
106+
it('renders severity from useFiatOrderStatus', () => {
107+
useFiatOrderStatusMock.mockReturnValue({
108+
severity: 'error',
109+
statusText: 'Failed',
110+
cryptoSymbol: 'POL',
111+
paymentMethodName: 'Debit Card',
112+
});
113+
114+
const { getByTestId } = render();
115+
116+
expect(getByTestId('status-icon-error')).toBeDefined();
117+
});
118+
119+
it('includes status text in subtitle', () => {
120+
useFiatOrderStatusMock.mockReturnValue({
121+
severity: 'warning',
122+
statusText: 'Pending',
123+
cryptoSymbol: 'POL',
124+
paymentMethodName: 'Debit Card',
125+
});
126+
127+
const { getByTestId } = render();
128+
129+
expect(getByTestId('progress-list-item-subtitle').props.children).toEqual(
130+
expect.stringContaining('Pending'),
131+
);
132+
});
133+
134+
it('renders order details button when fiatOrderId exists', () => {
135+
const { getByTestId } = render();
136+
137+
expect(getByTestId('block-explorer-button')).toBeDefined();
138+
});
139+
140+
it('does not render order details button when fiatOrderId is missing', () => {
141+
const txWithoutFiatOrder = {
142+
...PARENT_TRANSACTION,
143+
metamaskPay: {},
144+
} as unknown as TransactionMeta;
145+
146+
const { queryByTestId } = render(txWithoutFiatOrder);
147+
148+
expect(queryByTestId('block-explorer-button')).toBeNull();
149+
});
150+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React, { useCallback } from 'react';
2+
import { useNavigation } from '@react-navigation/native';
3+
import { TransactionMeta } from '@metamask/transaction-controller';
4+
5+
import { IconName } from '../../../../../../component-library/components/Icons/Icon';
6+
import Routes from '../../../../../../constants/navigation/Routes';
7+
import I18n, { strings } from '../../../../../../../locales/i18n';
8+
import { getIntlDateTimeFormatter } from '../../../../../../util/intl';
9+
import { ProgressListItem } from '../../progress-list';
10+
import { useFiatOrderStatus } from '../../../hooks/activity/useFiatOrderStatus';
11+
12+
export function FiatOrderSummaryLine({
13+
parentTransaction,
14+
}: {
15+
parentTransaction: TransactionMeta;
16+
}) {
17+
const navigation = useNavigation();
18+
const { fiat } = parentTransaction.metamaskPay ?? {};
19+
const fiatOrderId = fiat?.orderId;
20+
const fiatProvider = fiat?.provider;
21+
const walletAddress = parentTransaction.txParams.from;
22+
23+
const { severity, statusText, cryptoSymbol, paymentMethodName } =
24+
useFiatOrderStatus(
25+
fiatOrderId,
26+
fiatProvider,
27+
walletAddress,
28+
parentTransaction.status,
29+
);
30+
31+
const title =
32+
cryptoSymbol && paymentMethodName
33+
? strings('transaction_details.summary_title.fiat_purchase', {
34+
token: cryptoSymbol,
35+
paymentMethod: paymentMethodName,
36+
})
37+
: strings('transaction_details.summary_title.fiat_purchase', {
38+
token: '...',
39+
paymentMethod: '...',
40+
});
41+
42+
const subtitle = formatSubtitle(parentTransaction.time, statusText);
43+
44+
const handleOrderDetailsPress = useCallback(() => {
45+
navigation.navigate(Routes.RAMP.RAMPS_ORDER_DETAILS, {
46+
orderId: fiatOrderId,
47+
showCloseButton: true,
48+
});
49+
}, [navigation, fiatOrderId]);
50+
51+
return (
52+
<ProgressListItem
53+
title={title}
54+
subtitle={subtitle}
55+
severity={severity}
56+
buttonIcon={fiatOrderId ? IconName.Export : undefined}
57+
onButtonPress={fiatOrderId ? handleOrderDetailsPress : undefined}
58+
/>
59+
);
60+
}
61+
62+
function formatSubtitle(timestamp: number, statusText: string): string {
63+
const date = new Date(timestamp);
64+
65+
const timeString = getIntlDateTimeFormatter(I18n.locale, {
66+
hour: 'numeric',
67+
minute: 'numeric',
68+
hour12: true,
69+
}).format(date);
70+
71+
const month = getIntlDateTimeFormatter(I18n.locale, {
72+
month: 'short',
73+
}).format(date);
74+
75+
const dateString = `${month} ${date.getDate()}, ${date.getFullYear()}`;
76+
77+
return `${statusText}${timeString}${dateString}`;
78+
}

app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { DepositSummaryLine } from './deposit-summary-line';
2323
import { ApprovalSummaryLine } from './approval-summary-line';
2424
import { ReceiveSummaryLine } from './receive-summary-line';
2525
import { DefaultSummaryLine } from './default-summary-line';
26+
import { FiatOrderSummaryLine } from './fiat-order-summary-line';
2627

2728
export function TransactionDetailsSummary() {
2829
const { transactionMeta } = useTransactionDetails();
@@ -67,12 +68,16 @@ export function TransactionDetailsSummary() {
6768
const hasDepositTransactions =
6869
(requiredTransactionIds?.length ?? 0) > 0 || batchTransactionIds.length > 0;
6970

70-
const { sourceHash } = metamaskPay ?? {};
71+
const { sourceHash, fiat } = metamaskPay ?? {};
72+
const { orderId: fiatOrderId } = fiat ?? {};
7173

7274
return (
7375
<Box gap={12}>
7476
<Text color={TextColor.Alternative}>Summary</Text>
7577
<ProgressList>
78+
{fiatOrderId ? (
79+
<FiatOrderSummaryLine parentTransaction={transactionMeta} />
80+
) : null}
7681
{!hasDepositTransactions && sourceHash ? (
7782
<SourceHashSummaryLine
7883
parentTransaction={transactionMeta}

app/components/Views/confirmations/components/confirm/confirm-component.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ jest.mock('../../hooks/gas/useGasFeeToken');
4040
jest.mock('../../hooks/tokens/useTokenWithBalance');
4141
jest.mock('../../hooks/alerts/useConfirmationAlerts');
4242
jest.mock('../../hooks/ui/useFullScreenConfirmation');
43+
jest.mock('../../hooks/pay/useTransactionPayAutoFiatSubmission');
4344
jest.mock('../../../../hooks/useRefreshSmartTransactionsLiveness', () => ({
4445
useRefreshSmartTransactionsLiveness: jest.fn(),
4546
}));

app/components/Views/confirmations/components/footer/footer.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,12 @@ describe('Footer', () => {
106106
jest.clearAllMocks();
107107

108108
mockUseConfirmationContext.mockReturnValue({
109+
headlessBuyError: undefined,
109110
isFooterVisible: true,
110111
isHeadlessBuyInProgress: false,
111112
isTransactionDataUpdating: false,
112113
isTransactionValueUpdating: false,
114+
setHeadlessBuyError: jest.fn(),
113115
setIsFooterVisible: jest.fn(),
114116
setIsHeadlessBuyInProgress: jest.fn(),
115117
setIsTransactionDataUpdating: jest.fn(),
@@ -218,10 +220,12 @@ describe('Footer', () => {
218220

219221
it('disables confirm button if isTransactionValueUpdating', () => {
220222
mockUseConfirmationContext.mockReturnValue({
223+
headlessBuyError: undefined,
221224
isFooterVisible: true,
222225
isHeadlessBuyInProgress: false,
223226
isTransactionDataUpdating: true,
224227
isTransactionValueUpdating: true,
228+
setHeadlessBuyError: jest.fn(),
225229
setIsFooterVisible: jest.fn(),
226230
setIsHeadlessBuyInProgress: jest.fn(),
227231
setIsTransactionDataUpdating: jest.fn(),
@@ -279,10 +283,12 @@ describe('Footer', () => {
279283

280284
it('hides footer by default for moneyAccountDeposit transaction type', () => {
281285
mockUseConfirmationContext.mockReturnValue({
286+
headlessBuyError: undefined,
282287
isFooterVisible: undefined,
283288
isHeadlessBuyInProgress: false,
284289
isTransactionDataUpdating: false,
285290
isTransactionValueUpdating: false,
291+
setHeadlessBuyError: jest.fn(),
286292
setIsFooterVisible: jest.fn(),
287293
setIsHeadlessBuyInProgress: jest.fn(),
288294
setIsTransactionDataUpdating: jest.fn(),
@@ -313,10 +319,12 @@ describe('Footer', () => {
313319

314320
it('hides footer by default for moneyAccountWithdraw transaction type', () => {
315321
mockUseConfirmationContext.mockReturnValue({
322+
headlessBuyError: undefined,
316323
isFooterVisible: undefined,
317324
isHeadlessBuyInProgress: false,
318325
isTransactionDataUpdating: false,
319326
isTransactionValueUpdating: false,
327+
setHeadlessBuyError: jest.fn(),
320328
setIsFooterVisible: jest.fn(),
321329
setIsHeadlessBuyInProgress: jest.fn(),
322330
setIsTransactionDataUpdating: jest.fn(),
@@ -347,10 +355,12 @@ describe('Footer', () => {
347355

348356
it('hides footer when isFooterVisible is false', () => {
349357
mockUseConfirmationContext.mockReturnValue({
358+
headlessBuyError: undefined,
350359
isFooterVisible: false,
351360
isHeadlessBuyInProgress: false,
352361
isTransactionDataUpdating: false,
353362
isTransactionValueUpdating: false,
363+
setHeadlessBuyError: jest.fn(),
354364
setIsFooterVisible: jest.fn(),
355365
setIsHeadlessBuyInProgress: jest.fn(),
356366
setIsTransactionDataUpdating: jest.fn(),

app/components/Views/confirmations/components/info-root/info-root.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ jest.mock('../info/custom-amount-info', () => ({
5555

5656
jest.mock('../../hooks/gas/useGasFeeToken');
5757
jest.mock('../../hooks/tokens/useTokenWithBalance');
58+
jest.mock('../../hooks/pay/useTransactionPayAutoFiatSubmission');
5859

5960
jest.mock('../../../../hooks/useRefreshSmartTransactionsLiveness', () => ({
6061
useRefreshSmartTransactionsLiveness: jest.fn(),

app/components/Views/confirmations/components/info-root/info-root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { MusdConversionInfoRoot } from '../info/musd-conversion-info-root';
3030
import { MoneyAccountDepositInfo } from '../info/money-account-deposit-info';
3131
import { MoneyAccountWithdrawInfo } from '../info/money-account-withdraw-info';
3232
import { useRefreshSmartTransactionsLiveness } from '../../../../hooks/useRefreshSmartTransactionsLiveness';
33+
import { useTransactionPayAutoFiatSubmission } from '../../hooks/pay/useTransactionPayAutoFiatSubmission';
3334
import PerpsOrderView from '../../../../UI/Perps/Views/PerpsOrderView';
3435

3536
interface ConfirmationInfoComponentRequest {
@@ -91,6 +92,7 @@ const Info = ({ route }: InfoProps) => {
9192
const { isDowngrade, isUpgradeOnly } = use7702TransactionType();
9293
// Refresh STX liveness for the transaction's network
9394
useRefreshSmartTransactionsLiveness(transactionMetadata?.chainId);
95+
useTransactionPayAutoFiatSubmission();
9496

9597
if (!approvalRequest?.type) {
9698
return null;

0 commit comments

Comments
 (0)