Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `fiatOrderId` and `fiatProvider` optional fields to `MetamaskPayMetadata` type for persisting fiat on-ramp order data on transactions ([#8694](https://github.com/MetaMask/core/pull/8694))

## [65.3.0]

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

/** Fiat on-ramp order ID (normalized format: /providers/{provider}/orders/{id}). */
fiatOrderId?: string;

/** Fiat on-ramp provider code (e.g. "transak-native"). */
fiatProvider?: 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.2.0]

### 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,11 @@ describe('submitFiatQuotes', () => {
ORDER_ID_MOCK,
WALLET_ADDRESS_MOCK,
);
expect(resolveSourceAmountRawMock).toHaveBeenCalledWith({
messenger: expect.anything(),
order,
fiatAsset: FIAT_ASSET_MOCK,
});
expect(getRelayQuotesMock).toHaveBeenCalledTimes(1);
expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([
expect.objectContaining({
Expand All @@ -297,6 +306,48 @@ 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({
fiatOrderId: ORDER_ID_MOCK,
fiatProvider: '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',
fiatOrderId: ORDER_ID_MOCK,
fiatProvider: 'transak-native-staging',
});
});

it('throws if wallet address is missing', async () => {
const { request } = getRequest({
transaction: {
Expand Down Expand Up @@ -511,7 +562,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 +604,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 +633,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,19 @@ 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.fiatOrderId = orderId;
tx.metamaskPay.fiatProvider = providerCode;
},
);

log('Starting fiat order polling', {
orderId,
providerCode,
Expand Down Expand Up @@ -108,41 +122,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 +310,10 @@ async function submitRelayAfterFiatCompletion({
transactionId,
});

const tokenInfo = getTokenInfo(
const sourceAmountRaw = await resolveSourceAmountRaw({
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 sourceAmountRaw = getRawSourceAmountFromOrder({
cryptoAmount: order.cryptoAmount,
decimals: tokenInfo.decimals,
order,
fiatAsset,
});

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