diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index c61ad8d1ae..fc47969fa6 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)) + ## [65.3.0] ### Changed 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 db1b2799c5..9bef40fdfb 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.2.0] ### 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..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 @@ -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,11 @@ describe('submitFiatQuotes', () => { ORDER_ID_MOCK, WALLET_ADDRESS_MOCK, ); + expect(resolveSourceAmountRawMock).toHaveBeenCalledWith({ + messenger: expect.anything(), + order, + fiatAsset: FIAT_ASSET_MOCK, + }); expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ expect.objectContaining({ @@ -297,6 +306,48 @@ describe('submitFiatQuotes', () => { expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); + it('persists fiat order metadata on the transaction before polling', async () => { + const { request } = getRequest(); + + await submitFiatQuotes(request); + + expect(updateTransactionMock).toHaveBeenCalledWith( + { + transactionId: TRANSACTION_ID_MOCK, + messenger: request.messenger, + note: 'Persist fiat order metadata', + }, + expect.any(Function), + ); + + const txDraft = { metamaskPay: undefined } as unknown as TransactionMeta; + const updateFn = updateTransactionMock.mock.calls[0][1]; + updateFn(txDraft); + + expect(txDraft.metamaskPay).toStrictEqual({ + fiatOrderId: ORDER_ID_MOCK, + fiatProvider: 'transak-native-staging', + }); + }); + + it('preserves existing metamaskPay fields when persisting fiat order metadata', async () => { + const { request } = getRequest(); + + await submitFiatQuotes(request); + + const txDraft = { + metamaskPay: { totalFiat: '20.00' }, + } as unknown as TransactionMeta; + const updateFn = updateTransactionMock.mock.calls[0][1]; + updateFn(txDraft); + + expect(txDraft.metamaskPay).toStrictEqual({ + totalFiat: '20.00', + fiatOrderId: ORDER_ID_MOCK, + fiatProvider: 'transak-native-staging', + }); + }); + it('throws if wallet address is missing', async () => { const { request } = getRequest({ transaction: { @@ -511,7 +562,11 @@ describe('submitFiatQuotes', () => { }); it('throws if token info is unavailable for the fiat asset', async () => { - getTokenInfoMock.mockReturnValue(undefined); + resolveSourceAmountRawMock.mockRejectedValue( + new Error( + `Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`, + ), + ); const { request } = getRequest(); await expect(submitFiatQuotes(request)).rejects.toThrow( @@ -549,20 +604,16 @@ describe('submitFiatQuotes', () => { ); }); - it.each([ - ['0', 'Invalid fiat order crypto amount: 0'], - ['-1', 'Invalid fiat order crypto amount: -1'], - ['NaN', 'Invalid fiat order crypto amount: NaN'], - ])( - 'throws if order crypto amount is invalid (%s)', - async (cryptoAmount, expectedError) => { - const { request } = getRequest({ - order: getFiatOrderMock({ cryptoAmount }), - }); - - await expect(submitFiatQuotes(request)).rejects.toThrow(expectedError); - }, - ); + it('throws if resolveSourceAmountRaw rejects', async () => { + resolveSourceAmountRawMock.mockRejectedValue( + new Error('Invalid fiat order crypto amount: 0'), + ); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Invalid fiat order crypto amount: 0', + ); + }); it('throws if request has no fiat quotes', async () => { const { request } = getRequest(); @@ -582,10 +633,11 @@ describe('submitFiatQuotes', () => { ); }); - it('throws if crypto amount rounds to zero after decimal shift', async () => { - const { request } = getRequest({ - order: getFiatOrderMock({ cryptoAmount: '0.0000000000000000001' }), - }); + it('throws if resolveSourceAmountRaw throws for zero amount', async () => { + resolveSourceAmountRawMock.mockRejectedValue( + new Error('Computed fiat order source amount is not positive'), + ); + const { request } = getRequest(); await expect(submitFiatQuotes(request)).rejects.toThrow( 'Computed fiat order source amount is not positive', 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..23d06e6481 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,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, @@ -108,41 +122,6 @@ function extractProviderCode(provider: string | undefined): string | null { return parts.length >= 2 && parts[0] === 'providers' ? parts[1] : null; } -/** - * Converts the order's human-readable crypto amount to a raw token amount. - * - * @param options - The conversion options. - * @param options.cryptoAmount - Human-readable crypto amount from the completed order. - * @param options.decimals - Token decimals for the fiat asset. - * @returns The raw token amount as a string. - */ -function getRawSourceAmountFromOrder({ - cryptoAmount, - decimals, -}: { - cryptoAmount: RampsOrder['cryptoAmount']; - decimals: number; -}): string { - const normalizedAmount = new BigNumber(String(cryptoAmount)); - - if (!normalizedAmount.isFinite() || normalizedAmount.lte(0)) { - throw new Error( - `Invalid fiat order crypto amount: ${String(cryptoAmount)}`, - ); - } - - const rawAmount = normalizedAmount - .shiftedBy(decimals) - .decimalPlaces(0, BigNumber.ROUND_DOWN) - .toFixed(0); - - if (!new BigNumber(rawAmount).gt(0)) { - throw new Error('Computed fiat order source amount is not positive'); - } - - return rawAmount; -} - /** * Validates that the completed order's crypto asset matches the expected fiat asset. * @@ -331,21 +310,10 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); - const tokenInfo = getTokenInfo( + const sourceAmountRaw = await resolveSourceAmountRaw({ messenger, - fiatAsset.address, - fiatAsset.chainId, - ); - - if (!tokenInfo) { - throw new Error( - `Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`, - ); - } - - const sourceAmountRaw = getRawSourceAmountFromOrder({ - cryptoAmount: order.cryptoAmount, - decimals: tokenInfo.decimals, + order, + fiatAsset, }); const baseRequest = quotes[0].request; 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..88f37f5466 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,57 @@ +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 { 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, +}; + +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; +} const FEATURE_FLAG_ASSET_MOCK: TransactionPayFiatAsset = { address: '0x0000000000000000000000000000000000000abc', @@ -166,4 +212,180 @@ describe('Fiat Utils', () => { expect(result).toStrictEqual(ETH_MAINNET_FIAT_ASSET); }); }); + + describe('resolveSourceAmountRaw', () => { + const { + messenger: resolveMessenger, + findNetworkClientIdByChainIdMock, + getNetworkClientByIdMock, + getTokensControllerStateMock, + getRemoteFeatureFlagControllerStateMock: + resolveRemoteFeatureFlagControllerStateMock, + } = getMessengerMock(); + + let mockGetTransaction: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + 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(() => ({ + 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' }, + }); + + const result = await resolveSourceAmountRaw({ + messenger: resolveMessenger, + 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: resolveMessenger, + 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: resolveMessenger, + 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: resolveMessenger, + 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: resolveMessenger, + order: getOrderMock(), + fiatAsset: NATIVE_FIAT_ASSET_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, + }), + ).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..bfb1c84cf4 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -1,11 +1,19 @@ +import type { RampsOrder } from '@metamask/ramps-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import { 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 { getFiatAssetPerTransactionType } from '../../utils/feature-flags'; +import { getTokenInfo } from '../../utils/token'; +import { getTransferredAmountFromTxHash } from '../../utils/transaction-receipt'; 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 +34,102 @@ 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. + * @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 }, + ); + } + } + + 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-receipt.test.ts b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts new file mode 100644 index 0000000000..4d6e953dc4 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.test.ts @@ -0,0 +1,226 @@ +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 new file mode 100644 index 0000000000..021a926adc --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/transaction-receipt.ts @@ -0,0 +1,80 @@ +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; +}