Skip to content

Commit d126223

Browse files
authored
feat: Derive fiat order sourceAmount from on-chain tx data and persist fiat order metadata (MetaMask#8694)
## Explanation ### 1. Derive fiat order `sourceAmount` from on-chain tx data Currently, the fiat submit flow derives `sourceAmountRaw` from `order.cryptoAmount` - a human-readable value reported by the on-ramp provider. This value may not precisely reflect what was actually received on-chain. This PR reads the actual transferred amount from the completed on-chain transaction (`order.txHash`) instead. For native tokens, the amount is taken from `tx.value`. For ERC-20 tokens, the amount is decoded from the `transfer(address,uint256)` call data. If the on-chain read fails or the transaction hash is missing, the existing `order.cryptoAmount` derivation is used as a fallback. The implementation introduces: - **`getTransferredAmountFromTxHash`** - a generic utility in `utils/transaction-receipt.ts` that reads transferred amounts from any on-chain transaction (native or ERC-20). Takes explicit `chainId` and `tokenAddress` params for reusability. - **`resolveSourceAmountRaw`** - a fiat-strategy-specific function in `strategy/fiat/utils.ts` that orchestrates the on-chain read with `order.cryptoAmount` fallback. - **`getRawSourceAmountFromOrderCryptoAmount`** - the existing decimal-shift conversion, moved from `fiat-submit.ts` to `strategy/fiat/utils.ts` and renamed for clarity. ### 2. Persist fiat order metadata on `metamaskPay` The `TransactionPayController` state is cleaned up when a transaction is finalized (confirmed/failed/dropped). This means `fiatPayment.orderId` and the provider info are gone by the time the user opens the activity/transaction-details view. To enable the mobile activity view to show a fiat order status row (and query `RampsController:getOrder` for live status), this PR persists the fiat order ID and provider code on `transaction.metamaskPay` **before** polling begins in `submitFiatQuotes`. Changes: - **`MetamaskPayMetadata`** (`transaction-controller/types.ts`) — Added `fiatOrderId?: string` and `fiatProvider?: string` fields. - **`submitFiatQuotes`** (`fiat-submit.ts`) — Calls `updateTransaction` to persist `fiatOrderId` and `fiatProvider` on `tx.metamaskPay` before `waitForOrderCompletion` starts polling. This ensures data is available even while the order is still in-flight. - **Tests** — Two new tests verify metadata persistence and that existing `metamaskPay` fields are preserved. ## References - Related to the fiat strategy submit flow introduced in MetaMask#8347 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Updates fiat on-ramp submission to depend on on-chain receipt/trace parsing and persists new metadata onto `TransactionMeta`, which could affect payment execution and activity display if RPC methods/log parsing behave unexpectedly across networks. > > **Overview** > Fiat on-ramp submit flow now **persists order identifiers** onto `transaction.metamaskPay.fiat` (order ID + provider code) before polling, so downstream activity views can query order status even after controller state cleanup. > > The relay leg’s `sourceAmountRaw` is now **derived from the actual on-chain transfer** referenced by `order.txHash` via new utilities that parse ERC-20 `Transfer` logs or native transfers (using `debug_traceTransaction` with a `tx.value` fallback), falling back to `order.cryptoAmount` conversion when on-chain reads are unavailable. > > Adds `MetamaskPayMetadata.fiat` to the transaction-controller types and updates/extends unit tests around fiat submission, amount resolution, and receipt/trace parsing. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 46177a2. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e81c393 commit d126223

9 files changed

Lines changed: 1120 additions & 77 deletions

File tree

packages/transaction-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add optional `fiat` object (with `orderId` and `provider` properties) to `MetamaskPayMetadata` type for persisting fiat on-ramp order data on transactions ([#8694](https://github.com/MetaMask/core/pull/8694))
1213
- Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8759](https://github.com/MetaMask/core/pull/8759))
1314

1415
### Changed

packages/transaction-controller/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2170,6 +2170,14 @@ export type MetamaskPayMetadata = {
21702170
/** Chain ID of the payment token. */
21712171
chainId?: Hex;
21722172

2173+
/** Fiat on-ramp metadata (order ID and provider). */
2174+
fiat?: {
2175+
/** Order ID (normalized format: /providers/{provider}/orders/{id}). */
2176+
orderId: string;
2177+
/** Provider code (e.g. "transak-native"). */
2178+
provider: string;
2179+
};
2180+
21732181
/**
21742182
* Whether this is a post-quote transaction (e.g., withdrawal flow).
21752183
* When true, the token represents the destination rather than source.

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- Derive fiat order source amount from on-chain transaction data (`order.txHash`) with fallback to `order.cryptoAmount` ([#8694](https://github.com/MetaMask/core/pull/8694))
13+
- Persist fiat order ID and provider code on `transaction.metamaskPay` before polling, so activity views can query order status after controller state cleanup ([#8694](https://github.com/MetaMask/core/pull/8694))
14+
1015
## [22.3.1]
1116

1217
### Changed

packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,19 @@ import type {
1414
QuoteRequest,
1515
TransactionPayQuote,
1616
} from '../../types';
17-
import { buildCaipAssetType, getTokenInfo } from '../../utils/token';
17+
import { buildCaipAssetType } from '../../utils/token';
18+
import { updateTransaction } from '../../utils/transaction';
1819
import { getRelayQuotes } from '../relay/relay-quotes';
1920
import { submitRelayQuotes } from '../relay/relay-submit';
2021
import type { RelayQuote } from '../relay/types';
2122
import type { TransactionPayFiatAsset } from './constants';
2223
import { submitFiatQuotes } from './fiat-submit';
2324
import type { FiatQuote } from './types';
24-
import { deriveFiatAssetForFiatPayment } from './utils';
25+
import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils';
2526

2627
jest.mock('./utils');
2728
jest.mock('../../utils/token');
29+
jest.mock('../../utils/transaction');
2830
jest.mock('../relay/relay-quotes');
2931
jest.mock('../relay/relay-submit');
3032

@@ -45,6 +47,8 @@ const FIAT_ASSET_MOCK: TransactionPayFiatAsset = {
4547
chainId: '0x89',
4648
};
4749

50+
const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966';
51+
4852
const RAMPS_QUOTE_MOCK: RampsQuote = {
4953
provider: '/providers/transak-native-staging',
5054
quote: {
@@ -230,14 +234,13 @@ function getRequest({
230234
};
231235
}
232236

233-
const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966';
234-
235237
describe('submitFiatQuotes', () => {
236238
const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType);
237-
const getTokenInfoMock = jest.mocked(getTokenInfo);
238239
const deriveFiatAssetForFiatPaymentMock = jest.mocked(
239240
deriveFiatAssetForFiatPayment,
240241
);
242+
const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw);
243+
const updateTransactionMock = jest.mocked(updateTransaction);
241244
const getRelayQuotesMock = jest.mocked(getRelayQuotes);
242245
const submitRelayQuotesMock = jest.mocked(submitRelayQuotes);
243246

@@ -246,8 +249,8 @@ describe('submitFiatQuotes', () => {
246249
jest.useRealTimers();
247250

248251
buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK);
249-
getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' });
250252
deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK);
253+
resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000');
251254
getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]);
252255
submitRelayQuotesMock.mockResolvedValue({
253256
transactionHash: '0x1234',
@@ -264,6 +267,7 @@ describe('submitFiatQuotes', () => {
264267
},
265268
status: RampsOrderStatus.Completed,
266269
});
270+
resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000');
267271
const { callMock, request } = getRequest({ order });
268272

269273
const result = await submitFiatQuotes(request);
@@ -274,6 +278,12 @@ describe('submitFiatQuotes', () => {
274278
ORDER_ID_MOCK,
275279
WALLET_ADDRESS_MOCK,
276280
);
281+
expect(resolveSourceAmountRawMock).toHaveBeenCalledWith({
282+
messenger: expect.anything(),
283+
order,
284+
fiatAsset: FIAT_ASSET_MOCK,
285+
walletAddress: WALLET_ADDRESS_MOCK,
286+
});
277287
expect(getRelayQuotesMock).toHaveBeenCalledTimes(1);
278288
expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([
279289
expect.objectContaining({
@@ -297,6 +307,46 @@ describe('submitFiatQuotes', () => {
297307
expect(result).toStrictEqual({ transactionHash: '0x1234' });
298308
});
299309

310+
it('persists fiat order metadata on the transaction before polling', async () => {
311+
const { request } = getRequest();
312+
313+
await submitFiatQuotes(request);
314+
315+
expect(updateTransactionMock).toHaveBeenCalledWith(
316+
{
317+
transactionId: TRANSACTION_ID_MOCK,
318+
messenger: request.messenger,
319+
note: 'Persist fiat order metadata',
320+
},
321+
expect.any(Function),
322+
);
323+
324+
const txDraft = { metamaskPay: undefined } as unknown as TransactionMeta;
325+
const updateFn = updateTransactionMock.mock.calls[0][1];
326+
updateFn(txDraft);
327+
328+
expect(txDraft.metamaskPay).toStrictEqual({
329+
fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' },
330+
});
331+
});
332+
333+
it('preserves existing metamaskPay fields when persisting fiat order metadata', async () => {
334+
const { request } = getRequest();
335+
336+
await submitFiatQuotes(request);
337+
338+
const txDraft = {
339+
metamaskPay: { totalFiat: '20.00' },
340+
} as unknown as TransactionMeta;
341+
const updateFn = updateTransactionMock.mock.calls[0][1];
342+
updateFn(txDraft);
343+
344+
expect(txDraft.metamaskPay).toStrictEqual({
345+
totalFiat: '20.00',
346+
fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' },
347+
});
348+
});
349+
300350
it('throws if wallet address is missing', async () => {
301351
const { request } = getRequest({
302352
transaction: {
@@ -511,7 +561,11 @@ describe('submitFiatQuotes', () => {
511561
});
512562

513563
it('throws if token info is unavailable for the fiat asset', async () => {
514-
getTokenInfoMock.mockReturnValue(undefined);
564+
resolveSourceAmountRawMock.mockRejectedValue(
565+
new Error(
566+
`Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`,
567+
),
568+
);
515569
const { request } = getRequest();
516570

517571
await expect(submitFiatQuotes(request)).rejects.toThrow(
@@ -549,20 +603,16 @@ describe('submitFiatQuotes', () => {
549603
);
550604
});
551605

552-
it.each([
553-
['0', 'Invalid fiat order crypto amount: 0'],
554-
['-1', 'Invalid fiat order crypto amount: -1'],
555-
['NaN', 'Invalid fiat order crypto amount: NaN'],
556-
])(
557-
'throws if order crypto amount is invalid (%s)',
558-
async (cryptoAmount, expectedError) => {
559-
const { request } = getRequest({
560-
order: getFiatOrderMock({ cryptoAmount }),
561-
});
562-
563-
await expect(submitFiatQuotes(request)).rejects.toThrow(expectedError);
564-
},
565-
);
606+
it('throws if resolveSourceAmountRaw rejects', async () => {
607+
resolveSourceAmountRawMock.mockRejectedValue(
608+
new Error('Invalid fiat order crypto amount: 0'),
609+
);
610+
const { request } = getRequest();
611+
612+
await expect(submitFiatQuotes(request)).rejects.toThrow(
613+
'Invalid fiat order crypto amount: 0',
614+
);
615+
});
566616

567617
it('throws if request has no fiat quotes', async () => {
568618
const { request } = getRequest();
@@ -582,10 +632,11 @@ describe('submitFiatQuotes', () => {
582632
);
583633
});
584634

585-
it('throws if crypto amount rounds to zero after decimal shift', async () => {
586-
const { request } = getRequest({
587-
order: getFiatOrderMock({ cryptoAmount: '0.0000000000000000001' }),
588-
});
635+
it('throws if resolveSourceAmountRaw throws for zero amount', async () => {
636+
resolveSourceAmountRawMock.mockRejectedValue(
637+
new Error('Computed fiat order source amount is not positive'),
638+
);
639+
const { request } = getRequest();
589640

590641
await expect(submitFiatQuotes(request)).rejects.toThrow(
591642
'Computed fiat order source amount is not positive',

packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import type {
1414
QuoteRequest,
1515
TransactionPayControllerMessenger,
1616
} from '../../types';
17-
import { buildCaipAssetType, getTokenInfo } from '../../utils/token';
17+
import { buildCaipAssetType } from '../../utils/token';
18+
import { updateTransaction } from '../../utils/transaction';
1819
import { getRelayQuotes } from '../relay/relay-quotes';
1920
import { submitRelayQuotes } from '../relay/relay-submit';
2021
import type { RelayQuote } from '../relay/types';
2122
import type { TransactionPayFiatAsset } from './constants';
2223
import type { FiatQuote } from './types';
23-
import { deriveFiatAssetForFiatPayment } from './utils';
24+
import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils';
2425

2526
const log = createModuleLogger(projectLogger, 'fiat-submit');
2627

@@ -70,6 +71,18 @@ export async function submitFiatQuotes(
7071
throw new Error('Missing provider code for fiat submission');
7172
}
7273

74+
updateTransaction(
75+
{
76+
transactionId,
77+
messenger,
78+
note: 'Persist fiat order metadata',
79+
},
80+
(tx) => {
81+
tx.metamaskPay ??= {};
82+
tx.metamaskPay.fiat = { orderId, provider: providerCode };
83+
},
84+
);
85+
7386
log('Starting fiat order polling', {
7487
orderId,
7588
providerCode,
@@ -108,41 +121,6 @@ function extractProviderCode(provider: string | undefined): string | null {
108121
return parts.length >= 2 && parts[0] === 'providers' ? parts[1] : null;
109122
}
110123

111-
/**
112-
* Converts the order's human-readable crypto amount to a raw token amount.
113-
*
114-
* @param options - The conversion options.
115-
* @param options.cryptoAmount - Human-readable crypto amount from the completed order.
116-
* @param options.decimals - Token decimals for the fiat asset.
117-
* @returns The raw token amount as a string.
118-
*/
119-
function getRawSourceAmountFromOrder({
120-
cryptoAmount,
121-
decimals,
122-
}: {
123-
cryptoAmount: RampsOrder['cryptoAmount'];
124-
decimals: number;
125-
}): string {
126-
const normalizedAmount = new BigNumber(String(cryptoAmount));
127-
128-
if (!normalizedAmount.isFinite() || normalizedAmount.lte(0)) {
129-
throw new Error(
130-
`Invalid fiat order crypto amount: ${String(cryptoAmount)}`,
131-
);
132-
}
133-
134-
const rawAmount = normalizedAmount
135-
.shiftedBy(decimals)
136-
.decimalPlaces(0, BigNumber.ROUND_DOWN)
137-
.toFixed(0);
138-
139-
if (!new BigNumber(rawAmount).gt(0)) {
140-
throw new Error('Computed fiat order source amount is not positive');
141-
}
142-
143-
return rawAmount;
144-
}
145-
146124
/**
147125
* Validates that the completed order's crypto asset matches the expected fiat asset.
148126
*
@@ -331,21 +309,13 @@ async function submitRelayAfterFiatCompletion({
331309
transactionId,
332310
});
333311

334-
const tokenInfo = getTokenInfo(
335-
messenger,
336-
fiatAsset.address,
337-
fiatAsset.chainId,
338-
);
339-
340-
if (!tokenInfo) {
341-
throw new Error(
342-
`Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`,
343-
);
344-
}
312+
const walletAddress = transaction.txParams.from as Hex;
345313

346-
const sourceAmountRaw = getRawSourceAmountFromOrder({
347-
cryptoAmount: order.cryptoAmount,
348-
decimals: tokenInfo.decimals,
314+
const sourceAmountRaw = await resolveSourceAmountRaw({
315+
messenger,
316+
order,
317+
fiatAsset,
318+
walletAddress,
349319
});
350320

351321
const baseRequest = quotes[0].request;

0 commit comments

Comments
 (0)