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

- Bump `@metamask/transaction-controller` from `^65.0.0` to `^65.1.0` ([#8691](https://github.com/MetaMask/core/pull/8691))
- 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))

## [21.0.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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('../relay/relay-quotes');
Expand Down Expand Up @@ -231,6 +231,7 @@ describe('submitFiatQuotes', () => {
const deriveFiatAssetForFiatPaymentMock = jest.mocked(
deriveFiatAssetForFiatPayment,
);
const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw);
const getRelayQuotesMock = jest.mocked(getRelayQuotes);
const submitRelayQuotesMock = jest.mocked(submitRelayQuotes);

Expand All @@ -239,6 +240,7 @@ describe('submitFiatQuotes', () => {
jest.useRealTimers();

deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK);
resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000');
getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]);
submitRelayQuotesMock.mockResolvedValue({
transactionHash: '0x1234',
Expand All @@ -255,6 +257,7 @@ describe('submitFiatQuotes', () => {
},
status: RampsOrderStatus.Completed,
});
resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000');
const { callMock, request } = getRequest({ order });

const result = await submitFiatQuotes(request);
Expand All @@ -265,6 +268,11 @@ describe('submitFiatQuotes', () => {
'order-123',
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 Down Expand Up @@ -502,20 +510,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 @@ -535,10 +539,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 @@ -19,7 +19,7 @@ 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 @@ -109,41 +109,6 @@ function parseOrderId(
return { orderCode: parts[3], providerCode: parts[1] };
}

/**
* 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 @@ -334,9 +299,10 @@ async function submitRelayAfterFiatCompletion({
transactionId,
});

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

const baseRequest = quotes[0].request;
Expand Down
183 changes: 181 additions & 2 deletions packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,59 @@
import { Interface } from '@ethersproject/abi';
import { Web3Provider } from '@ethersproject/providers';
import { abiERC20 } from '@metamask/metamask-eth-abis';
import type { RampsOrder } from '@metamask/ramps-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import { TransactionType } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';

import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants';
import { deriveFiatAssetForFiatPayment } from './utils';
import { NATIVE_TOKEN_ADDRESS } from '../../constants';
import { getMessengerMock } from '../../tests/messenger-mock';
import { FIAT_ASSET_ID_BY_TX_TYPE, TransactionPayFiatAsset } from './constants';
import {
deriveFiatAssetForFiatPayment,
getRawSourceAmountFromOrderCryptoAmount,
resolveSourceAmountRaw,
} from './utils';

jest.mock('@ethersproject/providers', () => ({
...jest.requireActual('@ethersproject/providers'),
Web3Provider: jest.fn(),
}));

const TX_HASH_MOCK = '0xabc123';
const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex;
const ERC20_ADDRESS_MOCK = '0x2222222222222222222222222222222222222222' as Hex;
const CHAIN_ID_MOCK = '0x1' as Hex;
const NETWORK_CLIENT_ID_MOCK = 'net-client-1';
const PROVIDER_MOCK = { request: jest.fn() };

const NATIVE_FIAT_ASSET_MOCK: TransactionPayFiatAsset = {
address: NATIVE_TOKEN_ADDRESS,
caipAssetId: 'eip155:1/slip44:60',
chainId: CHAIN_ID_MOCK,
decimals: 18,
};

const ERC20_FIAT_ASSET_MOCK: TransactionPayFiatAsset = {
address: ERC20_ADDRESS_MOCK,
caipAssetId: 'eip155:1/erc20:0x2222222222222222222222222222222222222222',
chainId: CHAIN_ID_MOCK,
decimals: 6,
};

const erc20Interface = new Interface(abiERC20);

function buildTransferCallData(to: Hex, amount: string): string {
return erc20Interface.encodeFunctionData('transfer', [to, amount]);
}

function getOrderMock(overrides: Partial<RampsOrder> = {}): RampsOrder {
return {
cryptoAmount: '1.5',
txHash: TX_HASH_MOCK,
...overrides,
} as RampsOrder;
}

describe('Fiat Utils', () => {
describe('deriveFiatAssetForFiatPayment', () => {
Expand Down Expand Up @@ -41,4 +92,132 @@ describe('Fiat Utils', () => {
expect(result).toBeUndefined();
});
});

describe('resolveSourceAmountRaw', () => {
const {
messenger,
findNetworkClientIdByChainIdMock,
getNetworkClientByIdMock,
} = getMessengerMock();

let mockGetTransaction: jest.Mock;

beforeEach(() => {
jest.resetAllMocks();

mockGetTransaction = jest.fn();

findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK);
getNetworkClientByIdMock.mockReturnValue({
provider: PROVIDER_MOCK,
} as never);

(Web3Provider as unknown as jest.Mock).mockImplementation(() => ({
getTransaction: mockGetTransaction,
}));
});

it('returns on-chain amount when txHash is present and read succeeds', async () => {
mockGetTransaction.mockResolvedValue({
data: buildTransferCallData(WALLET_ADDRESS_MOCK, '7000000'),
value: { toString: () => '0' },
});

const result = await resolveSourceAmountRaw({
messenger,
order: getOrderMock(),
fiatAsset: ERC20_FIAT_ASSET_MOCK,
});

expect(result).toBe('7000000');
});

it('falls back to cryptoAmount when txHash is missing', async () => {
const result = await resolveSourceAmountRaw({
messenger,
order: getOrderMock({ txHash: '' }),
fiatAsset: ERC20_FIAT_ASSET_MOCK,
});

expect(result).toBe('1500000');
expect(mockGetTransaction).not.toHaveBeenCalled();
});

it('falls back to cryptoAmount when on-chain read returns undefined', async () => {
mockGetTransaction.mockResolvedValue(null);

const result = await resolveSourceAmountRaw({
messenger,
order: getOrderMock(),
fiatAsset: ERC20_FIAT_ASSET_MOCK,
});

expect(result).toBe('1500000');
});

it('falls back to cryptoAmount when on-chain read throws', async () => {
mockGetTransaction.mockRejectedValue(new Error('Network error'));

const result = await resolveSourceAmountRaw({
messenger,
order: getOrderMock(),
fiatAsset: ERC20_FIAT_ASSET_MOCK,
});

expect(result).toBe('1500000');
});

it('returns on-chain native token amount when txHash is present', async () => {
mockGetTransaction.mockResolvedValue({
value: { toString: () => '2000000000000000000' },
});

const result = await resolveSourceAmountRaw({
messenger,
order: getOrderMock(),
fiatAsset: NATIVE_FIAT_ASSET_MOCK,
});

expect(result).toBe('2000000000000000000');
});
});

describe('getRawSourceAmountFromOrderCryptoAmount', () => {
it('converts human-readable amount to raw token amount', () => {
expect(
getRawSourceAmountFromOrderCryptoAmount({
cryptoAmount: '1.2345',
decimals: 18,
}),
).toBe('1234500000000000000');
});

it('truncates fractional sub-decimal amounts', () => {
expect(
getRawSourceAmountFromOrderCryptoAmount({
cryptoAmount: '1.1234567',
decimals: 6,
}),
).toBe('1123456');
});

it.each([
['0', 'Invalid fiat order crypto amount: 0'],
['-1', 'Invalid fiat order crypto amount: -1'],
['NaN', 'Invalid fiat order crypto amount: NaN'],
])('throws for invalid crypto amount %s', (cryptoAmount, expectedError) => {
expect(() =>
getRawSourceAmountFromOrderCryptoAmount({ cryptoAmount, decimals: 18 }),
).toThrow(expectedError);
});

it('throws when computed amount rounds to zero', () => {
expect(() =>
getRawSourceAmountFromOrderCryptoAmount({
cryptoAmount: '0.0000000000000000001',
decimals: 18,
}),
).toThrow('Computed fiat order source amount is not positive');
});
});
});
Loading
Loading