From 69643826df977ff801c11e3026742dba4117bc18 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 11:37:34 -0600 Subject: [PATCH 1/3] feat(transaction-pay-controller): add Money Account vault deposit and withdraw actions Money Account mUSD that arrives from an external payout (MoonPay/Iron) can only be vaulted by CHOMP's delayed auto-sweep, and there is no reusable path for redeeming vmUSD straight to a partner deposit address. Add two messenger actions so the client can drive both directions itself. --- README.md | 1 + .../transaction-pay-controller/CHANGELOG.md | 5 + .../transaction-pay-controller/package.json | 1 + ...actionPayController-method-action-types.ts | 28 +++ .../src/TransactionPayController.test.ts | 122 ++++++++++ .../src/TransactionPayController.ts | 70 ++++++ .../transaction-pay-controller/src/index.ts | 4 + .../src/tests/messenger-mock.ts | 8 + .../transaction-pay-controller/src/types.ts | 10 + .../src/utils/ma-vault-deposit.test.ts | 47 +++- .../src/utils/ma-vault-deposit.ts | 87 +++++-- .../src/utils/ma-vault-payout.test.ts | 198 ++++++++++++++++ .../src/utils/ma-vault-payout.ts | 81 +++++++ .../src/utils/ma-vault-withdraw.test.ts | 212 ++++++++++++++++++ .../src/utils/ma-vault-withdraw.ts | 130 +++++++++++ .../utils/money-account-vault-config.test.ts | 90 ++++++++ .../src/utils/money-account-vault-config.ts | 86 +++++++ .../tsconfig.build.json | 3 + .../transaction-pay-controller/tsconfig.json | 3 + yarn.lock | 3 +- 20 files changed, 1171 insertions(+), 18 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-payout.ts create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts create mode 100644 packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/money-account-vault-config.ts diff --git a/README.md b/README.md index 55139eccac1..4a0b3fa22b9 100644 --- a/README.md +++ b/README.md @@ -643,6 +643,7 @@ linkStyle default opacity:0.5 transaction_pay_controller --> gas_fee_controller; transaction_pay_controller --> keyring_controller; transaction_pay_controller --> messenger; + transaction_pay_controller --> money_account_utils; transaction_pay_controller --> network_controller; transaction_pay_controller --> ramps_controller; transaction_pay_controller --> remote_feature_flag_controller; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 28b5678e75a..16883e3ce3f 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 `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849)) + ### Changed - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 3ac7dd8ee89..eec01336006 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -67,6 +67,7 @@ "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/money-account-utils": "^1.1.0", "@metamask/network-controller": "^35.0.1", "@metamask/ramps-controller": "^20.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0", diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 14a91436fa2..1f0578a6c3e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -49,6 +49,32 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { handler: TransactionPayController['updateFiatPayment']; }; +/** + * Vaults mUSD received in a completed Iron payout transaction. + * + * Concurrent calls for the same payout hash share one in-flight submission. + * + * @param request - Completed Iron payout details. + * @returns Hash of the confirmed vault transaction. + */ +export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { + type: `TransactionPayController:submitMoneyAccountVaultDeposit`; + handler: TransactionPayController['submitMoneyAccountVaultDeposit']; +}; + +/** + * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. + * + * Concurrent calls with the same request ID share one in-flight batch setup. + * + * @param request - Backend-bound exact-out Iron intent. + * @returns Pending transaction batch ID. + */ +export type TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction = { + type: `TransactionPayController:submitMoneyAccountVaultWithdraw`; + handler: TransactionPayController['submitMoneyAccountVaultWithdraw']; +}; + /** * Gets the delegation transaction for a given transaction. * @@ -144,6 +170,8 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction + | TransactionPayControllerSubmitMoneyAccountVaultDepositAction + | TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetFiatOptionsAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 467f6406ab2..0701c877516 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -16,6 +16,8 @@ import type { UpdateTransactionDataCallback, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; +import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -31,6 +33,8 @@ jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); jest.mock('./utils/feature-flags'); +jest.mock('./utils/ma-vault-payout'); +jest.mock('./utils/ma-vault-withdraw'); const TRANSACTION_ID_MOCK = '123-456'; const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; @@ -50,6 +54,12 @@ describe('TransactionPayController', () => { ); const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); + const submitMoneyAccountVaultDepositFromPayoutMock = jest.mocked( + submitMoneyAccountVaultDepositFromPayout, + ); + const submitMoneyAccountVaultWithdrawUtilMock = jest.mocked( + submitMoneyAccountVaultWithdrawUtil, + ); let messenger: TransactionPayControllerMessenger; let getKeyringControllerStateMock: jest.Mock; @@ -106,6 +116,118 @@ describe('TransactionPayController', () => { }); }); + describe('Money Account vault actions', () => { + const moneyAccountAddress = + '0x1111111111111111111111111111111111111111' as Hex; + const transactionHash = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const recipient = '0x2222222222222222222222222222222222222222' as Hex; + + it('exposes the payout deposit action through the messenger', async () => { + submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({ + transactionHash, + }); + createController(); + + const result = await messenger.call( + 'TransactionPayController:submitMoneyAccountVaultDeposit', + { + moneyAccountAddress, + transactionHash, + }, + ); + + expect(submitMoneyAccountVaultDepositFromPayoutMock).toHaveBeenCalledWith( + { moneyAccountAddress, transactionHash }, + messenger, + ); + expect(result).toStrictEqual({ transactionHash }); + }); + + it('deduplicates concurrent payout deposit actions by transaction hash', async () => { + let resolveSubmit: + | ((value: { transactionHash?: Hex }) => void) + | undefined; + submitMoneyAccountVaultDepositFromPayoutMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + const first = controller.submitMoneyAccountVaultDeposit(request); + const second = controller.submitMoneyAccountVaultDeposit(request); + resolveSubmit?.({ transactionHash }); + + expect(await first).toStrictEqual({ transactionHash }); + expect(await second).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(1); + }); + + it('exposes the exact-out withdraw action through the messenger', async () => { + submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ + batchId: '0x123' as Hex, + }); + createController(); + const request = { + amountInRaw: '5000000', + autorampId: 'autoramp-id', + chainId: '0x8f' as Hex, + moneyAccountAddress, + quoteId: 'quote-id', + quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), + recipient, + requestId: 'request-id', + tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, + }; + + const result = await messenger.call( + 'TransactionPayController:submitMoneyAccountVaultWithdraw', + request, + ); + + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledWith( + request, + messenger, + ); + expect(result).toStrictEqual({ batchId: '0x123' }); + }); + + it('deduplicates concurrent withdraw actions by request ID', async () => { + let resolveSubmit: ((value: { batchId: Hex }) => void) | undefined; + submitMoneyAccountVaultWithdrawUtilMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + const controller = createController(); + const request = { + amountInRaw: '5000000', + autorampId: 'autoramp-id', + chainId: '0x8f' as Hex, + moneyAccountAddress, + quoteId: 'quote-id', + quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), + recipient, + requestId: 'request-id', + tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, + }; + + const first = controller.submitMoneyAccountVaultWithdraw(request); + const second = controller.submitMoneyAccountVaultWithdraw(request); + resolveSubmit?.({ batchId: '0x123' }); + + expect(await first).toStrictEqual({ batchId: '0x123' }); + expect(await second).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); + }); + }); + describe('updatePaymentToken', () => { it('calls util', () => { createController().updatePaymentToken({ diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 0f35951e683..c623162e1c9 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -29,6 +29,10 @@ import type { UpdatePaymentTokenRequest, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -45,6 +49,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'polymarketGetDepositWalletAddress', 'polymarketSubmitDepositWalletBatch', 'setTransactionConfig', + 'submitMoneyAccountVaultDeposit', + 'submitMoneyAccountVaultWithdraw', 'updateFiatPayment', 'updatePaymentToken', ] as const; @@ -87,6 +93,16 @@ export class TransactionPayController extends BaseController< readonly #resolveSourceAmount?: ResolveSourceAmountCallback; + readonly #vaultDepositRequests = new Map< + string, + Promise<{ transactionHash?: `0x${string}` }> + >(); + + readonly #vaultWithdrawRequests = new Map< + string, + Promise<{ batchId: `0x${string}` }> + >(); + constructor({ fiatOptions, getAmountData, @@ -215,6 +231,60 @@ export class TransactionPayController extends BaseController< }); } + /** + * Vaults mUSD received in a completed Iron payout transaction. + * + * Concurrent calls for the same payout hash share one in-flight submission. + * + * @param request - Completed Iron payout details. + * @returns Hash of the confirmed vault transaction. + */ + submitMoneyAccountVaultDeposit( + request: SubmitMoneyAccountVaultDepositRequest, + ): Promise<{ transactionHash?: `0x${string}` }> { + const key = request.transactionHash.toLowerCase(); + const current = this.#vaultDepositRequests.get(key); + if (current) { + return current; + } + + const pending = submitMoneyAccountVaultDepositFromPayout( + request, + this.messenger, + ).finally(() => { + this.#vaultDepositRequests.delete(key); + }); + this.#vaultDepositRequests.set(key, pending); + return pending; + } + + /** + * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. + * + * Concurrent calls with the same request ID share one in-flight batch setup. + * + * @param request - Backend-bound exact-out Iron intent. + * @returns Pending transaction batch ID. + */ + submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + ): Promise<{ batchId: `0x${string}` }> { + const key = request.requestId; + const current = this.#vaultWithdrawRequests.get(key); + if (current) { + return current; + } + + const pending = submitMoneyAccountVaultWithdrawUtil( + request, + this.messenger, + ).finally(() => { + this.#vaultWithdrawRequests.delete(key); + }); + this.#vaultWithdrawRequests.set(key, pending); + return pending; + } + /** * Gets the delegation transaction for a given transaction. * diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 1d52593f72e..8b6fadbda0f 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -39,9 +39,13 @@ export type { TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, TransactionPayControllerSetTransactionConfigAction, + TransactionPayControllerSubmitMoneyAccountVaultDepositAction, + TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction, TransactionPayControllerUpdatePaymentTokenAction, TransactionPayControllerUpdateFiatPaymentAction, } from './TransactionPayController-method-action-types.js'; +export type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +export type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; export { PaymentOverride, TransactionPayStrategy } from './constants.js'; export { TransactionPayController } from './TransactionPayController.js'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook.js'; diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index f81bbf7516d..b9f63a01d41 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -70,6 +70,8 @@ export function getMessengerMock({ TransactionControllerAddTransactionBatchAction['handler'] > = jest.fn(); + const getMoneyAccountBalanceMock = jest.fn(); + const findNetworkClientIdByChainIdMock: jest.MockedFn< NetworkControllerFindNetworkClientIdByChainIdAction['handler'] > = jest.fn(); @@ -191,6 +193,11 @@ export function getMessengerMock({ addTransactionBatchMock, ); + messenger.registerActionHandler( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + getMoneyAccountBalanceMock, + ); + messenger.registerActionHandler( 'NetworkController:findNetworkClientIdByChainId', findNetworkClientIdByChainIdMock, @@ -320,6 +327,7 @@ export function getMessengerMock({ getGasFeeControllerStateMock, getGasFeeTokensMock, getKeyringControllerStateMock, + getMoneyAccountBalanceMock, getNetworkClientByIdMock, getNetworkConfigurationByChainIdMock, getRemoteFeatureFlagControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index b8ee97a3990..4f70d5f702c 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -61,6 +61,15 @@ import type { } from './constants.js'; import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types.js'; +type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { + type: 'MoneyAccountBalanceService:getMoneyAccountBalance'; + handler: (accountAddress: Hex) => Promise<{ + musdBalance: string; + totalBalance: string; + vmusdValueInMusd: string; + }>; +}; + export type AllowedActions = | AccountTrackerControllerGetStateAction | AssetsControllerGetStateForTransactionPayAction @@ -68,6 +77,7 @@ export type AllowedActions = | GetGasFeeState | KeyringControllerGetStateAction | KeyringControllerSignTypedMessageAction + | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByChainIdAction diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 1aca3ef304c..337cb27ba8d 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -7,7 +7,10 @@ import type { Hex } from '@metamask/utils'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; -import { submitMoneyAccountVaultDeposit } from './ma-vault-deposit.js'; +import { + submitMoneyAccountVaultDeposit, + submitMoneyAccountVaultDepositBatch, +} from './ma-vault-deposit.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -512,4 +515,46 @@ describe('submitMoneyAccountVaultDeposit', () => { expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); }); }); + + describe('parentless vault batches', () => { + const depositCalls: BatchTransactionParams[] = [ + { data: '0xapprove' as Hex, to: '0xapprove' as Hex }, + { data: '0xdeposit' as Hex, to: '0xdeposit' as Hex }, + ]; + + it('submits without updating a parent transaction', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0xvault' }); + }); + + it('returns before submission when disabled', async () => { + const callMock = jest.fn(); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: true, + }); + + expect(callMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0x' }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index 8f3facf82f5..84d52d00bbc 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -92,6 +92,59 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: nestedTransactions, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled: false, + }); +} + +/** + * Submits pre-built Money Account vault calls without requiring a parent + * transaction. When `transactionId` is supplied, submitted child IDs are also + * linked to that parent for the existing Fiat and Relay flows. + * + * @param options - Submission options. + * @param options.depositCalls - Pre-built approve and deposit calls. + * @param options.fromBlock - Block at which to begin the CHOMP race check. + * @param options.messenger - Transaction Pay controller messenger. + * @param options.moneyAccountAddress - Money Account that owns the mUSD. + * @param options.sourceAmountRaw - Raw mUSD amount to deposit. + * @param options.transactionId - Optional parent transaction to link children. + * @param options.vaultDisabled - Whether vault submission is disabled. + * @returns Hash of the final confirmed vault transaction. + */ +export async function submitMoneyAccountVaultDepositBatch({ + depositCalls, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled, +}: { + depositCalls: NestedTransactionMetadata[]; + fromBlock?: Hex; + messenger: TransactionPayControllerMessenger; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + transactionId?: string; + vaultDisabled: boolean; +}): Promise<{ transactionHash?: Hex }> { + if (vaultDisabled) { + log('Skipping vault deposit because vaultDisabled is true', { + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { transactionHash: '0x' }; + } + // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. const preChompHash = await tryFindChompDeposit({ @@ -117,23 +170,25 @@ export async function submitMoneyAccountVaultDeposit({ messenger, (id) => { transactionIds.push(id); - updateTransaction( - { - transactionId, - messenger, - note: 'Add required transaction ID from Money Account vault submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(id); - }, - ); + if (transactionId) { + updateTransaction( + { + transactionId, + messenger, + note: 'Add required transaction ID from Money Account vault submission', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(id); + }, + ); + } }, ); log('Submitting Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -151,7 +206,7 @@ export async function submitMoneyAccountVaultDeposit({ origin: ORIGIN_METAMASK, requireApproval: false, skipInitialGasEstimate: true, - transactions: nestedTransactions.map((nestedTransaction, index) => ({ + transactions: depositCalls.map((nestedTransaction, index) => ({ params: { data: nestedTransaction.data, to: nestedTransaction.to, @@ -185,7 +240,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Submitted Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -209,7 +264,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Confirmed Money Account vault deposit', { hash, moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -316,7 +371,7 @@ async function tryFindChompDeposit({ messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; - transactionId: string; + transactionId?: string; }): Promise { if (!fromBlock) { return undefined; diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts new file mode 100644 index 00000000000..4c4cb468068 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts @@ -0,0 +1,198 @@ +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './ma-vault-payout.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./ma-vault-deposit'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); +jest.mock('./transaction'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const PAYOUT_HASH = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const VAULT_HASH = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: '0x3333333333333333333333333333333333333333' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444' as Hex, + tellerAddress: '0x5555555555555555555555555555555555555555' as Hex, +}; + +function getMessenger(): TransactionPayControllerMessenger { + return { + call: jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + throw new Error(`Unexpected action: ${action}`); + }), + } as unknown as TransactionPayControllerMessenger; +} + +describe('submitMoneyAccountVaultDepositFromPayout', () => { + const buildMoneyAccountDepositBatchMock = jest.mocked( + buildMoneyAccountDepositBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); + const submitMoneyAccountVaultDepositBatchMock = jest.mocked( + submitMoneyAccountVaultDepositBatch, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: '5000000', + blockNumber: '0x123', + }); + buildMoneyAccountDepositBatchMock.mockResolvedValue({ + approveTx: { + params: { + data: '0xapprove', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + }, + depositTx: { + params: { + data: '0xdeposit', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + }, + } as never); + submitMoneyAccountVaultDepositBatchMock.mockResolvedValue({ + transactionHash: VAULT_HASH, + }); + }); + + it('resolves the Iron payout and submits a parentless vault batch', async () => { + const messenger = getMessenger(); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: PAYOUT_HASH, + walletAddress: MONEY_ACCOUNT_ADDRESS, + }); + expect(buildMoneyAccountDepositBatchMock).toHaveBeenCalledWith({ + amount: 5000000n, + provider: expect.anything(), + ...VAULT_CONFIG, + }); + expect(submitMoneyAccountVaultDepositBatchMock).toHaveBeenCalledWith({ + depositCalls: [ + expect.objectContaining({ data: '0xapprove' }), + expect.objectContaining({ data: '0xdeposit' }), + ], + fromBlock: '0x123', + messenger, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + expect(result).toStrictEqual({ transactionHash: VAULT_HASH }); + }); + + it('defaults vaultDisabled to false', async () => { + const messenger = getMessenger(); + + await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a payout without an mUSD transfer to the Money Account', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: '0x123', + }); + + await expect( + submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ), + ).rejects.toThrow('Payout transaction has no mUSD transfer'); + + expect(buildMoneyAccountDepositBatchMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositBatchMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when vaulting is disabled', async () => { + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: true, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when deposits are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts new file mode 100644 index 00000000000..d181f057dae --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts @@ -0,0 +1,81 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +export type SubmitMoneyAccountVaultDepositRequest = { + moneyAccountAddress: Hex; + transactionHash: Hex; + vaultDisabled?: boolean; +}; + +/** + * Resolves an Iron payout transaction and vaults the received mUSD. + * + * @param request - Iron payout details. + * @param messenger - Transaction Pay controller messenger. + * @returns Hash of the confirmed vault transaction, or `0x` when disabled. + */ +export async function submitMoneyAccountVaultDepositFromPayout( + request: SubmitMoneyAccountVaultDepositRequest, + messenger: TransactionPayControllerMessenger, +): Promise<{ transactionHash?: Hex }> { + const { + moneyAccountAddress, + transactionHash, + vaultDisabled = false, + } = request; + + if ( + vaultDisabled || + !isMoneyAccountVaultActionEnabled(messenger, 'deposit') + ) { + return { transactionHash: '0x' }; + } + + const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: transactionHash, + walletAddress: moneyAccountAddress, + }); + + if (!amountRaw || BigInt(amountRaw) <= 0n) { + throw new Error('Payout transaction has no mUSD transfer'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount: BigInt(amountRaw), + provider, + ...vaultConfig, + }); + + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: [ + { ...approveTx.params, type: approveTx.type }, + { ...depositTx.params, type: depositTx.type }, + ], + fromBlock: blockNumber, + messenger, + moneyAccountAddress, + sourceAmountRaw: amountRaw, + vaultDisabled: false, + }); +} diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts new file mode 100644 index 00000000000..a2bdd242e5b --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts @@ -0,0 +1,212 @@ +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw } from './ma-vault-withdraw.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const IRON_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x3333333333333333333333333333333333333333' as Hex, + boringVault: '0x4444444444444444444444444444444444444444' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x5555555555555555555555555555555555555555' as Hex, + tellerAddress: '0x6666666666666666666666666666666666666666' as Hex, +}; + +function getRequest( + overrides: Partial = {}, +): SubmitMoneyAccountVaultWithdrawRequest { + return { + amountInRaw: '5000000', + autorampId: 'autoramp-id', + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + quoteId: 'quote-id', + quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), + recipient: IRON_ADDRESS, + requestId: 'request-id', + tokenAddress: MUSD_MONAD_ADDRESS, + ...overrides, + }; +} + +function getMessenger({ + balance = '5000000', +}: { + balance?: string; +} = {}): { + callMock: jest.Mock; + messenger: TransactionPayControllerMessenger; +} { + const callMock = jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + if (action === 'MoneyAccountBalanceService:getMoneyAccountBalance') { + return Promise.resolve({ + musdBalance: '0', + totalBalance: balance, + vmusdValueInMusd: balance, + }); + } + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: '0xbatch' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + messenger: { + call: callMock, + } as unknown as TransactionPayControllerMessenger, + }; +} + +describe('submitMoneyAccountVaultWithdraw', () => { + const buildMoneyAccountWithdrawBatchMock = jest.mocked( + buildMoneyAccountWithdrawBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + buildMoneyAccountWithdrawBatchMock.mockResolvedValue({ + transferTx: { + params: { + data: '0xtransfer', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + type: 'tokenMethodTransfer', + }, + withdrawTx: { + params: { + data: '0xwithdraw', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + type: 'moneyAccountWithdraw', + }, + } as never); + }); + + it('creates one user-confirmed atomic batch to the Iron address', async () => { + const { callMock, messenger } = getMessenger(); + const request = getRequest(); + + const result = await submitMoneyAccountVaultWithdraw(request, messenger); + + expect(buildMoneyAccountWithdrawBatchMock).toHaveBeenCalledWith({ + accountantAddress: VAULT_CONFIG.accountantAddress, + amount: 5000000n, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + provider: expect.anything(), + recipient: IRON_ADDRESS, + tellerAddress: VAULT_CONFIG.tellerAddress, + }); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: MONEY_ACCOUNT_ADDRESS, + isGasFeeSponsored: true, + isInternal: true, + networkClientId: NETWORK_CLIENT_ID, + origin: 'metamask', + requestId: 'request-id', + requireApproval: true, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ data: '0xwithdraw' }), + }), + expect.objectContaining({ + params: expect.objectContaining({ data: '0xtransfer' }), + }), + ], + }), + ); + expect(result).toStrictEqual({ batchId: '0xbatch' }); + }); + + it('rejects an amount above the withdrawable vmUSD value', async () => { + const { messenger } = getMessenger({ balance: '4999999' }); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), messenger), + ).rejects.toThrow('Insufficient withdrawable vmUSD balance'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it('rejects when Money Account withdrawals are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), getMessenger().messenger), + ).rejects.toThrow('Money Account vault withdrawal is disabled'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [{ amountInRaw: '0' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: '-1' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: 'invalid' }, 'Withdrawal amount must be greater than zero'], + [{ quoteValidUntil: 'invalid' }, 'Iron quote expiry is invalid'], + [ + { quoteValidUntil: new Date(Date.now() - 1_000).toISOString() }, + 'Iron quote has expired', + ], + [{ chainId: '0x1' }, 'Pix withdrawal must use Monad'], + [ + { tokenAddress: '0x7777777777777777777777777777777777777777' }, + 'Pix withdrawal must use mUSD', + ], + [{ recipient: '0x1234' }, 'Iron recipient is invalid'], + [ + { recipient: MONEY_ACCOUNT_ADDRESS }, + 'Iron recipient must differ from the Money Account', + ], + [{ requestId: '' }, 'Missing Iron request identifiers'], + [{ quoteId: '' }, 'Missing Iron request identifiers'], + [{ autorampId: '' }, 'Missing Iron request identifiers'], + ])('rejects invalid exact-out input %#', async (overrides, message) => { + await expect( + submitMoneyAccountVaultWithdraw( + getRequest(overrides), + getMessenger().messenger, + ), + ).rejects.toThrow(message); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts new file mode 100644 index 00000000000..bedf2a6a366 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts @@ -0,0 +1,130 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { TransactionBatchResult } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +export type SubmitMoneyAccountVaultWithdrawRequest = { + amountInRaw: string; + autorampId: string; + chainId: Hex; + moneyAccountAddress: Hex; + quoteId: string; + quoteValidUntil: string; + recipient: Hex; + requestId: string; + tokenAddress: Hex; +}; + +/** + * Creates a user-confirmed atomic vmUSD withdrawal and mUSD transfer to Iron. + * + * @param request - Backend-bound exact-out Iron intent. + * @param messenger - Transaction Pay controller messenger. + * @returns The pending transaction batch ID. + */ +export async function submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + validateRequest(request); + + if (!isMoneyAccountVaultActionEnabled(messenger, 'withdraw')) { + throw new Error('Money Account vault withdrawal is disabled'); + } + + const amount = BigInt(request.amountInRaw); + const balance = await messenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + request.moneyAccountAddress, + ); + + if (amount > BigInt(balance.vmusdValueInMusd)) { + throw new Error('Insufficient withdrawable vmUSD balance'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { withdrawTx, transferTx } = await buildMoneyAccountWithdrawBatch({ + accountantAddress: vaultConfig.accountantAddress, + amount, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: request.moneyAccountAddress, + provider, + recipient: request.recipient, + tellerAddress: vaultConfig.tellerAddress, + }); + + return await messenger.call('TransactionController:addTransactionBatch', { + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: request.moneyAccountAddress, + isGasFeeSponsored: true, + isInternal: true, + networkClientId, + origin: ORIGIN_METAMASK, + requestId: request.requestId, + requireApproval: true, + skipInitialGasEstimate: true, + transactions: [withdrawTx, transferTx], + }); +} + +function validateRequest( + request: SubmitMoneyAccountVaultWithdrawRequest, +): void { + if (!request.requestId || !request.quoteId || !request.autorampId) { + throw new Error('Missing Iron request identifiers'); + } + + let amount: bigint; + try { + amount = BigInt(request.amountInRaw); + } catch { + throw new Error('Withdrawal amount must be greater than zero'); + } + + if (amount <= 0n) { + throw new Error('Withdrawal amount must be greater than zero'); + } + + const expiry = Date.parse(request.quoteValidUntil); + if (Number.isNaN(expiry)) { + throw new Error('Iron quote expiry is invalid'); + } + if (expiry <= Date.now()) { + throw new Error('Iron quote has expired'); + } + + if (request.chainId !== CHAIN_ID_MONAD) { + throw new Error('Pix withdrawal must use Monad'); + } + if (request.tokenAddress.toLowerCase() !== MUSD_MONAD_ADDRESS.toLowerCase()) { + throw new Error('Pix withdrawal must use mUSD'); + } + if (!isValidHexAddress(request.recipient)) { + throw new Error('Iron recipient is invalid'); + } + if ( + request.recipient.toLowerCase() === + request.moneyAccountAddress.toLowerCase() + ) { + throw new Error('Iron recipient must differ from the Money Account'); + } +} diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts new file mode 100644 index 00000000000..82ff8e0a9ca --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts @@ -0,0 +1,90 @@ +import type { Hex, Json } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; + +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222', + boringVault: '0x3333333333333333333333333333333333333333', + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444', + tellerAddress: '0x5555555555555555555555555555555555555555', +}; + +function getMessenger( + flag: unknown, + moneyAccount: unknown = undefined, +): TransactionPayControllerMessenger { + return { + call: jest.fn(() => ({ + remoteFeatureFlags: { + moneyAccount: moneyAccount as Json, + moneyAccountVaultConfig: flag as Json, + }, + })), + } as unknown as TransactionPayControllerMessenger; +} + +describe('getMoneyAccountVaultConfig', () => { + it('returns a valid Monad vault config', () => { + expect( + getMoneyAccountVaultConfig(getMessenger(VAULT_CONFIG)), + ).toStrictEqual(VAULT_CONFIG as Record); + }); + + it.each([ + ['deposit', { moneyAccountDepositEnabled: true }], + ['withdraw', { moneyAccountWithdrawEnabled: true }], + ] as const)('returns true when %s is enabled', (action, flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + action, + ), + ).toBe(true); + }); + + it.each(['deposit', 'withdraw'] as const)( + 'defaults %s to disabled', + (action) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, {}), + action, + ), + ).toBe(false); + }, + ); + + it.each([undefined, [], 'enabled'])( + 'treats non-object Money Account flags as disabled', + (flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + 'deposit', + ), + ).toBe(false); + }, + ); + + it('throws when vault config is missing', () => { + expect(() => getMoneyAccountVaultConfig(getMessenger(undefined))).toThrow( + 'Money Account vault config is unavailable', + ); + }); + + it.each([ + { ...VAULT_CONFIG, chainId: '0x1' }, + { ...VAULT_CONFIG, tellerAddress: '0x1234' }, + { ...VAULT_CONFIG, lensAddress: undefined }, + ])('throws when vault config is invalid', (config) => { + expect(() => getMoneyAccountVaultConfig(getMessenger(config))).toThrow( + 'Money Account vault config is invalid', + ); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts new file mode 100644 index 00000000000..5785eb2cd59 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts @@ -0,0 +1,86 @@ +import type { Hex, Json } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; + +const VAULT_CONFIG_FLAG = 'moneyAccountVaultConfig'; +const REQUIRED_ADDRESS_KEYS = [ + 'boringVault', + 'tellerAddress', + 'accountantAddress', + 'lensAddress', +] as const; + +type MoneyAccountVaultAction = 'deposit' | 'withdraw'; + +export type MoneyAccountVaultConfig = { + accountantAddress: Hex; + boringVault: Hex; + chainId: Hex; + lensAddress: Hex; + tellerAddress: Hex; +}; + +/** + * Reads and validates the Money Account vault configuration. + * + * @param messenger - Transaction Pay controller messenger. + * @returns Validated Monad vault configuration. + */ +export function getMoneyAccountVaultConfig( + messenger: TransactionPayControllerMessenger, +): MoneyAccountVaultConfig { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.[VAULT_CONFIG_FLAG]; + + if (value === undefined) { + throw new Error('Money Account vault config is unavailable'); + } + + if (!isVaultConfig(value)) { + throw new Error('Money Account vault config is invalid'); + } + + return value; +} + +/** + * Returns whether the requested Money Account vault action is enabled. + * + * @param messenger - Transaction Pay controller messenger. + * @param action - Vault action to inspect. + * @returns Whether the remote feature flag explicitly enables the action. + */ +export function isMoneyAccountVaultActionEnabled( + messenger: TransactionPayControllerMessenger, + action: MoneyAccountVaultAction, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.moneyAccount; + if (!value || Array.isArray(value) || typeof value !== 'object') { + return false; + } + + const key = + action === 'deposit' + ? 'moneyAccountDepositEnabled' + : 'moneyAccountWithdrawEnabled'; + return value[key] === true; +} + +function isVaultConfig(value: Json): value is Json & MoneyAccountVaultConfig { + if ( + !value || + Array.isArray(value) || + typeof value !== 'object' || + value.chainId !== CHAIN_ID_MONAD + ) { + return false; + } + + return REQUIRED_ADDRESS_KEYS.every((key) => { + const address = value[key]; + return typeof address === 'string' && isValidHexAddress(address as Hex); + }); +} diff --git a/packages/transaction-pay-controller/tsconfig.build.json b/packages/transaction-pay-controller/tsconfig.build.json index 4865a1c8327..ad329d91746 100644 --- a/packages/transaction-pay-controller/tsconfig.build.json +++ b/packages/transaction-pay-controller/tsconfig.build.json @@ -39,6 +39,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../money-account-utils/tsconfig.build.json" + }, { "path": "../sentinel-api-service/tsconfig.build.json" } diff --git a/packages/transaction-pay-controller/tsconfig.json b/packages/transaction-pay-controller/tsconfig.json index 67ae32f3465..fbae571a6cc 100644 --- a/packages/transaction-pay-controller/tsconfig.json +++ b/packages/transaction-pay-controller/tsconfig.json @@ -37,6 +37,9 @@ { "path": "../messenger" }, + { + "path": "../money-account-utils" + }, { "path": "../sentinel-api-service" } diff --git a/yarn.lock b/yarn.lock index c6b4cce7c4c..e13be51af24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7943,7 +7943,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-utils@workspace:packages/money-account-utils": +"@metamask/money-account-utils@npm:^1.1.0, @metamask/money-account-utils@workspace:packages/money-account-utils": version: 0.0.0-use.local resolution: "@metamask/money-account-utils@workspace:packages/money-account-utils" dependencies: @@ -9384,6 +9384,7 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/money-account-utils": "npm:^1.1.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/ramps-controller": "npm:^20.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" From 4a9cb439ab86b47b80623209d9a18dc589e927d9 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:32:17 -0600 Subject: [PATCH 2/3] fix(transaction-pay-controller): harden Money Account vault deposit/withdraw Persist successful deposit/withdraw results for process-lifetime idempotency, tighten CHOMP matching to exact vault destination and amount, slim the withdraw request surface, and return { skipped: true } instead of a fake 0x hash when vaulting is disabled. --- .../transaction-pay-controller/CHANGELOG.md | 7 ++ ...actionPayController-method-action-types.ts | 7 +- .../src/TransactionPayController.test.ts | 88 +++++++++++++-- .../src/TransactionPayController.ts | 29 ++++- .../transaction-pay-controller/src/index.ts | 1 + .../src/strategy/fiat/FiatStrategy.test.ts | 13 +++ .../src/strategy/fiat/FiatStrategy.ts | 4 + .../src/strategy/fiat/fiat-direct-musd.ts | 3 +- .../src/strategy/fiat/fiat-submit.test.ts | 4 +- .../src/strategy/fiat/fiat-submit.ts | 6 +- .../transaction-pay-controller/src/types.ts | 1 + .../src/utils/chomp.test.ts | 103 ++++++++++++++---- .../src/utils/chomp.ts | 43 +++++++- .../src/utils/ma-vault-deposit.test.ts | 18 ++- .../src/utils/ma-vault-deposit.ts | 22 +++- .../src/utils/ma-vault-payout.test.ts | 4 +- .../src/utils/ma-vault-payout.ts | 8 +- .../src/utils/ma-vault-withdraw.test.ts | 21 +--- .../src/utils/ma-vault-withdraw.ts | 31 ++---- 19 files changed, 312 insertions(+), 101 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 16883e3ce3f..ce39a90775f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,8 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849)) - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) +### Fixed + +- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849)) + ## [26.3.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 1f0578a6c3e..0301e9ba2fe 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -53,9 +53,12 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. + * Successful results are retained so retries return the prior hash without + * submitting again. * * @param request - Completed Iron payout details. - * @returns Hash of the confirmed vault transaction. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { type: `TransactionPayController:submitMoneyAccountVaultDeposit`; @@ -66,6 +69,8 @@ export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. * * Concurrent calls with the same request ID share one in-flight batch setup. + * Successful batch results are retained so a later call returns the same + * `batchId` without creating another approval. * * @param request - Backend-bound exact-out Iron intent. * @returns Pending transaction batch ID. diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 0701c877516..63c20eb62f3 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -168,6 +168,42 @@ describe('TransactionPayController', () => { ).toHaveBeenCalledTimes(1); }); + it('returns the prior result on retry after a successful deposit without resubmitting', async () => { + submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({ + transactionHash, + }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + const first = await controller.submitMoneyAccountVaultDeposit(request); + const second = await controller.submitMoneyAccountVaultDeposit(request); + + expect(first).toStrictEqual({ transactionHash }); + expect(second).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(1); + }); + + it('retries after a failed deposit', async () => { + submitMoneyAccountVaultDepositFromPayoutMock + .mockRejectedValueOnce(new Error('vault failed')) + .mockResolvedValueOnce({ transactionHash }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).rejects.toThrow('vault failed'); + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).resolves.toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(2); + }); + it('exposes the exact-out withdraw action through the messenger', async () => { submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ batchId: '0x123' as Hex, @@ -175,14 +211,9 @@ describe('TransactionPayController', () => { createController(); const request = { amountInRaw: '5000000', - autorampId: 'autoramp-id', - chainId: '0x8f' as Hex, moneyAccountAddress, - quoteId: 'quote-id', - quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), recipient, requestId: 'request-id', - tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, }; const result = await messenger.call( @@ -208,14 +239,9 @@ describe('TransactionPayController', () => { const controller = createController(); const request = { amountInRaw: '5000000', - autorampId: 'autoramp-id', - chainId: '0x8f' as Hex, moneyAccountAddress, - quoteId: 'quote-id', - quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), recipient, requestId: 'request-id', - tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, }; const first = controller.submitMoneyAccountVaultWithdraw(request); @@ -226,6 +252,48 @@ describe('TransactionPayController', () => { expect(await second).toStrictEqual({ batchId: '0x123' }); expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); }); + + it('returns the same batchId on retry after approval is created without resubmitting', async () => { + submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ + batchId: '0x123' as Hex, + }); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + const first = await controller.submitMoneyAccountVaultWithdraw(request); + const second = await controller.submitMoneyAccountVaultWithdraw(request); + + expect(first).toStrictEqual({ batchId: '0x123' }); + expect(second).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); + }); + + it('retries withdraw after a failed batch setup', async () => { + submitMoneyAccountVaultWithdrawUtilMock + .mockRejectedValueOnce(new Error('batch failed')) + .mockResolvedValueOnce({ batchId: '0x123' as Hex }); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + await expect( + controller.submitMoneyAccountVaultWithdraw(request), + ).rejects.toThrow('batch failed'); + + await expect( + controller.submitMoneyAccountVaultWithdraw(request), + ).resolves.toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(2); + }); }); describe('updatePaymentToken', () => { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index c623162e1c9..0687182d265 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -29,6 +29,7 @@ import type { UpdatePaymentTokenRequest, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js'; import type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; import type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; @@ -93,11 +94,22 @@ export class TransactionPayController extends BaseController< readonly #resolveSourceAmount?: ResolveSourceAmountCallback; + /** + * In-flight and completed payout vault deposits, keyed by payout tx hash. + * Completed successes stay cached for the controller lifetime so webhook + * replays / retries do not re-submit. Preferable to persisted state here + * because vaulting is idempotent per process and avoids a state migration. + */ readonly #vaultDepositRequests = new Map< string, - Promise<{ transactionHash?: `0x${string}` }> + Promise >(); + /** + * In-flight and completed withdraw batch setups, keyed by requestId. + * Successful `addTransactionBatch` results stay cached for the controller + * lifetime so a second call cannot open another approval for the same id. + */ readonly #vaultWithdrawRequests = new Map< string, Promise<{ batchId: `0x${string}` }> @@ -235,13 +247,16 @@ export class TransactionPayController extends BaseController< * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. + * Successful results are retained so retries return the prior hash without + * submitting again. * * @param request - Completed Iron payout details. - * @returns Hash of the confirmed vault transaction. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. */ submitMoneyAccountVaultDeposit( request: SubmitMoneyAccountVaultDepositRequest, - ): Promise<{ transactionHash?: `0x${string}` }> { + ): Promise { const key = request.transactionHash.toLowerCase(); const current = this.#vaultDepositRequests.get(key); if (current) { @@ -251,8 +266,9 @@ export class TransactionPayController extends BaseController< const pending = submitMoneyAccountVaultDepositFromPayout( request, this.messenger, - ).finally(() => { + ).catch((error: unknown) => { this.#vaultDepositRequests.delete(key); + throw error; }); this.#vaultDepositRequests.set(key, pending); return pending; @@ -262,6 +278,8 @@ export class TransactionPayController extends BaseController< * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. * * Concurrent calls with the same request ID share one in-flight batch setup. + * Successful batch results are retained so a later call returns the same + * `batchId` without creating another approval. * * @param request - Backend-bound exact-out Iron intent. * @returns Pending transaction batch ID. @@ -278,8 +296,9 @@ export class TransactionPayController extends BaseController< const pending = submitMoneyAccountVaultWithdrawUtil( request, this.messenger, - ).finally(() => { + ).catch((error: unknown) => { this.#vaultWithdrawRequests.delete(key); + throw error; }); this.#vaultWithdrawRequests.set(key, pending); return pending; diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 8b6fadbda0f..0c430b4f470 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -45,6 +45,7 @@ export type { TransactionPayControllerUpdateFiatPaymentAction, } from './TransactionPayController-method-action-types.js'; export type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +export type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js'; export type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; export { PaymentOverride, TransactionPayStrategy } from './constants.js'; export { TransactionPayController } from './TransactionPayController.js'; diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts index 9bf58a51114..74e47de23b2 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts @@ -89,6 +89,19 @@ describe('FiatStrategy', () => { ).rejects.toThrow('Fiat: Missing transaction hash'); }); + it('returns skipped when vault deposit is disabled', async () => { + submitFiatQuotesMock.mockResolvedValue({ skipped: true }); + + const result = await new FiatStrategy().execute({ + isSmartTransaction: () => false, + quotes: [QUOTE_MOCK], + messenger: {} as TransactionPayControllerMessenger, + transaction: { txParams: { from: '0x1' } } as TransactionMeta, + }); + + expect(result).toStrictEqual({ skipped: true }); + }); + it('preserves nested Post-Ramp and Vault prefixes', async () => { submitFiatQuotesMock.mockRejectedValue( new Error('Post-Ramp: Direct mUSD: Vault: Missing transaction hash'), diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts index b6444f3c286..989c87145b7 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts @@ -24,6 +24,10 @@ export class FiatStrategy implements PayStrategy { try { const result = await submitFiatQuotes(request); + if (result.skipped) { + return result; + } + if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); } 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 e1efce2806e..68ca081c666 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 @@ -18,6 +18,7 @@ import type { import { prefixError } from '../../utils/error-prefix.js'; import { getFiatVaultDisabled } from '../../utils/feature-flags.js'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; +import type { SubmitMoneyAccountVaultDepositResult } from '../../utils/ma-vault-deposit.js'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js'; import { MUSD_MONAD_FIAT_ASSET } from './constants.js'; import type { FiatQuote } from './types.js'; @@ -130,7 +131,7 @@ export async function submitDirectMusdAfterFiatCompletion({ }: { order: RampsOrder; request: PayStrategyExecuteRequest; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const { messenger, transaction } = request; try { 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 2c8310931c0..cbabadb992a 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 @@ -1326,7 +1326,7 @@ describe('submitFiatQuotes', () => { ); }); - it('skips the vault batch and returns an empty hash when vaultDisabled is enabled', async () => { + it('skips the vault batch and returns skipped when vaultDisabled is enabled', async () => { const { callMock, request } = getRequest({ quotes: [ getFiatQuoteMock({ @@ -1378,7 +1378,7 @@ describe('submitFiatQuotes', () => { const result = await submitFiatQuotes(request); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalledWith( 'TransactionPayController:getAmountData', expect.anything(), 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 19042c2a3ee..e546e2b85a4 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -133,6 +133,10 @@ export async function submitFiatQuotes( request, }); + if (result.skipped) { + return result; + } + if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); } @@ -239,7 +243,7 @@ async function submitRelayAfterFiatCompletion({ }: { order: RampsOrder; request: PayStrategyExecuteRequest; -}): Promise<{ transactionHash?: Hex }> { +}): Promise<{ skipped?: true; transactionHash?: Hex }> { const { messenger, quotes, transaction } = request; const transactionId = transaction.id; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 4f70d5f702c..47adc7a8ba5 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -834,6 +834,7 @@ export type PayStrategy = { /** Execute or submit the quotes to obtain required tokens. */ execute: (request: PayStrategyExecuteRequest) => Promise<{ + skipped?: true; transactionHash?: Hex; }>; }; diff --git a/packages/transaction-pay-controller/src/utils/chomp.test.ts b/packages/transaction-pay-controller/src/utils/chomp.test.ts index acbffb5e2f8..f940befa94e 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.test.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.test.ts @@ -9,13 +9,20 @@ jest.mock('./provider'); const MONEY_ACCOUNT_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const OTHER_RECIPIENT = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' 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 = +// uint256 hex for 5000000 (exact source amount) +const TRANSFER_DATA_EXACT = '0x00000000000000000000000000000000000000000000000000000000004c4b40'; +// uint256 hex for 5000001 (above source amount) +const TRANSFER_DATA_ABOVE = + '0x00000000000000000000000000000000000000000000000000000000004c4b41'; // uint256 hex for 4999999 (< source amount) const TRANSFER_DATA_INSUFFICIENT = '0x00000000000000000000000000000000000000000000000000000000004c4b3f'; @@ -28,11 +35,17 @@ function padAddress(address: string): string { } const MONEY_ACCOUNT_PADDED = padAddress(MONEY_ACCOUNT_ADDRESS); - -function buildMusdTransferLog( - txHash: Hex = CHOMP_TX_HASH, - data: string = TRANSFER_DATA_SUFFICIENT, -): { +const BORING_VAULT_PADDED = padAddress(BORING_VAULT_ADDRESS); + +function buildMusdTransferLog({ + txHash = CHOMP_TX_HASH, + data = TRANSFER_DATA_EXACT, + to = BORING_VAULT_ADDRESS, +}: { + txHash?: Hex; + data?: string; + to?: Hex; +} = {}): { address: string; topics: string[]; data: string; @@ -44,7 +57,7 @@ function buildMusdTransferLog( topics: [ ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, - padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + padAddress(to), ], transactionHash: txHash, }; @@ -62,7 +75,7 @@ describe('chomp', () => { }); describe('findRecentChompVaultDeposit', () => { - it('returns the CHOMP tx hash when a Transfer log with sufficient amount is found', async () => { + it('returns the CHOMP tx hash when Transfer is to the vault with exact amount', async () => { rpcRequestMock.mockResolvedValueOnce([buildMusdTransferLog()]); const result = await findRecentChompVaultDeposit({ @@ -70,16 +83,50 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBe(CHOMP_TX_HASH); - // Only eth_getLogs should have been called. + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when Transfer is not to the vault', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog({ to: OTHER_RECIPIENT }), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, + }); + + expect(result).toBeUndefined(); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when the transfer amount does not exactly match', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog({ data: TRANSFER_DATA_ABOVE }), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, + }); + + expect(result).toBeUndefined(); 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), + buildMusdTransferLog({ data: TRANSFER_DATA_INSUFFICIENT }), ]); const result = await findRecentChompVaultDeposit({ @@ -87,6 +134,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); @@ -101,13 +149,14 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); - it('queries eth_getLogs with the correct filter', async () => { + it('queries eth_getLogs filtered to transfers from the Money Account to the vault', async () => { rpcRequestMock.mockResolvedValueOnce([]); await findRecentChompVaultDeposit({ @@ -115,6 +164,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(rpcRequestMock).toHaveBeenCalledWith( @@ -126,22 +176,26 @@ 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, + BORING_VAULT_PADDED, + ], }), ], }), ); }); - it('processes logs newest-first and returns the most recent match', async () => { + it('processes logs newest-first and returns the most recent exact vault match', async () => { const olderHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; const newerHash = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex; rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(olderHash), - buildMusdTransferLog(newerHash), + buildMusdTransferLog({ txHash: olderHash }), + buildMusdTransferLog({ txHash: newerHash }), ]); const result = await findRecentChompVaultDeposit({ @@ -149,19 +203,23 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBe(newerHash); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); - it('skips logs with insufficient amount and returns the first sufficient one', async () => { - const insufficientHash = + it('skips amount mismatches and returns the first exact vault match', async () => { + const mismatchedHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(insufficientHash, TRANSFER_DATA_INSUFFICIENT), - buildMusdTransferLog(CHOMP_TX_HASH), + buildMusdTransferLog({ + txHash: mismatchedHash, + data: TRANSFER_DATA_INSUFFICIENT, + }), + buildMusdTransferLog(), ]); const result = await findRecentChompVaultDeposit({ @@ -169,16 +227,16 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); - // 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'), + buildMusdTransferLog({ data: '0x' }), ]); const result = await findRecentChompVaultDeposit({ @@ -186,6 +244,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); diff --git a/packages/transaction-pay-controller/src/utils/chomp.ts b/packages/transaction-pay-controller/src/utils/chomp.ts index 2c2cc466276..073ea4b3492 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.ts @@ -19,18 +19,34 @@ type RpcLog = { transactionHash: Hex; }; +/** + * Finds a recent mUSD Transfer from the Money Account into the boring vault + * whose amount exactly matches `sourceAmountRaw`. Exact amount + vault `to` + * avoid treating Pix/other outbound transfers as CHOMP vault success. + * + * @param options - Scan options. + * @param options.messenger - Controller messenger for RPC. + * @param options.moneyAccountAddress - Money Account that sent the transfer. + * @param options.sourceAmountRaw - Exact raw mUSD amount expected. + * @param options.fromBlock - Inclusive block to start the log scan. + * @param options.vaultAddress - Boring vault address that must be the Transfer `to`. + * @returns Matching transaction hash, if any. + */ export async function findRecentChompVaultDeposit({ messenger, moneyAccountAddress, sourceAmountRaw, fromBlock, + vaultAddress, }: { messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; fromBlock: Hex; + vaultAddress: Hex; }): Promise { const fromPadded = padAddress(moneyAccountAddress); + const toPadded = padAddress(vaultAddress); const logs = await rpcRequest({ messenger, @@ -41,7 +57,7 @@ export async function findRecentChompVaultDeposit({ address: MUSD_MONAD_ADDRESS, fromBlock, toBlock: 'latest', - topics: [ERC20_TRANSFER_TOPIC, fromPadded, null], + topics: [ERC20_TRANSFER_TOPIC, fromPadded, toPadded], }, ], }); @@ -50,16 +66,30 @@ export async function findRecentChompVaultDeposit({ count: logs.length, fromBlock, moneyAccountAddress, + vaultAddress, }); const requiredAmount = BigInt(sourceAmountRaw); + const vaultTopic = toPadded.toLowerCase(); // Examine newest logs first so we return the most recent CHOMP match. for (const txLog of [...logs].reverse()) { + const logTo = txLog.topics[2]?.toLowerCase(); + if (logTo !== vaultTopic) { + log('CHOMP scan: skipping log - transfer is not to the vault', { + expectedTo: vaultAddress, + logTo, + txHash: txLog.transactionHash, + }); + continue; + } + const transferAmount = BigInt(txLog.data === '0x' ? '0x0' : txLog.data); - if (transferAmount < requiredAmount) { - log('CHOMP scan: skipping log — transfer amount below required', { + // Exact amount only: >= would falsely treat larger outbound transfers + // (e.g. Pix) as vault deposits when `to` filtering alone is insufficient. + if (transferAmount !== requiredAmount) { + log('CHOMP scan: skipping log - transfer amount is not an exact match', { requiredAmount: requiredAmount.toString(), transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, @@ -72,12 +102,17 @@ export async function findRecentChompVaultDeposit({ sourceAmountRaw, transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, + vaultAddress, }); return txLog.transactionHash; } - log('CHOMP scan: no match found', { fromBlock, moneyAccountAddress }); + log('CHOMP scan: no match found', { + fromBlock, + moneyAccountAddress, + vaultAddress, + }); return undefined; } diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 337cb27ba8d..d167538513e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -11,6 +11,7 @@ import { submitMoneyAccountVaultDeposit, submitMoneyAccountVaultDepositBatch, } from './ma-vault-deposit.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -20,12 +21,15 @@ import { } from './transaction.js'; jest.mock('./chomp'); +jest.mock('./money-account-vault-config'); jest.mock('./provider'); jest.mock('./transaction'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS_MOCK = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; const NETWORK_CLIENT_ID_MOCK = 'network-client-id-mock'; const TRANSACTION_MOCK = { @@ -75,6 +79,9 @@ function callSubmit({ describe('submitMoneyAccountVaultDeposit', () => { const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); @@ -85,6 +92,13 @@ describe('submitMoneyAccountVaultDeposit', () => { beforeEach(() => { jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue({ + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: BORING_VAULT_ADDRESS_MOCK, + chainId: '0x8f' as Hex, + lensAddress: '0x3333333333333333333333333333333333333333' as Hex, + tellerAddress: '0x4444444444444444444444444444444444444444' as Hex, + }); getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); collectTransactionIdsMock.mockImplementation( (_chainId, _from, _messenger, onTransaction) => { @@ -273,7 +287,7 @@ describe('submitMoneyAccountVaultDeposit', () => { const result = await callSubmit({ callMock, vaultDisabled: true }); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalled(); expect(updateTransactionMock).not.toHaveBeenCalled(); expect(collectTransactionIdsMock).not.toHaveBeenCalled(); @@ -554,7 +568,7 @@ describe('submitMoneyAccountVaultDeposit', () => { }); expect(callMock).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index 84d52d00bbc..7f46689106e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -13,6 +13,7 @@ import { MUSD_MONAD_FIAT_ASSET } from '../strategy/fiat/constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; import { prefixError } from './error-prefix.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -25,6 +26,11 @@ const log = createModuleLogger(projectLogger, 'ma-vault-deposit'); export const VAULT_ERROR_PREFIX = 'Vault: '; +export type SubmitMoneyAccountVaultDepositResult = { + skipped?: true; + transactionHash?: Hex; +}; + /** * Submits a Money Account mUSD vault deposit batch on Monad once the source * mUSD has settled in the Money Account (fiat on-ramp, Relay bridge, or any @@ -47,7 +53,8 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; * @param options.transaction - Original Money Account transaction meta. * @param options.vaultDisabled - When `true`, skip the vault batch and leave * the settled mUSD in the Money Account. Caller-evaluated kill-switch. - * @returns Hash of the final submitted child transaction, if available. + * @returns Hash of the final submitted child transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export async function submitMoneyAccountVaultDeposit({ fromBlock, @@ -65,7 +72,7 @@ export async function submitMoneyAccountVaultDeposit({ sourceAmountRaw: string; transaction: TransactionMeta; vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const transactionId = transaction.id; const moneyAccountAddress = (moneyAccountAddressOverride ?? transaction.txParams.from) as Hex | undefined; @@ -81,7 +88,7 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); - return { transactionHash: '0x' }; + return { skipped: true }; } const nestedTransactions = await resolveVaultDepositBatch({ @@ -116,7 +123,8 @@ export async function submitMoneyAccountVaultDeposit({ * @param options.sourceAmountRaw - Raw mUSD amount to deposit. * @param options.transactionId - Optional parent transaction to link children. * @param options.vaultDisabled - Whether vault submission is disabled. - * @returns Hash of the final confirmed vault transaction. + * @returns Hash of the final confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export async function submitMoneyAccountVaultDepositBatch({ depositCalls, @@ -134,7 +142,7 @@ export async function submitMoneyAccountVaultDepositBatch({ sourceAmountRaw: string; transactionId?: string; vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { if (vaultDisabled) { log('Skipping vault deposit because vaultDisabled is true', { moneyAccountAddress, @@ -142,7 +150,7 @@ export async function submitMoneyAccountVaultDepositBatch({ transactionId, }); - return { transactionHash: '0x' }; + return { skipped: true }; } // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already @@ -378,11 +386,13 @@ async function tryFindChompDeposit({ } try { + const { boringVault } = getMoneyAccountVaultConfig(messenger); return await findRecentChompVaultDeposit({ fromBlock, messenger, moneyAccountAddress, sourceAmountRaw, + vaultAddress: boringVault, }); } catch (chompError) { log('CHOMP check failed', { chompError, transactionId }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts index 4c4cb468068..ca8098195ef 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts @@ -176,7 +176,7 @@ describe('submitMoneyAccountVaultDepositFromPayout', () => { getMessenger(), ); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); }); @@ -192,7 +192,7 @@ describe('submitMoneyAccountVaultDepositFromPayout', () => { getMessenger(), ); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts index d181f057dae..1c55b0948d2 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts @@ -4,6 +4,7 @@ import type { Hex } from '@metamask/utils'; import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './ma-vault-deposit.js'; import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; import { getMoneyAccountVaultConfig, @@ -23,12 +24,13 @@ export type SubmitMoneyAccountVaultDepositRequest = { * * @param request - Iron payout details. * @param messenger - Transaction Pay controller messenger. - * @returns Hash of the confirmed vault transaction, or `0x` when disabled. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` when + * vaulting is disabled. */ export async function submitMoneyAccountVaultDepositFromPayout( request: SubmitMoneyAccountVaultDepositRequest, messenger: TransactionPayControllerMessenger, -): Promise<{ transactionHash?: Hex }> { +): Promise { const { moneyAccountAddress, transactionHash, @@ -39,7 +41,7 @@ export async function submitMoneyAccountVaultDepositFromPayout( vaultDisabled || !isMoneyAccountVaultActionEnabled(messenger, 'deposit') ) { - return { transactionHash: '0x' }; + return { skipped: true }; } const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts index a2bdd242e5b..fb732dcc2eb 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts @@ -33,14 +33,9 @@ function getRequest( ): SubmitMoneyAccountVaultWithdrawRequest { return { amountInRaw: '5000000', - autorampId: 'autoramp-id', - chainId: CHAIN_ID_MONAD, moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, - quoteId: 'quote-id', - quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), recipient: IRON_ADDRESS, requestId: 'request-id', - tokenAddress: MUSD_MONAD_ADDRESS, ...overrides, }; } @@ -181,25 +176,13 @@ describe('submitMoneyAccountVaultWithdraw', () => { [{ amountInRaw: '0' }, 'Withdrawal amount must be greater than zero'], [{ amountInRaw: '-1' }, 'Withdrawal amount must be greater than zero'], [{ amountInRaw: 'invalid' }, 'Withdrawal amount must be greater than zero'], - [{ quoteValidUntil: 'invalid' }, 'Iron quote expiry is invalid'], - [ - { quoteValidUntil: new Date(Date.now() - 1_000).toISOString() }, - 'Iron quote has expired', - ], - [{ chainId: '0x1' }, 'Pix withdrawal must use Monad'], - [ - { tokenAddress: '0x7777777777777777777777777777777777777777' }, - 'Pix withdrawal must use mUSD', - ], [{ recipient: '0x1234' }, 'Iron recipient is invalid'], [ { recipient: MONEY_ACCOUNT_ADDRESS }, 'Iron recipient must differ from the Money Account', ], - [{ requestId: '' }, 'Missing Iron request identifiers'], - [{ quoteId: '' }, 'Missing Iron request identifiers'], - [{ autorampId: '' }, 'Missing Iron request identifiers'], - ])('rejects invalid exact-out input %#', async (overrides, message) => { + [{ requestId: '' }, 'Missing withdraw request id'], + ])('rejects invalid withdraw input %#', async (overrides, message) => { await expect( submitMoneyAccountVaultWithdraw( getRequest(overrides), diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts index bedf2a6a366..7beb1bb9f6f 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts @@ -5,7 +5,7 @@ import type { TransactionBatchResult } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { isValidHexAddress } from '@metamask/utils'; -import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import { CHAIN_ID_MONAD } from '../constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; import { getMoneyAccountVaultConfig, @@ -13,22 +13,21 @@ import { } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; +/** + * On-chain withdraw intent. Quote / Pix / Iron identifiers stay outside Core; + * Monad and mUSD are fixed by the Money Account vault config constants. + */ export type SubmitMoneyAccountVaultWithdrawRequest = { amountInRaw: string; - autorampId: string; - chainId: Hex; moneyAccountAddress: Hex; - quoteId: string; - quoteValidUntil: string; recipient: Hex; requestId: string; - tokenAddress: Hex; }; /** * Creates a user-confirmed atomic vmUSD withdrawal and mUSD transfer to Iron. * - * @param request - Backend-bound exact-out Iron intent. + * @param request - Exact-out withdraw intent. * @param messenger - Transaction Pay controller messenger. * @returns The pending transaction batch ID. */ @@ -89,8 +88,8 @@ export async function submitMoneyAccountVaultWithdraw( function validateRequest( request: SubmitMoneyAccountVaultWithdrawRequest, ): void { - if (!request.requestId || !request.quoteId || !request.autorampId) { - throw new Error('Missing Iron request identifiers'); + if (!request.requestId) { + throw new Error('Missing withdraw request id'); } let amount: bigint; @@ -104,20 +103,6 @@ function validateRequest( throw new Error('Withdrawal amount must be greater than zero'); } - const expiry = Date.parse(request.quoteValidUntil); - if (Number.isNaN(expiry)) { - throw new Error('Iron quote expiry is invalid'); - } - if (expiry <= Date.now()) { - throw new Error('Iron quote has expired'); - } - - if (request.chainId !== CHAIN_ID_MONAD) { - throw new Error('Pix withdrawal must use Monad'); - } - if (request.tokenAddress.toLowerCase() !== MUSD_MONAD_ADDRESS.toLowerCase()) { - throw new Error('Pix withdrawal must use mUSD'); - } if (!isValidHexAddress(request.recipient)) { throw new Error('Iron recipient is invalid'); } From bd674c3ebf3457a580bcd27b81046a550a8083b3 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 14:52:45 -0600 Subject: [PATCH 3/3] fix(transaction-pay-controller): do not cache skipped vault deposits Retain successful deposit results for controller-lifetime dedupe, but clear skipped results so enabling vaulting later can retry the same payout hash. --- .../src/TransactionPayController.test.ts | 19 ++++++++++++++++++ .../src/TransactionPayController.ts | 20 +++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 63c20eb62f3..c708945641d 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -204,6 +204,25 @@ describe('TransactionPayController', () => { ).toHaveBeenCalledTimes(2); }); + it('retries after a skipped deposit once vaulting is enabled', async () => { + submitMoneyAccountVaultDepositFromPayoutMock + .mockResolvedValueOnce({ skipped: true }) + .mockResolvedValueOnce({ transactionHash }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).resolves.toStrictEqual({ skipped: true }); + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).resolves.toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(2); + }); + it('exposes the exact-out withdraw action through the messenger', async () => { submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ batchId: '0x123' as Hex, diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 0687182d265..aa6c777a860 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -247,8 +247,9 @@ export class TransactionPayController extends BaseController< * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. - * Successful results are retained so retries return the prior hash without - * submitting again. + * Successful results are retained for the controller lifetime so retries + * return the prior hash without submitting again. Skipped results (vaulting + * disabled) are not retained, so a later enablement can retry the same hash. * * @param request - Completed Iron payout details. * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` @@ -266,10 +267,17 @@ export class TransactionPayController extends BaseController< const pending = submitMoneyAccountVaultDepositFromPayout( request, this.messenger, - ).catch((error: unknown) => { - this.#vaultDepositRequests.delete(key); - throw error; - }); + ) + .then((result) => { + if (result.skipped) { + this.#vaultDepositRequests.delete(key); + } + return result; + }) + .catch((error: unknown) => { + this.#vaultDepositRequests.delete(key); + throw error; + }); this.#vaultDepositRequests.set(key, pending); return pending; }