From e3d50fc6a0b44a4aacb21be7c5cbac275c3f01e6 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 12:53:57 +0100 Subject: [PATCH 01/10] fix(transaction-pay-controller): wait for keyring unlock before fiat post-ramp second leg --- .../transaction-pay-controller/CHANGELOG.md | 6 + .../src/strategy/fiat/fiat-submit.test.ts | 110 ++++++++++++++++++ .../src/strategy/fiat/fiat-submit.ts | 34 ++++++ .../transaction-pay-controller/src/types.ts | 2 + 4 files changed, 152 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c3e3997a40..afa0d5022c 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Wait for keyring unlock before executing fiat post-ramp second leg ([#9267](https://github.com/MetaMask/core/pull/9267)) + - `submitRelayAfterFiatCompletion` now checks `KeyringController:getState().isUnlocked` before proceeding; if the keyring is locked it waits indefinitely for `KeyringController:unlock` before continuing, preventing `Account does not support EIP-7702` errors when the wallet is locked during the fiat order completion callback. + - Added `KeyringControllerUnlockEvent` to `AllowedEvents` in `TransactionPayController`. + ## [23.16.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 b1227b0a07..9f6bc395d0 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; @@ -400,6 +404,93 @@ 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('1234500000000000000'); + + 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++) { + // eslint-disable-next-line no-await-in-loop + 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 }); @@ -467,6 +558,9 @@ describe('submitFiatQuotes', () => { }, }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -699,6 +793,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -760,6 +858,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -1169,6 +1271,10 @@ describe('submitFiatQuotes', () => { }; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'TransactionPayController:getFiatOptions') { return undefined; } @@ -1230,6 +1336,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..2a1b8de786 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -126,6 +126,8 @@ export async function submitFiatQuotes( }); try { + await waitForKeyringUnlock(messenger, transactionId); + const result = await submitRelayAfterFiatCompletion({ order, request }); if (result.transactionHash === undefined) { @@ -313,3 +315,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/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 From c1cf3090309b2300f855458b8077b2415d1ec499 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 13:05:18 +0100 Subject: [PATCH 02/10] fix: format fiat-submit.test.ts --- .../src/strategy/fiat/fiat-submit.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 9f6bc395d0..d55444e675 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 @@ -423,7 +423,10 @@ describe('submitFiatQuotes', () => { return { transactionData: { [TRANSACTION_ID_MOCK]: { - fiatPayment: { orderId: ORDER_ID_MOCK, rampsQuote: RAMPS_QUOTE_MOCK }, + fiatPayment: { + orderId: ORDER_ID_MOCK, + rampsQuote: RAMPS_QUOTE_MOCK, + }, isLoading: false, tokens: [], }, From 0e62f89591ec1a1f45d49cca079d58f6227052e9 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 13:14:34 +0100 Subject: [PATCH 03/10] fix: remove unused eslint-disable directive in fiat-submit.test.ts --- .../src/strategy/fiat/fiat-submit.test.ts | 1 - 1 file changed, 1 deletion(-) 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 d55444e675..989225e225 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 @@ -470,7 +470,6 @@ describe('submitFiatQuotes', () => { // Flush microtasks until waitForKeyringUnlock subscribes (order polling // resolves in one tick; submitRelayAfterFiatCompletion starts in the next) for (let i = 0; i < 5; i++) { - // eslint-disable-next-line no-await-in-loop await Promise.resolve(); } From 7743070b40b089d9f2c42f32b3946b53f197eabd Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 15:09:02 +0100 Subject: [PATCH 04/10] feat(transaction-pay-controller): add CHOMP idempotency for direct mUSD vault deposit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CHOMP (auto-vault) detection to submitDirectMusdVaultDeposit: - Pre-check via eth_getLogs before addTransactionBatch; returns CHOMP hash without adding a child transaction when CHOMP has already vaulted the funds. - Post-check in the catch path to handle the race where CHOMP wins between pre-check and submit (e.g. the EIP-7702 account-support error case). - Errors in either CHOMP check are swallowed so they never break the normal vault submit path. CHOMP baseline block is derived from the ramps settlement tx receipt blockNumber already fetched in getTransferredAmountFromTxHash (ERC-20 path), requiring no extra network request. resolveSourceAmountRaw now returns { amountRaw, chompFromBlock } and getTransferredAmountFromTxHash returns { amountRaw, blockNumber }. Detection: single eth_getLogs call — mUSD Transfer(from=moneyAccount) with amount >= sourceAmountRaw; newest log wins. --- .../fiat/fiat-direct-musd-chomp.test.ts | 194 ++++++++++++++++++ .../strategy/fiat/fiat-direct-musd-chomp.ts | 114 ++++++++++ .../strategy/fiat/fiat-direct-musd.test.ts | 181 ++++++++++++++++ .../src/strategy/fiat/fiat-direct-musd.ts | 87 +++++++- .../src/strategy/fiat/fiat-submit.test.ts | 8 +- .../src/strategy/fiat/fiat-submit.ts | 12 +- .../src/strategy/fiat/utils.test.ts | 20 +- .../src/strategy/fiat/utils.ts | 45 +++- .../src/utils/transaction.test.ts | 48 +++-- .../src/utils/transaction.ts | 39 +++- 10 files changed, 684 insertions(+), 64 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.ts diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.test.ts new file mode 100644 index 0000000000..094fb6c49b --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-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 './fiat-direct-musd-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); + +/** Build a matching mUSD Transfer-out log for the Money Account. */ +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('fiat-direct-musd-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/fiat-direct-musd-chomp.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.ts new file mode 100644 index 0000000000..532d2cad10 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.ts @@ -0,0 +1,114 @@ +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, 'fiat-direct-musd-chomp'); + +/** keccak256('Transfer(address,address,uint256)') */ +const ERC20_TRANSFER_TOPIC = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +type RpcLog = { + address: string; + topics: string[]; + data: string; + transactionHash: Hex; +}; + +/** + * Scans recent Monad logs for a CHOMP auto-vault deposit that already + * transferred the required mUSD amount out of the Money Account. Returns + * the on-chain tx hash of the first (newest) matching Transfer log. + * + * Detection requires both conditions: + * 1. A Transfer(from=moneyAccount) on the mUSD contract within [fromBlock, latest]. + * 2. The transferred amount is >= sourceAmountRaw. + * + * Logs are examined newest-first so the most recent CHOMP deposit wins. + * Only a single `eth_getLogs` call is made — no per-tx follow-up requests. + * + * @param options - Detection options. + * @param options.messenger - Controller messenger. + * @param options.moneyAccountAddress - Money Account that owns the mUSD. + * @param options.sourceAmountRaw - Minimum mUSD amount (in raw units) that must + * have been transferred out of the Money Account for the CHOMP deposit to count. + * @param options.fromBlock - Starting block for the log query (hex block number + * derived from the ramps settlement tx, e.g. "0x1a2b3c"). + * @returns The transaction hash of the matching CHOMP deposit, or `undefined` if + * none is found. + */ +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; +} + +/** + * Pads an EVM address to a 32-byte (64 hex character) topics value. + * + * @param address - 20-byte hex address with or without 0x prefix. + * @returns 0x-prefixed 32-byte hex string suitable for `eth_getLogs` topics. + */ +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..a434a24000 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 @@ -26,12 +26,14 @@ import { isDirectMusdMoneyAccountQuote, submitDirectMusdVaultDeposit, } from './fiat-direct-musd'; +import { findRecentChompVaultDeposit } from './fiat-direct-musd-chomp'; import type { FiatQuote } from './types'; jest.mock('../../utils/feature-flags'); jest.mock('../../utils/provider'); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); +jest.mock('./fiat-direct-musd-chomp'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = @@ -584,5 +586,184 @@ 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({ + chompFromBlock: 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({ + chompFromBlock: 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({ + chompFromBlock: 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({ + chompFromBlock: 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({ + chompFromBlock: 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 chompFromBlock 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..64dd8701eb 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 @@ -20,6 +20,7 @@ import type { import { prefixError } from '../../utils/error-prefix'; import { getFiatVaultDisabled } from '../../utils/feature-flags'; import { getNetworkClientId } from '../../utils/provider'; +import { findRecentChompVaultDeposit } from './fiat-direct-musd-chomp'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; import { collectTransactionIds, @@ -128,6 +129,9 @@ export function isDirectMusdMoneyAccountQuote( /** * Submits the direct mUSD post-Ramp path after fiat settlement. * + * Derives the CHOMP idempotency baseline block from the ramps settlement tx + * receipt (already fetched for the amount) — no additional network request. + * * @param options - Submit options. * @param options.order - Completed fiat order. * @param options.request - Strategy execute request. @@ -149,14 +153,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, chompFromBlock } = + await resolveSourceAmountRaw({ + messenger, + order, + fiatAsset: MUSD_MONAD_FIAT_ASSET, + walletAddress: transaction.txParams.from as Hex, + }); return await submitDirectMusdVaultDeposit({ + chompFromBlock, request, sourceAmountRaw, transaction, @@ -169,17 +175,29 @@ export async function submitDirectMusdAfterFiatCompletion({ /** * Submits the direct mUSD Money Account vault batch after fiat settlement. * + * Before calling `addTransactionBatch` and again in the catch path, the + * function checks whether CHOMP has already auto-vaulted the funds by scanning + * recent Monad logs. When a valid CHOMP deposit is found in either location the + * function returns its on-chain hash without adding a local vault child + * transaction. + * * @param options - Submit options. + * @param options.chompFromBlock - 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({ + chompFromBlock, request, sourceAmountRaw, transaction, }: { + chompFromBlock?: Hex; request: PayStrategyExecuteRequest; sourceAmountRaw: string; transaction: PayStrategyExecuteRequest['transaction']; @@ -251,6 +269,35 @@ export async function submitDirectMusdVaultDeposit({ }, ); + // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already + // auto-vaulted the funds during or before the checkout window. + if (chompFromBlock) { + try { + const chompHash = await findRecentChompVaultDeposit({ + fromBlock: chompFromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + }); + + if (chompHash) { + log('CHOMP already vaulted funds; skipping addTransactionBatch', { + chompHash, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { transactionHash: chompHash }; + } + } catch (chompError) { + log('CHOMP pre-check failed; proceeding with vault submit', { + chompError, + transactionId, + }); + } + } + const networkClientId = getNetworkClientId( messenger, MUSD_MONAD_FIAT_ASSET.chainId, @@ -309,6 +356,32 @@ 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. + if (chompFromBlock) { + try { + const chompHash = await findRecentChompVaultDeposit({ + fromBlock: chompFromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + }); + + if (chompHash) { + log('CHOMP detected after addTransactionBatch failure; returning CHOMP hash', { + chompHash, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { transactionHash: chompHash }; + } + } catch (chompError) { + log('CHOMP post-check failed', { chompError, transactionId }); + } + } + throw prefixError(error, VAULT_ERROR_PREFIX); } finally { end(); 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 989225e225..e1c0790844 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 @@ -342,7 +342,7 @@ describe('submitFiatQuotes', () => { ); waitForTransactionConfirmedMock.mockResolvedValue(); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); - resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1000000000000000000', chompFromBlock: undefined }); fundFiatOrderFromTestSourceMock.mockResolvedValue( getFiatOrderMock({ cryptoAmount: '1', @@ -369,7 +369,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', chompFromBlock: undefined }); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -416,7 +416,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', chompFromBlock: undefined }); const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getState') { @@ -539,7 +539,7 @@ describe('submitFiatQuotes', () => { ], } as unknown as TransactionMeta; - resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', chompFromBlock: undefined }); const { callMock, request } = getRequest({ transaction: nestedTransaction, 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 2a1b8de786..949170a4fe 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -128,7 +128,10 @@ export async function submitFiatQuotes( try { await waitForKeyringUnlock(messenger, transactionId); - const result = await submitRelayAfterFiatCompletion({ order, request }); + const result = await submitRelayAfterFiatCompletion({ + order, + request, + }); if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); @@ -248,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); @@ -261,7 +267,7 @@ async function submitRelayAfterFiatCompletion({ const baseRequest = fiatQuote.request; - const sourceAmountRaw = await resolveSourceAmountRaw({ + const { amountRaw: sourceAmountRaw } = await resolveSourceAmountRaw({ messenger, order, fiatAsset, 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..66025df025 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.chompFromBlock).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.chompFromBlock).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.chompFromBlock).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.chompFromBlock).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.chompFromBlock).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..98870c869b 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. + */ + chompFromBlock: 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, chompFromBlock: 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, chompFromBlock: undefined }; } /** 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..0d63f009fc 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.blockNumber; 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 = { From 7e533780e0f1ae543756f31cc3a1a229f242843f Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 15:37:57 +0100 Subject: [PATCH 05/10] refactor(transaction-pay-controller): rename chomp file, extract tryFindChompDeposit helper, rename chompFromBlock to fromBlock --- ...irect-musd-chomp.test.ts => chomp.test.ts} | 4 +- .../{fiat-direct-musd-chomp.ts => chomp.ts} | 2 +- .../strategy/fiat/fiat-direct-musd.test.ts | 16 +-- .../src/strategy/fiat/fiat-direct-musd.ts | 136 +++++++++++------- .../src/strategy/fiat/fiat-submit.test.ts | 8 +- .../src/strategy/fiat/utils.test.ts | 10 +- .../src/strategy/fiat/utils.ts | 6 +- 7 files changed, 108 insertions(+), 74 deletions(-) rename packages/transaction-pay-controller/src/strategy/fiat/{fiat-direct-musd-chomp.test.ts => chomp.test.ts} (98%) rename packages/transaction-pay-controller/src/strategy/fiat/{fiat-direct-musd-chomp.ts => chomp.ts} (98%) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts similarity index 98% rename from packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.test.ts rename to packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts index 094fb6c49b..69ad1408eb 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts @@ -3,7 +3,7 @@ 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 './fiat-direct-musd-chomp'; +import { findRecentChompVaultDeposit } from './chomp'; jest.mock('../../utils/provider'); @@ -50,7 +50,7 @@ function buildMessenger(): TransactionPayControllerMessenger { return {} as TransactionPayControllerMessenger; } -describe('fiat-direct-musd-chomp', () => { +describe('chomp', () => { const rpcRequestMock = jest.mocked(rpcRequest); beforeEach(() => { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts similarity index 98% rename from packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.ts rename to packages/transaction-pay-controller/src/strategy/fiat/chomp.ts index 532d2cad10..f91c9cacd4 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd-chomp.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts @@ -6,7 +6,7 @@ import { projectLogger } from '../../logger'; import type { TransactionPayControllerMessenger } from '../../types'; import { rpcRequest } from '../../utils/provider'; -const log = createModuleLogger(projectLogger, 'fiat-direct-musd-chomp'); +const log = createModuleLogger(projectLogger, 'chomp'); /** keccak256('Transfer(address,address,uint256)') */ const ERC20_TRANSFER_TOPIC = 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 a434a24000..497f7359d9 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 @@ -26,14 +26,14 @@ import { isDirectMusdMoneyAccountQuote, submitDirectMusdVaultDeposit, } from './fiat-direct-musd'; -import { findRecentChompVaultDeposit } from './fiat-direct-musd-chomp'; +import { findRecentChompVaultDeposit } from './chomp'; import type { FiatQuote } from './types'; jest.mock('../../utils/feature-flags'); jest.mock('../../utils/provider'); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); -jest.mock('./fiat-direct-musd-chomp'); +jest.mock('./chomp'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = @@ -620,7 +620,7 @@ describe('fiat-direct-musd', () => { const callMock = makeCallMock(); const result = await submitDirectMusdVaultDeposit({ - chompFromBlock: CHOMP_FROM_BLOCK, + fromBlock: CHOMP_FROM_BLOCK, request: getExecuteRequest({ callMock }), sourceAmountRaw: '5000000', transaction: TRANSACTION_MOCK, @@ -657,7 +657,7 @@ describe('fiat-direct-musd', () => { }); const result = await submitDirectMusdVaultDeposit({ - chompFromBlock: CHOMP_FROM_BLOCK, + fromBlock: CHOMP_FROM_BLOCK, request: getExecuteRequest({ callMock }), sourceAmountRaw: '5000000', transaction: TRANSACTION_MOCK, @@ -688,7 +688,7 @@ describe('fiat-direct-musd', () => { await expect( submitDirectMusdVaultDeposit({ - chompFromBlock: CHOMP_FROM_BLOCK, + fromBlock: CHOMP_FROM_BLOCK, request: getExecuteRequest({ callMock }), sourceAmountRaw: '5000000', transaction: TRANSACTION_MOCK, @@ -704,7 +704,7 @@ describe('fiat-direct-musd', () => { const callMock = makeCallMock(); const result = await submitDirectMusdVaultDeposit({ - chompFromBlock: CHOMP_FROM_BLOCK, + fromBlock: CHOMP_FROM_BLOCK, request: getExecuteRequest({ callMock }), sourceAmountRaw: '5000000', transaction: TRANSACTION_MOCK, @@ -742,7 +742,7 @@ describe('fiat-direct-musd', () => { await expect( submitDirectMusdVaultDeposit({ - chompFromBlock: CHOMP_FROM_BLOCK, + fromBlock: CHOMP_FROM_BLOCK, request: getExecuteRequest({ callMock }), sourceAmountRaw: '5000000', transaction: TRANSACTION_MOCK, @@ -752,7 +752,7 @@ describe('fiat-direct-musd', () => { expect(findRecentChompVaultDepositMock).toHaveBeenCalledTimes(2); }); - it('skips CHOMP checks when chompFromBlock is not provided', async () => { + it('skips CHOMP checks when fromBlock is not provided', async () => { const callMock = makeCallMock(); const result = await submitDirectMusdVaultDeposit({ 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 64dd8701eb..b645505c0a 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 @@ -20,7 +20,7 @@ import type { import { prefixError } from '../../utils/error-prefix'; import { getFiatVaultDisabled } from '../../utils/feature-flags'; import { getNetworkClientId } from '../../utils/provider'; -import { findRecentChompVaultDeposit } from './fiat-direct-musd-chomp'; +import { findRecentChompVaultDeposit } from './chomp'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; import { collectTransactionIds, @@ -153,7 +153,7 @@ export async function submitDirectMusdAfterFiatCompletion({ transactionId: transaction.id, }); - const { amountRaw: sourceAmountRaw, chompFromBlock } = + const { amountRaw: sourceAmountRaw, fromBlock } = await resolveSourceAmountRaw({ messenger, order, @@ -162,7 +162,7 @@ export async function submitDirectMusdAfterFiatCompletion({ }); return await submitDirectMusdVaultDeposit({ - chompFromBlock, + fromBlock, request, sourceAmountRaw, transaction, @@ -182,7 +182,7 @@ export async function submitDirectMusdAfterFiatCompletion({ * transaction. * * @param options - Submit options. - * @param options.chompFromBlock - Optional Monad block baseline for CHOMP + * @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. @@ -192,12 +192,12 @@ export async function submitDirectMusdAfterFiatCompletion({ * hash), if available. */ export async function submitDirectMusdVaultDeposit({ - chompFromBlock, + fromBlock, request, sourceAmountRaw, transaction, }: { - chompFromBlock?: Hex; + fromBlock?: Hex; request: PayStrategyExecuteRequest; sourceAmountRaw: string; transaction: PayStrategyExecuteRequest['transaction']; @@ -271,31 +271,23 @@ export async function submitDirectMusdVaultDeposit({ // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. - if (chompFromBlock) { - try { - const chompHash = await findRecentChompVaultDeposit({ - fromBlock: chompFromBlock, - messenger, - moneyAccountAddress, - sourceAmountRaw, - }); + const preChompHash = await tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); - if (chompHash) { - log('CHOMP already vaulted funds; skipping addTransactionBatch', { - chompHash, - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); + if (preChompHash) { + log('CHOMP already vaulted funds; skipping addTransactionBatch', { + chompHash: preChompHash, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); - return { transactionHash: chompHash }; - } - } catch (chompError) { - log('CHOMP pre-check failed; proceeding with vault submit', { - chompError, - transactionId, - }); - } + return { transactionHash: preChompHash }; } const networkClientId = getNetworkClientId( @@ -358,28 +350,23 @@ 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. - if (chompFromBlock) { - try { - const chompHash = await findRecentChompVaultDeposit({ - fromBlock: chompFromBlock, - messenger, - moneyAccountAddress, - sourceAmountRaw, - }); - - if (chompHash) { - log('CHOMP detected after addTransactionBatch failure; returning CHOMP hash', { - chompHash, - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); - - return { transactionHash: chompHash }; - } - } catch (chompError) { - log('CHOMP post-check failed', { chompError, transactionId }); - } + const postChompHash = await tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + if (postChompHash) { + log('CHOMP detected after addTransactionBatch failure; returning CHOMP hash', { + chompHash: postChompHash, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { transactionHash: postChompHash }; } throw prefixError(error, VAULT_ERROR_PREFIX); @@ -500,3 +487,50 @@ function getRampsProviderFee(fiatQuote: RampsQuote): BigNumber { fiatQuote.quote.networkFee ?? 0, ); } + +/** + * Checks for a recent CHOMP auto-vault deposit, swallowing any errors so the + * caller's normal flow is never interrupted by a detection failure. + * + * When `fromBlock` is `undefined` the check is skipped and `undefined` is + * returned immediately — no network request is made. + * + * @param options - Detection options. + * @param options.fromBlock - Starting block for the CHOMP log query. When + * `undefined` the check is skipped. + * @param options.messenger - Controller messenger. + * @param options.moneyAccountAddress - Money Account that owns the mUSD. + * @param options.sourceAmountRaw - Minimum mUSD amount required for a match. + * @param options.transactionId - Parent transaction ID used for logging. + * @returns The matching CHOMP tx hash, or `undefined` if none is found or the + * check fails. + */ +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 e1c0790844..46bc54fa45 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 @@ -342,7 +342,7 @@ describe('submitFiatQuotes', () => { ); waitForTransactionConfirmedMock.mockResolvedValue(); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1000000000000000000', chompFromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1000000000000000000', fromBlock: undefined }); fundFiatOrderFromTestSourceMock.mockResolvedValue( getFiatOrderMock({ cryptoAmount: '1', @@ -369,7 +369,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', chompFromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', fromBlock: undefined }); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -416,7 +416,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', chompFromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', fromBlock: undefined }); const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getState') { @@ -539,7 +539,7 @@ describe('submitFiatQuotes', () => { ], } as unknown as TransactionMeta; - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', chompFromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', fromBlock: undefined }); const { callMock, request } = getRequest({ transaction: nestedTransaction, 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 66025df025..f0fa144883 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -315,7 +315,7 @@ describe('Fiat Utils', () => { }); expect(result.amountRaw).toBe('7000000'); - expect(result.chompFromBlock).toBe('0x1a2b3c'); + expect(result.fromBlock).toBe('0x1a2b3c'); }); it('falls back to cryptoAmount when txHash is missing', async () => { @@ -327,7 +327,7 @@ describe('Fiat Utils', () => { }); expect(result.amountRaw).toBe('1500000'); - expect(result.chompFromBlock).toBeUndefined(); + expect(result.fromBlock).toBeUndefined(); expect(PROVIDER_MOCK.request).not.toHaveBeenCalled(); }); @@ -342,7 +342,7 @@ describe('Fiat Utils', () => { }); expect(result.amountRaw).toBe('1500000'); - expect(result.chompFromBlock).toBeUndefined(); + expect(result.fromBlock).toBeUndefined(); }); it('falls back to cryptoAmount when on-chain read throws', async () => { @@ -356,7 +356,7 @@ describe('Fiat Utils', () => { }); expect(result.amountRaw).toBe('1500000'); - expect(result.chompFromBlock).toBeUndefined(); + expect(result.fromBlock).toBeUndefined(); }); it('returns native amount from debug_traceTransaction', async () => { @@ -374,7 +374,7 @@ describe('Fiat Utils', () => { }); expect(result.amountRaw).toBe('2000000000000000000'); - expect(result.chompFromBlock).toBeUndefined(); + expect(result.fromBlock).toBeUndefined(); }); it('falls back to tx.value for native when trace is unsupported', async () => { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 98870c869b..3a94efc795 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -183,7 +183,7 @@ export type ResolvedSourceAmount = { * CHOMP idempotency log queries — it reuses the receipt already fetched for * the amount and requires no additional network request. */ - chompFromBlock: Hex | undefined; + fromBlock: Hex | undefined; }; /** @@ -231,7 +231,7 @@ export async function resolveSourceAmountRaw({ onChainAmount, blockNumber, }); - return { amountRaw: onChainAmount, chompFromBlock: blockNumber }; + return { amountRaw: onChainAmount, fromBlock: blockNumber }; } } catch (error) { log( @@ -258,7 +258,7 @@ export async function resolveSourceAmountRaw({ decimals: tokenInfo.decimals, }); - return { amountRaw, chompFromBlock: undefined }; + return { amountRaw, fromBlock: undefined }; } /** From 22347ca45a269564994172ff137466d909923683 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 15:46:02 +0100 Subject: [PATCH 06/10] chore(transaction-pay-controller): update changelog; move CHOMP match log into chomp util --- packages/transaction-pay-controller/CHANGELOG.md | 5 +++++ .../src/strategy/fiat/fiat-direct-musd.ts | 14 -------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index afa0d5022c..5f4881f7fd 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] +### Added + +- Add CHOMP idempotency for direct mUSD vault deposits ([#9267](https://github.com/MetaMask/core/pull/9267)) + - Before calling `addTransactionBatch` and again in the catch path, `submitDirectMusdVaultDeposit` scans recent Monad logs for a CHOMP auto-vault deposit that already transferred the required mUSD out of the Money Account; when found the on-chain CHOMP hash is returned directly, skipping or superseding the local batch submission. + ### Fixed - Wait for keyring unlock before executing fiat post-ramp second leg ([#9267](https://github.com/MetaMask/core/pull/9267)) 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 b645505c0a..ff99cba3d8 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 @@ -280,13 +280,6 @@ export async function submitDirectMusdVaultDeposit({ }); if (preChompHash) { - log('CHOMP already vaulted funds; skipping addTransactionBatch', { - chompHash: preChompHash, - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); - return { transactionHash: preChompHash }; } @@ -359,13 +352,6 @@ export async function submitDirectMusdVaultDeposit({ }); if (postChompHash) { - log('CHOMP detected after addTransactionBatch failure; returning CHOMP hash', { - chompHash: postChompHash, - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); - return { transactionHash: postChompHash }; } From 1ec310ea792e21c4aea78cf0c69a347eb9df41c0 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 16:19:16 +0100 Subject: [PATCH 07/10] chore: fix formatting (lint:misc) --- .../src/strategy/fiat/chomp.test.ts | 15 +++++++------- .../strategy/fiat/fiat-direct-musd.test.ts | 14 ++++--------- .../src/strategy/fiat/fiat-direct-musd.ts | 2 +- .../src/strategy/fiat/fiat-submit.test.ts | 20 +++++++++++++++---- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts index 69ad1408eb..0cbf24055b 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts @@ -14,7 +14,8 @@ const CHOMP_TX_HASH = 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'; +const TRANSFER_DATA_SUFFICIENT = + '0x00000000000000000000000000000000000000000000000000000000004c4b40'; // uint256 hex for 4999999 (< source amount) const TRANSFER_DATA_INSUFFICIENT = '0x00000000000000000000000000000000000000000000000000000000004c4b3f'; @@ -41,7 +42,11 @@ function buildMusdTransferLog( return { address: MUSD_MONAD_ADDRESS, data, - topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')], + topics: [ + ERC20_TRANSFER_TOPIC, + MONEY_ACCOUNT_PADDED, + padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + ], transactionHash: txHash, }; } @@ -122,11 +127,7 @@ describe('chomp', () => { address: MUSD_MONAD_ADDRESS, fromBlock: FROM_BLOCK, toBlock: 'latest', - topics: [ - ERC20_TRANSFER_TOPIC, - MONEY_ACCOUNT_PADDED, - null, - ], + topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, null], }), ], }), 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 497f7359d9..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,13 +20,13 @@ import { updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import { findRecentChompVaultDeposit } from './chomp'; import { DEFAULT_FIAT_CURRENCY, MUSD_MONAD_FIAT_ASSET } from './constants'; import { getDirectMusdFiatQuote, isDirectMusdMoneyAccountQuote, submitDirectMusdVaultDeposit, } from './fiat-direct-musd'; -import { findRecentChompVaultDeposit } from './chomp'; import type { FiatQuote } from './types'; jest.mock('../../utils/feature-flags'); @@ -643,9 +643,7 @@ describe('fiat-direct-musd', () => { const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getAmountData') { return Promise.resolve({ - updates: [ - { data: '0xnewApprove', nestedTransactionIndex: 0 }, - ], + updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], }); } @@ -673,9 +671,7 @@ describe('fiat-direct-musd', () => { const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getAmountData') { return Promise.resolve({ - updates: [ - { data: '0xnewApprove', nestedTransactionIndex: 0 }, - ], + updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], }); } @@ -727,9 +723,7 @@ describe('fiat-direct-musd', () => { const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getAmountData') { return Promise.resolve({ - updates: [ - { data: '0xnewApprove', nestedTransactionIndex: 0 }, - ], + updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], }); } 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 ff99cba3d8..8f40a56c03 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 @@ -20,7 +20,6 @@ import type { import { prefixError } from '../../utils/error-prefix'; import { getFiatVaultDisabled } from '../../utils/feature-flags'; import { getNetworkClientId } from '../../utils/provider'; -import { findRecentChompVaultDeposit } from './chomp'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; import { collectTransactionIds, @@ -28,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 { 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 46bc54fa45..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 @@ -342,7 +342,10 @@ describe('submitFiatQuotes', () => { ); waitForTransactionConfirmedMock.mockResolvedValue(); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1000000000000000000', fromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1000000000000000000', + fromBlock: undefined, + }); fundFiatOrderFromTestSourceMock.mockResolvedValue( getFiatOrderMock({ cryptoAmount: '1', @@ -369,7 +372,10 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', fromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1234500000000000000', + fromBlock: undefined, + }); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -416,7 +422,10 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', fromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1234500000000000000', + fromBlock: undefined, + }); const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getState') { @@ -539,7 +548,10 @@ describe('submitFiatQuotes', () => { ], } as unknown as TransactionMeta; - resolveSourceAmountRawMock.mockResolvedValue({ amountRaw: '1234500000000000000', fromBlock: undefined }); + resolveSourceAmountRawMock.mockResolvedValue({ + amountRaw: '1234500000000000000', + fromBlock: undefined, + }); const { callMock, request } = getRequest({ transaction: nestedTransaction, From f5a40a067d5358d8ef6ba9632204e21905406ebe Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 22:29:48 +0100 Subject: [PATCH 08/10] fix(transaction-pay-controller): remove added JSDoc from new helpers; fix prefer-destructuring --- .../src/strategy/fiat/chomp.test.ts | 1 - .../src/strategy/fiat/chomp.ts | 28 -------- .../src/strategy/fiat/fiat-direct-musd.ts | 68 ++++++------------- .../src/utils/transaction.ts | 2 +- 4 files changed, 22 insertions(+), 77 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts index 0cbf24055b..8c92e0a830 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.test.ts @@ -29,7 +29,6 @@ function padAddress(address: string): string { const MONEY_ACCOUNT_PADDED = padAddress(MONEY_ACCOUNT_ADDRESS); -/** Build a matching mUSD Transfer-out log for the Money Account. */ function buildMusdTransferLog( txHash: Hex = CHOMP_TX_HASH, data: string = TRANSFER_DATA_SUFFICIENT, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts b/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts index f91c9cacd4..9290409129 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/chomp.ts @@ -19,28 +19,6 @@ type RpcLog = { transactionHash: Hex; }; -/** - * Scans recent Monad logs for a CHOMP auto-vault deposit that already - * transferred the required mUSD amount out of the Money Account. Returns - * the on-chain tx hash of the first (newest) matching Transfer log. - * - * Detection requires both conditions: - * 1. A Transfer(from=moneyAccount) on the mUSD contract within [fromBlock, latest]. - * 2. The transferred amount is >= sourceAmountRaw. - * - * Logs are examined newest-first so the most recent CHOMP deposit wins. - * Only a single `eth_getLogs` call is made — no per-tx follow-up requests. - * - * @param options - Detection options. - * @param options.messenger - Controller messenger. - * @param options.moneyAccountAddress - Money Account that owns the mUSD. - * @param options.sourceAmountRaw - Minimum mUSD amount (in raw units) that must - * have been transferred out of the Money Account for the CHOMP deposit to count. - * @param options.fromBlock - Starting block for the log query (hex block number - * derived from the ramps settlement tx, e.g. "0x1a2b3c"). - * @returns The transaction hash of the matching CHOMP deposit, or `undefined` if - * none is found. - */ export async function findRecentChompVaultDeposit({ messenger, moneyAccountAddress, @@ -103,12 +81,6 @@ export async function findRecentChompVaultDeposit({ return undefined; } -/** - * Pads an EVM address to a 32-byte (64 hex character) topics value. - * - * @param address - 20-byte hex address with or without 0x prefix. - * @returns 0x-prefixed 32-byte hex string suitable for `eth_getLogs` topics. - */ 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.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index 8f40a56c03..60f886c10e 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 @@ -126,17 +126,14 @@ export function isDirectMusdMoneyAccountQuote( return quote?.request.isDirectMusdMoneyAccount === true; } -/** - * Submits the direct mUSD post-Ramp path after fiat settlement. - * - * Derives the CHOMP idempotency baseline block from the ramps settlement tx - * receipt (already fetched for the amount) — no additional network request. - * - * @param options - Submit options. - * @param options.order - Completed fiat order. - * @param options.request - Strategy execute request. - * @returns Hash of the submitted direct mUSD transaction, if available. - */ + /** + * Submits the direct mUSD post-Ramp path after fiat settlement. + * + * @param options - Submit options. + * @param options.order - Completed fiat order. + * @param options.request - Strategy execute request. + * @returns Hash of the submitted direct mUSD transaction, if available. + */ export async function submitDirectMusdAfterFiatCompletion({ order, request, @@ -172,25 +169,19 @@ export async function submitDirectMusdAfterFiatCompletion({ } } -/** - * Submits the direct mUSD Money Account vault batch after fiat settlement. - * - * Before calling `addTransactionBatch` and again in the catch path, the - * function checks whether CHOMP has already auto-vaulted the funds by scanning - * recent Monad logs. When a valid CHOMP deposit is found in either location the - * function returns its on-chain hash without adding a local vault child - * transaction. - * - * @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 (or the CHOMP deposit - * hash), if available. - */ + /** + * 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 (or the CHOMP deposit + * hash), if available. + */ export async function submitDirectMusdVaultDeposit({ fromBlock, request, @@ -474,23 +465,6 @@ function getRampsProviderFee(fiatQuote: RampsQuote): BigNumber { ); } -/** - * Checks for a recent CHOMP auto-vault deposit, swallowing any errors so the - * caller's normal flow is never interrupted by a detection failure. - * - * When `fromBlock` is `undefined` the check is skipped and `undefined` is - * returned immediately — no network request is made. - * - * @param options - Detection options. - * @param options.fromBlock - Starting block for the CHOMP log query. When - * `undefined` the check is skipped. - * @param options.messenger - Controller messenger. - * @param options.moneyAccountAddress - Money Account that owns the mUSD. - * @param options.sourceAmountRaw - Minimum mUSD amount required for a match. - * @param options.transactionId - Parent transaction ID used for logging. - * @returns The matching CHOMP tx hash, or `undefined` if none is found or the - * check fails. - */ async function tryFindChompDeposit({ fromBlock, messenger, diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 0d63f009fc..b8a64c31b7 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -520,7 +520,7 @@ async function getErc20TransferAmount( return { amountRaw: undefined, blockNumber: undefined }; } - const blockNumber = receipt.blockNumber; + const { blockNumber } = receipt; let total = new BigNumber(0); for (const txLog of receipt.logs) { From c5b3a23c0776142ea3835d806748643c585a6818 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 22:32:52 +0100 Subject: [PATCH 09/10] chore(transaction-pay-controller): simplify changelog entries --- packages/transaction-pay-controller/CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5f4881f7fd..4eb4586046 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,13 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add CHOMP idempotency for direct mUSD vault deposits ([#9267](https://github.com/MetaMask/core/pull/9267)) - - Before calling `addTransactionBatch` and again in the catch path, `submitDirectMusdVaultDeposit` scans recent Monad logs for a CHOMP auto-vault deposit that already transferred the required mUSD out of the Money Account; when found the on-chain CHOMP hash is returned directly, skipping or superseding the local batch submission. ### Fixed - Wait for keyring unlock before executing fiat post-ramp second leg ([#9267](https://github.com/MetaMask/core/pull/9267)) - - `submitRelayAfterFiatCompletion` now checks `KeyringController:getState().isUnlocked` before proceeding; if the keyring is locked it waits indefinitely for `KeyringController:unlock` before continuing, preventing `Account does not support EIP-7702` errors when the wallet is locked during the fiat order completion callback. - - Added `KeyringControllerUnlockEvent` to `AllowedEvents` in `TransactionPayController`. ## [23.16.1] From f307c21cbd6603f5699bd771d6e0a2eb0a4341c2 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 25 Jun 2026 22:33:37 +0100 Subject: [PATCH 10/10] chore: fix prettier formatting in fiat-direct-musd.ts --- .../src/strategy/fiat/fiat-direct-musd.ts | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) 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 60f886c10e..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 @@ -126,14 +126,14 @@ export function isDirectMusdMoneyAccountQuote( return quote?.request.isDirectMusdMoneyAccount === true; } - /** - * Submits the direct mUSD post-Ramp path after fiat settlement. - * - * @param options - Submit options. - * @param options.order - Completed fiat order. - * @param options.request - Strategy execute request. - * @returns Hash of the submitted direct mUSD transaction, if available. - */ +/** + * Submits the direct mUSD post-Ramp path after fiat settlement. + * + * @param options - Submit options. + * @param options.order - Completed fiat order. + * @param options.request - Strategy execute request. + * @returns Hash of the submitted direct mUSD transaction, if available. + */ export async function submitDirectMusdAfterFiatCompletion({ order, request, @@ -169,19 +169,19 @@ 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 (or the CHOMP deposit - * hash), if available. - */ +/** + * 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 (or the CHOMP deposit + * hash), if available. + */ export async function submitDirectMusdVaultDeposit({ fromBlock, request,