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: