From faca4988b298f75ca6e9fd46a22e7b2480dc02f1 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 9 Jul 2026 16:52:42 +0300 Subject: [PATCH 1/8] fix: gate Money Account payment override prepend to same-chain flows Cross-chain flows (e.g. Predict withdraw on Polygon depositing to a Money Account on Monad) carry the deposit in the Relay quote's destination txs[] with a delegation signed for the destination chain. Prepending it onto the source-chain execute batch made that delegation get redeemed on the source chain, so the on-chain signature check recovered a wrong signer and reverted with InvalidEOASignature() (0x3db6791c). Only prepend the override for same-chain flows; cross-chain flows fall through to prepend the original tx. --- .../transaction-pay-controller/CHANGELOG.md | 4 ++++ .../src/strategy/relay/relay-submit.test.ts | 20 +++++++++++++++++++ .../src/strategy/relay/relay-submit.ts | 13 +++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index cd90bf6e15..98aa1646e9 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix cross-chain Money Account deposits (e.g. Predict withdraw to a Money Account) reverting with `InvalidEOASignature()` (`0x3db6791c`) by only prepending the payment override onto the source Relay execute batch for same-chain flows ([#9439](https://github.com/MetaMask/core/pull/9439)) + ## [24.0.1] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 395c4a75bb..aa3f6e2c5e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -966,6 +966,12 @@ describe('Relay Submit Utils', () => { [ORIGINAL_TRANSACTION_ID_MOCK]: TRANSACTION_DATA_MOCK, }, }); + + // The payment override is only prepended onto the source execute batch + // for same-chain flows, so align the quote's source and destination + // chains for these override-prepend assertions. + request.quotes[0].original.details.currencyOut.currency.chainId = + request.quotes[0].original.details.currencyIn.currency.chainId; }); it('prepends override tx params to submit batch', async () => { @@ -999,6 +1005,20 @@ describe('Relay Submit Utils', () => { expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); }); + it('does not prepend override for cross-chain flows', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + request.quotes[0].original.details.currencyIn.currency.chainId = 137; + request.quotes[0].original.details.currencyOut.currency.chainId = 143; + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + }); + it('does not prepend when callback returns empty array', async () => { request.quotes[0].request.paymentOverride = PaymentOverride.MoneyAccount; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 5f7da9bbec..ed3142a52a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -430,9 +430,20 @@ async function submitTransactions( quote.request.from.toLowerCase() !== (transaction.txParams.from as Hex).toLowerCase(); + // Only prepend the payment override onto the source execute batch for + // same-chain flows. For cross-chain flows (e.g. Predict withdraw on Polygon + // depositing to a Money Account on Monad) the deposit is carried in the relay + // quote's destination txs[] and runs on the destination chain; its delegation + // is signed for the destination chainId, so redeeming it inside the + // source-chain batch recovers a wrong signer and reverts. In that case we + // fall through and prepend the original (e.g. Predict withdraw) tx instead. + const isSameChainOverride = + quote.original.details.currencyIn.currency.chainId === + quote.original.details.currencyOut.currency.chainId; + let allParams = normalizedParams; - if (quote.request.paymentOverride) { + if (quote.request.paymentOverride && isSameChainOverride) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); From cb53dabb6523f8f08d5fd2f491371d4c47c0e3e6 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 10 Jul 2026 09:19:31 +0300 Subject: [PATCH 2/8] Update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 98aa1646e9..76bdc5548b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controllers` from `^109.3.1` to `^109.4.0` ([#9450](https://github.com/MetaMask/core/pull/9450)) - Bump `@metamask/transaction-controller` from `^68.3.0` to `^68.4.0` ([#9456](https://github.com/MetaMask/core/pull/9456)) +### Fixed + +- Fix cross-chain Money Account deposits (e.g. Predict withdraw to a Money Account) reverting with `InvalidEOASignature()` (`0x3db6791c`) by only prepending the payment override onto the source Relay execute batch for same-chain flows ([#9449](https://github.com/MetaMask/core/pull/9449)) + ## [24.0.0] ### Added From bcf6474ddabe647768e7b9414378e5e824eb2821 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 13 Jul 2026 10:57:37 +0300 Subject: [PATCH 3/8] Temp --- .../src/TransactionPayController.ts | 2 + .../src/strategy/relay/relay-post-ma-vault.ts | 117 ++++++++++++- .../src/strategy/relay/relay-quotes.ts | 30 +++- .../src/strategy/relay/relay-submit.ts | 39 ++++- .../transaction-pay-controller/src/types.ts | 25 +++ .../src/utils/ma-vault-deposit.ts | 164 ++++++++++++------ .../src/utils/quotes.ts | 10 ++ .../src/utils/transaction.ts | 21 +++ 8 files changed, 354 insertions(+), 54 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index c9e31d480d..7b7ce4f39e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -157,6 +157,7 @@ export class TransactionPayController extends BaseController< refundTo: transactionData.refundTo, accountOverride: transactionData.accountOverride, paymentOverride: transactionData.paymentOverride, + moneyAccountAddress: transactionData.moneyAccountAddress, }; const previousAccountOverride = config.accountOverride; @@ -172,6 +173,7 @@ export class TransactionPayController extends BaseController< transactionData.isQuoteRequired = config.isQuoteRequired; transactionData.refundTo = config.refundTo; transactionData.paymentOverride = config.paymentOverride; + transactionData.moneyAccountAddress = config.moneyAccountAddress; if ( !config.isPostQuote && diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts index 8c2cd7768c..ef42cb2124 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts @@ -1,15 +1,25 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { projectLogger } from '../../logger'; import type { + QuoteRequest, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; -import { getTransferredAmountFromTxHash } from '../../utils/transaction'; +import { + getTransferredAmountFromTxHash, + isPerpsWithdrawTransaction, + isPredictWithdrawTransaction, +} from '../../utils/transaction'; import { MUSD_MONAD_FIAT_ASSET } from '../fiat/constants'; +import { isMoneyAccountDepositTransaction } from '../fiat/utils'; import { FALLBACK_HASH } from './constants'; import type { RelayCompletionOutcome, RelayQuote } from './types'; @@ -45,14 +55,33 @@ export async function submitPostRelayVaultDeposit({ transaction, }); + const moneyAccountAddress = (quote.request.moneyAccountAddress ?? + transaction.txParams.from) as Hex; + + // Deposit flows carry the approve + vault-deposit calls on their own nested + // transactions. Withdraw flows (Perps/Predict) do not, so build a fresh + // vault-deposit batch from the settled amount via the client callback. + const depositCalls = isMoneyAccountDepositTransaction(transaction) + ? undefined + : await buildWithdrawVaultDepositCalls({ + messenger, + quote, + sourceAmountRaw, + transaction, + }); + log('Submitting post-Relay vault deposit', { + moneyAccountAddress, sourceAmountRaw, targetHash: completion.targetHash, transactionId: transaction.id, + usesFreshDepositBatch: Boolean(depositCalls), }); return submitMoneyAccountVaultDeposit({ messenger, + moneyAccountAddress, + depositCalls, sourceAmountRaw, transaction, // This is intentionally set to false, turning this on will leverage @@ -61,6 +90,54 @@ export async function submitPostRelayVaultDeposit({ }); } +/** + * Builds the vault-deposit batch for a withdraw flow whose destination is the + * Money Account. The parent withdraw transaction has no vault calls, so the + * client `getPaymentOverrideData` callback is used to build a fresh approve + + * teller-deposit batch for the settled mUSD amount, sent from the Money + * Account. + * + * @param options - Build options. + * @param options.messenger - Controller messenger. + * @param options.quote - The Relay quote that was submitted. + * @param options.sourceAmountRaw - Settled mUSD amount in raw units. + * @param options.transaction - Original withdraw transaction meta. + * @returns The vault-deposit batch calls, or undefined when none are returned. + */ +async function buildWithdrawVaultDepositCalls({ + messenger, + quote, + sourceAmountRaw, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + sourceAmountRaw: string; + transaction: TransactionMeta; +}): Promise { + const { transactionData } = messenger.call( + 'TransactionPayController:getState', + ); + + // getPaymentOverrideData expects a human-readable amount; the settled amount + // is raw, so shift it down by the destination token decimals. + const { decimals } = quote.original.details.currencyOut.currency; + const amountHuman = new BigNumber(sourceAmountRaw) + .shiftedBy(-decimals) + .toFixed(); + + const { calls } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: amountHuman, + transaction, + transactionData: transactionData[transaction.id], + }, + ); + + return calls.length ? calls : undefined; +} + /** * Resolves the actual mUSD amount that landed in the Money Account after a * Relay bridge to Monad. Prefers the on-chain Transfer log on @@ -134,3 +211,39 @@ async function resolvePostRelayAmount({ return fallback; } + +/** + * Whether a quote must settle into the Money Account vault via a post-Relay + * vault deposit (Relay lands mUSD on the Money Account, then a separate + * sponsored deposit mints vmUSD) instead of embedding the deposit atomically. + * + * Two cases, both keyed on transaction type (resolving nested batch types): + * - Crypto/fiat Money Account deposits, but only for max amount — non-max + * deposits embed a fixed-amount vault batch (EXACT_OUTPUT). + * - Perps/Predict withdraws whose destination is mUSD on Monad. These are + * EXACT_INPUT post-quote flows whose settled mUSD is only known after Relay + * completes, so they always use the post-Relay deposit. + * + * @param request - The quote request to test. + * @param transaction - The parent transaction metadata. + * @returns True when the quote uses the post-Relay Money Account vault deposit. + */ +export function isPostRelayMoneyAccountVaultDeposit( + request: QuoteRequest, + transaction: TransactionMeta, +): boolean { + if ( + Boolean(request.isMaxAmount) && + isMoneyAccountDepositTransaction(transaction) + ) { + return true; + } + + return ( + (isPerpsWithdrawTransaction(transaction) || + isPredictWithdrawTransaction(transaction)) && + request.targetChainId === MUSD_MONAD_FIAT_ASSET.chainId && + request.targetTokenAddress.toLowerCase() === + MUSD_MONAD_FIAT_ASSET.address.toLowerCase() + ); +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index acd5b446eb..189c8985e8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -61,6 +61,7 @@ import { applyHyperliquidActivationFee } from './hyperliquid-activation'; import { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; +import { isPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; import type { RelayQuote, RelayQuoteMetamask, @@ -290,6 +291,21 @@ async function getSingleQuote( ); try { + const usePostRelayVaultDeposit = isPostRelayMoneyAccountVaultDeposit( + request, + transaction, + ); + + // Redirect the bridged mUSD to the Money Account (a separate account from the + // funding EOA) so the post-Relay vault deposit can move it into the vault. + // Withdraw flows supply the Money Account explicitly via `moneyAccountAddress` + // because their `txParams.from` is the funding EOA; deposit flows fall back to + // `txParams.from`, which is already the Money Account. + if (usePostRelayVaultDeposit) { + request.recipient = (request.moneyAccountAddress ?? + transaction.txParams.from) as Hex; + } + // For post-quote or max amount flows, use EXACT_INPUT - user specifies how much to send, // and we show them how much they'll receive after fees. // For regular flows with a target amount, use EXPECTED_OUTPUT. @@ -332,7 +348,19 @@ async function getSingleQuote( !(request.skipProcessTransactions ?? request.isPostQuote) && !request.isPolymarketDepositWallet; - if (shouldProcessTransactions) { + if (usePostRelayVaultDeposit) { + // Skip only the atomic vault embedding: mUSD settles to the Money Account + // and the vault deposit runs post-Relay. The caller-specified refundTo + // (e.g. the Predict Safe proxy) is still honoured below so failed Relay + // attempts refund to the correct account. + log( + 'Money Account vault deposit: skipping atomic embedding; mUSD settles to the Money Account for a post-Relay vault deposit', + ); + + if (request.refundTo) { + body.refundTo = request.refundTo; + } + } else if (shouldProcessTransactions) { await processTransactions(transaction, request, body, messenger); } else if ( request.isPostQuote && diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index ed3142a52a..541762a434 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -45,6 +45,10 @@ import { submitPolymarketWithdraw, } from './polymarket/withdraw'; import { getRelayStatus } from './relay-api'; +import { + isPostRelayMoneyAccountVaultDeposit, + submitPostRelayVaultDeposit, +} from './relay-post-ma-vault'; import { submitViaRelayExecute } from './relay-submit-execute'; import type { RelayCompletionOutcome, @@ -182,6 +186,24 @@ async function executeSingleQuote( }, ); + // Money Account vault flows leave mUSD on the Money Account rather than + // embedding the vault deposit in the Relay batch. Once Relay has settled, move + // the settled mUSD into the vault via a separate sponsored deposit and return + // its hash. + if ( + completion.status === 'success' && + isPostRelayMoneyAccountVaultDeposit(quote.request, transaction) + ) { + const { transactionHash } = await submitPostRelayVaultDeposit({ + completion, + messenger, + quote, + transaction, + }); + + return { transactionHash: transactionHash ?? completion.targetHash }; + } + return { transactionHash: completion.targetHash }; } @@ -441,9 +463,24 @@ async function submitTransactions( quote.original.details.currencyIn.currency.chainId === quote.original.details.currencyOut.currency.chainId; + // Post-Relay Money Account vault deposits leave mUSD on the Money Account and + // run the vault deposit after Relay settles, so the atomic vault-deposit + // override must NOT be embedded here. The original post-quote transaction + // (e.g. the Predict withdraw that releases the source funds) and refundTo are + // still required, so this only skips the payment-override branch below and + // falls through to the normal post-quote prepend. + const usePostRelayVaultDeposit = isPostRelayMoneyAccountVaultDeposit( + quote.request, + transaction, + ); + let allParams = normalizedParams; - if (quote.request.paymentOverride && isSameChainOverride) { + if ( + quote.request.paymentOverride && + isSameChainOverride && + !usePostRelayVaultDeposit + ) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index c1904e38f0..f2f07f16dc 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -130,6 +130,15 @@ export type TransactionConfig = { /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; + /** + * Money Account address for Money Account payment-override flows. Set for + * withdraw flows (Perps/Predict) whose destination is the Money Account, + * where `transaction.txParams.from` is the EOA rather than the Money + * Account. Used as the post-Relay bridge recipient and as the sender of the + * sponsored vault deposit. + */ + moneyAccountAddress?: Hex; + /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; @@ -319,6 +328,15 @@ export type TransactionData = { /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; + /** + * Money Account address for Money Account payment-override flows. Set for + * withdraw flows (Perps/Predict) whose destination is the Money Account, + * where `transaction.txParams.from` is the EOA rather than the Money + * Account. Used as the post-Relay bridge recipient and as the sender of the + * sponsored vault deposit. + */ + moneyAccountAddress?: Hex; + /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; @@ -503,6 +521,13 @@ export type QuoteRequest = { /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; + /** + * Money Account address for Money Account payment-override withdraw flows, + * where the parent transaction's `from` is the EOA rather than the Money + * Account. Used as the post-Relay bridge recipient and vault-deposit sender. + */ + moneyAccountAddress?: Hex; + /** Optional recipient address for Relay requests. When set, overrides the default `from` address. */ recipient?: Hex; 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 ad2d02e39a..80996474bc 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -1,5 +1,9 @@ import { ORIGIN_METAMASK } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + NestedTransactionMetadata, + TransactionMeta, +} from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -32,6 +36,12 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; * @param options - Submit options. * @param options.fromBlock - Block number to start searching for CHOMP deposits. * @param options.messenger - Controller messenger. + * @param options.moneyAccountAddress - Money Account address sending the vault + * deposit. Defaults to `transaction.txParams.from` (the deposit-flow sender); + * withdraw flows pass it explicitly since their `from` is the funding EOA. + * @param options.depositCalls - Pre-built vault-deposit batch for withdraw + * flows, whose parent transaction carries no vault calls. When omitted the + * batch is derived from the transaction's own nested calls. * @param options.sourceAmountRaw - Settled mUSD amount in raw units. * @param options.transaction - Original Money Account transaction meta. * @param options.vaultDisabled - When `true`, skip the vault batch and leave @@ -41,18 +51,27 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; export async function submitMoneyAccountVaultDeposit({ fromBlock, messenger, + moneyAccountAddress: moneyAccountAddressOverride, + depositCalls, sourceAmountRaw, transaction, vaultDisabled, }: { fromBlock?: Hex; messenger: TransactionPayControllerMessenger; + moneyAccountAddress?: Hex; + depositCalls?: BatchTransactionParams[]; sourceAmountRaw: string; transaction: TransactionMeta; vaultDisabled: boolean; }): Promise<{ transactionHash?: Hex }> { const transactionId = transaction.id; - const moneyAccountAddress = transaction.txParams.from as Hex | undefined; + + // Withdraw flows pass the Money Account explicitly because their + // `txParams.from` is the funding EOA; deposit flows fall back to + // `txParams.from`, which is already the Money Account. + const moneyAccountAddress = (moneyAccountAddressOverride ?? + transaction.txParams.from) as Hex | undefined; if (!moneyAccountAddress) { throw new Error('Missing Money Account address'); @@ -68,54 +87,13 @@ export async function submitMoneyAccountVaultDeposit({ return { transactionHash: '0x' }; } - const updatedTransaction = - getTransaction(transactionId, messenger) ?? transaction; - const { updates } = await messenger.call( - 'TransactionPayController:getAmountData', - { - amount: sourceAmountRaw, - transaction: updatedTransaction, - }, - ); - - if (!updates.length) { - throw new Error('No amount updates'); - } - - const nestedTransactions = updatedTransaction.nestedTransactions?.map( - (nestedTransaction) => ({ ...nestedTransaction }), - ); - - if (!nestedTransactions?.length) { - throw new Error('Missing nested transactions'); - } - - for (const { nestedTransactionIndex, data } of updates) { - if (nestedTransactions[nestedTransactionIndex]) { - nestedTransactions[nestedTransactionIndex].data = data; - } - } - - updateTransaction( - { - transactionId, - messenger, - note: 'Money Account vault deposit: update vault amount', - }, - (tx) => { - for (const { nestedTransactionIndex, data } of updates) { - if (tx.nestedTransactions?.[nestedTransactionIndex]) { - tx.nestedTransactions[nestedTransactionIndex].data = data; - } - } - - if (tx.requiredAssets?.[0]) { - tx.requiredAssets[0].amount = `0x${BigInt(sourceAmountRaw).toString( - 16, - )}`; - } - }, - ); + const nestedTransactions = await resolveVaultDepositBatch({ + depositCalls, + messenger, + sourceAmountRaw, + transaction, + transactionId, + }); // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. @@ -244,6 +222,92 @@ export async function submitMoneyAccountVaultDeposit({ return { transactionHash: hash as Hex }; } +/** + * Resolves the vault-deposit batch (approve + teller deposit) to submit. + * + * Withdraw flows (Perps/Predict) have no vault calls on their own nested + * transactions, so the caller supplies a freshly built `depositCalls` batch. + * Deposit flows re-encode their existing nested vault calldata with the + * settled amount via `getAmountData` and also mutate the parent transaction so + * its stored calls and `requiredAssets` reflect the settled amount. + * + * @param options - Resolution options. + * @param options.depositCalls - Pre-built deposit batch for withdraw flows. + * @param options.messenger - Controller messenger. + * @param options.sourceAmountRaw - Settled mUSD amount in raw units. + * @param options.transaction - Original Money Account transaction meta. + * @param options.transactionId - ID of the original transaction. + * @returns Nested transactions to submit as the vault deposit batch. + */ +async function resolveVaultDepositBatch({ + depositCalls, + messenger, + sourceAmountRaw, + transaction, + transactionId, +}: { + depositCalls?: BatchTransactionParams[]; + messenger: TransactionPayControllerMessenger; + sourceAmountRaw: string; + transaction: TransactionMeta; + transactionId: string; +}): Promise { + if (depositCalls?.length) { + return depositCalls; + } + + const updatedTransaction = + getTransaction(transactionId, messenger) ?? transaction; + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { + amount: sourceAmountRaw, + transaction: updatedTransaction, + }, + ); + + if (!updates.length) { + throw new Error('No amount updates'); + } + + const nestedTransactions = updatedTransaction.nestedTransactions?.map( + (nestedTransaction) => ({ ...nestedTransaction }), + ); + + if (!nestedTransactions?.length) { + throw new Error('Missing nested transactions'); + } + + for (const { nestedTransactionIndex, data } of updates) { + if (nestedTransactions[nestedTransactionIndex]) { + nestedTransactions[nestedTransactionIndex].data = data; + } + } + + updateTransaction( + { + transactionId, + messenger, + note: 'Money Account vault deposit: update vault amount', + }, + (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; + } + } + + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${BigInt(sourceAmountRaw).toString( + 16, + )}`; + } + }, + ); + + return nestedTransactions; +} + async function tryFindChompDeposit({ fromBlock, messenger, diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index c7376dd8e9..293d97f0f3 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -87,6 +87,7 @@ export async function updateQuotes( isHyperliquidSource, isPolymarketDepositWallet, paymentOverride, + moneyAccountAddress, paymentToken: originalPaymentToken, fiatPayment, refundTo, @@ -125,6 +126,7 @@ export async function updateQuotes( isHyperliquidSource, isPolymarketDepositWallet, paymentOverride, + moneyAccountAddress, paymentToken, refundTo, sourceAmounts, @@ -355,6 +357,7 @@ function clearControllerIfCurrent( * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.paymentOverride - Optional payment override type for the transaction. + * @param request.moneyAccountAddress - Optional Money Account address for Money Account withdraw flows. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction. @@ -369,6 +372,7 @@ function buildQuoteRequests({ isHyperliquidSource, isPolymarketDepositWallet, paymentOverride, + moneyAccountAddress, paymentToken, refundTo, sourceAmounts, @@ -381,6 +385,7 @@ function buildQuoteRequests({ isHyperliquidSource?: boolean; isPolymarketDepositWallet?: boolean; paymentOverride?: PaymentOverride; + moneyAccountAddress?: Hex; paymentToken: TransactionPaymentToken | undefined; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -398,6 +403,7 @@ function buildQuoteRequests({ isHyperliquidSource, isPolymarketDepositWallet, paymentOverride, + moneyAccountAddress, destinationToken: paymentToken, refundTo, sourceAmounts, @@ -443,6 +449,7 @@ function buildQuoteRequests({ * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.paymentOverride - Optional payment override type for the transaction. + * @param request.moneyAccountAddress - Optional Money Account address for Money Account withdraw flows. * @param request.destinationToken - Destination token (paymentToken in post-quote mode). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction (includes source token info). @@ -455,6 +462,7 @@ function buildPostQuoteRequests({ isHyperliquidSource, isPolymarketDepositWallet, paymentOverride, + moneyAccountAddress, destinationToken, refundTo, sourceAmounts, @@ -465,6 +473,7 @@ function buildPostQuoteRequests({ isHyperliquidSource?: boolean; isPolymarketDepositWallet?: boolean; paymentOverride?: PaymentOverride; + moneyAccountAddress?: Hex; destinationToken: TransactionPaymentToken; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -496,6 +505,7 @@ function buildPostQuoteRequests({ isHyperliquidSource, isPolymarketDepositWallet, paymentOverride, + moneyAccountAddress, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, sourceTokenAmount: sourceAmount.sourceAmountRaw, diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index fa2b8c8f1e..531da93e8d 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -330,6 +330,27 @@ export function isPredictWithdrawTransaction( ); } +/** + * Check whether a transaction is a Perps withdrawal. + * + * Returns `true` when the transaction's own type is `perpsWithdraw`, or + * when any of its nested transactions has that type. + * + * @param transaction - Transaction metadata. + * @returns `true` when the transaction is a Perps withdrawal. + */ +export function isPerpsWithdrawTransaction( + transaction: TransactionMeta, +): boolean { + return ( + transaction.type === TransactionType.perpsWithdraw || + (transaction.nestedTransactions?.some( + (nt) => nt.type === TransactionType.perpsWithdraw, + ) ?? + false) + ); +} + /** * Handle a transaction change by updating its associated data. * From fee493adb9ef3244a36a7112906e833074351ead Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 12:40:11 +0300 Subject: [PATCH 4/8] Remove changelog --- packages/transaction-pay-controller/CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0d70ebd3fb..b7ab86bfb0 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -40,10 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controllers` from `^109.3.1` to `^109.4.0` ([#9450](https://github.com/MetaMask/core/pull/9450)) - Bump `@metamask/transaction-controller` from `^68.3.0` to `^68.4.0` ([#9456](https://github.com/MetaMask/core/pull/9456)) -### Fixed - -- Fix cross-chain Money Account deposits (e.g. Predict withdraw to a Money Account) reverting with `InvalidEOASignature()` (`0x3db6791c`) by only prepending the payment override onto the source Relay execute batch for same-chain flows ([#9449](https://github.com/MetaMask/core/pull/9449)) - ## [24.0.0] ### Added From 819b91b3c531ae4ead3ce4ce2653b928e4d73cd7 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 13:19:20 +0300 Subject: [PATCH 5/8] Update tests --- .../relay/relay-post-ma-vault.test.ts | 304 +++++++++++++++++- .../src/strategy/relay/relay-quotes.test.ts | 138 ++++++++ .../src/strategy/relay/relay-submit.test.ts | 55 ++++ .../src/utils/ma-vault-deposit.test.ts | 32 ++ 4 files changed, 517 insertions(+), 12 deletions(-) diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts index 445183f585..4ec1ba02b4 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts @@ -1,7 +1,11 @@ +import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../../constants'; +import { getMessengerMock } from '../../tests/messenger-mock'; import type { + QuoteRequest, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; @@ -9,10 +13,16 @@ import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; import { getTransferredAmountFromTxHash } from '../../utils/transaction'; import { MUSD_MONAD_FIAT_ASSET } from '../fiat/constants'; import { FALLBACK_HASH } from './constants'; -import { submitPostRelayVaultDeposit } from './relay-post-ma-vault'; +import { + isPostRelayMoneyAccountVaultDeposit, + submitPostRelayVaultDeposit, +} from './relay-post-ma-vault'; import type { RelayCompletionOutcome, RelayQuote } from './types'; -jest.mock('../../utils/transaction'); +jest.mock('../../utils/transaction', () => ({ + ...jest.requireActual('../../utils/transaction'), + getTransferredAmountFromTxHash: jest.fn(), +})); jest.mock('../../utils/ma-vault-deposit'); const TRANSACTION_ID_MOCK = 'tx-id'; @@ -30,24 +40,37 @@ const TRANSACTION_MOCK = { txParams: { from: MONEY_ACCOUNT_ADDRESS_MOCK }, } as unknown as TransactionMeta; -function buildMessenger(): TransactionPayControllerMessenger { - return {} as TransactionPayControllerMessenger; -} +const PERPS_WITHDRAW_TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: MONEY_ACCOUNT_ADDRESS_MOCK }, + type: TransactionType.perpsWithdraw, +} as unknown as TransactionMeta; + +const MONEY_ACCOUNT_DEPOSIT_TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: MONEY_ACCOUNT_ADDRESS_MOCK }, + type: TransactionType.moneyAccountDeposit, +} as unknown as TransactionMeta; function buildQuote(overrides?: { recipient?: Hex; from?: Hex; minimumAmount?: string; + moneyAccountAddress?: Hex; }): TransactionPayQuote { return { request: { from: overrides?.from ?? MONEY_ACCOUNT_ADDRESS_MOCK, recipient: overrides?.recipient, + moneyAccountAddress: overrides?.moneyAccountAddress, }, original: { details: { currencyOut: { minimumAmount: overrides?.minimumAmount ?? MINIMUM_AMOUNT_MOCK, + currency: { + decimals: 6, + }, }, }, }, @@ -64,11 +87,24 @@ describe('submitPostRelayVaultDeposit', () => { submitMoneyAccountVaultDeposit, ); + const { getControllerStateMock, getPaymentOverrideDataMock, messenger } = + getMessengerMock(); + + function buildMessenger(): TransactionPayControllerMessenger { + return messenger; + } + beforeEach(() => { jest.resetAllMocks(); submitMoneyAccountVaultDepositMock.mockResolvedValue({ transactionHash: VAULT_HASH_MOCK, }); + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_ID_MOCK]: { isLoading: false, tokens: [] }, + }, + } as never); + getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); }); describe('resolvePostRelayAmount — on-chain path', () => { @@ -125,7 +161,12 @@ describe('submitPostRelayVaultDeposit', () => { const quote = { request: {}, original: { - details: { currencyOut: { minimumAmount: MINIMUM_AMOUNT_MOCK } }, + details: { + currencyOut: { + minimumAmount: MINIMUM_AMOUNT_MOCK, + currency: { decimals: 6 }, + }, + }, }, } as unknown as TransactionPayQuote; @@ -230,12 +271,14 @@ describe('submitPostRelayVaultDeposit', () => { transaction: TRANSACTION_MOCK, }); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith({ - messenger: expect.anything(), - sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, - transaction: TRANSACTION_MOCK, - vaultDisabled: false, - }); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + messenger: expect.anything(), + sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, + transaction: TRANSACTION_MOCK, + vaultDisabled: false, + }), + ); }); it('returns the transactionHash from submitMoneyAccountVaultDeposit', async () => { @@ -254,4 +297,241 @@ describe('submitPostRelayVaultDeposit', () => { expect(result).toStrictEqual({ transactionHash: VAULT_HASH_MOCK }); }); }); + + describe('deposit flow (isMoneyAccountDepositTransaction=true)', () => { + it('skips buildWithdrawVaultDepositCalls and passes undefined depositCalls for deposit transactions', async () => { + getTransferredAmountMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + + await submitPostRelayVaultDeposit({ + completion: buildCompletion(TARGET_HASH_MOCK), + messenger: buildMessenger(), + quote: buildQuote(), + transaction: MONEY_ACCOUNT_DEPOSIT_TRANSACTION_MOCK, + }); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK }), + ); + }); + }); + + describe('buildWithdrawVaultDepositCalls — withdraw flow', () => { + it('calls getPaymentOverrideData and passes depositCalls to submitMoneyAccountVaultDeposit', async () => { + const depositCallsMock = [ + { to: '0xvault' as Hex, data: '0xdeposit' as Hex, value: '0x0' as Hex }, + ]; + getTransferredAmountMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + getPaymentOverrideDataMock.mockResolvedValue({ calls: depositCallsMock }); + + await submitPostRelayVaultDeposit({ + completion: buildCompletion(TARGET_HASH_MOCK), + messenger: buildMessenger(), + quote: buildQuote(), + transaction: PERPS_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith( + expect.objectContaining({ + amount: expect.any(String), + transaction: PERPS_WITHDRAW_TRANSACTION_MOCK, + }), + ); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, + transaction: PERPS_WITHDRAW_TRANSACTION_MOCK, + }), + ); + }); + + it('passes undefined depositCalls when getPaymentOverrideData returns empty calls', async () => { + getTransferredAmountMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); + + await submitPostRelayVaultDeposit({ + completion: buildCompletion(TARGET_HASH_MOCK), + messenger: buildMessenger(), + quote: buildQuote(), + transaction: PERPS_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, + }), + ); + }); + + it('uses quote.request.moneyAccountAddress as moneyAccountAddress when provided', async () => { + const moneyAccountOverride = + '0xmoneyaccount000000000000000000000000001' as Hex; + getTransferredAmountMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + + await submitPostRelayVaultDeposit({ + completion: buildCompletion(TARGET_HASH_MOCK), + messenger: buildMessenger(), + quote: buildQuote({ moneyAccountAddress: moneyAccountOverride }), + transaction: PERPS_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + transaction: PERPS_WITHDRAW_TRANSACTION_MOCK, + }), + ); + }); + }); +}); + +describe('isPostRelayMoneyAccountVaultDeposit', () => { + it('returns true for isMaxAmount moneyAccountDeposit transaction', () => { + const transaction = { + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + + const result = isPostRelayMoneyAccountVaultDeposit( + { + from: MONEY_ACCOUNT_ADDRESS_MOCK, + isMaxAmount: true, + sourceBalanceRaw: '0', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: CHAIN_ID_MONAD, + targetTokenAddress: MUSD_MONAD_ADDRESS, + } as unknown as QuoteRequest, + transaction, + ); + + expect(result).toBe(true); + }); + + it('returns false for isMaxAmount with non-deposit transaction type', () => { + const transaction = { + type: TransactionType.simpleSend, + } as TransactionMeta; + + const result = isPostRelayMoneyAccountVaultDeposit( + { + from: MONEY_ACCOUNT_ADDRESS_MOCK, + isMaxAmount: true, + sourceBalanceRaw: '0', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: CHAIN_ID_MONAD, + targetTokenAddress: MUSD_MONAD_ADDRESS, + } as unknown as QuoteRequest, + transaction, + ); + + expect(result).toBe(false); + }); + + it('returns true for perpsWithdraw targeting mUSD on Monad', () => { + const transaction = { + type: TransactionType.perpsWithdraw, + } as TransactionMeta; + + const result = isPostRelayMoneyAccountVaultDeposit( + { + from: MONEY_ACCOUNT_ADDRESS_MOCK, + isMaxAmount: false, + sourceBalanceRaw: '0', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: CHAIN_ID_MONAD, + targetTokenAddress: MUSD_MONAD_ADDRESS, + } as unknown as QuoteRequest, + transaction, + ); + + expect(result).toBe(true); + }); + + it('returns true for predictWithdraw targeting mUSD on Monad', () => { + const transaction = { + type: TransactionType.predictWithdraw, + } as TransactionMeta; + + const result = isPostRelayMoneyAccountVaultDeposit( + { + from: MONEY_ACCOUNT_ADDRESS_MOCK, + isMaxAmount: false, + sourceBalanceRaw: '0', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: CHAIN_ID_MONAD, + targetTokenAddress: MUSD_MONAD_ADDRESS, + } as unknown as QuoteRequest, + transaction, + ); + + expect(result).toBe(true); + }); + + it('returns false for perpsWithdraw targeting a different chain', () => { + const transaction = { + type: TransactionType.perpsWithdraw, + } as TransactionMeta; + + const result = isPostRelayMoneyAccountVaultDeposit( + { + from: MONEY_ACCOUNT_ADDRESS_MOCK, + isMaxAmount: false, + sourceBalanceRaw: '0', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: '0x1' as Hex, + targetTokenAddress: MUSD_MONAD_ADDRESS, + } as unknown as QuoteRequest, + transaction, + ); + + expect(result).toBe(false); + }); + + it('returns false for perpsWithdraw targeting a different token', () => { + const transaction = { + type: TransactionType.perpsWithdraw, + } as TransactionMeta; + + const result = isPostRelayMoneyAccountVaultDeposit( + { + from: MONEY_ACCOUNT_ADDRESS_MOCK, + isMaxAmount: false, + sourceBalanceRaw: '0', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: CHAIN_ID_MONAD, + targetTokenAddress: '0xsomeothertoken' as Hex, + } as unknown as QuoteRequest, + transaction, + ); + + expect(result).toBe(false); + }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index cd6ffa2197..f0a4446ab5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -12,7 +12,9 @@ import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM, CHAIN_ID_HYPERCORE, + CHAIN_ID_MONAD, CHAIN_ID_POLYGON, + MUSD_MONAD_ADDRESS, NATIVE_TOKEN_ADDRESS, PaymentOverride, POLYGON_USDCE_ADDRESS, @@ -396,6 +398,142 @@ describe('Relay Quotes Utils', () => { ); }); + it('sets recipient to txParams.from when usePostRelayVaultDeposit is true for moneyAccountDeposit', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const moneyAccountDeposit = { + txParams: { from: FROM_MOCK }, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: moneyAccountDeposit, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(FROM_MOCK); + }); + + it('sets recipient to moneyAccountAddress when usePostRelayVaultDeposit is true and moneyAccountAddress is set', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const moneyAccountAddress = + '0xmoneyaccount00000000000000000000000001' as Hex; + const moneyAccountDeposit = { + txParams: { from: FROM_MOCK }, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { ...QUOTE_REQUEST_MOCK, isMaxAmount: true, moneyAccountAddress }, + ], + transaction: moneyAccountDeposit, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(moneyAccountAddress); + }); + + it('sets refundTo on body when usePostRelayVaultDeposit is true and refundTo is provided', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const refundTo = '0xrefund000000000000000000000000000000001' as Hex; + const moneyAccountDeposit = { + txParams: { from: FROM_MOCK }, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true, refundTo }], + transaction: moneyAccountDeposit, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.refundTo).toBe(refundTo); + }); + + it('does not set refundTo when usePostRelayVaultDeposit is true but refundTo is not provided', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const moneyAccountDeposit = { + txParams: { from: FROM_MOCK }, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: moneyAccountDeposit, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.refundTo).toBeUndefined(); + }); + + it('sets recipient to txParams.from for perpsWithdraw targeting mUSD on Monad', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const perpsWithdraw = { + txParams: { from: FROM_MOCK }, + type: TransactionType.perpsWithdraw, + } as TransactionMeta; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_MONAD, + targetTokenAddress: MUSD_MONAD_ADDRESS, + }, + ], + transaction: perpsWithdraw, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(FROM_MOCK); + }); + it('throws if isMaxAmount is true and transaction includes data', async () => { await expect( getRelayQuotes({ diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index aa3f6e2c5e..5c791a439e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -28,6 +28,7 @@ import { waitForTransactionConfirmed, } from '../../utils/transaction'; import { RELAY_STATUS_URL } from './constants'; +import { submitPostRelayVaultDeposit } from './relay-post-ma-vault'; import { submitRelayQuotes } from './relay-submit'; import { submitViaRelayExecute } from './relay-submit-execute'; import type { RelayQuote } from './types'; @@ -38,6 +39,10 @@ jest.mock('../../utils/feature-flags'); jest.mock('./hyperliquid-withdraw'); jest.mock('./polymarket/withdraw'); jest.mock('./relay-submit-execute'); +jest.mock('./relay-post-ma-vault', () => ({ + ...jest.requireActual('./relay-post-ma-vault'), + submitPostRelayVaultDeposit: jest.fn(), +})); const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const TRANSACTION_HASH_MOCK = '0x1234'; @@ -160,6 +165,9 @@ describe('Relay Submit Utils', () => { ); const submitViaRelayExecuteMock = jest.mocked(submitViaRelayExecute); + const submitPostRelayVaultDepositMock = jest.mocked( + submitPostRelayVaultDeposit, + ); beforeEach(() => { jest.resetAllMocks(); @@ -182,6 +190,9 @@ describe('Relay Submit Utils', () => { waitForTransactionConfirmedMock.mockResolvedValue(); getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + submitPostRelayVaultDepositMock.mockResolvedValue({ + transactionHash: TRANSACTION_HASH_MOCK as Hex, + }); getFeatureFlagsMock.mockReturnValue({ relayFallbackGas: { @@ -1716,6 +1727,50 @@ describe('Relay Submit Utils', () => { }); }); + describe('post-relay Money Account vault deposit path', () => { + beforeEach(() => { + request.transaction = { + ...request.transaction, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta; + request.quotes[0].request.isMaxAmount = true; + }); + + it('calls submitPostRelayVaultDeposit on successful completion', async () => { + await submitRelayQuotes(request); + + expect(submitPostRelayVaultDepositMock).toHaveBeenCalledTimes(1); + expect(submitPostRelayVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + messenger, + quote: request.quotes[0], + transaction: request.transaction, + }), + ); + }); + + it('returns transactionHash from submitPostRelayVaultDeposit when available', async () => { + const vaultHash = '0xvaulthash' as Hex; + submitPostRelayVaultDepositMock.mockResolvedValue({ + transactionHash: vaultHash, + }); + + const result = await submitRelayQuotes(request); + + expect(result.transactionHash).toBe(vaultHash); + }); + + it('falls back to completion.targetHash when submitPostRelayVaultDeposit returns no hash', async () => { + submitPostRelayVaultDepositMock.mockResolvedValue({ + transactionHash: undefined, + }); + + const result = await submitRelayQuotes(request); + + expect(result.transactionHash).toBe(TRANSACTION_HASH_MOCK); + }); + }); + describe('EIP-7702 execute path', () => { beforeEach(() => { request.quotes[0].original.metamask.isExecute = true; 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 6a24aac3c9..4c833fc1d3 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 @@ -422,4 +422,36 @@ describe('submitMoneyAccountVaultDeposit', () => { expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); }); }); + + describe('depositCalls pre-built batch', () => { + it('uses depositCalls directly when provided, skipping getAmountData', async () => { + const depositCalls = [ + { + to: '0xvault' as Hex, + data: '0xdeposit' as Hex, + value: '0x0' as Hex, + }, + ]; + 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 submitMoneyAccountVaultDeposit({ + depositCalls, + messenger: buildMessenger(callMock), + sourceAmountRaw: '5000000', + transaction: TRANSACTION_MOCK, + vaultDisabled: false, + }); + + expect(callMock).not.toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.anything(), + ); + expect(result).toStrictEqual({ transactionHash: '0xvault' }); + }); + }); }); From 3c620b93180ada882bf8b55ab1acae9007c0f127 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 13:37:00 +0300 Subject: [PATCH 6/8] Add changelog --- packages/transaction-pay-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index b7ab86bfb0..ee9ed1c181 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Enable max-amount Relay bridge flows to deposit settled mUSD into the Money Account vault via a post-Relay sponsored deposit ([#9497](https://github.com/MetaMask/core/pull/9497)) - Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows ([#9051](https://github.com/MetaMask/core/pull/9051)) - Trigger quote refresh when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger From b13f8fea009a1836998ca279b64760192e305c27 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 14:16:35 +0300 Subject: [PATCH 7/8] Adjust changelog --- packages/transaction-pay-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ee9ed1c181..38009a3b52 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Enable max-amount Relay bridge flows to deposit settled mUSD into the Money Account vault via a post-Relay sponsored deposit ([#9497](https://github.com/MetaMask/core/pull/9497)) - Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows ([#9051](https://github.com/MetaMask/core/pull/9051)) - Trigger quote refresh when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger @@ -17,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^10.2.1` to `^11.0.0` ([#9485](https://github.com/MetaMask/core/pull/9485)) - Bump `@metamask/ramps-controller` from `^16.0.0` to `^17.0.0` ([#9491](https://github.com/MetaMask/core/pull/9491)) +- Enable max-amount Relay bridge flows to deposit settled mUSD into the Money Account vault via a post-Relay sponsored deposit ([#9497](https://github.com/MetaMask/core/pull/9497)) ## [24.0.3] From db08be46d362d47d6e9f3fd5b8d93813248877bb Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 14 Jul 2026 14:17:56 +0300 Subject: [PATCH 8/8] Update changelog --- packages/transaction-pay-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 96b713ce10..c8ceb3fd3a 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Enable max-amount Relay bridge flows to deposit settled mUSD into the Money Account vault via a post-Relay sponsored deposit ([#9497](https://github.com/MetaMask/core/pull/9497)) + ## [24.1.0] ### Added @@ -20,7 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replace hardcoded `STABLECOINS` usage in `token.ts` and `relay-quotes.ts` with the remotely configurable `getStablecoins(messenger)` lookup ([#9495](https://github.com/MetaMask/core/pull/9495)) - Bump `@metamask/assets-controller` from `^10.2.1` to `^11.0.0` ([#9485](https://github.com/MetaMask/core/pull/9485)) - Bump `@metamask/ramps-controller` from `^16.0.0` to `^17.0.0` ([#9491](https://github.com/MetaMask/core/pull/9491)) -- Enable max-amount Relay bridge flows to deposit settled mUSD into the Money Account vault via a post-Relay sponsored deposit ([#9497](https://github.com/MetaMask/core/pull/9497)) ## [24.0.3]