From 356602931be976e31e72f53cfefda9caf9e1713e Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 23:06:51 +0100 Subject: [PATCH 1/2] fix(transaction-pay-controller): fund direct mUSD money account deposits atomically --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/strategy/fiat/fiat-direct-musd.ts | 233 ++++++++++++++++++ .../src/strategy/fiat/fiat-quotes.test.ts | 50 +++- .../src/strategy/fiat/fiat-quotes.ts | 82 ++---- .../strategy/fiat/fiat-submit-simple.test.ts | 77 ++++++ .../src/strategy/fiat/fiat-submit-simple.ts | 3 + .../fiat-submit-with-transaction-data.test.ts | 4 - .../src/strategy/fiat/fiat-submit.test.ts | 26 ++ .../src/strategy/fiat/fiat-submit.ts | 38 ++- .../src/strategy/relay/relay-quotes.test.ts | 43 ++++ .../src/strategy/relay/relay-quotes.ts | 14 +- .../src/strategy/relay/relay-submit.test.ts | 136 ++++++++++ .../src/strategy/relay/relay-submit.ts | 34 ++- .../transaction-pay-controller/src/types.ts | 3 + .../src/utils/token-transfer.ts | 20 ++ 15 files changed, 657 insertions(+), 110 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts create mode 100644 packages/transaction-pay-controller/src/utils/token-transfer.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 17116e6f03..c923ca7d06 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 direct mUSD fiat Money Account deposits to execute atomically through Relay execute by preserving canonical Relay quote steps and prepending delegated Money Account funding during submission. + ## [23.8.0] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts new file mode 100644 index 0000000000..5197361078 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -0,0 +1,233 @@ +import type { + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import type { + PayStrategyGetQuotesRequest, + QuoteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getNetworkClientId } from '../../utils/provider'; +import { buildCaipAssetType } from '../../utils/token'; +import { buildTokenTransferData } from '../../utils/token-transfer'; +import type { RelayQuote } from '../relay/types'; +import { + DEFAULT_FIAT_CURRENCY, + MUSD_MONAD_FIAT_ASSET, + MUSD_PROBE_AMOUNT_USD, +} from './constants'; +import type { TransactionPayFiatAsset } from './constants'; + +const log = createModuleLogger(projectLogger, 'fiat-direct-musd'); + +export type DirectMusdFiatQuoteOptions = { + fiatAsset: TransactionPayFiatAsset; + rampsWalletAddress: Hex; + relayRequestOverrides: Pick< + QuoteRequest, + 'isDirectMusdMoneyAccount' | 'recipient' + >; +}; + +/** + * Returns direct mUSD quote options when ramps can sell mUSD to the Money Account. + * + * @param options - Direct mUSD quote options. + * @param options.messenger - Controller messenger. + * @param options.moneyAccountAddress - Money Account receiving the fiat on-ramp. + * @returns Direct quote options, or undefined when direct mUSD is unavailable. + */ +export async function getDirectMusdFiatQuoteOptions({ + messenger, + moneyAccountAddress, +}: { + messenger: PayStrategyGetQuotesRequest['messenger']; + moneyAccountAddress: Hex; +}): Promise { + const probeOk = await probeMusdFiatAvailability({ + messenger, + walletAddress: moneyAccountAddress, + }); + + if (!probeOk) { + return undefined; + } + + return { + fiatAsset: MUSD_MONAD_FIAT_ASSET, + rampsWalletAddress: moneyAccountAddress, + relayRequestOverrides: { + isDirectMusdMoneyAccount: true, + recipient: moneyAccountAddress, + }, + }; +} + +/** + * Detects a direct mUSD Money Account quote from its stored request marker. + * + * @param quote - Quote to inspect. + * @returns True when the quote originated from the direct mUSD fiat path. + */ +export function isDirectMusdMoneyAccountQuote( + quote: Pick, 'request'> | undefined, +): boolean { + return isDirectMusdMoneyAccountRequest(quote?.request); +} + +/** + * Detects a direct mUSD Money Account quote request. + * + * @param request - Quote request to inspect. + * @returns True when the request originated from the direct mUSD fiat path. + */ +export function isDirectMusdMoneyAccountRequest( + request: QuoteRequest | undefined, +): boolean { + return request?.isDirectMusdMoneyAccount === true; +} + +/** + * Direct mUSD relies on Relay execute so funding and Relay settlement are atomic. + * + * @param quote - Relay quote to validate. + */ +export function assertDirectMusdRelayExecute( + quote: Pick, 'original' | 'request'>, +): void { + if ( + isDirectMusdMoneyAccountQuote(quote) && + quote.original.metamask?.isExecute !== true + ) { + throw new Error('Direct mUSD Money Account quotes require Relay execute'); + } +} + +/** + * Builds the delegated Money Account funding transaction for direct mUSD submit. + * + * @param options - Funding options. + * @param options.messenger - Controller messenger. + * @param options.quote - Direct mUSD Relay quote. + * @param options.relayParams - First Relay transaction params, used for fee fields. + * @param options.transaction - Original Money Account transaction. + * @returns Transaction params to prepend, or undefined for non-direct quotes. + */ +export async function buildDirectMusdFundingParams({ + messenger, + quote, + relayParams, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + relayParams?: TransactionParams; + transaction: TransactionMeta; +}): Promise { + if (!isDirectMusdMoneyAccountQuote(quote)) { + return undefined; + } + + assertDirectMusdRelayExecute(quote); + + const moneyAccountAddress = transaction.txParams.from as Hex | undefined; + + if (!moneyAccountAddress) { + throw new Error('Missing Money Account address for direct mUSD funding'); + } + + const networkClientId = getNetworkClientId( + messenger, + quote.request.sourceChainId, + ); + + const fundingTransaction = { + ...transaction, + chainId: quote.request.sourceChainId, + nestedTransactions: undefined, + networkClientId, + txParams: { + ...transaction.txParams, + data: buildTokenTransferData(quote.request.from, quote.sourceAmount.raw), + from: moneyAccountAddress, + to: quote.request.sourceTokenAddress, + value: '0x0', + }, + } as TransactionMeta; + + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction: fundingTransaction }, + ); + + if (delegation.authorizationList?.length) { + throw new Error( + 'Direct mUSD Money Account funding requires an already-upgraded Money Account', + ); + } + + log('Built direct mUSD funding delegation', { + moneyAccountAddress, + sourceAmountRaw: quote.sourceAmount.raw, + }); + + return { + data: delegation.data, + from: quote.request.from, + maxFeePerGas: relayParams?.maxFeePerGas, + maxPriorityFeePerGas: relayParams?.maxPriorityFeePerGas, + to: delegation.to, + value: delegation.value, + }; +} + +/** + * Direct same-chain mUSD still needs Relay status polling because execute returns asynchronously. + * + * @param quote - Relay quote to inspect. + * @returns True when same-chain polling should not be short-circuited. + */ +export function shouldForceDirectMusdRelayPolling( + quote: Pick, 'request'>, +): boolean { + return isDirectMusdMoneyAccountQuote(quote); +} + +async function probeMusdFiatAvailability({ + messenger, + walletAddress, +}: { + messenger: PayStrategyGetQuotesRequest['messenger']; + walletAddress: string; +}): Promise { + try { + const quotes = await messenger.call('RampsController:getQuotes', { + amount: MUSD_PROBE_AMOUNT_USD, + assetId: buildCaipAssetType( + MUSD_MONAD_FIAT_ASSET.chainId, + MUSD_MONAD_FIAT_ASSET.address, + ), + autoSelectProvider: true, + fiat: DEFAULT_FIAT_CURRENCY, + restrictToKnownOrNativeProviders: true, + walletAddress, + }); + + const isAvailable = (quotes.success?.length ?? 0) > 0; + + log('mUSD fiat probe result', { + isAvailable, + providerCount: quotes.success?.length ?? 0, + }); + + return isAvailable; + } catch (error) { + log('mUSD fiat probe failed', { error }); + return false; + } +} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts index 7b5e9bae13..4e4f973442 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -94,11 +94,13 @@ const AMOUNT_MOCK = { }; function getRelayQuoteMock({ + isExecute, metaMaskUsd = '4', providerUsd = '1', sourceNetworkUsd = '2', targetNetworkUsd = '3', }: { + isExecute?: boolean; metaMaskUsd?: string; providerUsd?: string; sourceNetworkUsd?: string; @@ -123,7 +125,9 @@ function getRelayQuoteMock({ }, targetNetwork: { fiat: targetNetworkUsd, usd: targetNetworkUsd }, }, - original: {} as RelayQuote, + original: (isExecute === undefined + ? {} + : { metamask: { isExecute } }) as RelayQuote, request: {} as never, sourceAmount: AMOUNT_MOCK, strategy: TransactionPayStrategy.Relay, @@ -795,7 +799,7 @@ describe('getFiatQuotes', () => { return { callMock, request: { - accountSupports7702: false, + accountSupports7702: true, fiatPaymentMethod, from: WALLET_ADDRESS, messenger: { @@ -810,6 +814,12 @@ describe('getFiatQuotes', () => { beforeEach(() => { isMoneyAccountDepositTransactionMock.mockReturnValue(true); buildCaipAssetTypeMock.mockReturnValue(MUSD_CAIP_ID_MOCK); + getRelayQuotesMock.mockImplementation(async ({ requests }) => [ + { + ...getRelayQuoteMock({ isExecute: true }), + request: requests[0], + }, + ]); }); it('calls probe with mUSD asset and money account address', async () => { @@ -831,16 +841,19 @@ describe('getFiatQuotes', () => { }); }); - it('passes recipient and paymentOverride in relay request for direct flow', async () => { + it('uses the account override as Relay user and the money account as recipient for direct flow', async () => { const { request } = getDirectRequest(); await getFiatQuotes(request); expect(getRelayQuotesMock).toHaveBeenCalledWith( expect.objectContaining({ + accountSupports7702: true, + from: WALLET_ADDRESS, requests: [ expect.objectContaining({ - paymentOverride: 'moneyAccount', + from: WALLET_ADDRESS, + isDirectMusdMoneyAccount: true, recipient: MONEY_ACCOUNT_ADDRESS, sourceChainId: MUSD_MONAD_FIAT_ASSET.chainId, sourceTokenAddress: MUSD_MONAD_FIAT_ASSET.address, @@ -850,6 +863,35 @@ describe('getFiatQuotes', () => { ); }); + it('falls back to standard flow when direct Relay quote is not execute', async () => { + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + getRelayQuotesMock + .mockImplementationOnce(async ({ requests }) => [ + { + ...getRelayQuoteMock({ isExecute: false }), + request: requests[0], + }, + ]) + .mockImplementationOnce(async ({ requests }) => [ + { + ...getRelayQuoteMock(), + request: requests[0], + }, + ]); + const { request } = getDirectRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toHaveLength(1); + expect(getRelayQuotesMock).toHaveBeenCalledTimes(2); + expect(getRelayQuotesMock.mock.calls[1][0].requests[0]).toStrictEqual( + expect.objectContaining({ + sourceChainId: FIAT_ASSET_MOCK.chainId, + sourceTokenAddress: FIAT_ASSET_MOCK.address, + }), + ); + }); + it('returns combined quote when probe and quote both succeed', async () => { const { request } = getDirectRequest(); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index 491dcc6526..e6f544f600 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -3,11 +3,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { - NATIVE_TOKEN_ADDRESS, - PaymentOverride, - TransactionPayStrategy, -} from '../../constants'; +import { NATIVE_TOKEN_ADDRESS, TransactionPayStrategy } from '../../constants'; import { projectLogger } from '../../logger'; import type { PayStrategyGetQuotesRequest, @@ -25,12 +21,12 @@ import { } from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import type { RelayQuote } from '../relay/types'; -import { - DEFAULT_FIAT_CURRENCY, - MUSD_MONAD_FIAT_ASSET, - MUSD_PROBE_AMOUNT_USD, -} from './constants'; +import { DEFAULT_FIAT_CURRENCY } from './constants'; import type { TransactionPayFiatAsset } from './constants'; +import { + assertDirectMusdRelayExecute, + getDirectMusdFiatQuoteOptions, +} from './fiat-direct-musd'; import type { FiatQuote } from './types'; import { deriveFiatAssetForFiatPayment, @@ -39,6 +35,12 @@ import { const log = createModuleLogger(projectLogger, 'fiat-strategy'); +type FiatQuotePipelineOptions = { + fiatAsset: TransactionPayFiatAsset; + rampsWalletAddress: Hex; + relayRequestOverrides?: Partial; +}; + /** * Fetches MM Pay fiat strategy quotes using a relay-first estimation flow. * @@ -62,20 +64,16 @@ export async function getFiatQuotes( if (useDirectMusd) { const moneyAccountAddress = transaction.txParams.from as Hex; - const probeOk = await probeMusdFiatAvailability({ + const directOptions = await getDirectMusdFiatQuoteOptions({ messenger, - walletAddress: moneyAccountAddress, + moneyAccountAddress, }); - if (probeOk) { - const directResult = await executeFiatQuotePipeline(request, { - fiatAsset: MUSD_MONAD_FIAT_ASSET, - rampsWalletAddress: moneyAccountAddress, - relayRequestOverrides: { - paymentOverride: PaymentOverride.MoneyAccount, - recipient: moneyAccountAddress, - }, - }); + if (directOptions) { + const directResult = await executeFiatQuotePipeline( + request, + directOptions, + ); if (directResult.length > 0) { return directResult; @@ -89,12 +87,6 @@ export async function getFiatQuotes( }); } -type FiatQuotePipelineOptions = { - fiatAsset: TransactionPayFiatAsset; - rampsWalletAddress: Hex; - relayRequestOverrides?: Partial; -}; - async function executeFiatQuotePipeline( request: PayStrategyGetQuotesRequest, options: FiatQuotePipelineOptions, @@ -156,6 +148,8 @@ async function executeFiatQuotePipeline( throw new Error('No relay quote available for fiat estimation'); } + assertDirectMusdRelayExecute(relayQuote); + const isSourceNative = fiatAsset.address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); const gasDeductedFromSource = @@ -232,40 +226,6 @@ async function executeFiatQuotePipeline( return []; } -async function probeMusdFiatAvailability({ - messenger, - walletAddress, -}: { - messenger: PayStrategyGetQuotesRequest['messenger']; - walletAddress: string; -}): Promise { - try { - const quotes = await messenger.call('RampsController:getQuotes', { - amount: MUSD_PROBE_AMOUNT_USD, - assetId: buildCaipAssetType( - MUSD_MONAD_FIAT_ASSET.chainId, - MUSD_MONAD_FIAT_ASSET.address, - ), - autoSelectProvider: true, - fiat: DEFAULT_FIAT_CURRENCY, - restrictToKnownOrNativeProviders: true, - walletAddress, - }); - - const isAvailable = (quotes.success?.length ?? 0) > 0; - - log('mUSD fiat probe result', { - isAvailable, - providerCount: quotes.success?.length ?? 0, - }); - - return isAvailable; - } catch (error) { - log('mUSD fiat probe failed', { error }); - return false; - } -} - async function getRampsQuote({ adjustedAmount, fiatAsset, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts index 1622bc6c39..3d5b21b4bc 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.test.ts @@ -146,6 +146,83 @@ describe('submitSimpleRelay', () => { expect(result).toStrictEqual({ transactionHash: '0xabc' }); }); + it('preserves direct mUSD marker and account override for re-quotes', async () => { + const moneyAccountAddress = + '0x2222222222222222222222222222222222222222' as Hex; + const directBaseRequest = { + ...BASE_QUOTE_REQUEST_MOCK, + from: WALLET_ADDRESS_MOCK, + isDirectMusdMoneyAccount: true, + recipient: moneyAccountAddress, + } as QuoteRequest; + const directRelayQuote = { + ...RELAY_QUOTE_MOCK, + original: { + ...RELAY_QUOTE_MOCK.original, + metamask: { isExecute: true }, + } as RelayQuote, + request: directBaseRequest, + }; + const req = buildRequest({ accountSupports7702: true }); + getRelayQuotesMock.mockResolvedValue([directRelayQuote]); + + await submitSimpleRelay({ + baseRequest: directBaseRequest, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }); + + expect(getRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountSupports7702: true, + from: WALLET_ADDRESS_MOCK, + requests: [ + expect.objectContaining({ + from: WALLET_ADDRESS_MOCK, + isDirectMusdMoneyAccount: true, + recipient: moneyAccountAddress, + }), + ], + }), + ); + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountSupports7702: true, + }), + ); + }); + + it('throws when direct mUSD re-quote is not Relay execute', async () => { + const directBaseRequest = { + ...BASE_QUOTE_REQUEST_MOCK, + isDirectMusdMoneyAccount: true, + recipient: '0x2222222222222222222222222222222222222222' as Hex, + } as QuoteRequest; + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_MOCK, + original: { + ...RELAY_QUOTE_MOCK.original, + metamask: { isExecute: false }, + } as RelayQuote, + request: directBaseRequest, + }, + ]); + const req = buildRequest(); + + await expect( + submitSimpleRelay({ + baseRequest: directBaseRequest, + request: req, + sourceAmountRaw: '1000000000000000000', + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Direct mUSD Money Account quotes require Relay execute'); + + expect(submitRelayQuotesMock).not.toHaveBeenCalled(); + }); + it('throws when relay returns no quotes', async () => { getRelayQuotesMock.mockResolvedValue([]); const req = buildRequest(); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts index 0b2f40475b..1f7c06acf6 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-simple.ts @@ -6,6 +6,7 @@ import type { PayStrategyExecuteRequest, QuoteRequest } from '../../types'; import { getFiatMaxRateDriftPercent } from '../../utils/feature-flags'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; +import { assertDirectMusdRelayExecute } from './fiat-direct-musd'; import type { FiatQuote } from './types'; import { validateRelayRateDrift } from './utils'; @@ -59,6 +60,8 @@ export async function submitSimpleRelay({ throw new Error('No relay quotes returned for completed fiat order'); } + assertDirectMusdRelayExecute(relayQuotes[0]); + validateRelayRateDrift({ originalQuote: originalRelayQuote, discoveryQuote: relayQuotes[0].original, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-transaction-data.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-transaction-data.test.ts index 1791680a5f..ef1bab8a75 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-transaction-data.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit-with-transaction-data.test.ts @@ -12,7 +12,6 @@ import { getFiatFeeReserveMultiplier, getFiatMaxRateDriftPercent, } from '../../utils/feature-flags'; -import { getNetworkClientId } from '../../utils/provider'; import { getTransaction, updateTransaction } from '../../utils/transaction'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; @@ -21,7 +20,6 @@ import { submitWithTransactionData } from './fiat-submit-with-transaction-data'; import type { FiatQuote } from './types'; jest.mock('../../utils/feature-flags'); -jest.mock('../../utils/provider'); jest.mock('../../utils/transaction'); jest.mock('../relay/relay-quotes'); jest.mock('../relay/relay-submit'); @@ -136,7 +134,6 @@ describe('submitWithCalldataReEncoding', () => { const getRelayQuotesMock = jest.mocked(getRelayQuotes); const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); const getTransactionMock = jest.mocked(getTransaction); - const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const updateTransactionMock = jest.mocked(updateTransaction); const getFiatFeeReserveMultiplierMock = jest.mocked( getFiatFeeReserveMultiplier, @@ -150,7 +147,6 @@ describe('submitWithCalldataReEncoding', () => { getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_MOCK]); submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xabc' }); getTransactionMock.mockReturnValue(TRANSACTION_MOCK); - getNetworkClientIdMock.mockReturnValue('polygon-mainnet'); getFiatFeeReserveMultiplierMock.mockReturnValue(1); getFiatMaxRateDriftPercentMock.mockReturnValue(10); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index a1b0f502f2..192a610994 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -109,6 +109,7 @@ const RELAY_QUOTE_RESULT_MOCK = { }, totalImpact: { usd: '-0.15' }, }, + metamask: { isExecute: true }, } as unknown as RelayQuote, request: BASE_QUOTE_REQUEST_MOCK, sourceAmount: { @@ -182,6 +183,7 @@ function getFiatQuoteMock({ }, totalImpact: { usd: '-0.15' }, }, + metamask: { isExecute: true }, } as unknown as RelayQuote, }, request, @@ -839,6 +841,7 @@ describe('submitFiatQuotes', () => { const MUSD_QUOTE_REQUEST: QuoteRequest = { from: WALLET_ADDRESS_MOCK, + isDirectMusdMoneyAccount: true, sourceBalanceRaw: '10000000', sourceChainId: MUSD_MONAD_FIAT_ASSET.chainId, sourceTokenAddress: MUSD_MONAD_FIAT_ASSET.address, @@ -895,6 +898,29 @@ describe('submitFiatQuotes', () => { expect(deriveFiatAssetForFiatPaymentMock).not.toHaveBeenCalled(); }); + it('uses simple Relay submit for direct mUSD even when transaction has nested calldata', async () => { + const { callMock, request } = getRequest({ + quotes: [getFiatQuoteMock({ request: MUSD_QUOTE_REQUEST })], + transaction: { + ...MUSD_TRANSACTION_MOCK, + nestedTransactions: [ + { data: '0xapprove' as Hex, to: '0xapprove' as Hex }, + { data: '0xdeposit' as Hex, to: '0xdeposit' as Hex }, + ], + } as TransactionMeta, + }); + + await submitFiatQuotes(request); + + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect( + callMock.mock.calls.some( + ([action]: [string]) => + action === 'TransactionPayController:getAmountData', + ), + ).toBe(false); + }); + it('falls back to deriveFiatAssetForFiatPayment when quote is not direct mUSD', async () => { const order = getFiatOrderMock({ cryptoCurrency: { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 9245d4d98a..3b0b40d9ea 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -20,6 +20,10 @@ import { buildCaipAssetType } from '../../utils/token'; import { updateTransaction } from '../../utils/transaction'; import type { TransactionPayFiatAsset } from './constants'; import { MUSD_MONAD_FIAT_ASSET } from './constants'; +import { + assertDirectMusdRelayExecute, + isDirectMusdMoneyAccountQuote, +} from './fiat-direct-musd'; import { submitSimpleRelay } from './fiat-submit-simple'; import { submitWithTransactionData } from './fiat-submit-with-transaction-data'; import type { FiatQuote } from './types'; @@ -247,7 +251,8 @@ async function submitRelayAfterFiatCompletion({ throw new Error('Multiple fiat quotes are not supported for submission'); } - const isDirectMusd = isDirectMusdToMoneyAccountQuote(quotes); + const fiatQuote = quotes[0]; + const isDirectMusd = isDirectMusdMoneyAccountQuote(fiatQuote); const fiatAsset = isDirectMusd ? MUSD_MONAD_FIAT_ASSET : deriveFiatAssetForFiatPayment(transaction, messenger); @@ -258,7 +263,12 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); - const baseRequest = quotes[0].request; + const baseRequest = fiatQuote.request; + + assertDirectMusdRelayExecute({ + original: fiatQuote.original.relayQuote, + request: baseRequest, + }); const sourceAmountWalletAddress = isDirectMusd ? (transaction.txParams.from as Hex) @@ -278,7 +288,7 @@ async function submitRelayAfterFiatCompletion({ // the target amount, calldata re-encoding, then a delegation quote. // Simple deposits (Perps, Predict) skip straight to a single EXACT_INPUT // relay quote — cheaper fees, no leftover dust, one fewer request. - if (hasNestedCalldata) { + if (hasNestedCalldata && !isDirectMusd) { return await submitWithTransactionData({ baseRequest, request, @@ -295,26 +305,6 @@ async function submitRelayAfterFiatCompletion({ }); } -/** - * Detects whether the given quotes originated from the direct mUSD-to- - * Money-Account flow by inspecting the stored quote request's source - * chain and token. This is more reliable than re-checking the feature - * flag, which could change between quote and submit. - * - * @param quotes - The fiat quotes to inspect. - * @returns `true` if the first quote targets mUSD on Monad as its source. - */ -function isDirectMusdToMoneyAccountQuote( - quotes: PayStrategyExecuteRequest['quotes'], -): boolean { - const request = quotes[0]?.request; - return ( - request?.sourceChainId === MUSD_MONAD_FIAT_ASSET.chainId && - request?.sourceTokenAddress.toLowerCase() === - MUSD_MONAD_FIAT_ASSET.address.toLowerCase() - ); -} - function getWalletAddress({ quotes, transaction, @@ -324,7 +314,7 @@ function getWalletAddress({ transaction: PayStrategyExecuteRequest['transaction']; accountOverride: Hex | undefined; }): Hex { - const address = isDirectMusdToMoneyAccountQuote(quotes) + const address = isDirectMusdMoneyAccountQuote(quotes[0]) ? transaction.txParams.from : (accountOverride ?? transaction.txParams.from); 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 2a21f0cf30..e5a325c7a5 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 @@ -302,6 +302,49 @@ describe('Relay Quotes Utils', () => { expect(body.originGasOverhead).toBeUndefined(); }); + it('keeps direct mUSD Relay request canonical and does not rewrite returned steps', async () => { + const moneyAccountAddress = + '0x2222222222222222222222222222222222222222' as Hex; + const accountOverride = + '0x3333333333333333333333333333333333333333' as Hex; + const quoteMock = cloneDeep(QUOTE_MOCK); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => quoteMock, + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + from: accountOverride, + isDirectMusdMoneyAccount: true, + recipient: moneyAccountAddress, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( + expect.objectContaining({ + recipient: moneyAccountAddress, + user: accountOverride, + }), + ); + + const step = result[0].original.steps[0] as RelayTransactionStep; + expect(step.items[0].data.from).toBe( + QUOTE_MOCK.steps[0].items[0].data.from, + ); + }); + it('includes originGasOverhead when relay execute is enabled on EIP-7702 chain', async () => { isRelayExecuteEnabledMock.mockReturnValue(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 49b6248427..e72e5e7d6a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -55,6 +55,7 @@ import { normalizeTokenAddress, TokenAddressTarget, } from '../../utils/token'; +import { buildTokenTransferData } from '../../utils/token-transfer'; import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; import { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw'; @@ -1157,19 +1158,6 @@ function calculateProviderFee(quote: RelayQuote): BigNumber { return new BigNumber(quote.details.totalImpact.usd).abs(); } -/** - * Build token transfer data. - * - * @param recipient - Recipient address. - * @param amountRaw - Amount in raw format. - * @returns Token transfer data. - */ -function buildTokenTransferData(recipient: Hex, amountRaw: string): Hex { - return new Interface([ - 'function transfer(address to, uint256 amount)', - ]).encodeFunctionData('transfer', [recipient, amountRaw]) as Hex; -} - /** * Get transfer recipient from token transfer data. * 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 c43dfb22e6..42e5c572bc 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 @@ -21,6 +21,7 @@ import { getRelayPollingTimeout, } from '../../utils/feature-flags'; import { getLiveTokenBalance, normalizeTokenAddress } from '../../utils/token'; +import { buildTokenTransferData } from '../../utils/token-transfer'; import { collectTransactionIds, getTransaction, @@ -1613,8 +1614,16 @@ describe('Relay Submit Utils', () => { }); describe('EIP-7702 execute path', () => { + const DIRECT_ACCOUNT_OVERRIDE_MOCK = + '0x3333333333333333333333333333333333333333' as Hex; const DELEGATION_MANAGER_MOCK = '0xdelegationManager' as Hex; const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; + const DIRECT_MONEY_ACCOUNT_MOCK = + '0x2222222222222222222222222222222222222222' as Hex; + const DIRECT_SOURCE_AMOUNT_RAW_MOCK = '123456'; + const FUNDING_DELEGATION_DATA_MOCK = '0xfundingdata' as Hex; + const FUNDING_DELEGATION_TO_MOCK = '0xfundingmanager' as Hex; + const FUNDING_DELEGATION_VALUE_MOCK = '0x0' as Hex; const DELEGATION_RESULT_MOCK = { authorizationList: [ @@ -1642,6 +1651,39 @@ describe('Relay Submit Utils', () => { relayFallbackGas: { max: 123 }, } as FeatureFlags; + function configureDirectMusdRequest({ + isExecute = true, + sameChain = false, + }: { + isExecute?: boolean; + sameChain?: boolean; + } = {}): void { + request.quotes[0].request = { + ...request.quotes[0].request, + from: DIRECT_ACCOUNT_OVERRIDE_MOCK, + isDirectMusdMoneyAccount: true, + isPostQuote: true, + sourceChainId: CHAIN_ID_MOCK, + sourceTokenAddress: TOKEN_ADDRESS_MOCK, + }; + request.quotes[0].sourceAmount = { + ...request.quotes[0].sourceAmount, + raw: DIRECT_SOURCE_AMOUNT_RAW_MOCK, + }; + request.quotes[0].original.metamask.isExecute = isExecute; + request.transaction = { + ...request.transaction, + txParams: { + from: DIRECT_MONEY_ACCOUNT_MOCK, + }, + } as TransactionMeta; + + if (sameChain) { + request.quotes[0].original.details.currencyOut.currency.chainId = + request.quotes[0].original.details.currencyIn.currency.chainId; + } + } + beforeEach(() => { request.quotes[0].original.metamask.isExecute = true; getDelegationTransactionMock.mockResolvedValue(DELEGATION_RESULT_MOCK); @@ -1882,6 +1924,100 @@ describe('Relay Submit Utils', () => { expect(tx.metamaskPay?.sourceHash).toBe(SOURCE_HASH_MOCK); }); + it('prepends delegated direct mUSD funding before unchanged Relay params', async () => { + configureDirectMusdRequest(); + getDelegationTransactionMock + .mockResolvedValueOnce({ + data: FUNDING_DELEGATION_DATA_MOCK, + to: FUNDING_DELEGATION_TO_MOCK, + value: FUNDING_DELEGATION_VALUE_MOCK, + }) + .mockResolvedValueOnce(DELEGATION_RESULT_MOCK); + + await submitRelayQuotes(request); + + const fundingData = buildTokenTransferData( + DIRECT_ACCOUNT_OVERRIDE_MOCK, + DIRECT_SOURCE_AMOUNT_RAW_MOCK, + ); + + expect(getDelegationTransactionMock).toHaveBeenNthCalledWith(1, { + transaction: expect.objectContaining({ + chainId: CHAIN_ID_MOCK, + networkClientId: NETWORK_CLIENT_ID_MOCK, + txParams: expect.objectContaining({ + data: fundingData, + from: DIRECT_MONEY_ACCOUNT_MOCK, + to: TOKEN_ADDRESS_MOCK, + value: '0x0', + }), + }), + }); + expect(getDelegationTransactionMock).toHaveBeenNthCalledWith(2, { + transaction: expect.objectContaining({ + nestedTransactions: [ + { + data: FUNDING_DELEGATION_DATA_MOCK, + to: FUNDING_DELEGATION_TO_MOCK, + value: FUNDING_DELEGATION_VALUE_MOCK, + }, + { + data: '0x1234', + to: '0xfedcb', + value: '0x4d2', + }, + ], + txParams: expect.objectContaining({ + from: DIRECT_ACCOUNT_OVERRIDE_MOCK, + }), + }), + }); + }); + + it('throws when direct mUSD funding delegation returns an authorization list', async () => { + configureDirectMusdRequest(); + getDelegationTransactionMock.mockResolvedValueOnce({ + authorizationList: DELEGATION_RESULT_MOCK.authorizationList, + data: FUNDING_DELEGATION_DATA_MOCK, + to: FUNDING_DELEGATION_TO_MOCK, + value: FUNDING_DELEGATION_VALUE_MOCK, + }); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Direct mUSD Money Account funding requires an already-upgraded Money Account', + ); + expect(getDelegationTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('throws when direct mUSD quote is not Relay execute', async () => { + configureDirectMusdRequest({ isExecute: false }); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Direct mUSD Money Account quotes require Relay execute', + ); + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + expect(addTransactionMock).not.toHaveBeenCalled(); + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + }); + + it('polls Relay status for same-chain direct mUSD execute quotes', async () => { + configureDirectMusdRequest({ sameChain: true }); + getDelegationTransactionMock + .mockResolvedValueOnce({ + data: FUNDING_DELEGATION_DATA_MOCK, + to: FUNDING_DELEGATION_TO_MOCK, + value: FUNDING_DELEGATION_VALUE_MOCK, + }) + .mockResolvedValueOnce(DELEGATION_RESULT_MOCK); + + await submitRelayQuotes(request); + + expect(successfulFetchMock).toHaveBeenCalledWith( + `${RELAY_STATUS_URL}?requestId=${REQUEST_ID_MOCK}`, + { method: 'GET' }, + ); + }); + it('includes original transaction in nestedTransactions for post-quote flow', async () => { request.quotes[0].request.isPostQuote = true; request.transaction = { 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 29b1c26271..367e5d4df1 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -32,6 +32,11 @@ import { updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import { + assertDirectMusdRelayExecute, + buildDirectMusdFundingParams, + shouldForceDirectMusdRelayPolling, +} from '../fiat/fiat-direct-musd'; import { RELAY_DEPOSIT_TYPES, RELAY_FAILURE_STATUSES, @@ -123,6 +128,7 @@ async function executeSingleQuote( } const completion = await waitForRelayCompletion(quote.original, messenger, { + forcePolling: shouldForceDirectMusdRelayPolling(quote), onSourceHash: (hash) => { log('Source hash received', hash); setRelaySourceHash(transaction, messenger, hash); @@ -184,11 +190,12 @@ async function waitForRelayCompletion( quote: RelayQuote, messenger: TransactionPayControllerMessenger, options: { + forcePolling?: boolean; onSourceHash?: (hash: Hex) => void; tolerateFailure?: boolean; }, ): Promise { - const { onSourceHash, tolerateFailure } = options; + const { forcePolling, onSourceHash, tolerateFailure } = options; const isSameChain = quote.details.currencyIn.currency.chainId === @@ -197,7 +204,7 @@ async function waitForRelayCompletion( const isSingleDepositStep = quote.steps.length === 1 && quote.steps[0].id === 'deposit'; - if (isSameChain && !isSingleDepositStep) { + if (isSameChain && !isSingleDepositStep && !forcePolling) { log('Skipping polling as same chain'); return { status: 'success', targetHash: FALLBACK_HASH }; } @@ -378,10 +385,18 @@ async function submitTransactions( throw new Error(`Unsupported step kind: ${invalidKind}`); } + const isDirectMusd = shouldForceDirectMusdRelayPolling(quote); + + assertDirectMusdRelayExecute(quote); + // In post-quote flows (e.g. Predict withdraw), the source tokens are held in // the Safe — not the EOA — and only become available after the original tx // executes as part of the batch. Skip the EOA balance check here. - if (!quote.request.isPostQuote && !quote.request.paymentOverride) { + if ( + !quote.request.isPostQuote && + !quote.request.paymentOverride && + !isDirectMusd + ) { await validateSourceBalance(quote, messenger); } @@ -403,7 +418,18 @@ async function submitTransactions( let allParams = normalizedParams; - if (quote.request.paymentOverride) { + if (isDirectMusd) { + const fundingParams = await buildDirectMusdFundingParams({ + messenger, + quote, + relayParams: normalizedParams[0], + transaction, + }); + + if (fundingParams) { + allParams = [fundingParams, ...normalizedParams]; + } + } else if (quote.request.paymentOverride) { 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 15897ece30..e98a69cee4 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -493,6 +493,9 @@ export type QuoteRequest = { /** Whether the source of funds is a Polymarket deposit wallet. */ isPolymarketDepositWallet?: boolean; + /** Whether this quote is the direct mUSD-to-Money-Account fiat flow. */ + isDirectMusdMoneyAccount?: boolean; + /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; diff --git a/packages/transaction-pay-controller/src/utils/token-transfer.ts b/packages/transaction-pay-controller/src/utils/token-transfer.ts new file mode 100644 index 0000000000..2e326124ff --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/token-transfer.ts @@ -0,0 +1,20 @@ +import { Interface } from '@ethersproject/abi'; +import type { Hex } from '@metamask/utils'; + +const TOKEN_TRANSFER_INTERFACE = new Interface([ + 'function transfer(address to, uint256 amount)', +]); + +/** + * Build ERC-20 `transfer(address,uint256)` calldata. + * + * @param recipient - Recipient address. + * @param amountRaw - Amount in raw token units. + * @returns Token transfer calldata. + */ +export function buildTokenTransferData(recipient: Hex, amountRaw: string): Hex { + return TOKEN_TRANSFER_INTERFACE.encodeFunctionData('transfer', [ + recipient, + amountRaw, + ]) as Hex; +} From 321f6edbcb8011958e25a8e1a940e99a99079431 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Tue, 16 Jun 2026 23:08:29 +0100 Subject: [PATCH 2/2] docs(transaction-pay-controller): link direct mUSD changelog entry --- 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 c923ca7d06..d37e5a5e9b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fix direct mUSD fiat Money Account deposits to execute atomically through Relay execute by preserving canonical Relay quote steps and prepending delegated Money Account funding during submission. +- Fix direct mUSD fiat Money Account deposits to execute atomically through Relay execute by preserving canonical Relay quote steps and prepending delegated Money Account funding during submission ([#9161](https://github.com/MetaMask/core/pull/9161)) ## [23.8.0]