Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd369b2
feat: derive fiat order source amount from on-chain tx data with cryp…
OGPoyraz May 5, 2026
f20bda2
docs: add changelog entry with PR link
OGPoyraz May 5, 2026
e9eee5d
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 5, 2026
7f8375a
Addres cursor comment
OGPoyraz May 5, 2026
0a7e13c
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 6, 2026
795ca74
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 11, 2026
26cf4a7
Changelog fix
OGPoyraz May 11, 2026
eb1964b
Add tx.to check
OGPoyraz May 11, 2026
0a27c9e
Fix unit tests
OGPoyraz May 11, 2026
dd7031f
fix: verify tx.to matches tokenAddress for ERC-20 path, fix no-shadow…
OGPoyraz May 11, 2026
a423212
feat: persist fiat order metadata on metamaskPay for activity view
OGPoyraz May 11, 2026
3535057
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 11, 2026
fcf291e
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 12, 2026
7e1d6cd
Address comments
OGPoyraz May 12, 2026
5fdb759
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 12, 2026
0469895
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 12, 2026
bf1e13a
Fix changelog
OGPoyraz May 12, 2026
d7699cd
Address comment
OGPoyraz May 12, 2026
610664e
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 12, 2026
46177a2
Merge branch 'main' into ogp/fiat-order-txhash-implementation
OGPoyraz May 12, 2026
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
1 change: 1 addition & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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))
- Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8759](https://github.com/MetaMask/core/pull/8759))

### Changed
Expand Down
8 changes: 8 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2170,6 +2170,14 @@ export type MetamaskPayMetadata = {
/** Chain ID of the payment token. */
chainId?: Hex;

/** Fiat on-ramp metadata (order ID and provider). */
fiat?: {
/** Order ID (normalized format: /providers/{provider}/orders/{id}). */
orderId: string;
/** Provider code (e.g. "transak-native"). */
provider: string;
};

/**
* Whether this is a post-quote transaction (e.g., withdrawal flow).
* When true, the token represents the destination rather than source.
Expand Down
5 changes: 5 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- 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))
- 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))

## [22.3.1]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ import type {
QuoteRequest,
TransactionPayQuote,
} from '../../types';
import { buildCaipAssetType, getTokenInfo } from '../../utils/token';
import { buildCaipAssetType } from '../../utils/token';
import { updateTransaction } from '../../utils/transaction';
import { getRelayQuotes } from '../relay/relay-quotes';
import { submitRelayQuotes } from '../relay/relay-submit';
import type { RelayQuote } from '../relay/types';
import type { TransactionPayFiatAsset } from './constants';
import { submitFiatQuotes } from './fiat-submit';
import type { FiatQuote } from './types';
import { deriveFiatAssetForFiatPayment } from './utils';
import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils';

jest.mock('./utils');
jest.mock('../../utils/token');
jest.mock('../../utils/transaction');
jest.mock('../relay/relay-quotes');
jest.mock('../relay/relay-submit');

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

const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966';

const RAMPS_QUOTE_MOCK: RampsQuote = {
provider: '/providers/transak-native-staging',
quote: {
Expand Down Expand Up @@ -230,14 +234,13 @@ function getRequest({
};
}

const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966';

describe('submitFiatQuotes', () => {
const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType);
const getTokenInfoMock = jest.mocked(getTokenInfo);
const deriveFiatAssetForFiatPaymentMock = jest.mocked(
deriveFiatAssetForFiatPayment,
);
const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw);
const updateTransactionMock = jest.mocked(updateTransaction);
const getRelayQuotesMock = jest.mocked(getRelayQuotes);
const submitRelayQuotesMock = jest.mocked(submitRelayQuotes);

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

buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK);
getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' });
deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK);
resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000');
getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]);
submitRelayQuotesMock.mockResolvedValue({
transactionHash: '0x1234',
Expand All @@ -264,6 +267,7 @@ describe('submitFiatQuotes', () => {
},
status: RampsOrderStatus.Completed,
});
resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000');
const { callMock, request } = getRequest({ order });

const result = await submitFiatQuotes(request);
Expand All @@ -274,6 +278,12 @@ describe('submitFiatQuotes', () => {
ORDER_ID_MOCK,
WALLET_ADDRESS_MOCK,
);
expect(resolveSourceAmountRawMock).toHaveBeenCalledWith({
messenger: expect.anything(),
order,
fiatAsset: FIAT_ASSET_MOCK,
walletAddress: WALLET_ADDRESS_MOCK,
});
expect(getRelayQuotesMock).toHaveBeenCalledTimes(1);
expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([
expect.objectContaining({
Expand All @@ -297,6 +307,46 @@ describe('submitFiatQuotes', () => {
expect(result).toStrictEqual({ transactionHash: '0x1234' });
});

it('persists fiat order metadata on the transaction before polling', async () => {
const { request } = getRequest();

await submitFiatQuotes(request);

expect(updateTransactionMock).toHaveBeenCalledWith(
{
transactionId: TRANSACTION_ID_MOCK,
messenger: request.messenger,
note: 'Persist fiat order metadata',
},
expect.any(Function),
);

const txDraft = { metamaskPay: undefined } as unknown as TransactionMeta;
const updateFn = updateTransactionMock.mock.calls[0][1];
updateFn(txDraft);

expect(txDraft.metamaskPay).toStrictEqual({
fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' },
});
});

it('preserves existing metamaskPay fields when persisting fiat order metadata', async () => {
const { request } = getRequest();

await submitFiatQuotes(request);

const txDraft = {
metamaskPay: { totalFiat: '20.00' },
} as unknown as TransactionMeta;
const updateFn = updateTransactionMock.mock.calls[0][1];
updateFn(txDraft);

expect(txDraft.metamaskPay).toStrictEqual({
totalFiat: '20.00',
fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' },
});
});

it('throws if wallet address is missing', async () => {
const { request } = getRequest({
transaction: {
Expand Down Expand Up @@ -511,7 +561,11 @@ describe('submitFiatQuotes', () => {
});

it('throws if token info is unavailable for the fiat asset', async () => {
getTokenInfoMock.mockReturnValue(undefined);
resolveSourceAmountRawMock.mockRejectedValue(
new Error(
`Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`,
),
);
const { request } = getRequest();

await expect(submitFiatQuotes(request)).rejects.toThrow(
Expand Down Expand Up @@ -549,20 +603,16 @@ describe('submitFiatQuotes', () => {
);
});

it.each([
['0', 'Invalid fiat order crypto amount: 0'],
['-1', 'Invalid fiat order crypto amount: -1'],
['NaN', 'Invalid fiat order crypto amount: NaN'],
])(
'throws if order crypto amount is invalid (%s)',
async (cryptoAmount, expectedError) => {
const { request } = getRequest({
order: getFiatOrderMock({ cryptoAmount }),
});

await expect(submitFiatQuotes(request)).rejects.toThrow(expectedError);
},
);
it('throws if resolveSourceAmountRaw rejects', async () => {
resolveSourceAmountRawMock.mockRejectedValue(
new Error('Invalid fiat order crypto amount: 0'),
);
const { request } = getRequest();

await expect(submitFiatQuotes(request)).rejects.toThrow(
'Invalid fiat order crypto amount: 0',
);
});

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

it('throws if crypto amount rounds to zero after decimal shift', async () => {
const { request } = getRequest({
order: getFiatOrderMock({ cryptoAmount: '0.0000000000000000001' }),
});
it('throws if resolveSourceAmountRaw throws for zero amount', async () => {
resolveSourceAmountRawMock.mockRejectedValue(
new Error('Computed fiat order source amount is not positive'),
);
const { request } = getRequest();

await expect(submitFiatQuotes(request)).rejects.toThrow(
'Computed fiat order source amount is not positive',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import type {
QuoteRequest,
TransactionPayControllerMessenger,
} from '../../types';
import { buildCaipAssetType, getTokenInfo } from '../../utils/token';
import { buildCaipAssetType } from '../../utils/token';
import { updateTransaction } from '../../utils/transaction';
import { getRelayQuotes } from '../relay/relay-quotes';
import { submitRelayQuotes } from '../relay/relay-submit';
import type { RelayQuote } from '../relay/types';
import type { TransactionPayFiatAsset } from './constants';
import type { FiatQuote } from './types';
import { deriveFiatAssetForFiatPayment } from './utils';
import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils';

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

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

updateTransaction(
{
transactionId,
messenger,
note: 'Persist fiat order metadata',
},
(tx) => {
tx.metamaskPay ??= {};
tx.metamaskPay.fiat = { orderId, provider: providerCode };
},
);

log('Starting fiat order polling', {
orderId,
providerCode,
Expand Down Expand Up @@ -108,41 +121,6 @@ function extractProviderCode(provider: string | undefined): string | null {
return parts.length >= 2 && parts[0] === 'providers' ? parts[1] : null;
}

/**
* Converts the order's human-readable crypto amount to a raw token amount.
*
* @param options - The conversion options.
* @param options.cryptoAmount - Human-readable crypto amount from the completed order.
* @param options.decimals - Token decimals for the fiat asset.
* @returns The raw token amount as a string.
*/
function getRawSourceAmountFromOrder({
cryptoAmount,
decimals,
}: {
cryptoAmount: RampsOrder['cryptoAmount'];
decimals: number;
}): string {
const normalizedAmount = new BigNumber(String(cryptoAmount));

if (!normalizedAmount.isFinite() || normalizedAmount.lte(0)) {
throw new Error(
`Invalid fiat order crypto amount: ${String(cryptoAmount)}`,
);
}

const rawAmount = normalizedAmount
.shiftedBy(decimals)
.decimalPlaces(0, BigNumber.ROUND_DOWN)
.toFixed(0);

if (!new BigNumber(rawAmount).gt(0)) {
throw new Error('Computed fiat order source amount is not positive');
}

return rawAmount;
}

/**
* Validates that the completed order's crypto asset matches the expected fiat asset.
*
Expand Down Expand Up @@ -331,21 +309,13 @@ async function submitRelayAfterFiatCompletion({
transactionId,
});

const tokenInfo = getTokenInfo(
messenger,
fiatAsset.address,
fiatAsset.chainId,
);

if (!tokenInfo) {
throw new Error(
`Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`,
);
}
const walletAddress = transaction.txParams.from as Hex;

const sourceAmountRaw = getRawSourceAmountFromOrder({
cryptoAmount: order.cryptoAmount,
decimals: tokenInfo.decimals,
const sourceAmountRaw = await resolveSourceAmountRaw({
messenger,
order,
fiatAsset,
walletAddress,
});

const baseRequest = quotes[0].request;
Expand Down
Loading
Loading