diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 014641aba5..265b87eb90 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add optional `fiat` object (with `orderId` and `provider` properties) to `MetamaskPayMetadata` type for persisting fiat on-ramp order data on transactions ([#8694](https://github.com/MetaMask/core/pull/8694)) - Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8759](https://github.com/MetaMask/core/pull/8759)) ### Changed diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 541dd360d1..6ec3b783f3 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2170,6 +2170,14 @@ export type MetamaskPayMetadata = { /** Chain ID of the payment token. */ chainId?: Hex; + /** Fiat on-ramp metadata (order ID and provider). */ + fiat?: { + /** Order ID (normalized format: /providers/{provider}/orders/{id}). */ + orderId: string; + /** Provider code (e.g. "transak-native"). */ + provider: string; + }; + /** * Whether this is a post-quote transaction (e.g., withdrawal flow). * When true, the token represents the destination rather than source. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a80cab2bd8..fbe8309fb6 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Derive fiat order source amount from on-chain transaction data (`order.txHash`) with fallback to `order.cryptoAmount` ([#8694](https://github.com/MetaMask/core/pull/8694)) +- Persist fiat order ID and provider code on `transaction.metamaskPay` before polling, so activity views can query order status after controller state cleanup ([#8694](https://github.com/MetaMask/core/pull/8694)) + ## [22.3.1] ### Changed 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 c26170a866..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 @@ -14,17 +14,19 @@ import type { QuoteRequest, TransactionPayQuote, } from '../../types'; -import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; +import { buildCaipAssetType } from '../../utils/token'; +import { updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; import { submitFiatQuotes } from './fiat-submit'; import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment } from './utils'; +import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; jest.mock('./utils'); jest.mock('../../utils/token'); +jest.mock('../../utils/transaction'); jest.mock('../relay/relay-quotes'); jest.mock('../relay/relay-submit'); @@ -45,6 +47,8 @@ const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { chainId: '0x89', }; +const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966'; + const RAMPS_QUOTE_MOCK: RampsQuote = { provider: '/providers/transak-native-staging', quote: { @@ -230,14 +234,13 @@ function getRequest({ }; } -const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966'; - describe('submitFiatQuotes', () => { const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType); - const getTokenInfoMock = jest.mocked(getTokenInfo); const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); + const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw); + const updateTransactionMock = jest.mocked(updateTransaction); const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); @@ -246,8 +249,8 @@ describe('submitFiatQuotes', () => { jest.useRealTimers(); buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK); - getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' }); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0x1234', @@ -264,6 +267,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); + resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -274,6 +278,12 @@ describe('submitFiatQuotes', () => { ORDER_ID_MOCK, WALLET_ADDRESS_MOCK, ); + expect(resolveSourceAmountRawMock).toHaveBeenCalledWith({ + messenger: expect.anything(), + order, + fiatAsset: FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, + }); expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ expect.objectContaining({ @@ -297,6 +307,46 @@ describe('submitFiatQuotes', () => { expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); + it('persists fiat order metadata on the transaction before polling', async () => { + const { request } = getRequest(); + + await submitFiatQuotes(request); + + expect(updateTransactionMock).toHaveBeenCalledWith( + { + transactionId: TRANSACTION_ID_MOCK, + messenger: request.messenger, + note: 'Persist fiat order metadata', + }, + expect.any(Function), + ); + + const txDraft = { metamaskPay: undefined } as unknown as TransactionMeta; + const updateFn = updateTransactionMock.mock.calls[0][1]; + updateFn(txDraft); + + expect(txDraft.metamaskPay).toStrictEqual({ + fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' }, + }); + }); + + it('preserves existing metamaskPay fields when persisting fiat order metadata', async () => { + const { request } = getRequest(); + + await submitFiatQuotes(request); + + const txDraft = { + metamaskPay: { totalFiat: '20.00' }, + } as unknown as TransactionMeta; + const updateFn = updateTransactionMock.mock.calls[0][1]; + updateFn(txDraft); + + expect(txDraft.metamaskPay).toStrictEqual({ + totalFiat: '20.00', + fiat: { orderId: ORDER_ID_MOCK, provider: 'transak-native-staging' }, + }); + }); + it('throws if wallet address is missing', async () => { const { request } = getRequest({ transaction: { @@ -511,7 +561,11 @@ describe('submitFiatQuotes', () => { }); it('throws if token info is unavailable for the fiat asset', async () => { - getTokenInfoMock.mockReturnValue(undefined); + resolveSourceAmountRawMock.mockRejectedValue( + new Error( + `Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`, + ), + ); const { request } = getRequest(); await expect(submitFiatQuotes(request)).rejects.toThrow( @@ -549,20 +603,16 @@ describe('submitFiatQuotes', () => { ); }); - it.each([ - ['0', 'Invalid fiat order crypto amount: 0'], - ['-1', 'Invalid fiat order crypto amount: -1'], - ['NaN', 'Invalid fiat order crypto amount: NaN'], - ])( - 'throws if order crypto amount is invalid (%s)', - async (cryptoAmount, expectedError) => { - const { request } = getRequest({ - order: getFiatOrderMock({ cryptoAmount }), - }); - - await expect(submitFiatQuotes(request)).rejects.toThrow(expectedError); - }, - ); + it('throws if resolveSourceAmountRaw rejects', async () => { + resolveSourceAmountRawMock.mockRejectedValue( + new Error('Invalid fiat order crypto amount: 0'), + ); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Invalid fiat order crypto amount: 0', + ); + }); it('throws if request has no fiat quotes', async () => { const { request } = getRequest(); @@ -582,10 +632,11 @@ describe('submitFiatQuotes', () => { ); }); - it('throws if crypto amount rounds to zero after decimal shift', async () => { - const { request } = getRequest({ - order: getFiatOrderMock({ cryptoAmount: '0.0000000000000000001' }), - }); + it('throws if resolveSourceAmountRaw throws for zero amount', async () => { + resolveSourceAmountRawMock.mockRejectedValue( + new Error('Computed fiat order source amount is not positive'), + ); + const { request } = getRequest(); await expect(submitFiatQuotes(request)).rejects.toThrow( 'Computed fiat order source amount is not positive', 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 a2b88eedfd..753ef243a8 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -14,13 +14,14 @@ import type { QuoteRequest, TransactionPayControllerMessenger, } from '../../types'; -import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; +import { buildCaipAssetType } from '../../utils/token'; +import { updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment } from './utils'; +import { deriveFiatAssetForFiatPayment, resolveSourceAmountRaw } from './utils'; const log = createModuleLogger(projectLogger, 'fiat-submit'); @@ -70,6 +71,18 @@ export async function submitFiatQuotes( throw new Error('Missing provider code for fiat submission'); } + updateTransaction( + { + transactionId, + messenger, + note: 'Persist fiat order metadata', + }, + (tx) => { + tx.metamaskPay ??= {}; + tx.metamaskPay.fiat = { orderId, provider: providerCode }; + }, + ); + log('Starting fiat order polling', { orderId, providerCode, @@ -108,41 +121,6 @@ function extractProviderCode(provider: string | undefined): string | null { return parts.length >= 2 && parts[0] === 'providers' ? parts[1] : null; } -/** - * Converts the order's human-readable crypto amount to a raw token amount. - * - * @param options - The conversion options. - * @param options.cryptoAmount - Human-readable crypto amount from the completed order. - * @param options.decimals - Token decimals for the fiat asset. - * @returns The raw token amount as a string. - */ -function getRawSourceAmountFromOrder({ - cryptoAmount, - decimals, -}: { - cryptoAmount: RampsOrder['cryptoAmount']; - decimals: number; -}): string { - const normalizedAmount = new BigNumber(String(cryptoAmount)); - - if (!normalizedAmount.isFinite() || normalizedAmount.lte(0)) { - throw new Error( - `Invalid fiat order crypto amount: ${String(cryptoAmount)}`, - ); - } - - const rawAmount = normalizedAmount - .shiftedBy(decimals) - .decimalPlaces(0, BigNumber.ROUND_DOWN) - .toFixed(0); - - if (!new BigNumber(rawAmount).gt(0)) { - throw new Error('Computed fiat order source amount is not positive'); - } - - return rawAmount; -} - /** * Validates that the completed order's crypto asset matches the expected fiat asset. * @@ -331,21 +309,13 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); - const tokenInfo = getTokenInfo( - messenger, - fiatAsset.address, - fiatAsset.chainId, - ); - - if (!tokenInfo) { - throw new Error( - `Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`, - ); - } + const walletAddress = transaction.txParams.from as Hex; - const sourceAmountRaw = getRawSourceAmountFromOrder({ - cryptoAmount: order.cryptoAmount, - decimals: tokenInfo.decimals, + const sourceAmountRaw = await resolveSourceAmountRaw({ + messenger, + order, + fiatAsset, + walletAddress, }); const baseRequest = quotes[0].request; 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 5f91a94114..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,11 +1,49 @@ +import { Web3Provider } from '@ethersproject/providers'; +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 { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; +import { NATIVE_TOKEN_ADDRESS } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; import type { TransactionPayFiatAsset } from './constants'; -import { deriveFiatAssetForFiatPayment } from './utils'; +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, + chainId: CHAIN_ID_MOCK, +}; + +const ERC20_FIAT_ASSET_MOCK: TransactionPayFiatAsset = { + address: ERC20_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, +}; + +function getOrderMock(overrides: Partial = {}): RampsOrder { + return { + cryptoAmount: '1.5', + txHash: TX_HASH_MOCK, + ...overrides, + } as RampsOrder; +} const FEATURE_FLAG_ASSET_MOCK: TransactionPayFiatAsset = { address: '0x0000000000000000000000000000000000000abc', @@ -166,4 +204,219 @@ describe('Fiat Utils', () => { expect(result).toStrictEqual(ETH_MAINNET_FIAT_ASSET); }); }); + + describe('resolveSourceAmountRaw', () => { + const { + messenger: resolveMessenger, + findNetworkClientIdByChainIdMock, + getNetworkClientByIdMock, + getTokensControllerStateMock, + getRemoteFeatureFlagControllerStateMock: + 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); + getNetworkClientByIdMock.mockReturnValue({ + provider: PROVIDER_MOCK, + } as never); + + resolveRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + }); + + getTokensControllerStateMock.mockReturnValue({ + allTokens: { + [CHAIN_ID_MOCK]: { + '0x0': [ + { + address: ERC20_ADDRESS_MOCK, + decimals: 6, + symbol: 'USDC', + aggregators: [], + image: '', + name: 'USDC', + isERC721: false, + }, + ], + }, + }, + allTokensStale: {}, + allIgnoredTokens: {}, + allDetectedTokens: {}, + } as never); + + (Web3Provider as unknown as jest.Mock).mockImplementation(() => ({ + getTransactionReceipt: mockGetTransactionReceipt, + send: mockSend, + getTransaction: mockGetTransaction, + })); + }); + + 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'); + }); + + it('falls back to cryptoAmount when txHash is missing', async () => { + const result = await resolveSourceAmountRaw({ + messenger: resolveMessenger, + order: getOrderMock({ txHash: '' }), + fiatAsset: ERC20_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, + }); + + expect(result).toBe('1500000'); + expect(mockGetTransactionReceipt).not.toHaveBeenCalled(); + }); + + 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 () => { + 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 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' }, + }); + + const result = await resolveSourceAmountRaw({ + messenger: resolveMessenger, + order: getOrderMock(), + fiatAsset: NATIVE_FIAT_ASSET_MOCK, + walletAddress: WALLET_ADDRESS_MOCK, + }); + + 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: 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}`, + ); + }); + }); + + 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 8759473531..c3615e1dab 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -1,11 +1,20 @@ +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'; +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'; import type { TransactionPayFiatAsset } from './constants'; import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; +const log = createModuleLogger(projectLogger, 'fiat-utils'); + export function deriveFiatAssetForFiatPayment( transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, @@ -26,3 +35,106 @@ function resolveTransactionType( (tx) => tx.type && FIAT_ASSET_ID_BY_TX_TYPE[tx.type] !== undefined, )?.type; } + +/** + * 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. + * @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 { + const onChainAmount = await getTransferredAmountFromTxHash({ + messenger, + txHash: order.txHash, + chainId: fiatAsset.chainId, + tokenAddress: fiatAsset.address, + walletAddress, + }); + + 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 }, + ); + } + } + + const tokenInfo = getTokenInfo( + messenger, + fiatAsset.address, + fiatAsset.chainId, + ); + + if (!tokenInfo) { + throw new Error( + `Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`, + ); + } + + return getRawSourceAmountFromOrderCryptoAmount({ + cryptoAmount: order.cryptoAmount, + decimals: tokenInfo.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.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..97e772e666 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,206 @@ function onTransactionFinalized( log('Transaction finalized', { transaction }); removeTransactionData(transaction.id); } + +const erc20Interface = new Interface(abiERC20); + +const ERC20_TRANSFER_EVENT_TOPIC = erc20Interface.getEventTopic('Transfer'); + +/** + * 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; +}