From cd369b2d91d753eba7409ca0d47647e2317425f1 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 5 May 2026 13:40:14 +0200 Subject: [PATCH 1/3] feat: derive fiat order source amount from on-chain tx data with cryptoAmount fallback --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/strategy/fiat/fiat-submit.test.ts | 43 ++-- .../src/strategy/fiat/fiat-submit.ts | 44 +---- .../src/strategy/fiat/utils.test.ts | 183 +++++++++++++++++- .../src/strategy/fiat/utils.ts | 95 +++++++++ .../src/utils/transaction-receipt.test.ts | 173 +++++++++++++++++ .../src/utils/transaction-receipt.ts | 71 +++++++ 7 files changed, 553 insertions(+), 60 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/transaction-receipt.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 607736bcc8..acde645f12 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow EIP-7702 authorizations from accounts in the Money keyring ([#8687](https://github.com/MetaMask/core/pull/8687)). - Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347)) +### Changed + +- Derive fiat order source amount from on-chain transaction data (`order.txHash`) with fallback to `order.cryptoAmount` + ## [21.0.0] ### Added diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 8b94b88f2f..1238ffdf9a 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -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'); @@ -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); @@ -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', @@ -255,6 +257,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); + resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -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({ @@ -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(); @@ -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', diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 94c2234428..5fdd0371e3 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -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'); @@ -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. * @@ -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; diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts index 1175bccd56..ef4df17d4f 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -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 { + return { + cryptoAmount: '1.5', + txHash: TX_HASH_MOCK, + ...overrides, + } as RampsOrder; +} describe('Fiat Utils', () => { describe('deriveFiatAssetForFiatPayment', () => { @@ -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'); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 6894127b13..79d6f14614 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -1,10 +1,18 @@ +import type { RampsOrder } from '@metamask/ramps-controller'; import { TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { projectLogger } from '../../logger'; +import type { TransactionPayControllerMessenger } from '../../types'; +import { getTransferredAmountFromTxHash } from '../../utils/transaction-receipt'; import { FIAT_ASSET_ID_BY_TX_TYPE, TransactionPayFiatAsset } from './constants'; +const log = createModuleLogger(projectLogger, 'fiat-utils'); + export function deriveFiatAssetForFiatPayment( transaction: TransactionMeta, ): TransactionPayFiatAsset | undefined { @@ -19,3 +27,90 @@ export function deriveFiatAssetForFiatPayment( return FIAT_ASSET_ID_BY_TX_TYPE[transactionType as TransactionType]; } + +/** + * Resolves the raw source amount for a completed fiat order. + * + * Attempts to read the actual transferred amount from the on-chain transaction + * identified by `order.txHash`. If the on-chain read fails or returns + * no amount, falls back to computing the amount from `order.cryptoAmount`. + * + * @param options - The resolution options. + * @param options.messenger - Controller messenger for network access. + * @param options.order - The completed on-ramp order. + * @param options.fiatAsset - The fiat asset describing the expected token. + * @returns The raw (atomic) source amount as a decimal string. + */ +export async function resolveSourceAmountRaw({ + messenger, + order, + fiatAsset, +}: { + messenger: TransactionPayControllerMessenger; + order: RampsOrder; + fiatAsset: TransactionPayFiatAsset; +}): Promise { + if (order.txHash) { + try { + const onChainAmount = await getTransferredAmountFromTxHash({ + messenger, + txHash: order.txHash, + chainId: fiatAsset.chainId, + tokenAddress: fiatAsset.address, + }); + + if (onChainAmount) { + log('Resolved source amount from on-chain transaction', { + txHash: order.txHash, + onChainAmount, + }); + return onChainAmount; + } + } catch (error) { + log( + 'Failed to read on-chain amount, falling back to order.cryptoAmount', + { txHash: order.txHash, error }, + ); + } + } + + return getRawSourceAmountFromOrderCryptoAmount({ + cryptoAmount: order.cryptoAmount, + decimals: fiatAsset.decimals, + }); +} + +/** + * 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. + */ +export function getRawSourceAmountFromOrderCryptoAmount({ + 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; +} diff --git a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts new file mode 100644 index 0000000000..5a54b349d7 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts @@ -0,0 +1,173 @@ +import { Interface } from '@ethersproject/abi'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Hex } from '@metamask/utils'; + +import { NATIVE_TOKEN_ADDRESS } from '../constants'; +import { getMessengerMock } from '../tests/messenger-mock'; +import { getTransferredAmountFromTxHash } from './transaction-receipt'; + +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 erc20Interface = new Interface(abiERC20); + +function buildTransferCallData(to: Hex, amount: string): string { + return erc20Interface.encodeFunctionData('transfer', [to, amount]); +} + +describe('getTransferredAmountFromTxHash', () => { + 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, + })); + }); + + describe('native token', () => { + it('returns tx.value for native token transfer', async () => { + mockGetTransaction.mockResolvedValue({ + value: { toString: () => '1500000000000000000' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + }); + + expect(result).toBe('1500000000000000000'); + }); + + it('returns undefined when transaction is not found', async () => { + mockGetTransaction.mockResolvedValue(null); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('ERC-20 token', () => { + it('decodes transfer amount from tx.data', async () => { + mockGetTransaction.mockResolvedValue({ + data: buildTransferCallData(WALLET_ADDRESS_MOCK, '5000000'), + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }); + + expect(result).toBe('5000000'); + }); + + it('returns undefined when transaction is not found', async () => { + mockGetTransaction.mockResolvedValue(null); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when tx.data is missing', async () => { + mockGetTransaction.mockResolvedValue({ + data: undefined, + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when tx.data has non-transfer selector', async () => { + mockGetTransaction.mockResolvedValue({ + data: `0x095ea7b3${'0'.repeat(128)}`, + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when tx.data is too short', async () => { + mockGetTransaction.mockResolvedValue({ + data: '0xa9059c', + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('propagates provider errors', async () => { + mockGetTransaction.mockRejectedValue(new Error('RPC error')); + + await expect( + getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }), + ).rejects.toThrow('RPC error'); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/transaction-receipt.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts new file mode 100644 index 0000000000..3cfc95c09b --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts @@ -0,0 +1,71 @@ +import { Interface } from '@ethersproject/abi'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { Hex } from '@metamask/utils'; + +import type { TransactionPayControllerMessenger } from '../types'; +import { getNativeToken } from './token'; + +// transfer(address,uint256) selector +const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; + +const erc20Interface = new Interface(abiERC20); + +/** + * Reads the transferred token amount from a completed on-chain transaction. + * + * For native tokens, the amount is read from the transaction's `value` field. + * For ERC-20 tokens, the amount is decoded from the transaction's input data, + * expecting a direct `transfer(address,uint256)` call. + * + * @param options - The options. + * @param options.messenger - Controller messenger for network access. + * @param options.txHash - Transaction hash of the completed on-chain transaction. + * @param options.chainId - Chain ID where the transaction was executed. + * @param options.tokenAddress - Address of the transferred token. + * @returns The raw (atomic) transferred amount as a decimal string, + * or `undefined` if the amount cannot be determined. + */ +export async function getTransferredAmountFromTxHash({ + messenger, + txHash, + chainId, + tokenAddress, +}: { + messenger: TransactionPayControllerMessenger; + txHash: string; + chainId: Hex; + tokenAddress: Hex; +}): Promise { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + const { provider } = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + const ethersProvider = new Web3Provider(provider); + const tx = await ethersProvider.getTransaction(txHash); + + if (!tx) { + return undefined; + } + + const isNative = + tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); + + if (isNative) { + return tx.value.toString(); + } + + if (!tx.data?.startsWith(ERC20_TRANSFER_SELECTOR)) { + return undefined; + } + + const decoded = erc20Interface.decodeFunctionData('transfer', tx.data); + + return decoded._value.toString(); +} From f20bda2b7f356fbb8de8110255f0fd74ddba74e3 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 5 May 2026 13:40:50 +0200 Subject: [PATCH 2/3] docs: add changelog entry with PR link --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index acde645f12..d929d68a87 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Derive fiat order source amount from on-chain transaction data (`order.txHash`) with fallback to `order.cryptoAmount` +- 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] From 7f8375a361ece8bde8700591768247e22f03fa60 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 5 May 2026 13:53:57 +0200 Subject: [PATCH 3/3] Addres cursor comment --- .../src/utils/transaction-receipt.test.ts | 31 +++++++++++++++++++ .../src/utils/transaction-receipt.ts | 9 ++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts index 5a54b349d7..b0498f8f81 100644 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts @@ -77,6 +77,21 @@ describe('getTransferredAmountFromTxHash', () => { expect(result).toBeUndefined(); }); + + it('returns undefined when native tx.value is zero', async () => { + mockGetTransaction.mockResolvedValue({ + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + }); + + expect(result).toBeUndefined(); + }); }); describe('ERC-20 token', () => { @@ -156,6 +171,22 @@ describe('getTransferredAmountFromTxHash', () => { expect(result).toBeUndefined(); }); + + it('returns undefined when ERC-20 transfer amount is zero', async () => { + mockGetTransaction.mockResolvedValue({ + data: buildTransferCallData(WALLET_ADDRESS_MOCK, '0'), + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + tokenAddress: ERC20_ADDRESS_MOCK, + }); + + expect(result).toBeUndefined(); + }); }); it('propagates provider errors', async () => { diff --git a/packages/transaction-pay-controller/src/utils/transaction-receipt.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts index 3cfc95c09b..5cf94b13f7 100644 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.ts +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts @@ -2,6 +2,7 @@ import { Interface } from '@ethersproject/abi'; import { Web3Provider } from '@ethersproject/providers'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import type { TransactionPayControllerMessenger } from '../types'; import { getNativeToken } from './token'; @@ -58,7 +59,7 @@ export async function getTransferredAmountFromTxHash({ tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); if (isNative) { - return tx.value.toString(); + return positiveOrUndefined(tx.value.toString()); } if (!tx.data?.startsWith(ERC20_TRANSFER_SELECTOR)) { @@ -67,5 +68,9 @@ export async function getTransferredAmountFromTxHash({ const decoded = erc20Interface.decodeFunctionData('transfer', tx.data); - return decoded._value.toString(); + return positiveOrUndefined(decoded._value.toString()); +} + +function positiveOrUndefined(amount: string): string | undefined { + return new BigNumber(amount).gt(0) ? amount : undefined; }