From 80fc18bd52ecc3f10f2b70b22d9fc31855e50c37 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 14:44:06 +0300 Subject: [PATCH 1/8] Enable max amount crypto deposit for MoneyAccountDeposit --- .../src/strategy/relay/relay-post-ma-vault.ts | 23 ++++++++++++++++ .../src/strategy/relay/relay-quotes.ts | 18 ++++++++++++- .../src/strategy/relay/relay-submit.ts | 27 ++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) 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..9f0b7d7a31 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 @@ -4,17 +4,40 @@ import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../../logger'; import type { + QuoteRequest, TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; import { getTransferredAmountFromTxHash } 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'; const log = createModuleLogger(projectLogger, 'relay-post-ma-vault'); +/** + * Whether a request is a max-amount deposit into the Money Account vault. + * Such flows skip atomic vault embedding: Relay settles mUSD into the Money + * Account, then a post-Relay vault deposit runs (see below). Keyed on the + * transaction type (resolving nested batch types) rather than `paymentOverride`, + * which is not set on every Money Account deposit entry path. + * + * @param request - The quote request to test. + * @param transaction - The parent transaction metadata. + * @returns True when the request is a max-amount Money Account deposit. + */ +export function isMaxAmountMoneyAccountDeposit( + request: QuoteRequest, + transaction: TransactionMeta, +): boolean { + return ( + Boolean(request.isMaxAmount) && + isMoneyAccountDepositTransaction(transaction) + ); +} + /** * Runs the Money Account vault deposit after a max-amount Relay bridge has * settled mUSD into the Money Account. Resolves the actually-settled amount, 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 d4fefe38af..a2d791f86d 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 { isMaxAmountMoneyAccountDeposit } from './relay-post-ma-vault'; import type { RelayQuote, RelayQuoteMetamask, @@ -290,6 +291,17 @@ async function getSingleQuote( ); try { + const isMoneyAccountMaxDeposit = isMaxAmountMoneyAccountDeposit( + 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. + if (isMoneyAccountMaxDeposit) { + request.recipient = 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. @@ -329,7 +341,11 @@ async function getSingleQuote( !(request.skipProcessTransactions ?? request.isPostQuote) && !request.isPolymarketDepositWallet; - if (shouldProcessTransactions) { + if (isMoneyAccountMaxDeposit) { + log( + 'Max-amount Money Account deposit: skipping atomic vault embedding; mUSD settles to the Money Account for a post-Relay vault deposit', + ); + } 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 5bb5b1cd43..fb68a15de3 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, submitRelayExecute } from './relay-api'; +import { + isMaxAmountMoneyAccountDeposit, + submitPostRelayVaultDeposit, +} from './relay-post-ma-vault'; import type { RelayCompletionOutcome, RelayExecuteRequest, @@ -176,6 +180,23 @@ async function executeSingleQuote( }, ); + // Max-amount deposits 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' && + isMaxAmountMoneyAccountDeposit(quote.request, transaction) + ) { + const { transactionHash } = await submitPostRelayVaultDeposit({ + completion, + messenger, + quote, + transaction, + }); + + return { transactionHash: transactionHash ?? completion.targetHash }; + } + return { transactionHash: completion.targetHash }; } @@ -420,7 +441,11 @@ async function submitTransactions( let allParams = normalizedParams; - if (quote.request.paymentOverride) { + if (isMaxAmountMoneyAccountDeposit(quote.request, transaction)) { + log( + 'Max-amount Money Account deposit: submitting Relay bridge only; vault deposit runs post-completion', + ); + } else if (quote.request.paymentOverride) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); From ee693890bf084a2551710d6deb543920a0786224 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 14:47:16 +0300 Subject: [PATCH 2/8] Add predict and perps withdraw to MA max --- .../src/strategy/relay/relay-post-ma-vault.ts | 37 ++++++++++++++----- .../src/strategy/relay/relay-quotes.ts | 10 ++--- .../src/strategy/relay/relay-submit.ts | 15 ++++---- .../src/utils/transaction.ts | 21 +++++++++++ 4 files changed, 62 insertions(+), 21 deletions(-) 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 9f0b7d7a31..1076dece08 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 @@ -9,7 +9,11 @@ import type { 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'; @@ -18,23 +22,38 @@ import type { RelayCompletionOutcome, RelayQuote } from './types'; const log = createModuleLogger(projectLogger, 'relay-post-ma-vault'); /** - * Whether a request is a max-amount deposit into the Money Account vault. - * Such flows skip atomic vault embedding: Relay settles mUSD into the Money - * Account, then a post-Relay vault deposit runs (see below). Keyed on the - * transaction type (resolving nested batch types) rather than `paymentOverride`, - * which is not set on every Money Account deposit entry path. + * 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 request is a max-amount Money Account deposit. + * @returns True when the quote uses the post-Relay Money Account vault deposit. */ -export function isMaxAmountMoneyAccountDeposit( +export function usesPostRelayMoneyAccountVaultDeposit( request: QuoteRequest, transaction: TransactionMeta, ): boolean { - return ( + 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 a2d791f86d..7cce470c29 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -61,7 +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 { isMaxAmountMoneyAccountDeposit } from './relay-post-ma-vault'; +import { usesPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; import type { RelayQuote, RelayQuoteMetamask, @@ -291,14 +291,14 @@ async function getSingleQuote( ); try { - const isMoneyAccountMaxDeposit = isMaxAmountMoneyAccountDeposit( + const usesPostRelayVaultDeposit = usesPostRelayMoneyAccountVaultDeposit( 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. - if (isMoneyAccountMaxDeposit) { + if (usesPostRelayVaultDeposit) { request.recipient = transaction.txParams.from as Hex; } @@ -341,9 +341,9 @@ async function getSingleQuote( !(request.skipProcessTransactions ?? request.isPostQuote) && !request.isPolymarketDepositWallet; - if (isMoneyAccountMaxDeposit) { + if (usesPostRelayVaultDeposit) { log( - 'Max-amount Money Account deposit: skipping atomic vault embedding; mUSD settles to the Money Account for a post-Relay vault deposit', + 'Money Account vault deposit: skipping atomic embedding; mUSD settles to the Money Account for a post-Relay vault deposit', ); } else if (shouldProcessTransactions) { await processTransactions(transaction, request, body, messenger); 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 fb68a15de3..301c4e4be8 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -46,8 +46,8 @@ import { } from './polymarket/withdraw'; import { getRelayStatus, submitRelayExecute } from './relay-api'; import { - isMaxAmountMoneyAccountDeposit, submitPostRelayVaultDeposit, + usesPostRelayMoneyAccountVaultDeposit, } from './relay-post-ma-vault'; import type { RelayCompletionOutcome, @@ -180,12 +180,13 @@ async function executeSingleQuote( }, ); - // Max-amount deposits 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. + // 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' && - isMaxAmountMoneyAccountDeposit(quote.request, transaction) + usesPostRelayMoneyAccountVaultDeposit(quote.request, transaction) ) { const { transactionHash } = await submitPostRelayVaultDeposit({ completion, @@ -441,9 +442,9 @@ async function submitTransactions( let allParams = normalizedParams; - if (isMaxAmountMoneyAccountDeposit(quote.request, transaction)) { + if (usesPostRelayMoneyAccountVaultDeposit(quote.request, transaction)) { log( - 'Max-amount Money Account deposit: submitting Relay bridge only; vault deposit runs post-completion', + 'Money Account vault deposit: submitting Relay bridge only; vault deposit runs post-completion', ); } else if (quote.request.paymentOverride) { const { transactionData } = messenger.call( diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index b8a64c31b7..c48054843e 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -327,6 +327,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 1dec9c571f66aadd7c903cac9bf56897fcf5acee Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 14:50:31 +0300 Subject: [PATCH 3/8] Temp --- .../src/strategy/relay/relay-post-ma-vault.ts | 73 ++++++++++--------- .../src/strategy/relay/relay-quotes.ts | 6 +- .../src/strategy/relay/relay-submit.ts | 6 +- 3 files changed, 43 insertions(+), 42 deletions(-) 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 1076dece08..741c4d4a15 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 @@ -21,42 +21,6 @@ import type { RelayCompletionOutcome, RelayQuote } from './types'; const log = createModuleLogger(projectLogger, 'relay-post-ma-vault'); -/** - * 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 usesPostRelayMoneyAccountVaultDeposit( - 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() - ); -} - /** * Runs the Money Account vault deposit after a max-amount Relay bridge has * settled mUSD into the Money Account. Resolves the actually-settled amount, @@ -176,3 +140,40 @@ 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 false; + // 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 7cce470c29..19b8edcaca 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -61,7 +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 { usesPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; +import { isPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; import type { RelayQuote, RelayQuoteMetamask, @@ -291,14 +291,14 @@ async function getSingleQuote( ); try { - const usesPostRelayVaultDeposit = usesPostRelayMoneyAccountVaultDeposit( + 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. - if (usesPostRelayVaultDeposit) { + if (usePostRelayVaultDeposit) { request.recipient = transaction.txParams.from as Hex; } 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 301c4e4be8..ff335f91ad 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -47,7 +47,7 @@ import { import { getRelayStatus, submitRelayExecute } from './relay-api'; import { submitPostRelayVaultDeposit, - usesPostRelayMoneyAccountVaultDeposit, + isPostRelayMoneyAccountVaultDeposit, } from './relay-post-ma-vault'; import type { RelayCompletionOutcome, @@ -186,7 +186,7 @@ async function executeSingleQuote( // its hash. if ( completion.status === 'success' && - usesPostRelayMoneyAccountVaultDeposit(quote.request, transaction) + isPostRelayMoneyAccountVaultDeposit(quote.request, transaction) ) { const { transactionHash } = await submitPostRelayVaultDeposit({ completion, @@ -442,7 +442,7 @@ async function submitTransactions( let allParams = normalizedParams; - if (usesPostRelayMoneyAccountVaultDeposit(quote.request, transaction)) { + if (isPostRelayMoneyAccountVaultDeposit(quote.request, transaction)) { log( 'Money Account vault deposit: submitting Relay bridge only; vault deposit runs post-completion', ); From 7f46fdc615c681848eed6c2121dc0e4084f11c6e Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 14:55:05 +0300 Subject: [PATCH 4/8] Update --- .../src/strategy/relay/relay-post-ma-vault.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 741c4d4a15..aa00a1b0c7 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 @@ -168,12 +168,11 @@ export function isPostRelayMoneyAccountVaultDeposit( return true; } - return false; - // return ( - // (isPerpsWithdrawTransaction(transaction) || - // isPredictWithdrawTransaction(transaction)) && - // request.targetChainId === MUSD_MONAD_FIAT_ASSET.chainId && - // request.targetTokenAddress.toLowerCase() === - // MUSD_MONAD_FIAT_ASSET.address.toLowerCase() - // ); + return ( + (isPerpsWithdrawTransaction(transaction) || + isPredictWithdrawTransaction(transaction)) && + request.targetChainId === MUSD_MONAD_FIAT_ASSET.chainId && + request.targetTokenAddress.toLowerCase() === + MUSD_MONAD_FIAT_ASSET.address.toLowerCase() + ); } From a04999546dd64bc4fa73109b73e80ea569f012b5 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 15:04:59 +0300 Subject: [PATCH 5/8] Add tests --- .../relay/relay-post-ma-vault.test.ts | 159 +++++++++++++++++- .../src/utils/transaction.test.ts | 59 +++++++ 2 files changed, 216 insertions(+), 2 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..23cb38b5b9 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 @@ -2,18 +2,28 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; 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 { 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/ma-vault-deposit'); +jest.mock('../fiat/utils'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = @@ -255,3 +265,148 @@ describe('submitPostRelayVaultDeposit', () => { }); }); }); + +describe('isPostRelayMoneyAccountVaultDeposit', () => { + const isMoneyAccountDepositTransactionMock = jest.mocked( + isMoneyAccountDepositTransaction, + ); + const isPerpsWithdrawTransactionMock = jest.mocked(isPerpsWithdrawTransaction); + const isPredictWithdrawTransactionMock = jest.mocked( + isPredictWithdrawTransaction, + ); + + const TRANSACTION_MOCK_2 = { + id: 'tx-id-2', + txParams: { from: '0xabc' as Hex }, + } as unknown as TransactionMeta; + + beforeEach(() => { + jest.resetAllMocks(); + isMoneyAccountDepositTransactionMock.mockReturnValue(false); + isPerpsWithdrawTransactionMock.mockReturnValue(false); + isPredictWithdrawTransactionMock.mockReturnValue(false); + }); + + function buildRequest( + overrides: Partial = {}, + ): QuoteRequest { + return { + isMaxAmount: false, + targetChainId: MUSD_MONAD_FIAT_ASSET.chainId, + targetTokenAddress: MUSD_MONAD_FIAT_ASSET.address, + ...overrides, + } as unknown as QuoteRequest; + } + + describe('Money Account deposit path', () => { + it('returns true when isMaxAmount is true and transaction is a Money Account deposit', () => { + isMoneyAccountDepositTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ isMaxAmount: true }), + TRANSACTION_MOCK_2, + ), + ).toBe(true); + }); + + it('returns false when isMaxAmount is false even if transaction is a Money Account deposit', () => { + isMoneyAccountDepositTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ isMaxAmount: false }), + TRANSACTION_MOCK_2, + ), + ).toBe(false); + }); + + it('returns false when isMaxAmount is true but transaction is not a Money Account deposit', () => { + isMoneyAccountDepositTransactionMock.mockReturnValue(false); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ isMaxAmount: true }), + TRANSACTION_MOCK_2, + ), + ).toBe(false); + }); + }); + + describe('Perps/Predict withdraw path', () => { + it('returns true when transaction is a Perps withdrawal targeting mUSD on Monad', () => { + isPerpsWithdrawTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ + targetChainId: MUSD_MONAD_FIAT_ASSET.chainId, + targetTokenAddress: MUSD_MONAD_FIAT_ASSET.address, + }), + TRANSACTION_MOCK_2, + ), + ).toBe(true); + }); + + it('returns true when transaction is a Predict withdrawal targeting mUSD on Monad', () => { + isPredictWithdrawTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ + targetChainId: MUSD_MONAD_FIAT_ASSET.chainId, + targetTokenAddress: MUSD_MONAD_FIAT_ASSET.address, + }), + TRANSACTION_MOCK_2, + ), + ).toBe(true); + }); + + it('returns false when transaction is a Perps withdrawal but targetChainId does not match Monad', () => { + isPerpsWithdrawTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ targetChainId: '0x1' as Hex }), + TRANSACTION_MOCK_2, + ), + ).toBe(false); + }); + + it('returns false when transaction is a Perps withdrawal but targetTokenAddress does not match mUSD', () => { + isPerpsWithdrawTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ + targetTokenAddress: '0xdeadbeef00000000000000000000000000000001' as Hex, + }), + TRANSACTION_MOCK_2, + ), + ).toBe(false); + }); + + it('matches targetTokenAddress case-insensitively', () => { + isPerpsWithdrawTransactionMock.mockReturnValue(true); + + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest({ + targetTokenAddress: + MUSD_MONAD_FIAT_ASSET.address.toUpperCase() as Hex, + }), + TRANSACTION_MOCK_2, + ), + ).toBe(true); + }); + + it('returns false when neither Perps nor Predict withdraw nor MA deposit', () => { + expect( + isPostRelayMoneyAccountVaultDeposit( + buildRequest(), + TRANSACTION_MOCK_2, + ), + ).toBe(false); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index edeab6a8b9..6c7d68ea5a 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -24,6 +24,7 @@ import { collectTransactionIds, getTransaction, getTransferredAmountFromTxHash, + isPerpsWithdrawTransaction, isPredictWithdrawTransaction, subscribeAssetChanges, subscribeTransactionChanges, @@ -656,6 +657,64 @@ describe('Transaction Utils', () => { expect(isPredictWithdrawTransaction(transaction)).toBe(false); }); }); + + describe('isPerpsWithdrawTransaction', () => { + it('returns true when the transaction type is perpsWithdraw', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + type: TransactionType.perpsWithdraw, + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(true); + }); + + it('returns true when a nested transaction has type perpsWithdraw', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.perpsWithdraw }], + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(true); + }); + + it('returns true when one of multiple nested transactions has type perpsWithdraw', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { type: TransactionType.simpleSend }, + { type: TransactionType.perpsWithdraw }, + ], + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(true); + }); + + it('returns false when nested transactions have different types', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.simpleSend }], + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(false); + }); + + it('returns false when nestedTransactions is undefined', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(false); + }); + + it('returns false when nestedTransactions is empty', () => { + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [], + } as TransactionMeta; + + expect(isPerpsWithdrawTransaction(transaction)).toBe(false); + }); + }); }); const TX_HASH_MOCK = '0xabc123'; From 3575b3b77eaefbf18c276bec5920590487f569a7 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 15:11:19 +0300 Subject: [PATCH 6/8] Fix tests and lint --- .../src/strategy/relay/relay-quotes.test.ts | 64 +++++++++++++++ .../src/strategy/relay/relay-quotes.ts | 2 +- .../src/strategy/relay/relay-submit.test.ts | 78 +++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) 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 32b86c08e8..7be838a18f 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 @@ -38,8 +38,10 @@ import { getTokenFiatRate, } from '../../utils/token'; import { getRelayQuotes } from './relay-quotes'; +import { isPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; import type { RelayQuote, RelayTransactionStep } from './types'; +jest.mock('./relay-post-ma-vault'); jest.mock('../../utils/token', () => ({ ...jest.createMockFromModule( '../../utils/token', @@ -175,6 +177,9 @@ describe('Relay Quotes Utils', () => { const isRelayExecuteEnabledMock = jest.mocked(isRelayExecuteEnabled); const getGasBufferMock = jest.mocked(getGasBuffer); const getSlippageMock = jest.mocked(getSlippage); + const isPostRelayMoneyAccountVaultDepositMock = jest.mocked( + isPostRelayMoneyAccountVaultDeposit, + ); const { messenger, @@ -232,6 +237,7 @@ describe('Relay Quotes Utils', () => { getNativeTokenMock.mockReturnValue(NATIVE_TOKEN_ADDRESS); isEIP7702ChainMock.mockReturnValue(true); isRelayExecuteEnabledMock.mockReturnValue(false); + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(false); getGasBufferMock.mockReturnValue(1.0); getSlippageMock.mockReturnValue(DEFAULT_SLIPPAGE); getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); @@ -935,6 +941,64 @@ describe('Relay Quotes Utils', () => { expect(body.user).toBe(FROM_MOCK); }); + it('overrides request.recipient with transaction.txParams.from when isPostRelayMoneyAccountVaultDeposit returns true', async () => { + const moneyAccountAddress = + '0xmoneyaccount00000000000000000000000001' as Hex; + + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(true); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { from: moneyAccountAddress }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(moneyAccountAddress); + }); + + it('does not override request.recipient when isPostRelayMoneyAccountVaultDeposit returns false', async () => { + const explicitRecipient = + '0xexplicitrecipient0000000000000000000001' as Hex; + + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(false); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, recipient: explicitRecipient }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: '0xdifferentaddress0000000000000000000001' as Hex, + }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(explicitRecipient); + }); + it('does not set refundTo in request body for post-quote when not provided', async () => { successfulFetchMock.mockResolvedValue({ ok: true, 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 19b8edcaca..1cd179e139 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -341,7 +341,7 @@ async function getSingleQuote( !(request.skipProcessTransactions ?? request.isPostQuote) && !request.isPolymarketDepositWallet; - if (usesPostRelayVaultDeposit) { + if (usePostRelayVaultDeposit) { log( 'Money Account vault deposit: skipping atomic embedding; mUSD settles to the Money Account for a post-Relay vault deposit', ); 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 fe35d3917b..5fdb5f18f8 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,10 @@ import { waitForTransactionConfirmed, } from '../../utils/transaction'; import { RELAY_STATUS_URL } from './constants'; +import { + isPostRelayMoneyAccountVaultDeposit, + submitPostRelayVaultDeposit, +} from './relay-post-ma-vault'; import { submitRelayQuotes } from './relay-submit'; import type { RelayQuote } from './types'; @@ -36,6 +40,7 @@ jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); jest.mock('./hyperliquid-withdraw'); jest.mock('./polymarket/withdraw'); +jest.mock('./relay-post-ma-vault'); const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const TRANSACTION_HASH_MOCK = '0x1234'; @@ -140,6 +145,12 @@ describe('Relay Submit Utils', () => { const normalizeTokenAddressMock = jest.mocked(normalizeTokenAddress); const getRelayPollingIntervalMock = jest.mocked(getRelayPollingInterval); const getRelayPollingTimeoutMock = jest.mocked(getRelayPollingTimeout); + const isPostRelayMoneyAccountVaultDepositMock = jest.mocked( + isPostRelayMoneyAccountVaultDeposit, + ); + const submitPostRelayVaultDepositMock = jest.mocked( + submitPostRelayVaultDeposit, + ); const { addTransactionMock, @@ -164,6 +175,8 @@ describe('Relay Submit Utils', () => { getRelayPollingIntervalMock.mockReturnValue(1); getRelayPollingTimeoutMock.mockReturnValue(undefined); + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(false); + submitPostRelayVaultDepositMock.mockResolvedValue({}); getLiveTokenBalanceMock.mockResolvedValue('9999999999'); normalizeTokenAddressMock.mockImplementation( @@ -1077,6 +1090,71 @@ describe('Relay Submit Utils', () => { }); }); + describe('post-Relay Money Account vault deposit', () => { + const VAULT_HASH_MOCK = + '0xvaulthash000000000000000000000000000000000000000000000000000001' as Hex; + const TARGET_HASH_MOCK = + '0xtargethash00000000000000000000000000000000000000000000000000001' as Hex; + + beforeEach(() => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + ...STATUS_RESPONSE_MOCK, + txHashes: [TARGET_HASH_MOCK], + }), + } as Response); + }); + + it('calls submitPostRelayVaultDeposit and returns its hash when relay completes successfully', async () => { + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(true); + submitPostRelayVaultDepositMock.mockResolvedValue({ + transactionHash: VAULT_HASH_MOCK, + }); + + const result = await submitRelayQuotes(request); + + expect(submitPostRelayVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + completion: expect.objectContaining({ status: 'success' }), + messenger, + quote: request.quotes[0], + transaction: request.transaction, + }), + ); + expect(result.transactionHash).toBe(VAULT_HASH_MOCK); + }); + + it('falls back to completion.targetHash when submitPostRelayVaultDeposit returns no hash', async () => { + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(true); + submitPostRelayVaultDepositMock.mockResolvedValue({ + transactionHash: undefined, + }); + + const result = await submitRelayQuotes(request); + + expect(result.transactionHash).toBe(TARGET_HASH_MOCK); + }); + + it('does not call submitPostRelayVaultDeposit when isPostRelayMoneyAccountVaultDeposit returns false', async () => { + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(false); + + await submitRelayQuotes(request); + + expect(submitPostRelayVaultDepositMock).not.toHaveBeenCalled(); + }); + + it('skips paymentOverride path when isPostRelayMoneyAccountVaultDeposit returns true', async () => { + isPostRelayMoneyAccountVaultDepositMock.mockReturnValue(true); + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + }); + }); + describe('post-quote flow', () => { beforeEach(() => { request.quotes[0].request.isPostQuote = true; From 198a16d25e58246c0e902b4a5a372d66bdb1d49d Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 15:13:58 +0300 Subject: [PATCH 7/8] Adjust changelog --- packages/transaction-pay-controller/CHANGELOG.md | 4 ++++ .../strategy/relay/relay-post-ma-vault.test.ts | 16 +++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c5baae2c71..869fbbed33 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 + +- Add `isPerpsWithdrawTransaction` utility and `isPostRelayMoneyAccountVaultDeposit` predicate to support a post-Relay vault deposit path for max-amount Money Account deposits and Perps/Predict withdrawals to mUSD on Monad ([#9404](https://github.com/MetaMask/core/pull/9404)) + ### Changed - Refactor vault deposit utilities into shared `utils/` modules (`chomp`, `ma-vault-deposit`, `relay-post-ma-vault`) to prepare for the Relay Money Account deposit path ([#9303](https://github.com/MetaMask/core/pull/9303)) 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 23cb38b5b9..ed170e1b35 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 @@ -270,7 +270,9 @@ describe('isPostRelayMoneyAccountVaultDeposit', () => { const isMoneyAccountDepositTransactionMock = jest.mocked( isMoneyAccountDepositTransaction, ); - const isPerpsWithdrawTransactionMock = jest.mocked(isPerpsWithdrawTransaction); + const isPerpsWithdrawTransactionMock = jest.mocked( + isPerpsWithdrawTransaction, + ); const isPredictWithdrawTransactionMock = jest.mocked( isPredictWithdrawTransaction, ); @@ -287,9 +289,7 @@ describe('isPostRelayMoneyAccountVaultDeposit', () => { isPredictWithdrawTransactionMock.mockReturnValue(false); }); - function buildRequest( - overrides: Partial = {}, - ): QuoteRequest { + function buildRequest(overrides: Partial = {}): QuoteRequest { return { isMaxAmount: false, targetChainId: MUSD_MONAD_FIAT_ASSET.chainId, @@ -379,7 +379,8 @@ describe('isPostRelayMoneyAccountVaultDeposit', () => { expect( isPostRelayMoneyAccountVaultDeposit( buildRequest({ - targetTokenAddress: '0xdeadbeef00000000000000000000000000000001' as Hex, + targetTokenAddress: + '0xdeadbeef00000000000000000000000000000001' as Hex, }), TRANSACTION_MOCK_2, ), @@ -402,10 +403,7 @@ describe('isPostRelayMoneyAccountVaultDeposit', () => { it('returns false when neither Perps nor Predict withdraw nor MA deposit', () => { expect( - isPostRelayMoneyAccountVaultDeposit( - buildRequest(), - TRANSACTION_MOCK_2, - ), + isPostRelayMoneyAccountVaultDeposit(buildRequest(), TRANSACTION_MOCK_2), ).toBe(false); }); }); From 1a997ab349af813b1c7c66ad2b84a39c086c7fa4 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Tue, 7 Jul 2026 15:17:43 +0300 Subject: [PATCH 8/8] Fix lint --- .../src/strategy/relay/relay-quotes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7be838a18f..c2c1a94fa1 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 @@ -37,8 +37,8 @@ import { getTokenBalance, getTokenFiatRate, } from '../../utils/token'; -import { getRelayQuotes } from './relay-quotes'; import { isPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; +import { getRelayQuotes } from './relay-quotes'; import type { RelayQuote, RelayTransactionStep } from './types'; jest.mock('./relay-post-ma-vault');