From d284d9b510731fbc84efb90e140f3665ef670768 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:37:13 +0200 Subject: [PATCH 1/2] feat: block MetaMask Pay submissions without quotes and handle no-op quotes cp-8.3.0 (#33194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile side of MetaMask/core#9484, which makes "no conversion needed" an explicit no-op quote and requires quotes at publish time for MetaMask Pay transactions. - Bump `@metamask/transaction-pay-controller` to `^25.0.0` (includes the no-op quote change) and `@metamask/transaction-controller` to `69.0.0` (required by the new pay controller). - `validateRequiredQuote` now also requires quotes for post-quote withdraw types when the post-quote flag is enabled; direct routes are allowed only when the controller state confirms them. - `validateRequiredQuote` also covers pay deposit and conversion types (perps deposits, predict deposits, mUSD conversion): they may submit without a quote only when the user pays with the required token itself and no conversion is pending, or the deposit is funded by a fiat on-ramp order. - New blocking legs in `useNoPayTokenQuotesAlert`: withdraw flows where the pay config or destination token is not set on the controller yet, and pay deposit/conversion flows where neither a payment token nor a fiat payment method is set. - No-op quotes (strategy `none`) are filtered everywhere: fee, duration, and step UI, metrics, and the publish guard. A no-op quote never counts as an executable quote; direct routes are validated from controller state instead, keeping the publish guard consistent with the confirmation alerts. - Metrics: add `mm_pay_quote_skipped`; strategy and step totals count only executable quotes. CHANGELOG entry: Improved quote validation for MetaMask Pay deposits, conversions and withdrawals Refs: https://github.com/MetaMask/core/pull/9484 ```gherkin Feature: MetaMask Pay quote validation Scenario: user withdraws to a different destination token Given the user has a Predict, Perps, or Money account balance When the user enters an amount and a quote is available Then the confirmation completes with the selected destination token Scenario: user withdraws to the same token and chain Given the user selects the source token as the destination Then the withdrawal completes as a direct transfer Scenario: user deposits or converts paying with another token Given the user selects a payment token on a different chain When a quote is available Then the transaction completes with the conversion Scenario: user deposits or converts paying with the required token Given the user selects the required token itself as the payment token Then the transaction completes as a direct transfer Scenario: user deposits paying with fiat Given the user selects a fiat payment method Then the confirmation is not blocked by the payment token alert ``` N/A N/A N/A - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **High Risk** > Changes transaction publish and confirmation blocking for MetaMask Pay funding paths; incorrect direct-route exceptions could still allow unfunded submissions or block legitimate flows. > > **Overview** > Bumps **`@metamask/transaction-pay-controller`** to **^25.0.0** (no-op quotes for direct routes) and aligns mobile with stricter publish-time and confirmation-time Pay guards. > > **No-op quotes** (`strategy: none`) are treated as non-executable everywhere: the quote selector filters them for UI, **`validateRequiredQuote`** and metrics only count executable quotes, and **`mm_pay_quote_skipped`** is set when no-op quotes were present. > > **Publish** (`validateRequiredQuote`) now also applies to **pay-token-required** deposits/conversions (perps/predict/mUSD, etc.) and **post-quote withdraws** when the feature flag is on. Submission without an executable quote is allowed only for validated direct routes (required token, no pending conversion), fiat orders with **`orderId`**, or post-quote withdraws with **`isPostQuote`** and no pending **`sourceAmounts`**—including withdraws with no destination token selected. > > **Confirmation** adds blocking **`NoPayTokenQuotes`** cases: withdraws with token selection enabled before Pay config is initialised (`!isPostQuote`), and pay-token-required types with no payment token and no fiat method—mirroring publish rules so users cannot confirm timing races that used to submit unfunded txs. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit cd7b38f91ca9195bb28e7d82d43c6e6723924680. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- .../confirmations/constants/confirmations.ts | 14 + .../alerts/useNoPayTokenQuotesAlert.test.ts | 161 +++++++++++ .../hooks/alerts/useNoPayTokenQuotesAlert.ts | 37 ++- .../metrics_properties/metamask-pay.test.ts | 73 +++++ .../metrics_properties/metamask-pay.ts | 17 +- .../transactionPayController.test.ts | 2 +- app/selectors/transactionPayController.ts | 22 +- app/util/transactions/hooks/index.test.ts | 255 +++++++++++++++++- app/util/transactions/hooks/index.ts | 132 ++++++++- package.json | 4 +- yarn.lock | 223 ++++++++++++++- 11 files changed, 911 insertions(+), 29 deletions(-) diff --git a/app/components/Views/confirmations/constants/confirmations.ts b/app/components/Views/confirmations/constants/confirmations.ts index 2124adf2583..30556c14107 100644 --- a/app/components/Views/confirmations/constants/confirmations.ts +++ b/app/components/Views/confirmations/constants/confirmations.ts @@ -143,3 +143,17 @@ export const MM_PAY_TRANSACTION_TYPES = [ export const QUOTE_REQUIRED_TRANSACTION_TYPES = [ TransactionType.moneyAccountDeposit, ] as const; + +/** + * MetaMask Pay transaction types that cannot work without a payment token + * (unless paying with fiat). Confirmation is blocked and publish throws when + * no payment token is set. Claims and withdraws are excluded because they can + * legitimately submit without engaging MetaMask Pay. + */ +export const PAY_TOKEN_REQUIRED_TRANSACTION_TYPES = [ + TransactionType.musdConversion, + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, +] as const; diff --git a/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.test.ts index 89c731f9a0f..535af992b97 100644 --- a/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.test.ts @@ -25,10 +25,12 @@ import { TransactionPaySourceAmount, } from '@metamask/transaction-pay-controller'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; +import { useTransactionPayWithdraw } from '../pay/useTransactionPayWithdraw'; import { TransactionType } from '@metamask/transaction-controller'; jest.mock('../pay/useTransactionPayToken'); jest.mock('../pay/useTransactionPayData'); +jest.mock('../pay/useTransactionPayWithdraw'); jest.mock('../transactions/useTransactionMetadataRequest'); const STATE_MOCK = merge( @@ -63,6 +65,7 @@ describe('useNoPayTokenQuotesAlert', () => { const useTransactionPayRequiredTokensMock = jest.mocked( useTransactionPayRequiredTokens, ); + const useTransactionPayWithdrawMock = jest.mocked(useTransactionPayWithdraw); beforeEach(() => { jest.resetAllMocks(); @@ -81,6 +84,10 @@ describe('useNoPayTokenQuotesAlert', () => { ]); useTransactionPayIsPostQuoteMock.mockReturnValue(false); useTransactionPayRequiredTokensMock.mockReturnValue([]); + useTransactionPayWithdrawMock.mockReturnValue({ + isWithdraw: false, + canSelectWithdrawToken: false, + }); jest.mocked(useTransactionPayFiatPayment).mockReturnValue(undefined); jest.mocked(useTransactionPayIsMaxAmount).mockReturnValue(false); }); @@ -351,6 +358,10 @@ describe('useNoPayTokenQuotesAlert', () => { skipIfBalance: false, } as TransactionPayRequiredToken, ]); + useTransactionPayWithdrawMock.mockReturnValue({ + isWithdraw: false, + canSelectWithdrawToken: false, + }); jest.mocked(useTransactionPayFiatPayment).mockReturnValue(undefined); jest.mocked(useTransactionPayIsMaxAmount).mockReturnValue(false); jest.mocked(useTransactionMetadataRequest).mockReturnValue({ @@ -412,4 +423,154 @@ describe('useNoPayTokenQuotesAlert', () => { expect(result.current).toStrictEqual([]); }); }); + + describe('withdraw initialisation', () => { + const BLOCKING_ALERT = expect.objectContaining({ + key: AlertKeys.NoPayTokenQuotes, + severity: Severity.Danger, + isBlocking: true, + }); + + beforeEach(() => { + useTransactionPayQuotesMock.mockReturnValue([]); + useTransactionPaySourceAmountsMock.mockReturnValue([]); + useTransactionPayWithdrawMock.mockReturnValue({ + isWithdraw: true, + canSelectWithdrawToken: true, + }); + useTransactionPayRequiredTokensMock.mockReturnValue([ + { + address: ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + amountRaw: '10000', + skipIfBalance: false, + } as TransactionPayRequiredToken, + ]); + }); + + it('returns alert when the pay config is not initialised', () => { + useTransactionPayIsPostQuoteMock.mockReturnValue(false); + + const { result } = runHook(); + + expect(result.current).toEqual([BLOCKING_ALERT]); + }); + + it('returns no alerts once the pay config is initialised, even with no destination token set', () => { + // Withdraws with no preferred or last-used token intentionally leave + // payToken unset and default to a direct, same-token transfer. + useTransactionPayIsPostQuoteMock.mockReturnValue(true); + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alerts when the destination token and pay config are set', () => { + useTransactionPayIsPostQuoteMock.mockReturnValue(true); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alerts when withdraw token selection is disabled', () => { + useTransactionPayWithdrawMock.mockReturnValue({ + isWithdraw: true, + canSelectWithdrawToken: false, + }); + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alerts while quotes are loading', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + } as ReturnType); + useIsTransactionPayLoadingMock.mockReturnValue(true); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + }); + + describe('pay-token-required transaction types', () => { + const BLOCKING_ALERT = expect.objectContaining({ + key: AlertKeys.NoPayTokenQuotes, + severity: Severity.Danger, + isBlocking: true, + }); + + beforeEach(() => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + } as ReturnType); + useTransactionPayQuotesMock.mockReturnValue([]); + useTransactionPaySourceAmountsMock.mockReturnValue([]); + useTransactionPayRequiredTokensMock.mockReturnValue([ + { + address: ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + amountRaw: '10000', + skipIfBalance: false, + } as TransactionPayRequiredToken, + ]); + jest.mocked(useTransactionMetadataRequest).mockReturnValue({ + type: TransactionType.perpsDeposit, + } as never); + }); + + it('returns alert for perps deposit when no payment token is set', () => { + const { result } = runHook(); + + expect(result.current).toEqual([BLOCKING_ALERT]); + }); + + it('returns no alerts when a fiat payment method is selected', () => { + jest.mocked(useTransactionPayFiatPayment).mockReturnValue({ + selectedPaymentMethodId: 'debit-credit-card', + } as never); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alerts when a payment token is set', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + address: ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }, + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alerts when the required amount is zero', () => { + useTransactionPayRequiredTokensMock.mockReturnValue([ + { + address: ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + amountRaw: '0', + skipIfBalance: false, + } as TransactionPayRequiredToken, + ]); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + }); }); diff --git a/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.ts b/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.ts index 9056dfd6e77..6c626ea2a63 100644 --- a/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useNoPayTokenQuotesAlert.ts @@ -14,8 +14,12 @@ import { useTransactionPaySourceAmounts, } from '../pay/useTransactionPayData'; import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest'; -import { QUOTE_REQUIRED_TRANSACTION_TYPES } from '../../constants/confirmations'; +import { + PAY_TOKEN_REQUIRED_TRANSACTION_TYPES, + QUOTE_REQUIRED_TRANSACTION_TYPES, +} from '../../constants/confirmations'; import { hasTransactionType } from '../../utils/transaction'; +import { useTransactionPayWithdraw } from '../pay/useTransactionPayWithdraw'; export function useNoPayTokenQuotesAlert() { const { payToken } = useTransactionPayToken(); @@ -27,6 +31,7 @@ export function useNoPayTokenQuotesAlert() { const isPostQuote = useTransactionPayIsPostQuote(); const isMaxAmount = useTransactionPayIsMaxAmount(); const transactionMeta = useTransactionMetadataRequest(); + const { canSelectWithdrawToken } = useTransactionPayWithdraw(); const fiatAmount = Number(fiatPayment?.amountFiat); const hasValidFiatAmount = Number.isFinite(fiatAmount) && fiatAmount > 0; @@ -87,11 +92,39 @@ export function useNoPayTokenQuotesAlert() { !quotes?.length && hasPositiveRequiredAmount; + // Withdraws with token selection enabled must have the pay config + // (isPostQuote) set on the controller before confirming. Blocks the + // timing race where initialisation (e.g. Predict account state) never + // completed. A destination token is not required here: withdraws with no + // preferred or last-used token intentionally leave payToken unset and + // default to a direct, same-token transfer (see getBestToken). That case + // is safe and is not the race this alert guards against; the actual + // conversion-pending case is covered by shouldShowPostQuoteNoQuotesAlert + // above, which does require payToken. + const shouldShowWithdrawNotInitialisedAlert = + canSelectWithdrawToken && + !isQuotesLoading && + hasPositiveRequiredAmount && + !isPostQuote; + + // Pay-type deposits and conversions must have a payment token set on the + // controller (or a fiat payment method selected) before confirming. Blocks + // the timing races where auto-selection never completed, so no quotes were + // fetched and the transaction previously submitted directly without funds. + const shouldShowPayTokenNotSelectedAlert = + hasTransactionType(transactionMeta, PAY_TOKEN_REQUIRED_TRANSACTION_TYPES) && + !isQuotesLoading && + hasPositiveRequiredAmount && + !payToken && + !hasSelectedFiatPaymentMethod; + const showAlert = shouldShowNonFiatNoQuotesAlert || shouldShowFiatNoQuotesAlert || shouldShowPostQuoteNoQuotesAlert || - shouldShowQuoteRequiredNoQuotesAlert; + shouldShowQuoteRequiredNoQuotesAlert || + shouldShowWithdrawNotInitialisedAlert || + shouldShowPayTokenNotSelectedAlert; return useMemo(() => { if (!showAlert) { diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts index 3f79e730c40..af5f15debae 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.test.ts @@ -697,6 +697,79 @@ describe('Metamask Pay Metrics', () => { }); }); + it('ignores no-op quotes and sets mm_pay_quote_skipped', () => { + request.transactionMeta.type = TransactionType.predictWithdraw; + request.transactionMeta.metamaskPay = { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000000000', + }; + + getStateMock.mockReturnValue({ + engine: { + backgroundState: { + TokensController: { allTokens: {} }, + TransactionPayController: { + transactionData: { + 'child-1': { + paymentToken: { symbol: 'USDC', chainId: '0x89' }, + quotes: [{ strategy: TransactionPayStrategy.None }], + tokens: [{ skipIfBalance: false, amountUsd: '5' }], + }, + }, + }, + }, + }, + } as never); + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).toStrictEqual( + expect.objectContaining({ + mm_pay: true, + mm_pay_quote_skipped: true, + mm_pay_transaction_step_total: 1, + mm_pay_transaction_step: 1, + }), + ); + expect(result.properties.mm_pay_strategy).toBeUndefined(); + }); + + it('sets mm_pay_quote_skipped as false when only executable quotes are present', () => { + request.transactionMeta.type = TransactionType.predictWithdraw; + request.transactionMeta.metamaskPay = { + chainId: '0x89', + tokenAddress: '0x0000000000000000000000000000000000000000', + }; + + getStateMock.mockReturnValue({ + engine: { + backgroundState: { + TokensController: { allTokens: {} }, + TransactionPayController: { + transactionData: { + 'child-1': { + paymentToken: { symbol: 'USDC', chainId: '0x89' }, + quotes: [{ strategy: TransactionPayStrategy.Relay }], + tokens: [{ skipIfBalance: false, amountUsd: '5' }], + }, + }, + }, + }, + }, + } as never); + + const result = getMetaMaskPayProperties(request) as TransactionMetrics; + + expect(result.properties).toStrictEqual( + expect.objectContaining({ + mm_pay_quote_skipped: false, + mm_pay_strategy: 'relay', + mm_pay_transaction_step_total: 2, + mm_pay_transaction_step: 2, + }), + ); + }); + describe('mm_pay_payment_method_selected', () => { it('defaults to crypto when no fiat method is selected', () => { request.transactionMeta.type = TransactionType.perpsDeposit; diff --git a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts index e44b024a219..2fa04ccb788 100644 --- a/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts +++ b/app/core/Engine/controllers/transaction-controller/metrics_properties/metamask-pay.ts @@ -12,6 +12,7 @@ import { } from '../../../../../components/Views/confirmations/utils/transaction-pay-metrics'; import { TransactionPayStrategy } from '@metamask/transaction-pay-controller'; import { RootState } from '../../../../../reducers'; +import { isNoOpQuote } from '../../../../../selectors/transactionPayController'; import { selectSingleTokenByAddressAndChainId } from '../../../../../selectors/tokensController'; import { Hex } from '@metamask/utils'; import { TRANSACTION_EVENTS } from '../../../../Analytics/events/confirmations'; @@ -215,7 +216,17 @@ function addPayTypeProperties( return; } - const { quotes, totals, tokens } = txPayData; + const { totals, tokens } = txPayData; + + // No-op quotes mark routes the controller validated as needing no + // conversion. They are not executable, so strategy and step totals must + // only count real quotes. + const quotes = (txPayData.quotes ?? []).filter( + (quote) => !isNoOpQuote(quote), + ); + properties.mm_pay_quote_skipped = + (txPayData.quotes ?? []).length > quotes.length; + const primaryRequiredToken = tokens?.find( (t: { skipIfBalance: boolean }) => !t.skipIfBalance, ); @@ -237,7 +248,7 @@ function addPayTypeProperties( .toString(10); } - const strategy = quotes?.[0]?.strategy; + const strategy = quotes[0]?.strategy; if (strategy === TransactionPayStrategy.Relay) { properties.mm_pay_strategy = 'relay'; @@ -245,7 +256,7 @@ function addPayTypeProperties( properties.mm_pay_strategy = 'fiat'; } - properties.mm_pay_transaction_step_total = (quotes?.length ?? 0) + 1; + properties.mm_pay_transaction_step_total = quotes.length + 1; properties.mm_pay_transaction_step = properties.mm_pay_transaction_step_total; const fiatPayment = txPayData.fiatPayment; diff --git a/app/selectors/transactionPayController.test.ts b/app/selectors/transactionPayController.test.ts index d570e252052..976f62d4531 100644 --- a/app/selectors/transactionPayController.test.ts +++ b/app/selectors/transactionPayController.test.ts @@ -136,7 +136,7 @@ describe('transactionPayController selectors', () => { TRANSACTION_ID_MOCK, ); - expect(result).toBe(quotes); + expect(result).toStrictEqual(quotes); }); }); diff --git a/app/selectors/transactionPayController.ts b/app/selectors/transactionPayController.ts index 73afb38d322..520203bac06 100644 --- a/app/selectors/transactionPayController.ts +++ b/app/selectors/transactionPayController.ts @@ -1,6 +1,21 @@ +import { + TransactionPayQuote, + TransactionPayStrategy, +} from '@metamask/transaction-pay-controller'; import { createSelector } from 'reselect'; import { RootState } from '../reducers'; +/** + * Check whether a quote is a no-op quote. The controller stores one when a + * route needs no conversion. No-op quotes cannot be executed and must be + * ignored anywhere quotes drive fees, steps, or routing UI. + */ +export function isNoOpQuote( + quote: Pick, 'strategy'>, +): boolean { + return quote.strategy === TransactionPayStrategy.None; +} + const selectTransactionPayControllerState = (state: RootState) => state.engine.backgroundState.TransactionPayController ?? { transactionData: {}, @@ -23,9 +38,14 @@ export const selectIsTransactionPayLoadingByTransactionId = createSelector( (transactionData) => transactionData?.isLoading ?? false, ); +// Executable quotes only. No-op quotes mark direct routes and must not +// surface in fee, duration, or step UI, so they are filtered here for all +// consumers. export const selectTransactionPayQuotesByTransactionId = createSelector( selectTransactionDataByTransactionId, - (transactionData) => transactionData?.quotes, + (transactionData) => + transactionData?.quotes && + transactionData.quotes.filter((quote) => !isNoOpQuote(quote)), ); export const selectTransactionPayTokensByTransactionId = createSelector( diff --git a/app/util/transactions/hooks/index.test.ts b/app/util/transactions/hooks/index.test.ts index f10a4a62884..babf60554c3 100644 --- a/app/util/transactions/hooks/index.test.ts +++ b/app/util/transactions/hooks/index.test.ts @@ -13,7 +13,10 @@ import { getSmartTransactionsFeatureFlagsForChain, selectShouldUseSmartTransaction, } from '../../../selectors/smartTransactionsController'; -import { selectMetaMaskPayFlags } from '../../../selectors/featureFlagController/confirmations'; +import { + selectMetaMaskPayFlags, + selectPayQuoteConfig, +} from '../../../selectors/featureFlagController/confirmations'; import { updateConfirmationMetric } from '../../../core/redux/slices/confirmationMetrics'; import { store } from '../../../store'; import { @@ -133,6 +136,9 @@ describe('getTransactionControllerHooks', () => { jest.mocked(accountSupports7702).mockResolvedValue(false); jest.mocked(isSendBundleSupported).mockResolvedValue(true); jest.mocked(getTransactionById).mockReturnValue(MOCK_TRANSACTION_META); + jest + .mocked(selectPayQuoteConfig) + .mockReturnValue({ enabled: false, tokens: [] } as never); }); it('returns the TransactionController hook functions', () => { @@ -321,6 +327,28 @@ describe('getTransactionControllerHooks', () => { ).rejects.toThrow('Could not find transaction with id missing-tx'); }); + function buildPayStateRequest( + payData: Record | undefined, + ): TransactionControllerHookRequest { + return buildRequest({ + initMessenger: { + call: jest.fn((action: string) => { + if (action === 'PredictController:publish') { + return { transactionHash: undefined }; + } + + if (action === 'TransactionPayController:getState') { + return { + transactionData: payData ? { '123': payData } : {}, + }; + } + + return undefined; + }), + } as unknown as TransactionControllerHookRequest['initMessenger'], + }); + } + describe('quote-required transaction types', () => { it('throws when moneyAccountDeposit has no quotes', async () => { const request = buildRequest({ @@ -390,5 +418,230 @@ describe('getTransactionControllerHooks', () => { expect(result).toStrictEqual({ transactionHash: undefined }); }); + + it('throws when moneyAccountDeposit only has a no-op quote', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ quotes: [{ strategy: 'none' }] }), + ); + const moneyAccountTx = { + ...MOCK_TRANSACTION_META, + type: TransactionType.moneyAccountDeposit, + }; + + await expect(hooks.publish?.(moneyAccountTx)).rejects.toThrow( + 'MetaMask Pay: Cannot submit without quote', + ); + }); + + it('does not throw for moneyAccountDeposit when an executable quote is in state', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ quotes: [{ strategy: 'relay' }] }), + ); + const moneyAccountTx = { + ...MOCK_TRANSACTION_META, + type: TransactionType.moneyAccountDeposit, + }; + + const result = await hooks.publish?.(moneyAccountTx); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + }); + + describe('pay-token-required transaction types', () => { + const REQUIRED_TOKEN = { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: '0xa4b1', + skipIfBalance: false, + }; + + const GAS_TOKEN = { + address: '0x0000000000000000000000000000000000000000', + chainId: '0xa4b1', + skipIfBalance: true, + }; + + const PERPS_DEPOSIT_TX = { + ...MOCK_TRANSACTION_META, + type: TransactionType.perpsDeposit, + }; + + it('throws for perps deposit when no pay state exists', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest(undefined), + ); + + await expect(hooks.publish?.(PERPS_DEPOSIT_TX)).rejects.toThrow( + 'MetaMask Pay: Cannot submit without quote', + ); + }); + + it('throws for perps deposit without quotes when paying with a different token', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + paymentToken: { address: '0x123', chainId: '0x1' }, + quotes: [], + sourceAmounts: [], + tokens: [REQUIRED_TOKEN], + }), + ); + + await expect(hooks.publish?.(PERPS_DEPOSIT_TX)).rejects.toThrow( + 'MetaMask Pay: Cannot submit without quote', + ); + }); + + it('throws for perps deposit without quotes when a required conversion is pending', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + paymentToken: REQUIRED_TOKEN, + quotes: [], + sourceAmounts: [{ targetTokenAddress: REQUIRED_TOKEN.address }], + tokens: [REQUIRED_TOKEN], + }), + ); + + await expect(hooks.publish?.(PERPS_DEPOSIT_TX)).rejects.toThrow( + 'MetaMask Pay: Cannot submit without quote', + ); + }); + + it('does not throw for perps deposit paying with the required token when only optional conversions are pending', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + paymentToken: REQUIRED_TOKEN, + quotes: [], + sourceAmounts: [{ targetTokenAddress: GAS_TOKEN.address }], + tokens: [REQUIRED_TOKEN, GAS_TOKEN], + }), + ); + + const result = await hooks.publish?.(PERPS_DEPOSIT_TX); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + + it('does not throw for perps deposit with only a no-op quote on a validated direct route', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + paymentToken: { + ...REQUIRED_TOKEN, + address: REQUIRED_TOKEN.address.toLowerCase(), + }, + quotes: [{ strategy: 'none' }], + sourceAmounts: [], + tokens: [REQUIRED_TOKEN], + }), + ); + + const result = await hooks.publish?.(PERPS_DEPOSIT_TX); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + }); + + describe('post-quote withdraw types', () => { + const PREDICT_WITHDRAW_TX = { + ...MOCK_TRANSACTION_META, + type: TransactionType.predictWithdraw, + }; + + const PAYMENT_TOKEN = { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: '0x1', + }; + + beforeEach(() => { + jest + .mocked(selectPayQuoteConfig) + .mockReturnValue({ enabled: true, tokens: [] } as never); + }); + + it('does not validate when the post-quote flag is disabled', async () => { + jest + .mocked(selectPayQuoteConfig) + .mockReturnValue({ enabled: false, tokens: [] } as never); + + const hooks = getTransactionControllerHooks( + buildPayStateRequest(undefined), + ); + + const result = await hooks.publish?.(PREDICT_WITHDRAW_TX); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + + it('throws for predict withdraw when pay state is missing', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest(undefined), + ); + + await expect(hooks.publish?.(PREDICT_WITHDRAW_TX)).rejects.toThrow( + 'MetaMask Pay: Cannot submit without quote', + ); + }); + + it('throws for predict withdraw without quotes when a conversion is pending', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + isPostQuote: true, + paymentToken: PAYMENT_TOKEN, + quotes: [], + sourceAmounts: [{ targetTokenAddress: PAYMENT_TOKEN.address }], + }), + ); + + await expect(hooks.publish?.(PREDICT_WITHDRAW_TX)).rejects.toThrow( + 'MetaMask Pay: Cannot submit without quote', + ); + }); + + it('does not throw for predict withdraw on a validated direct route', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + isPostQuote: true, + paymentToken: PAYMENT_TOKEN, + quotes: [], + sourceAmounts: [], + }), + ); + + const result = await hooks.publish?.(PREDICT_WITHDRAW_TX); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + + it('does not throw for predict withdraw with no preferred or last-used token and no pending conversion', async () => { + // Withdraws with no preferred or last-used token intentionally leave + // paymentToken unset and default to a direct, same-token transfer + // (see getBestToken in useAutomaticTransactionPayToken). + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + isPostQuote: true, + paymentToken: undefined, + quotes: [], + sourceAmounts: [], + }), + ); + + const result = await hooks.publish?.(PREDICT_WITHDRAW_TX); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + + it('does not throw for predict withdraw when an executable quote is in state', async () => { + const hooks = getTransactionControllerHooks( + buildPayStateRequest({ + isPostQuote: true, + paymentToken: PAYMENT_TOKEN, + quotes: [{ strategy: 'relay' }], + sourceAmounts: [{ targetTokenAddress: PAYMENT_TOKEN.address }], + }), + ); + + const result = await hooks.publish?.(PREDICT_WITHDRAW_TX); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); }); }); diff --git a/app/util/transactions/hooks/index.ts b/app/util/transactions/hooks/index.ts index 54b115276f2..3fc84ed3737 100644 --- a/app/util/transactions/hooks/index.ts +++ b/app/util/transactions/hooks/index.ts @@ -11,6 +11,7 @@ import { TransactionType, } from '@metamask/transaction-controller'; import { + type TransactionData, TransactionPayControllerMessenger, TransactionPayPublishHook, } from '@metamask/transaction-pay-controller'; @@ -21,7 +22,11 @@ import { getSmartTransactionsFeatureFlagsForChain, selectShouldUseSmartTransaction, } from '../../../selectors/smartTransactionsController'; -import { selectMetaMaskPayFlags } from '../../../selectors/featureFlagController/confirmations'; +import { isNoOpQuote } from '../../../selectors/transactionPayController'; +import { + selectMetaMaskPayFlags, + selectPayQuoteConfig, +} from '../../../selectors/featureFlagController/confirmations'; import { store } from '../../../store'; import type { TransactionControllerInitMessenger } from '../../../core/Engine/messengers/transaction-controller-messenger'; import { updateConfirmationMetric } from '../../../core/redux/slices/confirmationMetrics'; @@ -34,8 +39,14 @@ import { getTransactionById } from '..'; import { accountSupports7702 } from '../account-supports-7702'; import { isSendBundleSupported } from '../sentinel-api'; import { Delegation7702PublishHook } from './delegation-7702-publish'; -import { QUOTE_REQUIRED_TRANSACTION_TYPES } from '../../../components/Views/confirmations/constants/confirmations'; -import { hasTransactionType } from '../../../components/Views/confirmations/utils/transaction'; +import { + PAY_TOKEN_REQUIRED_TRANSACTION_TYPES, + QUOTE_REQUIRED_TRANSACTION_TYPES, +} from '../../../components/Views/confirmations/constants/confirmations'; +import { + getPostQuoteTransactionType, + hasTransactionType, +} from '../../../components/Views/confirmations/utils/transaction'; const TRANSACTION_SUBMISSION_METHOD_METRIC_NAME = 'transaction_submission_method'; @@ -127,7 +138,7 @@ function publishHook({ return payResult; } - validateRequiredQuote(transactionMeta, initMessenger); + validateRequiredQuote(transactionMeta, initMessenger, state); const { isExternalSign } = transactionMeta; const isRevokeDelegation = @@ -223,8 +234,25 @@ function publishHook({ function validateRequiredQuote( transactionMeta: TransactionMeta, messenger: TransactionControllerInitMessenger, + state: RootState, ) { - if (!hasTransactionType(transactionMeta, QUOTE_REQUIRED_TRANSACTION_TYPES)) { + const isQuoteRequiredType = hasTransactionType( + transactionMeta, + QUOTE_REQUIRED_TRANSACTION_TYPES, + ); + + const isPayTokenRequiredType = hasTransactionType( + transactionMeta, + PAY_TOKEN_REQUIRED_TRANSACTION_TYPES, + ); + + const postQuoteType = getPostQuoteTransactionType(transactionMeta); + + const isPostQuoteWithdraw = + Boolean(postQuoteType) && + selectPayQuoteConfig(state, postQuoteType).enabled === true; + + if (!isQuoteRequiredType && !isPostQuoteWithdraw && !isPayTokenRequiredType) { return; } @@ -232,11 +260,101 @@ function validateRequiredQuote( 'TransactionPayController:getState', ); - const quotes = transactionData?.[transactionMeta.id]?.quotes ?? []; + const data = transactionData?.[transactionMeta.id]; + const quotes = data?.quotes ?? []; + + // No-op quotes mark a direct route but cannot be executed, so they are not + // sufficient on their own. Ignoring them keeps this guard consistent with + // the confirmation alerts, which read quotes through the same filter, and + // lets the direct-route checks below decide instead. + const executableQuotes = quotes.filter((quote) => !isNoOpQuote(quote)); + + if (executableQuotes.length) { + return; + } + + if (isPayTokenRequiredType) { + if (isValidatedDirectDeposit(data) || isValidatedFiatDeposit(data)) { + return; + } - if (!quotes.length) { throw new Error('MetaMask Pay: Cannot submit without quote'); } + + // Quotes can be empty for a direct route in the window before the + // controller stores the no-op quote. Allow that only when the pay config + // is set and no conversion is pending. A destination token is not + // required: withdraws with no preferred or last-used token intentionally + // leave paymentToken unset and default to a direct, same-token transfer + // (see getBestToken in useAutomaticTransactionPayToken), which never + // populates sourceAmounts either. A withdraw that lost its quotes or + // never initialised isPostQuote still cannot submit without the + // conversion. + const isValidatedDirectRoute = + !isQuoteRequiredType && + data?.isPostQuote === true && + !data.sourceAmounts?.length; + + if (isValidatedDirectRoute) { + return; + } + + throw new Error('MetaMask Pay: Cannot submit without quote'); +} + +/** + * A pay-type deposit or conversion may submit without quotes only when the + * user pays with the required token itself and the controller recorded no + * pending conversion. Anything else (payment token missing or different, or + * a required conversion without a quote) means the transaction would land + * without funds, so it must not submit. + * + * @param data - Pay state for the transaction. + * @returns Whether direct submission is safe. + */ +function isValidatedDirectDeposit(data: TransactionData | undefined): boolean { + const paymentToken = data?.paymentToken; + + if (!paymentToken) { + return false; + } + + const tokens = data?.tokens ?? []; + const requiredToken = tokens.find((token) => !token.skipIfBalance); + + const isPayingWithRequiredToken = + Boolean(requiredToken) && + requiredToken?.chainId === paymentToken.chainId && + requiredToken?.address.toLowerCase() === paymentToken.address.toLowerCase(); + + if (!isPayingWithRequiredToken) { + return false; + } + + const hasRequiredConversion = (data?.sourceAmounts ?? []).some( + (sourceAmount) => + !tokens.find( + (token) => + token.address.toLowerCase() === + sourceAmount.targetTokenAddress.toLowerCase(), + )?.skipIfBalance, + ); + + return !hasRequiredConversion; +} + +/** + * A fiat-funded deposit submits without a pay quote because the on-ramp + * order itself is the funding source; the flow is validated at confirmation + * time by the fiat no-quotes alert (see useNoPayTokenQuotesAlert), and the + * auto-fiat-submission hook publishes as soon as the order is created, + * before any pay token is ever selected on the controller. + * + * @param data - Pay state for the transaction. + * @returns Whether the fiat-funded submission is safe. + */ +function isValidatedFiatDeposit(data: TransactionData | undefined): boolean { + return Boolean(data?.fiatPayment?.orderId); } function getSmartTransactionCommonParams(state: RootState, chainId: Hex) { diff --git a/package.json b/package.json index ac5193237e8..35089e3acb7 100644 --- a/package.json +++ b/package.json @@ -356,8 +356,8 @@ "@metamask/storage-service": "^1.0.0", "@metamask/superstruct": "^3.2.1", "@metamask/swappable-obj-proxy": "^2.1.0", - "@metamask/transaction-controller": "^68.2.0", - "@metamask/transaction-pay-controller": "^23.17.4", + "@metamask/transaction-controller": "^69.0.0", + "@metamask/transaction-pay-controller": "^25.0.0", "@metamask/tron-wallet-snap": "^1.31.0", "@metamask/utils": "^11.11.0", "@metamask/wallet": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index 76f9ed5bcc9..d5df4672768 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7918,6 +7918,31 @@ __metadata: languageName: node linkType: hard +"@metamask/account-tree-controller@npm:^7.5.5": + version: 7.5.5 + resolution: "@metamask/account-tree-controller@npm:7.5.5" + dependencies: + "@metamask/accounts-controller": "npm:^39.0.5" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/keyring-api": "npm:^23.5.0" + "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/multichain-account-service": "npm:^13.0.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + fast-deep-equal: "npm:^3.1.3" + lodash: "npm:^4.17.21" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/18913a9cdf65a5ab640d2cb608f6fb46b0f4ebce1537d4ed3e6502392efb15acb45b0648319a861421e6f527a40c7574e9e505fc4cefdc4f9fde0a06df1e428e + languageName: node + linkType: hard + "@metamask/accounts-controller@npm:^39.0.3": version: 39.0.3 resolution: "@metamask/accounts-controller@npm:39.0.3" @@ -8013,7 +8038,7 @@ __metadata: languageName: node linkType: hard -"@metamask/assets-controller@npm:^10.0.1, @metamask/assets-controller@npm:^10.2.0": +"@metamask/assets-controller@npm:^10.2.0": version: 10.2.0 resolution: "@metamask/assets-controller@npm:10.2.0" dependencies: @@ -8050,6 +8075,43 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controller@npm:^11.0.0": + version: 11.0.0 + resolution: "@metamask/assets-controller@npm:11.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/account-tree-controller": "npm:^7.5.5" + "@metamask/accounts-controller": "npm:^39.0.5" + "@metamask/assets-controllers": "npm:^109.4.1" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/client-controller": "npm:^1.0.1" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/core-backend": "npm:^6.5.0" + "@metamask/keyring-api": "npm:^23.5.0" + "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/keyring-internal-api": "npm:^11.0.1" + "@metamask/keyring-snap-client": "npm:^9.2.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/network-controller": "npm:^34.0.0" + "@metamask/network-enablement-controller": "npm:^5.5.0" + "@metamask/permission-controller": "npm:^13.1.1" + "@metamask/phishing-controller": "npm:^17.2.1" + "@metamask/polling-controller": "npm:^16.0.8" + "@metamask/preferences-controller": "npm:^23.1.0" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/transaction-controller": "npm:^69.0.0" + "@metamask/utils": "npm:^11.11.0" + async-mutex: "npm:^0.5.0" + bignumber.js: "npm:^9.1.2" + lodash: "npm:^4.17.21" + p-limit: "npm:^3.1.0" + checksum: 10/5a78033fe14262c938857bcc40bdafe40435f86983634515bc51f2613f2df5e27885b7d504c2d3bb8c02079a34917a46db62ad2da629a90fb9cdc2a48c926419 + languageName: node + linkType: hard + "@metamask/assets-controllers@npm:109.4.0": version: 109.4.0 resolution: "@metamask/assets-controllers@npm:109.4.0" @@ -8107,6 +8169,63 @@ __metadata: languageName: node linkType: hard +"@metamask/assets-controllers@npm:^109.4.1": + version: 109.4.1 + resolution: "@metamask/assets-controllers@npm:109.4.1" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/address": "npm:^5.7.0" + "@ethersproject/bignumber": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/abi-utils": "npm:^2.0.3" + "@metamask/account-tree-controller": "npm:^7.5.5" + "@metamask/accounts-controller": "npm:^39.0.5" + "@metamask/approval-controller": "npm:^9.0.2" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/core-backend": "npm:^6.5.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/keyring-api": "npm:^23.5.0" + "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/multichain-account-service": "npm:^13.0.0" + "@metamask/network-controller": "npm:^34.0.0" + "@metamask/network-enablement-controller": "npm:^5.5.0" + "@metamask/permission-controller": "npm:^13.1.1" + "@metamask/phishing-controller": "npm:^17.2.1" + "@metamask/polling-controller": "npm:^16.0.8" + "@metamask/preferences-controller": "npm:^23.1.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/storage-service": "npm:^1.0.2" + "@metamask/transaction-controller": "npm:^69.0.0" + "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^5.62.16" + "@types/bn.js": "npm:^5.1.5" + "@types/uuid": "npm:^8.3.0" + async-mutex: "npm:^0.5.0" + bitcoin-address-validation: "npm:^2.2.3" + bn.js: "npm:^5.2.1" + immer: "npm:^9.0.6" + lodash: "npm:^4.17.21" + multiformats: "npm:^9.9.0" + reselect: "npm:^5.1.1" + single-call-balance-checker-abi: "npm:^1.0.0" + uuid: "npm:^8.3.2" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/09146430729239c8ce2bed7ef3ac670c289087522b40ead1b2e99eb4e4ba4639f619517ccb3a65cd5ce3c478bbdd780b6d480d3b8f4153bb99a0f35907da5185 + languageName: node + linkType: hard + "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A109.4.0#~/.yarn/patches/@metamask-assets-controllers-npm-109.4.0-25a362106e.patch": version: 109.4.0 resolution: "@metamask/assets-controllers@patch:@metamask/assets-controllers@npm%3A109.4.0#~/.yarn/patches/@metamask-assets-controllers-npm-109.4.0-25a362106e.patch::version=109.4.0&hash=2c1a9a" @@ -9514,6 +9633,37 @@ __metadata: languageName: node linkType: hard +"@metamask/multichain-account-service@npm:^13.0.0": + version: 13.0.0 + resolution: "@metamask/multichain-account-service@npm:13.0.0" + dependencies: + "@ethereumjs/util": "npm:^9.1.0" + "@metamask/accounts-controller": "npm:^39.0.5" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/eth-snap-keyring": "npm:^23.0.0" + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/keyring-api": "npm:^23.5.0" + "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/keyring-internal-api": "npm:^11.0.1" + "@metamask/keyring-snap-client": "npm:^9.2.0" + "@metamask/keyring-utils": "npm:^3.3.1" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/snap-account-service": "npm:^2.0.0" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + async-mutex: "npm:^0.5.0" + lodash: "npm:^4.17.21" + peerDependencies: + "@metamask/account-api": ^1.0.4 + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/2ecb49a620420804ff74f1e6ce9a75303f2e41a5182b8570b6bef324c63e5be149ea1870815dbe8403a87c8fe17529322896fd9015e10766095a0d74c2aebafd + languageName: node + linkType: hard + "@metamask/multichain-api-client@npm:0.12.0": version: 0.12.0 resolution: "@metamask/multichain-api-client@npm:0.12.0" @@ -9647,6 +9797,24 @@ __metadata: languageName: node linkType: hard +"@metamask/network-enablement-controller@npm:^5.5.0": + version: 5.6.0 + resolution: "@metamask/network-enablement-controller@npm:5.6.0" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/keyring-api": "npm:^23.5.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/multichain-network-controller": "npm:^3.2.1" + "@metamask/network-controller": "npm:^34.0.0" + "@metamask/slip44": "npm:^4.3.0" + "@metamask/transaction-controller": "npm:^69.0.0" + "@metamask/utils": "npm:^11.11.0" + reselect: "npm:^5.1.1" + checksum: 10/c4fbedb43bc8331c24794c10a038809ec23b40a6042f589b3d72746a28846951c2e72c359ea6703b18840fc4fae6e5ffa7906979e58a1fb8f16f1fc735bd8514 + languageName: node + linkType: hard + "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A5.4.1#~/.yarn/patches/@metamask-network-enablement-controller-npm-5.4.1-020a82a308.patch": version: 5.4.1 resolution: "@metamask/network-enablement-controller@patch:@metamask/network-enablement-controller@npm%3A5.4.1#~/.yarn/patches/@metamask-network-enablement-controller-npm-5.4.1-020a82a308.patch::version=5.4.1&hash=bef511" @@ -9789,6 +9957,24 @@ __metadata: languageName: node linkType: hard +"@metamask/phishing-controller@npm:^17.2.1": + version: 17.2.1 + resolution: "@metamask/phishing-controller@npm:17.2.1" + dependencies: + "@metamask/address-book-controller": "npm:^7.1.2" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/transaction-controller": "npm:^69.0.0" + "@noble/hashes": "npm:^1.8.0" + "@types/punycode": "npm:^2.1.0" + ethereum-cryptography: "npm:^2.1.2" + fastest-levenshtein: "npm:^1.0.16" + punycode: "npm:^2.1.1" + checksum: 10/1227df06286cd0137ba0b69abec49f43b3b3cd32702f3da4ff14fd69ba39783e618dc90a94ce0f2e30bb9fcab9230383f795ea32a073ba5da5aecefdef8a8f53 + languageName: node + linkType: hard + "@metamask/polling-controller@npm:^16.0.4, @metamask/polling-controller@npm:^16.0.6, @metamask/polling-controller@npm:^16.0.7, @metamask/polling-controller@npm:^16.0.8": version: 16.0.8 resolution: "@metamask/polling-controller@npm:16.0.8" @@ -9919,7 +10105,7 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^15.0.0, @metamask/ramps-controller@npm:^15.1.0": +"@metamask/ramps-controller@npm:^15.1.0": version: 15.1.0 resolution: "@metamask/ramps-controller@npm:15.1.0" dependencies: @@ -9931,6 +10117,19 @@ __metadata: languageName: node linkType: hard +"@metamask/ramps-controller@npm:^17.0.0": + version: 17.0.0 + resolution: "@metamask/ramps-controller@npm:17.0.0" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/remote-feature-flag-controller": "npm:^4.2.2" + checksum: 10/45980e93d79bf72c00edc44e2cd075a738c44a1c0b3dd695a7bce688f7954f80f28988b56c7a5774b1747e335b66c16f6a7d36833030bf116cefafc7b2e5f223 + languageName: node + linkType: hard + "@metamask/react-data-query@npm:^0.2.0": version: 0.2.0 resolution: "@metamask/react-data-query@npm:0.2.0" @@ -10649,15 +10848,15 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-pay-controller@npm:^23.17.4": - version: 23.17.4 - resolution: "@metamask/transaction-pay-controller@npm:23.17.4" +"@metamask/transaction-pay-controller@npm:^25.0.0": + version: 25.0.0 + resolution: "@metamask/transaction-pay-controller@npm:25.0.0" dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^10.0.1" - "@metamask/assets-controllers": "npm:^109.3.0" + "@metamask/assets-controller": "npm:^11.0.0" + "@metamask/assets-controllers": "npm:^109.4.1" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.2.4" @@ -10665,15 +10864,15 @@ __metadata: "@metamask/messenger": "npm:^1.2.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^34.0.0" - "@metamask/ramps-controller": "npm:^15.0.0" + "@metamask/ramps-controller": "npm:^17.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" - "@metamask/transaction-controller": "npm:^68.2.2" + "@metamask/transaction-controller": "npm:^69.0.0" "@metamask/utils": "npm:^11.11.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" immer: "npm:^9.0.6" lodash: "npm:^4.17.21" - checksum: 10/7501ce059e3d7a13eadd2bad8b70bea56dd881cb6007eb71efaf1ad4edbc5450180355328152da1d490079a5ff17611cede9d90284a80e41dbca797c1274cf59 + checksum: 10/450fcfbe831d10104bc9bc5db539eb46ecece84551edd6a05c9a34c4d93c4535ef0ffc56be4f2d684f89da94fb0c7a9cc908d06f8d7263680c268600ac96fccc languageName: node linkType: hard @@ -35939,8 +36138,8 @@ __metadata: "@metamask/test-dapp-bitcoin": "npm:^0.2.0" "@metamask/test-dapp-multichain": "npm:^0.17.1" "@metamask/test-dapp-solana": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^68.2.0" - "@metamask/transaction-pay-controller": "npm:^23.17.4" + "@metamask/transaction-controller": "npm:^69.0.0" + "@metamask/transaction-pay-controller": "npm:^25.0.0" "@metamask/tron-wallet-snap": "npm:^1.31.0" "@metamask/utils": "npm:^11.11.0" "@metamask/wallet": "npm:^6.0.0" From 9e4af1f4b4b8c4bc2ee7eae3e9e37923eaa9a274 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Wed, 15 Jul 2026 22:32:50 -0700 Subject: [PATCH 2/2] dedupe --- yarn.lock | 47 ++--------------------------------------------- 1 file changed, 2 insertions(+), 45 deletions(-) diff --git a/yarn.lock b/yarn.lock index d5df4672768..e1daf6a8b22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7893,32 +7893,7 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.5.0, @metamask/account-tree-controller@npm:^7.5.3, @metamask/account-tree-controller@npm:^7.5.4": - version: 7.5.4 - resolution: "@metamask/account-tree-controller@npm:7.5.4" - dependencies: - "@metamask/accounts-controller": "npm:^39.0.4" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-api": "npm:^23.5.0" - "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/multichain-account-service": "npm:^12.0.0" - "@metamask/profile-sync-controller": "npm:^28.2.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/superstruct": "npm:^3.1.0" - "@metamask/utils": "npm:^11.11.0" - fast-deep-equal: "npm:^3.1.3" - lodash: "npm:^4.17.21" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/da47eacc508c2810b4f0d152d0eac98c912c3887f7375542191dc9c70135666d8421a3c9c1b585644c6fe6ff991ba44fbab2061f12f2aecc766390657bc84b87 - languageName: node - linkType: hard - -"@metamask/account-tree-controller@npm:^7.5.5": +"@metamask/account-tree-controller@npm:^7.2.0, @metamask/account-tree-controller@npm:^7.5.0, @metamask/account-tree-controller@npm:^7.5.3, @metamask/account-tree-controller@npm:^7.5.4, @metamask/account-tree-controller@npm:^7.5.5": version: 7.5.5 resolution: "@metamask/account-tree-controller@npm:7.5.5" dependencies: @@ -9939,25 +9914,7 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^17.2.0": - version: 17.2.0 - resolution: "@metamask/phishing-controller@npm:17.2.0" - dependencies: - "@metamask/address-book-controller": "npm:^7.1.2" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.1.0" - "@metamask/messenger": "npm:^1.2.0" - "@metamask/transaction-controller": "npm:^65.4.0" - "@noble/hashes": "npm:^1.8.0" - "@types/punycode": "npm:^2.1.0" - ethereum-cryptography: "npm:^2.1.2" - fastest-levenshtein: "npm:^1.0.16" - punycode: "npm:^2.1.1" - checksum: 10/de271d813043ca535e76cff52084ac9d4e0da762fa02e75425583dfc068c4b4bcb189266cb9cf58baf7ed79ab58f0b003a5643426780bb49192ca30212640b05 - languageName: node - linkType: hard - -"@metamask/phishing-controller@npm:^17.2.1": +"@metamask/phishing-controller@npm:^17.2.0, @metamask/phishing-controller@npm:^17.2.1": version: 17.2.1 resolution: "@metamask/phishing-controller@npm:17.2.1" dependencies: