From cd369b2d91d753eba7409ca0d47647e2317425f1 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 5 May 2026 13:40:14 +0200 Subject: [PATCH 1/9] 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/9] 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/9] 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; } From 26cf4a7b57431f2e4b959192645421b1b0aa39a3 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 11 May 2026 10:35:25 +0200 Subject: [PATCH 4/9] Changelog fix --- 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 7469c0dc77..636be3286f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Resolve fiat asset per transaction type from `confirmations_pay_fiat` remote feature flag, falling back to hardcoded map then ETH on mainnet ([#8631](https://github.com/MetaMask/core/pull/8631)) +- 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)) ### Fixed @@ -61,7 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/bridge-controller` from `^71.0.0` to `^71.1.1` ([#8706](https://github.com/MetaMask/core/pull/8706), [#8721](https://github.com/MetaMask/core/pull/8721)) - 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)) - Bump `@metamask/ramps-controller` from `^13.2.0` to `^13.3.0` ([#8698](https://github.com/MetaMask/core/pull/8698)) - Bump `@metamask/assets-controller` from `^6.3.0` to `^6.4.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) - Bump `@metamask/assets-controllers` from `^105.1.0` to `^106.0.0` ([#8721](https://github.com/MetaMask/core/pull/8721)) From eb1964b7cf9fa4bb73bd045ad8d38e8ad97ea53e Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 11 May 2026 10:38:47 +0200 Subject: [PATCH 5/9] Add tx.to check --- .../src/strategy/fiat/utils.test.ts | 1 + .../src/utils/transaction-receipt.test.ts | 19 +++++++++++++++++++ .../src/utils/transaction-receipt.ts | 4 ++++ 3 files changed, 24 insertions(+) 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 cb00b34701..d49e35ff8f 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -267,6 +267,7 @@ describe('Fiat Utils', () => { it('returns on-chain amount when txHash is present and read succeeds', async () => { mockGetTransaction.mockResolvedValue({ + to: ERC20_ADDRESS_MOCK, data: buildTransferCallData(WALLET_ADDRESS_MOCK, '7000000'), value: { toString: () => '0' }, }); 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 b0498f8f81..8022e7a865 100644 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts @@ -97,6 +97,7 @@ describe('getTransferredAmountFromTxHash', () => { describe('ERC-20 token', () => { it('decodes transfer amount from tx.data', async () => { mockGetTransaction.mockResolvedValue({ + to: ERC20_ADDRESS_MOCK, data: buildTransferCallData(WALLET_ADDRESS_MOCK, '5000000'), value: { toString: () => '0' }, }); @@ -172,8 +173,26 @@ describe('getTransferredAmountFromTxHash', () => { expect(result).toBeUndefined(); }); + it('returns undefined when tx.to does not match tokenAddress', async () => { + mockGetTransaction.mockResolvedValue({ + to: '0x3333333333333333333333333333333333333333', + 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).toBeUndefined(); + }); + it('returns undefined when ERC-20 transfer amount is zero', async () => { mockGetTransaction.mockResolvedValue({ + to: ERC20_ADDRESS_MOCK, data: buildTransferCallData(WALLET_ADDRESS_MOCK, '0'), value: { toString: () => '0' }, }); diff --git a/packages/transaction-pay-controller/src/utils/transaction-receipt.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts index 5cf94b13f7..021a926adc 100644 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.ts +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts @@ -62,6 +62,10 @@ export async function getTransferredAmountFromTxHash({ return positiveOrUndefined(tx.value.toString()); } + if (tx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { + return undefined; + } + if (!tx.data?.startsWith(ERC20_TRANSFER_SELECTOR)) { return undefined; } From 0a27c9e8d011f1a98b997c7c3ca1b692a9f72977 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 11 May 2026 10:53:09 +0200 Subject: [PATCH 6/9] Fix unit tests --- .../src/strategy/fiat/fiat-submit.test.ts | 77 +++++++++++++++---- .../src/strategy/fiat/utils.test.ts | 19 +++++ .../src/utils/transaction-receipt.test.ts | 3 + 3 files changed, 85 insertions(+), 14 deletions(-) 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 1238ffdf9a..b245668a57 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 @@ -14,6 +14,7 @@ import type { QuoteRequest, TransactionPayQuote, } from '../../types'; +import { buildCaipAssetType } from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; @@ -23,6 +24,7 @@ import type { FiatQuote } from './types'; import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; jest.mock('./utils'); +jest.mock('../../utils/token'); jest.mock('../relay/relay-quotes'); jest.mock('../relay/relay-submit'); @@ -40,11 +42,11 @@ const TRANSACTION_MOCK = { const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { address: '0x0000000000000000000000000000000000001010', - caipAssetId: 'eip155:137/slip44:966', chainId: '0x89', - decimals: 18, }; +const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966'; + const RAMPS_QUOTE_MOCK: RampsQuote = { provider: '/providers/transak-native-staging', quote: { @@ -180,11 +182,13 @@ function getFiatQuoteMock({ function getRequest({ orderId = ORDER_ID_MOCK, + rampsQuote = RAMPS_QUOTE_MOCK, order = getFiatOrderMock(), quotes = [getFiatQuoteMock()], transaction = TRANSACTION_MOCK, }: { orderId?: string; + rampsQuote?: RampsQuote | undefined; order?: RampsOrder; quotes?: TransactionPayQuote[]; transaction?: TransactionMeta; @@ -199,6 +203,7 @@ function getRequest({ [transaction.id]: { fiatPayment: { orderId, + rampsQuote, }, isLoading: false, tokens: [], @@ -228,6 +233,7 @@ function getRequest({ } describe('submitFiatQuotes', () => { + const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType); const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); @@ -239,6 +245,7 @@ describe('submitFiatQuotes', () => { jest.resetAllMocks(); jest.useRealTimers(); + buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); @@ -251,7 +258,7 @@ describe('submitFiatQuotes', () => { const order = getFiatOrderMock({ cryptoAmount: '1.2345', cryptoCurrency: { - assetId: FIAT_ASSET_MOCK.caipAssetId, + assetId: FIAT_ASSET_CAIP_ID_MOCK, chainId: 'eip155:137', symbol: 'POL', }, @@ -264,8 +271,8 @@ describe('submitFiatQuotes', () => { expect(callMock).toHaveBeenCalledWith( 'RampsController:getOrder', - 'transak', - 'order-123', + 'transak-native-staging', + ORDER_ID_MOCK, WALLET_ADDRESS_MOCK, ); expect(resolveSourceAmountRawMock).toHaveBeenCalledWith({ @@ -317,13 +324,45 @@ describe('submitFiatQuotes', () => { ); }); - it('throws if order ID format is invalid', async () => { + it('throws if ramps quote is missing from fiat payment state', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + throw new Error(`Unexpected action: ${action}`); + }); + + const request: PayStrategyExecuteRequest = { + isSmartTransaction: () => false, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes: [getFiatQuoteMock()], + transaction: TRANSACTION_MOCK, + }; + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing provider code for fiat submission', + ); + }); + + it('throws if provider string format is invalid', async () => { const { request } = getRequest({ - orderId: '/providers/transak/oops', + rampsQuote: { ...RAMPS_QUOTE_MOCK, provider: 'invalid' }, }); await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Invalid order ID format: /providers/transak/oops', + 'Missing provider code for fiat submission', ); }); @@ -372,7 +411,10 @@ describe('submitFiatQuotes', () => { return { transactionData: { [TRANSACTION_ID_MOCK]: { - fiatPayment: { orderId: ORDER_ID_MOCK }, + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, isLoading: false, tokens: [], }, @@ -419,7 +461,10 @@ describe('submitFiatQuotes', () => { return { transactionData: { [TRANSACTION_ID_MOCK]: { - fiatPayment: { orderId: ORDER_ID_MOCK }, + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, isLoading: false, tokens: [], }, @@ -471,12 +516,16 @@ describe('submitFiatQuotes', () => { dateNowSpy.mockRestore(); }); - it('throws if fiat asset mapping is missing', async () => { - deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + it('throws if token info is unavailable for the fiat asset', async () => { + 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( - 'Missing fiat asset mapping for transaction type: predictDeposit', + `Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`, ); }); @@ -491,7 +540,7 @@ describe('submitFiatQuotes', () => { }); await expect(submitFiatQuotes(request)).rejects.toThrow( - `Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_MOCK.caipAssetId}, got eip155:137/slip44:60`, + `Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_CAIP_ID_MOCK.toLowerCase()}, got eip155:137/slip44:60`, ); }); 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 d49e35ff8f..226cb25aa2 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -329,6 +329,25 @@ describe('Fiat Utils', () => { expect(result).toBe('2000000000000000000'); }); + + it('throws when token info cannot be resolved for fallback', async () => { + getTokensControllerStateMock.mockReturnValue({ + allTokens: {}, + allTokensStale: {}, + allIgnoredTokens: {}, + allDetectedTokens: {}, + } as never); + + await expect( + resolveSourceAmountRaw({ + messenger, + order: getOrderMock({ txHash: '' }), + fiatAsset: ERC20_FIAT_ASSET_MOCK, + }), + ).rejects.toThrow( + `Unable to resolve token info for fiat asset ${ERC20_ADDRESS_MOCK} on chain ${CHAIN_ID_MOCK}`, + ); + }); }); describe('getRawSourceAmountFromOrderCryptoAmount', () => { 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 8022e7a865..4d6e953dc4 100644 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts @@ -127,6 +127,7 @@ describe('getTransferredAmountFromTxHash', () => { it('returns undefined when tx.data is missing', async () => { mockGetTransaction.mockResolvedValue({ + to: ERC20_ADDRESS_MOCK, data: undefined, value: { toString: () => '0' }, }); @@ -143,6 +144,7 @@ describe('getTransferredAmountFromTxHash', () => { it('returns undefined when tx.data has non-transfer selector', async () => { mockGetTransaction.mockResolvedValue({ + to: ERC20_ADDRESS_MOCK, data: `0x095ea7b3${'0'.repeat(128)}`, value: { toString: () => '0' }, }); @@ -159,6 +161,7 @@ describe('getTransferredAmountFromTxHash', () => { it('returns undefined when tx.data is too short', async () => { mockGetTransaction.mockResolvedValue({ + to: ERC20_ADDRESS_MOCK, data: '0xa9059c', value: { toString: () => '0' }, }); From dd7031f98ef235b05bdbbb8bb28a8892be765773 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 11 May 2026 11:08:39 +0200 Subject: [PATCH 7/9] fix: verify tx.to matches tokenAddress for ERC-20 path, fix no-shadow lint, reach 100% branch coverage --- .../src/strategy/fiat/utils.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 226cb25aa2..88f37f5466 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -215,7 +215,7 @@ describe('Fiat Utils', () => { describe('resolveSourceAmountRaw', () => { const { - messenger, + messenger: resolveMessenger, findNetworkClientIdByChainIdMock, getNetworkClientByIdMock, getTokensControllerStateMock, @@ -273,7 +273,7 @@ describe('Fiat Utils', () => { }); const result = await resolveSourceAmountRaw({ - messenger, + messenger: resolveMessenger, order: getOrderMock(), fiatAsset: ERC20_FIAT_ASSET_MOCK, }); @@ -283,7 +283,7 @@ describe('Fiat Utils', () => { it('falls back to cryptoAmount when txHash is missing', async () => { const result = await resolveSourceAmountRaw({ - messenger, + messenger: resolveMessenger, order: getOrderMock({ txHash: '' }), fiatAsset: ERC20_FIAT_ASSET_MOCK, }); @@ -296,7 +296,7 @@ describe('Fiat Utils', () => { mockGetTransaction.mockResolvedValue(null); const result = await resolveSourceAmountRaw({ - messenger, + messenger: resolveMessenger, order: getOrderMock(), fiatAsset: ERC20_FIAT_ASSET_MOCK, }); @@ -308,7 +308,7 @@ describe('Fiat Utils', () => { mockGetTransaction.mockRejectedValue(new Error('Network error')); const result = await resolveSourceAmountRaw({ - messenger, + messenger: resolveMessenger, order: getOrderMock(), fiatAsset: ERC20_FIAT_ASSET_MOCK, }); @@ -322,7 +322,7 @@ describe('Fiat Utils', () => { }); const result = await resolveSourceAmountRaw({ - messenger, + messenger: resolveMessenger, order: getOrderMock(), fiatAsset: NATIVE_FIAT_ASSET_MOCK, }); @@ -340,7 +340,7 @@ describe('Fiat Utils', () => { await expect( resolveSourceAmountRaw({ - messenger, + messenger: resolveMessenger, order: getOrderMock({ txHash: '' }), fiatAsset: ERC20_FIAT_ASSET_MOCK, }), From a423212a7dd0f1fea849cb516aacaf0b90e2a3bc Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 11 May 2026 12:20:26 +0200 Subject: [PATCH 8/9] feat: persist fiat order metadata on metamaskPay for activity view --- packages/transaction-controller/CHANGELOG.md | 4 ++ packages/transaction-controller/src/types.ts | 6 +++ .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/strategy/fiat/fiat-submit.test.ts | 45 +++++++++++++++++++ .../src/strategy/fiat/fiat-submit.ts | 14 ++++++ 5 files changed, 70 insertions(+) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index e5dbfcb379..36320bfd0f 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -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)) + ### Changed - Trigger the first-time-interaction warning correctly for `safeTransferFrom` token transfers by including `TransactionType.tokenMethodSafeTransferFrom` in the effective-recipient decoding logic ([#8723](https://github.com/MetaMask/core/pull/8723)) diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 0f07ae1443..e0133e21d6 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -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. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 636be3286f..1944311e8e 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resolve fiat asset per transaction type from `confirmations_pay_fiat` remote feature flag, falling back to hardcoded map then ETH on mainnet ([#8631](https://github.com/MetaMask/core/pull/8631)) - 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)) ### Fixed 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 b245668a57..644281d03c 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 @@ -15,6 +15,7 @@ import type { TransactionPayQuote, } from '../../types'; 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'; @@ -25,6 +26,7 @@ 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'); @@ -238,6 +240,7 @@ describe('submitFiatQuotes', () => { deriveFiatAssetForFiatPayment, ); const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw); + const updateTransactionMock = jest.mocked(updateTransaction); const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); @@ -303,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: { 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 f50835fbe9..23d06e6481 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -15,6 +15,7 @@ import type { TransactionPayControllerMessenger, } from '../../types'; 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'; @@ -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, From 7e1d6cdf026b95ae5dc719b92a3fdccfddb6b761 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 12 May 2026 13:09:33 +0200 Subject: [PATCH 9/9] Address comments --- packages/transaction-controller/CHANGELOG.md | 2 +- packages/transaction-controller/src/types.ts | 12 +- .../src/strategy/fiat/fiat-submit.test.ts | 7 +- .../src/strategy/fiat/fiat-submit.ts | 6 +- .../src/strategy/fiat/utils.test.ts | 67 ++- .../src/strategy/fiat/utils.ts | 7 +- .../src/utils/transaction-receipt.test.ts | 226 --------- .../src/utils/transaction-receipt.ts | 80 ---- .../src/utils/transaction.test.ts | 435 ++++++++++++++++++ .../src/utils/transaction.ts | 210 +++++++++ 10 files changed, 715 insertions(+), 337 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts delete mode 100644 packages/transaction-pay-controller/src/utils/transaction-receipt.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 7d5ae6887f..8cf206d380 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- 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)) ### Changed diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index e0133e21d6..942c17aa31 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2165,11 +2165,13 @@ 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; + /** 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). 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 644281d03c..f3ea32cb8a 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 @@ -282,6 +282,7 @@ describe('submitFiatQuotes', () => { messenger: expect.anything(), order, fiatAsset: FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }); expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ @@ -325,8 +326,7 @@ describe('submitFiatQuotes', () => { updateFn(txDraft); expect(txDraft.metamaskPay).toStrictEqual({ - fiatOrderId: ORDER_ID_MOCK, - fiatProvider: 'transak-native-staging', + fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' }, }); }); @@ -343,8 +343,7 @@ describe('submitFiatQuotes', () => { expect(txDraft.metamaskPay).toStrictEqual({ totalFiat: '20.00', - fiatOrderId: ORDER_ID_MOCK, - fiatProvider: 'transak-native-staging', + fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' }, }); }); 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 23d06e6481..753ef243a8 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -79,8 +79,7 @@ export async function submitFiatQuotes( }, (tx) => { tx.metamaskPay ??= {}; - tx.metamaskPay.fiatOrderId = orderId; - tx.metamaskPay.fiatProvider = providerCode; + tx.metamaskPay.fiat = { orderId, provider: providerCode }; }, ); @@ -310,10 +309,13 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); + const walletAddress = transaction.txParams.from as Hex; + const sourceAmountRaw = await resolveSourceAmountRaw({ messenger, order, fiatAsset, + walletAddress, }); 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 88f37f5466..c6278b1c37 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -1,6 +1,4 @@ -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'; @@ -39,12 +37,6 @@ const ERC20_FIAT_ASSET_MOCK: TransactionPayFiatAsset = { chainId: CHAIN_ID_MOCK, }; -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', @@ -223,11 +215,15 @@ describe('Fiat Utils', () => { resolveRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); + let mockGetTransactionReceipt: jest.Mock; + let mockSend: jest.Mock; let mockGetTransaction: jest.Mock; beforeEach(() => { jest.resetAllMocks(); + mockGetTransactionReceipt = jest.fn(); + mockSend = jest.fn(); mockGetTransaction = jest.fn(); findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); @@ -261,21 +257,32 @@ describe('Fiat Utils', () => { } as never); (Web3Provider as unknown as jest.Mock).mockImplementation(() => ({ + getTransactionReceipt: mockGetTransactionReceipt, + send: mockSend, getTransaction: mockGetTransaction, })); }); - it('returns on-chain amount when txHash is present and read succeeds', async () => { - mockGetTransaction.mockResolvedValue({ - to: ERC20_ADDRESS_MOCK, - data: buildTransferCallData(WALLET_ADDRESS_MOCK, '7000000'), - value: { toString: () => '0' }, + it('returns on-chain ERC-20 amount from receipt logs', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [ + { + address: ERC20_ADDRESS_MOCK, + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + `0x000000000000000000000000${'aa'.repeat(20)}`, + `0x000000000000000000000000${WALLET_ADDRESS_MOCK.slice(2).toLowerCase()}`, + ], + data: '0x00000000000000000000000000000000000000000000000000000000006acfc0', + }, + ], }); const result = await resolveSourceAmountRaw({ messenger: resolveMessenger, order: getOrderMock(), fiatAsset: ERC20_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }); expect(result).toBe('7000000'); @@ -286,38 +293,60 @@ describe('Fiat Utils', () => { messenger: resolveMessenger, order: getOrderMock({ txHash: '' }), fiatAsset: ERC20_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }); expect(result).toBe('1500000'); - expect(mockGetTransaction).not.toHaveBeenCalled(); + expect(mockGetTransactionReceipt).not.toHaveBeenCalled(); }); - it('falls back to cryptoAmount when on-chain read returns undefined', async () => { - mockGetTransaction.mockResolvedValue(null); + it('falls back to cryptoAmount when receipt is null', async () => { + mockGetTransactionReceipt.mockResolvedValue(null); const result = await resolveSourceAmountRaw({ messenger: resolveMessenger, order: getOrderMock(), fiatAsset: ERC20_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }); expect(result).toBe('1500000'); }); it('falls back to cryptoAmount when on-chain read throws', async () => { - mockGetTransaction.mockRejectedValue(new Error('Network error')); + mockGetTransactionReceipt.mockRejectedValue(new Error('Network error')); const result = await resolveSourceAmountRaw({ messenger: resolveMessenger, order: getOrderMock(), fiatAsset: ERC20_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }); expect(result).toBe('1500000'); }); - it('returns on-chain native token amount when txHash is present', async () => { + it('returns native amount from debug_traceTransaction', async () => { + mockSend.mockResolvedValue({ + to: WALLET_ADDRESS_MOCK.toLowerCase(), + value: '0x1bc16d674ec80000', + calls: [], + }); + + const result = await resolveSourceAmountRaw({ + messenger: resolveMessenger, + order: getOrderMock(), + fiatAsset: NATIVE_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, + }); + + expect(result).toBe('2000000000000000000'); + }); + + it('falls back to tx.value for native when trace is unsupported', async () => { + mockSend.mockRejectedValue(new Error('Method not found')); mockGetTransaction.mockResolvedValue({ + to: WALLET_ADDRESS_MOCK.toLowerCase(), value: { toString: () => '2000000000000000000' }, }); @@ -325,6 +354,7 @@ describe('Fiat Utils', () => { messenger: resolveMessenger, order: getOrderMock(), fiatAsset: NATIVE_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }); expect(result).toBe('2000000000000000000'); @@ -343,6 +373,7 @@ describe('Fiat Utils', () => { messenger: resolveMessenger, order: getOrderMock({ txHash: '' }), fiatAsset: ERC20_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, }), ).rejects.toThrow( `Unable to resolve token info for fiat asset ${ERC20_ADDRESS_MOCK} on chain ${CHAIN_ID_MOCK}`, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index bfb1c84cf4..c3615e1dab 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -1,6 +1,7 @@ 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 { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -8,7 +9,7 @@ import { projectLogger } from '../../logger'; import type { TransactionPayControllerMessenger } from '../../types'; import { getFiatAssetPerTransactionType } from '../../utils/feature-flags'; import { getTokenInfo } from '../../utils/token'; -import { getTransferredAmountFromTxHash } from '../../utils/transaction-receipt'; +import { getTransferredAmountFromTxHash } from '../../utils/transaction'; import type { TransactionPayFiatAsset } from './constants'; import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; @@ -46,16 +47,19 @@ function resolveTransactionType( * @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. + * @param options.walletAddress - Recipient wallet address for on-chain lookup. * @returns The raw (atomic) source amount as a decimal string. */ export async function resolveSourceAmountRaw({ messenger, order, fiatAsset, + walletAddress, }: { messenger: TransactionPayControllerMessenger; order: RampsOrder; fiatAsset: TransactionPayFiatAsset; + walletAddress: Hex; }): Promise { if (order.txHash) { try { @@ -64,6 +68,7 @@ export async function resolveSourceAmountRaw({ txHash: order.txHash, chainId: fiatAsset.chainId, tokenAddress: fiatAsset.address, + walletAddress, }); if (onChainAmount) { diff --git a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts deleted file mode 100644 index 4d6e953dc4..0000000000 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -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(); - }); - - 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', () => { - it('decodes transfer amount from tx.data', async () => { - mockGetTransaction.mockResolvedValue({ - to: ERC20_ADDRESS_MOCK, - 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({ - to: ERC20_ADDRESS_MOCK, - 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({ - to: ERC20_ADDRESS_MOCK, - 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({ - to: ERC20_ADDRESS_MOCK, - 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('returns undefined when tx.to does not match tokenAddress', async () => { - mockGetTransaction.mockResolvedValue({ - to: '0x3333333333333333333333333333333333333333', - 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).toBeUndefined(); - }); - - it('returns undefined when ERC-20 transfer amount is zero', async () => { - mockGetTransaction.mockResolvedValue({ - to: ERC20_ADDRESS_MOCK, - 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 () => { - 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 deleted file mode 100644 index 021a926adc..0000000000 --- a/packages/transaction-pay-controller/src/utils/transaction-receipt.ts +++ /dev/null @@ -1,80 +0,0 @@ -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'; - -// 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 positiveOrUndefined(tx.value.toString()); - } - - if (tx.to?.toLowerCase() !== tokenAddress.toLowerCase()) { - return undefined; - } - - if (!tx.data?.startsWith(ERC20_TRANSFER_SELECTOR)) { - return undefined; - } - - const decoded = erc20Interface.decodeFunctionData('transfer', tx.data); - - return positiveOrUndefined(decoded._value.toString()); -} - -function positiveOrUndefined(amount: string): string | undefined { - return new BigNumber(amount).gt(0) ? amount : undefined; -} diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index c409c89670..a2328da53f 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -1,3 +1,6 @@ +import { Interface } from '@ethersproject/abi'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import { TransactionStatus, TransactionType, @@ -7,6 +10,7 @@ import type { TransactionControllerState } from '@metamask/transaction-controlle import type { Hex } from '@metamask/utils'; import { noop } from 'lodash'; +import { NATIVE_TOKEN_ADDRESS } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionData, @@ -19,6 +23,7 @@ import { FINALIZED_STATUSES, collectTransactionIds, getTransaction, + getTransferredAmountFromTxHash, isPredictWithdrawTransaction, subscribeAssetChanges, subscribeTransactionChanges, @@ -28,6 +33,10 @@ import { jest.mock('./feature-flags'); jest.mock('./required-tokens'); +jest.mock('@ethersproject/providers', () => ({ + ...jest.requireActual('@ethersproject/providers'), + Web3Provider: jest.fn(), +})); const TRANSACTION_ID_MOCK = '123-456'; const ERROR_MESSAGE_MOCK = 'Test error'; @@ -652,3 +661,429 @@ describe('Transaction Utils', () => { }); }); }); + +const TX_HASH_MOCK = '0xabc123'; +const WALLET_ADDRESS_RECEIPT_MOCK = + '0x1111111111111111111111111111111111111111' as Hex; +const ERC20_ADDRESS_RECEIPT_MOCK = + '0x2222222222222222222222222222222222222222' as Hex; +const CHAIN_ID_RECEIPT_MOCK = '0x1' as Hex; +const NETWORK_CLIENT_ID_RECEIPT_MOCK = 'net-client-1'; +const PROVIDER_RECEIPT_MOCK = { request: jest.fn() }; + +const TRANSFER_EVENT_TOPIC = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +const erc20Interface = new Interface(abiERC20); + +function paddedAddress(address: Hex): string { + return `0x000000000000000000000000${address.slice(2).toLowerCase()}`; +} + +function encodeTransferLog( + to: Hex, + amount: string, +): { + address: string; + topics: string[]; + data: string; +} { + const encoded = erc20Interface.encodeEventLog( + erc20Interface.getEvent('Transfer'), + [WALLET_ADDRESS_RECEIPT_MOCK, to, amount], + ); + + return { + address: ERC20_ADDRESS_RECEIPT_MOCK, + topics: encoded.topics, + data: encoded.data, + }; +} + +describe('getTransferredAmountFromTxHash', () => { + const { + messenger: receiptMessenger, + findNetworkClientIdByChainIdMock: receiptFindNetworkMock, + getNetworkClientByIdMock: receiptGetNetworkMock, + } = getMessengerMock(); + + let mockGetTransactionReceipt: jest.Mock; + let mockSend: jest.Mock; + let mockGetTx: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + mockGetTransactionReceipt = jest.fn(); + mockSend = jest.fn(); + mockGetTx = jest.fn(); + + receiptFindNetworkMock.mockReturnValue(NETWORK_CLIENT_ID_RECEIPT_MOCK); + receiptGetNetworkMock.mockReturnValue({ + provider: PROVIDER_RECEIPT_MOCK, + } as never); + + (Web3Provider as unknown as jest.Mock).mockImplementation(() => ({ + getTransactionReceipt: mockGetTransactionReceipt, + send: mockSend, + getTransaction: mockGetTx, + })); + }); + + describe('native token', () => { + it('returns amount from debug_traceTransaction for direct transfer', async () => { + mockSend.mockResolvedValue({ + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: '0xde0b6b3a7640000', + calls: [], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('1000000000000000000'); + expect(mockSend).toHaveBeenCalledWith('debug_traceTransaction', [ + TX_HASH_MOCK, + { tracer: 'callTracer' }, + ]); + }); + + it('sums native value from nested internal calls', async () => { + mockSend.mockResolvedValue({ + to: '0xcontract', + value: '0x0', + calls: [ + { + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: '0xde0b6b3a7640000', + calls: [], + }, + { + to: '0xother', + value: '0x1', + calls: [ + { + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: '0xde0b6b3a7640000', + }, + ], + }, + ], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('2000000000000000000'); + }); + + it('falls back to tx.value when debug_traceTransaction is unsupported', async () => { + mockSend.mockRejectedValue(new Error('Method not found')); + mockGetTx.mockResolvedValue({ + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: { toString: () => '1500000000000000000' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('1500000000000000000'); + }); + + it('returns undefined when trace returns zero value and tx.to does not match wallet', async () => { + mockSend.mockResolvedValue({ + to: '0xcontract', + value: '0x0', + }); + mockGetTx.mockResolvedValue({ + to: '0xcontract', + value: { toString: () => '1000000000000000000' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when trace is unsupported and transaction is not found', async () => { + mockSend.mockRejectedValue(new Error('Method not found')); + mockGetTx.mockResolvedValue(null); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when trace is unsupported and native tx.value is zero', async () => { + mockSend.mockRejectedValue(new Error('Method not found')); + mockGetTx.mockResolvedValue({ + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: { toString: () => '0' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('ignores trace value with 0x0', async () => { + mockSend.mockResolvedValue({ + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: '0x0', + }); + mockGetTx.mockResolvedValue({ + to: WALLET_ADDRESS_RECEIPT_MOCK.toLowerCase(), + value: { toString: () => '500' }, + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('500'); + }); + }); + + describe('ERC-20 token', () => { + it('decodes transfer amount from receipt logs', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '5000000')], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('5000000'); + }); + + it('sums multiple Transfer events to the same wallet', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [ + encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '3000000'), + encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '2000000'), + ], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('5000000'); + }); + + it('ignores Transfer events to other addresses', async () => { + const otherAddress = '0x3333333333333333333333333333333333333333' as Hex; + mockGetTransactionReceipt.mockResolvedValue({ + logs: [ + encodeTransferLog(otherAddress, '9000000'), + encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '1000000'), + ], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('1000000'); + }); + + it('ignores logs from other token contracts', async () => { + const otherToken = '0x4444444444444444444444444444444444444444' as Hex; + const transferLog = encodeTransferLog( + WALLET_ADDRESS_RECEIPT_MOCK, + '5000000', + ); + mockGetTransactionReceipt.mockResolvedValue({ + logs: [ + { ...transferLog, address: otherToken }, + encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '1000000'), + ], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('1000000'); + }); + + it('ignores logs with non-Transfer event topics', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [ + { + address: ERC20_ADDRESS_RECEIPT_MOCK, + topics: [ + '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', + paddedAddress(WALLET_ADDRESS_RECEIPT_MOCK), + paddedAddress(WALLET_ADDRESS_RECEIPT_MOCK), + ], + data: '0x00000000000000000000000000000000000000000000000000000000006acfc0', + }, + encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '2000000'), + ], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('2000000'); + }); + + it('returns undefined when receipt is not found', async () => { + mockGetTransactionReceipt.mockResolvedValue(null); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when no matching Transfer logs exist', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBeUndefined(); + }); + + it('skips malformed log entries gracefully', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [ + { + address: ERC20_ADDRESS_RECEIPT_MOCK, + topics: [TRANSFER_EVENT_TOPIC], + data: '0xBADDATA', + }, + encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '4000000'), + ], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBe('4000000'); + }); + + it('returns undefined when all Transfer amounts are zero', async () => { + mockGetTransactionReceipt.mockResolvedValue({ + logs: [encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '0')], + }); + + const result = await getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }); + + expect(result).toBeUndefined(); + }); + }); + + it('propagates provider errors for ERC-20', async () => { + mockGetTransactionReceipt.mockRejectedValue(new Error('RPC error')); + + await expect( + getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: ERC20_ADDRESS_RECEIPT_MOCK, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }), + ).rejects.toThrow('RPC error'); + }); + + it('propagates provider errors for native when both trace and getTransaction fail', async () => { + mockSend.mockRejectedValue(new Error('Trace failed')); + mockGetTx.mockRejectedValue(new Error('RPC error')); + + await expect( + getTransferredAmountFromTxHash({ + messenger: receiptMessenger, + txHash: TX_HASH_MOCK, + chainId: CHAIN_ID_RECEIPT_MOCK, + tokenAddress: NATIVE_TOKEN_ADDRESS, + walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, + }), + ).rejects.toThrow('RPC error'); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index c97d4004ec..6ccac02834 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -1,3 +1,6 @@ +import { Interface } from '@ethersproject/abi'; +import { Web3Provider } from '@ethersproject/providers'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import { TransactionStatus, TransactionType, @@ -5,6 +8,7 @@ import { import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import type { Patch } from 'immer'; import { cloneDeep } from 'lodash'; @@ -16,6 +20,7 @@ import type { } from '../types'; import { getAssetsUnifyStateFeature } from './feature-flags'; import { parseRequiredTokens } from './required-tokens'; +import { getNativeToken } from './token'; const log = createModuleLogger(projectLogger, 'transaction'); @@ -356,3 +361,208 @@ function onTransactionFinalized( log('Transaction finalized', { transaction }); removeTransactionData(transaction.id); } + +const ERC20_TRANSFER_EVENT_TOPIC = new Interface(abiERC20).getEventTopic( + 'Transfer', +); + +const erc20Interface = new Interface(abiERC20); + +/** + * Reads the transferred token amount from a completed on-chain transaction. + * + * For native tokens the amount is resolved via `debug_traceTransaction` + * (internal-call aware), falling back to the top-level `tx.value`. + * For ERC-20 tokens the amount is decoded from `Transfer` event logs + * in the transaction receipt. + * + * @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. + * @param options.walletAddress - Recipient wallet address to filter transfers to. + * @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, + walletAddress, +}: { + messenger: TransactionPayControllerMessenger; + txHash: string; + chainId: Hex; + tokenAddress: Hex; + walletAddress: Hex; +}): Promise { + const provider = getEthersProvider(messenger, chainId); + + const isNative = + tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); + + if (isNative) { + return await getNativeTransferAmount(provider, txHash, walletAddress); + } + + return await getErc20TransferAmount( + provider, + txHash, + tokenAddress, + walletAddress, + ); +} + +/** + * Resolves the native token amount received by a wallet from a transaction. + * + * 1. Attempts `debug_traceTransaction` with `callTracer` to walk internal + * calls and sum all native value transfers targeting `walletAddress`. + * 2. Falls back to the top-level `tx.value` when the wallet is the direct + * recipient and the trace RPC is unavailable or errors. + * + * @param provider - Ethers Web3Provider. + * @param txHash - Transaction hash. + * @param walletAddress - Recipient wallet address. + * @returns Raw amount as a decimal string, or `undefined`. + */ +async function getNativeTransferAmount( + provider: Web3Provider, + txHash: string, + walletAddress: Hex, +): Promise { + try { + const trace = await provider.send('debug_traceTransaction', [ + txHash, + { tracer: 'callTracer' }, + ]); + + const amount = sumNativeValueFromTrace(trace, walletAddress); + if (amount.gt(0)) { + return amount.toFixed(0); + } + } catch { + // debug_traceTransaction not supported — fall through to tx.value + } + + const tx = await provider.getTransaction(txHash); + if (!tx) { + return undefined; + } + + if (tx.to?.toLowerCase() !== walletAddress.toLowerCase()) { + return undefined; + } + + return positiveOrUndefined(tx.value.toString()); +} + +/** + * Resolves the ERC-20 token amount received by a wallet from a transaction + * by decoding `Transfer` event logs from the transaction receipt. + * + * @param provider - Ethers Web3Provider. + * @param txHash - Transaction hash. + * @param tokenAddress - ERC-20 token contract address. + * @param walletAddress - Recipient wallet address. + * @returns Raw amount as a decimal string, or `undefined`. + */ +async function getErc20TransferAmount( + provider: Web3Provider, + txHash: string, + tokenAddress: Hex, + walletAddress: Hex, +): Promise { + const receipt = await provider.getTransactionReceipt(txHash); + + if (!receipt) { + return undefined; + } + + let total = new BigNumber(0); + + for (const txLog of receipt.logs) { + if (txLog.address.toLowerCase() !== tokenAddress.toLowerCase()) { + continue; + } + + if (!txLog.topics[0] || txLog.topics[0] !== ERC20_TRANSFER_EVENT_TOPIC) { + continue; + } + + try { + const parsed = erc20Interface.parseLog(txLog); + const to = (parsed.args[1] as string).toLowerCase(); + + if (to !== walletAddress.toLowerCase()) { + continue; + } + + total = total.plus(parsed.args[2].toString()); + } catch { + continue; + } + } + + return positiveOrUndefined(total.toFixed(0)); +} + +type CallTrace = { + to?: string; + value?: string; + calls?: CallTrace[]; +}; + +/** + * Recursively walks a `callTracer` result and sums native value + * transferred to a specific address. + * + * @param trace - Call trace node. + * @param walletAddress - Target address to accumulate value for. + * @returns Accumulated native value as BigNumber. + */ +function sumNativeValueFromTrace( + trace: CallTrace, + walletAddress: Hex, +): BigNumber { + let total = new BigNumber(0); + + if ( + trace.to?.toLowerCase() === walletAddress.toLowerCase() && + trace.value && + trace.value !== '0x0' + ) { + total = total.plus(new BigNumber(trace.value)); + } + + if (trace.calls) { + for (const child of trace.calls) { + total = total.plus(sumNativeValueFromTrace(child, walletAddress)); + } + } + + return total; +} + +function getEthersProvider( + messenger: TransactionPayControllerMessenger, + chainId: Hex, +): Web3Provider { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + const { provider } = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + return new Web3Provider(provider); +} + +function positiveOrUndefined(amount: string): string | undefined { + return new BigNumber(amount).gt(0) ? amount : undefined; +}