Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -255,3 +265,146 @@ 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> = {}): 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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ 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 {
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';

Expand Down Expand Up @@ -134,3 +140,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()
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ import {
getTokenBalance,
getTokenFiatRate,
} from '../../utils/token';
import { isPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault';
import { getRelayQuotes } from './relay-quotes';
import type { RelayQuote, RelayTransactionStep } from './types';

jest.mock('./relay-post-ma-vault');
jest.mock('../../utils/token', () => ({
...jest.createMockFromModule<typeof import('../../utils/token')>(
'../../utils/token',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -290,6 +291,17 @@ 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.
if (usePostRelayVaultDeposit) {
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.
Expand Down Expand Up @@ -329,7 +341,11 @@ async function getSingleQuote(
!(request.skipProcessTransactions ?? request.isPostQuote) &&
!request.isPolymarketDepositWallet;

if (shouldProcessTransactions) {
if (usePostRelayVaultDeposit) {
log(
'Money Account vault deposit: skipping atomic embedding; mUSD settles to the Money Account for a post-Relay vault deposit',
);
} else if (shouldProcessTransactions) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-relay drops Predict refundTo

Medium Severity

For post-Relay Money Account vault quotes, getSingleQuote logs and skips the later branches that set body.refundTo from request.refundTo. Predict withdrawals to mUSD on Monad use that refund address so failed Relay attempts return funds to the Safe proxy instead of the EOA.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1a997ab. Configure here.

await processTransactions(transaction, request, body, messenger);
} else if (
request.isPostQuote &&
Expand Down
Loading