diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c3e3997a40..4eb4586046 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add CHOMP idempotency for direct mUSD vault deposits ([#9267](https://github.com/MetaMask/core/pull/9267)) + +### Fixed + +- Wait for keyring unlock before executing fiat post-ramp second leg ([#9267](https://github.com/MetaMask/core/pull/9267)) + ## [23.16.1] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts new file mode 100644 index 0000000000..8c92e0a830 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts @@ -0,0 +1,194 @@ +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../../constants'; +import type { TransactionPayControllerMessenger } from '../../types'; +import { rpcRequest } from '../../utils/provider'; +import { findRecentChompVaultDeposit } from './chomp'; + +jest.mock('../../utils/provider'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const CHOMP_TX_HASH = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; +const FROM_BLOCK = '0x100' as Hex; +const SOURCE_AMOUNT_RAW = '5000000'; // 5 mUSD (6 decimals) +// uint256 hex for 5000000 (>= source amount) +const TRANSFER_DATA_SUFFICIENT = + '0x00000000000000000000000000000000000000000000000000000000004c4b40'; +// uint256 hex for 4999999 (< source amount) +const TRANSFER_DATA_INSUFFICIENT = + '0x00000000000000000000000000000000000000000000000000000000004c4b3f'; + +const ERC20_TRANSFER_TOPIC = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +function padAddress(address: string): string { + return `0x${address.replace(/^0x/u, '').toLowerCase().padStart(64, '0')}`; +} + +const MONEY_ACCOUNT_PADDED = padAddress(MONEY_ACCOUNT_ADDRESS); + +function buildMusdTransferLog( + txHash: Hex = CHOMP_TX_HASH, + data: string = TRANSFER_DATA_SUFFICIENT, +): { + address: string; + topics: string[]; + data: string; + transactionHash: Hex; +} { + return { + address: MUSD_MONAD_ADDRESS, + data, + topics: [ + ERC20_TRANSFER_TOPIC, + MONEY_ACCOUNT_PADDED, + padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + ], + transactionHash: txHash, + }; +} + +function buildMessenger(): TransactionPayControllerMessenger { + return {} as TransactionPayControllerMessenger; +} + +describe('chomp', () => { + const rpcRequestMock = jest.mocked(rpcRequest); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('findRecentChompVaultDeposit', () => { + it('returns the CHOMP tx hash when a Transfer log with sufficient amount is found', async () => { + rpcRequestMock.mockResolvedValueOnce([buildMusdTransferLog()]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + expect(result).toBe(CHOMP_TX_HASH); + // Only eth_getLogs should have been called. + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when the mUSD transfer amount is below the required amount', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog(CHOMP_TX_HASH, TRANSFER_DATA_INSUFFICIENT), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + expect(result).toBeUndefined(); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when no mUSD Transfer logs are found', async () => { + rpcRequestMock.mockResolvedValueOnce([]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + expect(result).toBeUndefined(); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('queries eth_getLogs with the correct filter', async () => { + rpcRequestMock.mockResolvedValueOnce([]); + + await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + expect(rpcRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: CHAIN_ID_MONAD, + method: 'eth_getLogs', + params: [ + expect.objectContaining({ + address: MUSD_MONAD_ADDRESS, + fromBlock: FROM_BLOCK, + toBlock: 'latest', + topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, null], + }), + ], + }), + ); + }); + + it('processes logs newest-first and returns the most recent match', async () => { + const olderHash = + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; + const newerHash = + '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex; + + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog(olderHash), + buildMusdTransferLog(newerHash), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + expect(result).toBe(newerHash); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('skips logs with insufficient amount and returns the first sufficient one', async () => { + const insufficientHash = + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; + + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog(insufficientHash, TRANSFER_DATA_INSUFFICIENT), + buildMusdTransferLog(CHOMP_TX_HASH), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + // Logs reversed: CHOMP_TX_HASH checked first (newer), passes amount check. + expect(result).toBe(CHOMP_TX_HASH); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('treats a log with data "0x" as zero amount and skips it', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog(CHOMP_TX_HASH, '0x'), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + }); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts new file mode 100644 index 0000000000..9290409129 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts @@ -0,0 +1,86 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { TransactionPayControllerMessenger } from '../../types'; +import { rpcRequest } from '../../utils/provider'; + +const log = createModuleLogger(projectLogger, 'chomp'); + +/** keccak256('Transfer(address,address,uint256)') */ +const ERC20_TRANSFER_TOPIC = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +type RpcLog = { + address: string; + topics: string[]; + data: string; + transactionHash: Hex; +}; + +export async function findRecentChompVaultDeposit({ + messenger, + moneyAccountAddress, + sourceAmountRaw, + fromBlock, +}: { + messenger: TransactionPayControllerMessenger; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + fromBlock: Hex; +}): Promise { + const fromPadded = padAddress(moneyAccountAddress); + + const logs = await rpcRequest({ + messenger, + chainId: CHAIN_ID_MONAD, + method: 'eth_getLogs', + params: [ + { + address: MUSD_MONAD_ADDRESS, + fromBlock, + toBlock: 'latest', + topics: [ERC20_TRANSFER_TOPIC, fromPadded, null], + }, + ], + }); + + log('CHOMP scan: mUSD Transfer logs found', { + count: logs.length, + fromBlock, + moneyAccountAddress, + }); + + const requiredAmount = BigInt(sourceAmountRaw); + + // Examine newest logs first so we return the most recent CHOMP match. + for (const txLog of [...logs].reverse()) { + const transferAmount = BigInt(txLog.data === '0x' ? '0x0' : txLog.data); + + if (transferAmount < requiredAmount) { + log('CHOMP scan: skipping log — transfer amount below required', { + requiredAmount: requiredAmount.toString(), + transferAmount: transferAmount.toString(), + txHash: txLog.transactionHash, + }); + continue; + } + + log('CHOMP scan: match found', { + moneyAccountAddress, + sourceAmountRaw, + transferAmount: transferAmount.toString(), + txHash: txLog.transactionHash, + }); + + return txLog.transactionHash; + } + + log('CHOMP scan: no match found', { fromBlock, moneyAccountAddress }); + return undefined; +} + +function padAddress(address: Hex): string { + return `0x${address.replace(/^0x/u, '').toLowerCase().padStart(64, '0')}`; +} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts index bee80f29e6..26180c848c 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts @@ -20,6 +20,7 @@ import { updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import { findRecentChompVaultDeposit } from './chomp'; import { DEFAULT_FIAT_CURRENCY, MUSD_MONAD_FIAT_ASSET } from './constants'; import { getDirectMusdFiatQuote, @@ -32,6 +33,7 @@ jest.mock('../../utils/feature-flags'); jest.mock('../../utils/provider'); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); +jest.mock('./chomp'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = @@ -584,5 +586,178 @@ describe('fiat-direct-musd', () => { }), ).rejects.toThrow('Missing Money Account address'); }); + + describe('CHOMP idempotency', () => { + const CHOMP_FROM_BLOCK = '0x100' as Hex; + const CHOMP_HASH = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; + const findRecentChompVaultDepositMock = jest.mocked( + findRecentChompVaultDeposit, + ); + + function makeCallMock(): jest.Mock { + return jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [ + { data: '0xnewApprove', nestedTransactionIndex: 0 }, + { data: '0xnewDeposit', nestedTransactionIndex: 1 }, + ], + }); + } + + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + + throw new Error(`Unexpected action: ${action}`); + }); + } + + it('skips addTransactionBatch and returns the CHOMP hash when pre-check finds a match', async () => { + findRecentChompVaultDepositMock.mockResolvedValue(CHOMP_HASH); + + const callMock = makeCallMock(); + + const result = await submitDirectMusdVaultDeposit({ + fromBlock: CHOMP_FROM_BLOCK, + request: getExecuteRequest({ callMock }), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: CHOMP_HASH }); + expect(callMock).not.toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.anything(), + ); + expect(collectTransactionIdsMock).not.toHaveBeenCalled(); + }); + + it('detects CHOMP in the catch path and returns the CHOMP hash', async () => { + // Pre-check misses, addTransactionBatch fails, post-check hits. + findRecentChompVaultDepositMock + .mockResolvedValueOnce(undefined) // pre-check + .mockResolvedValueOnce(CHOMP_HASH); // post-check + + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], + }); + } + + if (action === 'TransactionController:addTransactionBatch') { + throw new Error('Account does not support EIP-7702'); + } + + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitDirectMusdVaultDeposit({ + fromBlock: CHOMP_FROM_BLOCK, + request: getExecuteRequest({ callMock }), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: CHOMP_HASH }); + expect(findRecentChompVaultDepositMock).toHaveBeenCalledTimes(2); + }); + + it('preserves the Vault-prefixed error when no CHOMP match is found in the catch path', async () => { + findRecentChompVaultDepositMock.mockResolvedValue(undefined); + + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], + }); + } + + if (action === 'TransactionController:addTransactionBatch') { + throw new Error('batch failed'); + } + + throw new Error(`Unexpected action: ${action}`); + }); + + await expect( + submitDirectMusdVaultDeposit({ + fromBlock: CHOMP_FROM_BLOCK, + request: getExecuteRequest({ callMock }), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Vault: batch failed'); + }); + + it('proceeds with vault submit when pre-check throws', async () => { + findRecentChompVaultDepositMock + .mockRejectedValueOnce(new Error('network error')) // pre-check fails + .mockResolvedValueOnce(undefined); // post-check (not invoked in success path) + + const callMock = makeCallMock(); + + const result = await submitDirectMusdVaultDeposit({ + fromBlock: CHOMP_FROM_BLOCK, + request: getExecuteRequest({ callMock }), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + }); + + // Normal path completes and returns the confirmed vault tx hash. + expect(result).toStrictEqual({ transactionHash: '0xdirect' }); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.anything(), + ); + }); + + it('re-throws the Vault-prefixed error when both addTransactionBatch and CHOMP post-check fail', async () => { + // Pre-check misses, addTransactionBatch fails, post-check also throws. + findRecentChompVaultDepositMock + .mockResolvedValueOnce(undefined) // pre-check + .mockRejectedValueOnce(new Error('rpc error')); // post-check throws + + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], + }); + } + + if (action === 'TransactionController:addTransactionBatch') { + throw new Error('Account does not support EIP-7702'); + } + + throw new Error(`Unexpected action: ${action}`); + }); + + await expect( + submitDirectMusdVaultDeposit({ + fromBlock: CHOMP_FROM_BLOCK, + request: getExecuteRequest({ callMock }), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Vault: Account does not support EIP-7702'); + + expect(findRecentChompVaultDepositMock).toHaveBeenCalledTimes(2); + }); + + it('skips CHOMP checks when fromBlock is not provided', async () => { + const callMock = makeCallMock(); + + const result = await submitDirectMusdVaultDeposit({ + request: getExecuteRequest({ callMock }), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: '0xdirect' }); + expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index d7bf0a6e38..5e27b21254 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -27,6 +27,7 @@ import { updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import { findRecentChompVaultDeposit } from './chomp'; import { MUSD_MONAD_FIAT_ASSET } from './constants'; import type { FiatQuote } from './types'; import { @@ -149,14 +150,16 @@ export async function submitDirectMusdAfterFiatCompletion({ transactionId: transaction.id, }); - const sourceAmountRaw = await resolveSourceAmountRaw({ - messenger, - order, - fiatAsset: MUSD_MONAD_FIAT_ASSET, - walletAddress: transaction.txParams.from as Hex, - }); + const { amountRaw: sourceAmountRaw, fromBlock } = + await resolveSourceAmountRaw({ + messenger, + order, + fiatAsset: MUSD_MONAD_FIAT_ASSET, + walletAddress: transaction.txParams.from as Hex, + }); return await submitDirectMusdVaultDeposit({ + fromBlock, request, sourceAmountRaw, transaction, @@ -170,16 +173,22 @@ export async function submitDirectMusdAfterFiatCompletion({ * Submits the direct mUSD Money Account vault batch after fiat settlement. * * @param options - Submit options. + * @param options.fromBlock - Optional Monad block baseline for CHOMP + * idempotency scanning; sourced from the ramps settlement tx receipt via + * {@link resolveSourceAmountRaw}. When omitted the CHOMP checks are skipped. * @param options.request - Strategy execute request. * @param options.sourceAmountRaw - Settled source amount in raw mUSD units. * @param options.transaction - Original Money Account transaction. - * @returns Hash of the final submitted child transaction, if available. + * @returns Hash of the final submitted child transaction (or the CHOMP deposit + * hash), if available. */ export async function submitDirectMusdVaultDeposit({ + fromBlock, request, sourceAmountRaw, transaction, }: { + fromBlock?: Hex; request: PayStrategyExecuteRequest; sourceAmountRaw: string; transaction: PayStrategyExecuteRequest['transaction']; @@ -251,6 +260,20 @@ export async function submitDirectMusdVaultDeposit({ }, ); + // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already + // auto-vaulted the funds during or before the checkout window. + const preChompHash = await tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + if (preChompHash) { + return { transactionHash: preChompHash }; + } + const networkClientId = getNetworkClientId( messenger, MUSD_MONAD_FIAT_ASSET.chainId, @@ -309,6 +332,20 @@ export async function submitDirectMusdVaultDeposit({ })), }); } catch (error) { + // CHOMP post-check: CHOMP may have won the race between pre-check and + // submit. Return the CHOMP hash instead of surfacing a Vault-prefixed error. + const postChompHash = await tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + if (postChompHash) { + return { transactionHash: postChompHash }; + } + throw prefixError(error, VAULT_ERROR_PREFIX); } finally { end(); @@ -427,3 +464,33 @@ function getRampsProviderFee(fiatQuote: RampsQuote): BigNumber { fiatQuote.quote.networkFee ?? 0, ); } + +async function tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, +}: { + fromBlock: Hex | undefined; + messenger: PayStrategyExecuteRequest['messenger']; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + transactionId: string; +}): Promise { + if (!fromBlock) { + return undefined; + } + + try { + return await findRecentChompVaultDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + }); + } catch (chompError) { + log('CHOMP check failed', { chompError, transactionId }); + return undefined; + } +} 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 b1227b0a07..72deb6007d 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 @@ -251,6 +251,10 @@ function getRequest({ }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { if (fiatOptionsError) { throw fiatOptionsError; @@ -338,7 +342,10 @@ describe('submitFiatQuotes', () => { ); waitForTransactionConfirmedMock.mockResolvedValue(); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); - resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1000000000000000000', + fromBlock: undefined, + }); fundFiatOrderFromTestSourceMock.mockResolvedValue( getFiatOrderMock({ cryptoAmount: '1', @@ -365,7 +372,10 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1234500000000000000', + fromBlock: undefined, + }); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -400,6 +410,98 @@ describe('submitFiatQuotes', () => { expect(result).toStrictEqual({ transactionHash: '0x1234' }); }); + it('waits for keyring unlock before submitting the post-ramp leg', async () => { + let unlockHandler: (() => void) | undefined; + + const order = getFiatOrderMock({ + cryptoAmount: '1.2345', + cryptoCurrency: { + assetId: FIAT_ASSET_CAIP_ID_MOCK, + chainId: 'eip155:137', + symbol: 'POL', + }, + status: RampsOrderStatus.Completed, + }); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1234500000000000000', + fromBlock: undefined, + }); + + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + if (action === 'KeyringController:getState') { + return { isUnlocked: false }; + } + if (action === 'TransactionPayController:getFiatOptions') { + return undefined; + } + if (action === 'RampsController:getOrder') { + return order; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + throw new Error(`Unexpected action: ${action}`); + }); + + const subscribeMock = jest.fn((_event: string, handler: () => void) => { + unlockHandler = handler; + }); + + const unsubscribeMock = jest.fn(); + + const request: PayStrategyExecuteRequest = { + isSmartTransaction: () => false, + messenger: { + call: callMock, + subscribe: subscribeMock, + unsubscribe: unsubscribeMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes: [getFiatQuoteMock()], + transaction: TRANSACTION_MOCK, + }; + + const submitPromise = submitFiatQuotes(request); + + // Flush microtasks until waitForKeyringUnlock subscribes (order polling + // resolves in one tick; submitRelayAfterFiatCompletion starts in the next) + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + + // Relay must not have been called yet — still waiting for unlock + expect(submitRelayQuotesMock).not.toHaveBeenCalled(); + expect(subscribeMock).toHaveBeenCalledWith( + 'KeyringController:unlock', + expect.any(Function), + ); + + // Simulate the user unlocking the wallet + unlockHandler?.(); + + const result = await submitPromise; + + expect(unsubscribeMock).toHaveBeenCalledWith( + 'KeyringController:unlock', + expect.any(Function), + ); + expect(submitRelayQuotesMock).toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + it('uses fiat test funding source instead of polling ramps order', async () => { const fiatOptions = { testFundingSource: FIAT_TEST_FUNDING_SOURCE_MOCK }; const { callMock, request } = getRequest({ fiatOptions }); @@ -446,7 +548,10 @@ describe('submitFiatQuotes', () => { ], } as unknown as TransactionMeta; - resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1234500000000000000', + fromBlock: undefined, + }); const { callMock, request } = getRequest({ transaction: nestedTransaction, @@ -467,6 +572,9 @@ describe('submitFiatQuotes', () => { }, }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -699,6 +807,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -760,6 +872,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -1169,6 +1285,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -1230,6 +1350,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } 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 24f505f29e..949170a4fe 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -126,7 +126,12 @@ export async function submitFiatQuotes( }); try { - const result = await submitRelayAfterFiatCompletion({ order, request }); + await waitForKeyringUnlock(messenger, transactionId); + + const result = await submitRelayAfterFiatCompletion({ + order, + request, + }); if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); @@ -246,7 +251,10 @@ async function submitRelayAfterFiatCompletion({ const isDirectMusd = isDirectMusdMoneyAccountQuote(fiatQuote); if (isDirectMusd) { - return await submitDirectMusdAfterFiatCompletion({ order, request }); + return await submitDirectMusdAfterFiatCompletion({ + order, + request, + }); } const fiatAsset = deriveFiatAssetForFiatPayment(transaction, messenger); @@ -259,7 +267,7 @@ async function submitRelayAfterFiatCompletion({ const baseRequest = fiatQuote.request; - const sourceAmountRaw = await resolveSourceAmountRaw({ + const { amountRaw: sourceAmountRaw } = await resolveSourceAmountRaw({ messenger, order, fiatAsset, @@ -313,3 +321,35 @@ function getWalletAddress({ return address as Hex; } + +function waitForKeyringUnlock( + messenger: TransactionPayControllerMessenger, + transactionId: string, +): Promise { + const { isUnlocked } = messenger.call('KeyringController:getState'); + + if (isUnlocked) { + return Promise.resolve(); + } + + log( + 'KeyringController is locked; waiting for unlock before fiat submit second leg', + { + transactionId, + }, + ); + + return new Promise((resolve) => { + const handler = (): void => { + messenger.unsubscribe('KeyringController:unlock', handler); + + log('KeyringController unlocked; resuming fiat submit second leg', { + transactionId, + }); + + resolve(); + }; + + messenger.subscribe('KeyringController:unlock', handler); + }); +} 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 8160adc7eb..f0fa144883 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -291,8 +291,9 @@ describe('Fiat Utils', () => { } as never); }); - it('returns on-chain ERC-20 amount from receipt logs', async () => { + it('returns on-chain ERC-20 amount and block number from receipt', async () => { PROVIDER_MOCK.request.mockResolvedValue({ + blockNumber: '0x1a2b3c', logs: [ { address: ERC20_ADDRESS_MOCK, @@ -313,7 +314,8 @@ describe('Fiat Utils', () => { walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result).toBe('7000000'); + expect(result.amountRaw).toBe('7000000'); + expect(result.fromBlock).toBe('0x1a2b3c'); }); it('falls back to cryptoAmount when txHash is missing', async () => { @@ -324,7 +326,8 @@ describe('Fiat Utils', () => { walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result).toBe('1500000'); + expect(result.amountRaw).toBe('1500000'); + expect(result.fromBlock).toBeUndefined(); expect(PROVIDER_MOCK.request).not.toHaveBeenCalled(); }); @@ -338,7 +341,8 @@ describe('Fiat Utils', () => { walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result).toBe('1500000'); + expect(result.amountRaw).toBe('1500000'); + expect(result.fromBlock).toBeUndefined(); }); it('falls back to cryptoAmount when on-chain read throws', async () => { @@ -351,7 +355,8 @@ describe('Fiat Utils', () => { walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result).toBe('1500000'); + expect(result.amountRaw).toBe('1500000'); + expect(result.fromBlock).toBeUndefined(); }); it('returns native amount from debug_traceTransaction', async () => { @@ -368,7 +373,8 @@ describe('Fiat Utils', () => { walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result).toBe('2000000000000000000'); + expect(result.amountRaw).toBe('2000000000000000000'); + expect(result.fromBlock).toBeUndefined(); }); it('falls back to tx.value for native when trace is unsupported', async () => { @@ -391,7 +397,7 @@ describe('Fiat Utils', () => { walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result).toBe('2000000000000000000'); + expect(result.amountRaw).toBe('2000000000000000000'); }); it('throws when token info cannot be resolved for fallback', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 54c7d6844d..3a94efc795 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -170,6 +170,22 @@ export function validateOrderAsset({ } } +/** + * Result from {@link resolveSourceAmountRaw}. + */ +export type ResolvedSourceAmount = { + /** Raw (atomic) source amount as a decimal string. */ + amountRaw: string; + /** + * Block number of the ramps settlement transaction as a 0x-prefixed hex + * string. Populated when `order.txHash` is present and the on-chain receipt + * was successfully fetched (ERC-20 only). Use this as the `fromBlock` for + * CHOMP idempotency log queries — it reuses the receipt already fetched for + * the amount and requires no additional network request. + */ + fromBlock: Hex | undefined; +}; + /** * Resolves the raw source amount for a completed fiat order. * @@ -177,12 +193,15 @@ export function validateOrderAsset({ * identified by `order.txHash`. If the on-chain read fails or returns * no amount, falls back to computing the amount from `order.cryptoAmount`. * + * Also returns the receipt `blockNumber` from the ramps tx when available, so + * callers can use it as a CHOMP idempotency baseline without any extra request. + * * @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. + * @returns The raw (atomic) source amount and optional receipt block number. */ export async function resolveSourceAmountRaw({ messenger, @@ -194,23 +213,25 @@ export async function resolveSourceAmountRaw({ order: RampsOrder; fiatAsset: TransactionPayFiatAsset; walletAddress: Hex; -}): Promise { +}): Promise { if (order.txHash) { try { - const onChainAmount = await getTransferredAmountFromTxHash({ - messenger, - txHash: order.txHash, - chainId: fiatAsset.chainId, - tokenAddress: fiatAsset.address, - walletAddress, - }); + const { amountRaw: onChainAmount, blockNumber } = + 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, + blockNumber, }); - return onChainAmount; + return { amountRaw: onChainAmount, fromBlock: blockNumber }; } } catch (error) { log( @@ -232,10 +253,12 @@ export async function resolveSourceAmountRaw({ ); } - return getRawSourceAmountFromOrderCryptoAmount({ + const amountRaw = getRawSourceAmountFromOrderCryptoAmount({ cryptoAmount: order.cryptoAmount, decimals: tokenInfo.decimals, }); + + return { amountRaw, fromBlock: undefined }; } /** diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index faeeaf5ad0..7752e4adc3 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -26,6 +26,7 @@ import type { GetGasFeeState } from '@metamask/gas-fee-controller'; import type { KeyringControllerGetStateAction, KeyringControllerSignTypedMessageAction, + KeyringControllerUnlockEvent, KeyringTypes, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; @@ -96,6 +97,7 @@ export type AllowedEvents = | AssetsControllerStateChangeEvent | BridgeStatusControllerStateChangeEvent | CurrencyRateStateChange + | KeyringControllerUnlockEvent | TokenRatesControllerStateChangeEvent | TokensControllerStateChangeEvent | TransactionControllerStateChangeEvent diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 20e4c8b566..edeab6a8b9 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -732,7 +732,8 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('1000000000000000000'); + expect(result.amountRaw).toBe('1000000000000000000'); + expect(result.blockNumber).toBeUndefined(); expect(PROVIDER_RECEIPT_MOCK.request).toHaveBeenCalledWith({ method: 'debug_traceTransaction', params: [TX_HASH_MOCK, { tracer: 'callTracer' }], @@ -770,7 +771,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('2000000000000000000'); + expect(result.amountRaw).toBe('2000000000000000000'); }); it('falls back to tx.value when debug_traceTransaction is unsupported', async () => { @@ -794,10 +795,10 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('1500000000000000000'); + expect(result.amountRaw).toBe('1500000000000000000'); }); - it('returns undefined when trace returns zero value and tx.to does not match wallet', async () => { + it('returns undefined amountRaw when trace returns zero value and tx.to does not match wallet', async () => { PROVIDER_RECEIPT_MOCK.request.mockImplementation( ({ method }: { method: string }) => { if (method === 'debug_traceTransaction') { @@ -818,10 +819,10 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBeUndefined(); + expect(result.amountRaw).toBeUndefined(); }); - it('returns undefined when trace is unsupported and transaction is not found', async () => { + it('returns undefined amountRaw when trace is unsupported and transaction is not found', async () => { PROVIDER_RECEIPT_MOCK.request.mockImplementation( ({ method }: { method: string }) => { if (method === 'debug_traceTransaction') { @@ -839,10 +840,10 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBeUndefined(); + expect(result.amountRaw).toBeUndefined(); }); - it('returns undefined when trace is unsupported and native tx.value is zero', async () => { + it('returns undefined amountRaw when trace is unsupported and native tx.value is zero', async () => { PROVIDER_RECEIPT_MOCK.request.mockImplementation( ({ method }: { method: string }) => { if (method === 'debug_traceTransaction') { @@ -863,7 +864,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBeUndefined(); + expect(result.amountRaw).toBeUndefined(); }); it('ignores trace value with 0x0', async () => { @@ -890,13 +891,14 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('500'); + expect(result.amountRaw).toBe('500'); }); }); describe('ERC-20 token', () => { it('decodes transfer amount from receipt logs', async () => { PROVIDER_RECEIPT_MOCK.request.mockResolvedValue({ + blockNumber: '0x1a2b3c', logs: [encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '5000000')], }); @@ -908,7 +910,8 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('5000000'); + expect(result.amountRaw).toBe('5000000'); + expect(result.blockNumber).toBe('0x1a2b3c'); }); it('sums multiple Transfer events to the same wallet', async () => { @@ -927,7 +930,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('5000000'); + expect(result.amountRaw).toBe('5000000'); }); it('ignores Transfer events to other addresses', async () => { @@ -947,7 +950,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('1000000'); + expect(result.amountRaw).toBe('1000000'); }); it('ignores logs from other token contracts', async () => { @@ -971,7 +974,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('1000000'); + expect(result.amountRaw).toBe('1000000'); }); it('ignores logs with non-Transfer event topics', async () => { @@ -998,10 +1001,10 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('2000000'); + expect(result.amountRaw).toBe('2000000'); }); - it('returns undefined when receipt is not found', async () => { + it('returns undefined amountRaw and blockNumber when receipt is not found', async () => { PROVIDER_RECEIPT_MOCK.request.mockResolvedValue(null); const result = await getTransferredAmountFromTxHash({ @@ -1012,10 +1015,11 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBeUndefined(); + expect(result.amountRaw).toBeUndefined(); + expect(result.blockNumber).toBeUndefined(); }); - it('returns undefined when no matching Transfer logs exist', async () => { + it('returns undefined amountRaw when no matching Transfer logs exist', async () => { PROVIDER_RECEIPT_MOCK.request.mockResolvedValue({ logs: [] }); const result = await getTransferredAmountFromTxHash({ @@ -1026,7 +1030,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBeUndefined(); + expect(result.amountRaw).toBeUndefined(); }); it('skips malformed log entries gracefully', async () => { @@ -1049,10 +1053,10 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBe('4000000'); + expect(result.amountRaw).toBe('4000000'); }); - it('returns undefined when all Transfer amounts are zero', async () => { + it('returns undefined amountRaw when all Transfer amounts are zero', async () => { PROVIDER_RECEIPT_MOCK.request.mockResolvedValue({ logs: [encodeTransferLog(WALLET_ADDRESS_RECEIPT_MOCK, '0')], }); @@ -1065,7 +1069,7 @@ describe('getTransferredAmountFromTxHash', () => { walletAddress: WALLET_ADDRESS_RECEIPT_MOCK, }); - expect(result).toBeUndefined(); + expect(result.amountRaw).toBeUndefined(); }); }); diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 2394906505..b8a64c31b7 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -366,13 +366,27 @@ const erc20Interface = new Interface(abiERC20); const ERC20_TRANSFER_EVENT_TOPIC = erc20Interface.getEventTopic('Transfer'); +/** + * Result from {@link getTransferredAmountFromTxHash}. + */ +export type TransferredAmountResult = { + /** Raw (atomic) transferred amount as a decimal string, or `undefined`. */ + amountRaw: string | undefined; + /** + * Block number of the on-chain transaction as a 0x-prefixed hex string. + * Populated only for ERC-20 tokens (sourced from the receipt); `undefined` + * for native token transactions. + */ + blockNumber: Hex | undefined; +}; + /** * 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. + * in the transaction receipt, and the receipt `blockNumber` is also returned. * * @param options - The options. * @param options.messenger - Controller messenger for network access. @@ -380,8 +394,7 @@ const ERC20_TRANSFER_EVENT_TOPIC = erc20Interface.getEventTopic('Transfer'); * @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. + * @returns The raw transferred amount and, for ERC-20, the receipt block number. */ export async function getTransferredAmountFromTxHash({ messenger, @@ -395,17 +408,19 @@ export async function getTransferredAmountFromTxHash({ chainId: Hex; tokenAddress: Hex; walletAddress: Hex; -}): Promise { +}): Promise { const isNative = tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); if (isNative) { - return await getNativeTransferAmount( + const amountRaw = await getNativeTransferAmount( messenger, chainId, txHash, walletAddress, ); + + return { amountRaw, blockNumber: undefined }; } return await getErc20TransferAmount( @@ -473,14 +488,16 @@ async function getNativeTransferAmount( /** * Resolves the ERC-20 token amount received by a wallet from a transaction - * by decoding `Transfer` event logs from the transaction receipt. + * by decoding `Transfer` event logs from the transaction receipt. Also + * returns the receipt `blockNumber` so callers can reuse it without a + * second network request. * * @param messenger - Controller messenger. * @param chainId - Chain ID where the transaction was executed. * @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`. + * @returns Raw amount (or `undefined`) and the receipt block number (or `undefined`). */ async function getErc20TransferAmount( messenger: TransactionPayControllerMessenger, @@ -488,8 +505,9 @@ async function getErc20TransferAmount( txHash: string, tokenAddress: Hex, walletAddress: Hex, -): Promise { +): Promise { const receipt = await rpcRequest<{ + blockNumber: Hex; logs: { address: string; topics: string[]; data: string }[]; } | null>({ messenger, @@ -499,9 +517,10 @@ async function getErc20TransferAmount( }); if (!receipt) { - return undefined; + return { amountRaw: undefined, blockNumber: undefined }; } + const { blockNumber } = receipt; let total = new BigNumber(0); for (const txLog of receipt.logs) { @@ -527,7 +546,7 @@ async function getErc20TransferAmount( } } - return positiveOrUndefined(total.toFixed(0)); + return { amountRaw: positiveOrUndefined(total.toFixed(0)), blockNumber }; } type CallTrace = {