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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/components/Views/confirmations/constants/confirmations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -63,6 +65,7 @@ describe('useNoPayTokenQuotesAlert', () => {
const useTransactionPayRequiredTokensMock = jest.mocked(
useTransactionPayRequiredTokens,
);
const useTransactionPayWithdrawMock = jest.mocked(useTransactionPayWithdraw);

beforeEach(() => {
jest.resetAllMocks();
Expand All @@ -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);
});
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<typeof useTransactionPayToken>);

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<typeof useTransactionPayToken>);

const { result } = runHook();

expect(result.current).toStrictEqual([]);
});

it('returns no alerts while quotes are loading', () => {
useTransactionPayTokenMock.mockReturnValue({
payToken: undefined,
} as ReturnType<typeof useTransactionPayToken>);
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<typeof useTransactionPayToken>);
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<typeof useTransactionPayToken>);

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([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
cursor[bot] marked this conversation as resolved.

// 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;
Comment thread
cursor[bot] marked this conversation as resolved.

return useMemo(() => {
if (!showAlert) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading