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 445183f585..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 @@ -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,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 { + 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/strategy/relay/relay-post-ma-vault.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts index 8c2cd7768c..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 @@ -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'; @@ -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() + ); +} 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..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,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( '../../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 d4fefe38af..1cd179e139 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -61,6 +61,7 @@ import { applyHyperliquidActivationFee } from './hyperliquid-activation'; import { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw'; import { fetchRelayQuote } from './relay-api'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station'; +import { isPostRelayMoneyAccountVaultDeposit } from './relay-post-ma-vault'; import type { RelayQuote, RelayQuoteMetamask, @@ -290,6 +291,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. @@ -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) { await processTransactions(transaction, request, body, messenger); } else if ( request.isPostQuote && 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; 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..ff335f91ad 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 { + submitPostRelayVaultDeposit, + isPostRelayMoneyAccountVaultDeposit, +} from './relay-post-ma-vault'; import type { RelayCompletionOutcome, RelayExecuteRequest, @@ -176,6 +180,24 @@ async function executeSingleQuote( }, ); + // Money Account vault flows leave mUSD on the Money Account rather than + // embedding the vault deposit in the Relay batch. Once Relay has settled, move + // the settled mUSD into the vault via a separate sponsored deposit and return + // its hash. + if ( + completion.status === 'success' && + isPostRelayMoneyAccountVaultDeposit(quote.request, transaction) + ) { + const { transactionHash } = await submitPostRelayVaultDeposit({ + completion, + messenger, + quote, + transaction, + }); + + return { transactionHash: transactionHash ?? completion.targetHash }; + } + return { transactionHash: completion.targetHash }; } @@ -420,7 +442,11 @@ async function submitTransactions( let allParams = normalizedParams; - if (quote.request.paymentOverride) { + if (isPostRelayMoneyAccountVaultDeposit(quote.request, transaction)) { + log( + 'Money Account vault deposit: submitting Relay bridge only; vault deposit runs post-completion', + ); + } else if (quote.request.paymentOverride) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); 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'; 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. *