diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index cab7f66d35f..4c7a871d05b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fall back from Across to later pay strategies when Across quotes would require a first-time EIP-7702 authorization list ([#8577](https://github.com/MetaMask/core/pull/8577)) +- Fall back to later pay strategies when an earlier quote requires origin native gas that the account cannot pay ([#8581](https://github.com/MetaMask/core/pull/8581)) ## [19.3.0] diff --git a/packages/transaction-pay-controller/src/utils/quote-usability.test.ts b/packages/transaction-pay-controller/src/utils/quote-usability.test.ts new file mode 100644 index 00000000000..3d48d255fc0 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quote-usability.test.ts @@ -0,0 +1,195 @@ +import type { Hex, Json } from '@metamask/utils'; + +import { getMessengerMock } from '../tests/messenger-mock'; +import type { TransactionPayQuote } from '../types'; +import { checkQuoteUsability } from './quote-usability'; +import { getNativeToken, getTokenBalance } from './token'; + +jest.mock('./token', () => ({ + ...jest.requireActual('./token'), + getTokenBalance: jest.fn(), +})); + +const ACCOUNT_MOCK = '0xabc' as Hex; +const SOURCE_CHAIN_ID_MOCK = '0x1' as Hex; +const SOURCE_TOKEN_ADDRESS_MOCK = + '0x1234567890123456789012345678901234567890' as Hex; +const TARGET_CHAIN_ID_MOCK = '0x2' as Hex; +const TARGET_TOKEN_ADDRESS_MOCK = + '0x9876543210987654321098765432109876543210' as Hex; + +const QUOTE_MOCK = { + dust: { + fiat: '0', + usd: '0', + }, + estimatedDuration: 1, + fees: { + metaMask: { + fiat: '0', + usd: '0', + }, + provider: { + fiat: '0', + usd: '0', + }, + sourceNetwork: { + estimate: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + max: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: {}, + request: { + from: ACCOUNT_MOCK, + sourceBalanceRaw: '100', + sourceChainId: SOURCE_CHAIN_ID_MOCK, + sourceTokenAddress: SOURCE_TOKEN_ADDRESS_MOCK, + sourceTokenAmount: '0', + targetAmountMinimum: '0', + targetChainId: TARGET_CHAIN_ID_MOCK, + targetTokenAddress: TARGET_TOKEN_ADDRESS_MOCK, + }, + sourceAmount: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + strategy: 'test', + targetAmount: { + fiat: '0', + usd: '0', + }, +} as TransactionPayQuote; + +describe('Quote Usability Utils', () => { + const { messenger } = getMessengerMock(); + const getTokenBalanceMock = jest.mocked(getTokenBalance); + + beforeEach(() => { + jest.resetAllMocks(); + + getTokenBalanceMock.mockReturnValue('100'); + }); + + describe('checkQuoteUsability', () => { + it('returns unusable if a quote requires an authorization list', () => { + const result = checkQuoteUsability({ + messenger, + quotes: [ + { + ...QUOTE_MOCK, + original: { + metamask: { + requiresAuthorizationList: true, + }, + }, + } as TransactionPayQuote, + ], + }); + + expect(result).toStrictEqual({ + reason: 'requires_authorization_list', + usable: false, + }); + }); + + it('uses the quote source balance for native source-token requirements', () => { + const result = checkQuoteUsability({ + messenger, + quotes: [ + { + ...QUOTE_MOCK, + request: { + ...QUOTE_MOCK.request, + sourceBalanceRaw: '5', + sourceTokenAddress: getNativeToken(SOURCE_CHAIN_ID_MOCK), + }, + sourceAmount: { + ...QUOTE_MOCK.sourceAmount, + raw: '10', + }, + } as TransactionPayQuote, + ], + }); + + expect(result).toStrictEqual({ + reason: 'insufficient_native_gas', + usable: false, + }); + expect(getTokenBalanceMock).not.toHaveBeenCalled(); + }); + + it('treats quotes with non-object original data as usable if no native balance is required', () => { + const result = checkQuoteUsability({ + messenger, + quotes: [ + { + ...QUOTE_MOCK, + original: undefined as never, + }, + ], + }); + + expect(result).toStrictEqual({ usable: true }); + }); + + it('treats invalid native amount data as zero', () => { + const result = checkQuoteUsability({ + messenger, + quotes: [ + { + ...QUOTE_MOCK, + request: { + ...QUOTE_MOCK.request, + sourceTokenAddress: getNativeToken(SOURCE_CHAIN_ID_MOCK), + }, + sourceAmount: { + ...QUOTE_MOCK.sourceAmount, + raw: 'invalid', + }, + } as TransactionPayQuote, + ], + }); + + expect(result).toStrictEqual({ usable: true }); + }); + + it('treats missing native gas amount data as zero', () => { + const result = checkQuoteUsability({ + messenger, + quotes: [ + { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + sourceNetwork: { + ...QUOTE_MOCK.fees.sourceNetwork, + max: { + ...QUOTE_MOCK.fees.sourceNetwork.max, + raw: undefined as never, + }, + }, + }, + } as TransactionPayQuote, + ], + }); + + expect(result).toStrictEqual({ usable: true }); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/quote-usability.ts b/packages/transaction-pay-controller/src/utils/quote-usability.ts new file mode 100644 index 00000000000..5d4636c8efb --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quote-usability.ts @@ -0,0 +1,153 @@ +import type { Hex, Json } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../types'; +import { + getNativeToken, + getTokenBalance, + normalizeTokenAddress, + TokenAddressTarget, +} from './token'; + +export type QuoteUsabilityReason = + | 'requires_authorization_list' + | 'requires_origin_gas' + | 'insufficient_native_gas'; + +export type QuoteUsabilityResult = + | { usable: true } + | { + reason: QuoteUsabilityReason; + usable: false; + }; + +type NativeRequirement = { + balanceRaw?: string; + from: Hex; + nativeGasRaw: BigNumber; + nativeTokenAddress: Hex; + sourceChainId: Hex; + totalRaw: BigNumber; +}; + +/** + * Check whether quotes are usable by the current account context. + * + * @param request - Request object. + * @param request.messenger - Controller messenger. + * @param request.quotes - Quotes to check. + * @returns Whether the quotes are usable. + */ +export function checkQuoteUsability({ + messenger, + quotes, +}: { + messenger: TransactionPayControllerMessenger; + quotes: TransactionPayQuote[]; +}): QuoteUsabilityResult { + if (quotes.some(requiresAuthorizationList)) { + return { usable: false, reason: 'requires_authorization_list' }; + } + + const nativeRequirements = getNativeRequirements(messenger, quotes); + + for (const requirement of nativeRequirements.values()) { + const balanceRaw = toBigNumber(requirement.balanceRaw); + + if (balanceRaw.isLessThan(requirement.totalRaw)) { + return { + usable: false, + reason: requirement.nativeGasRaw.isGreaterThan(0) + ? 'requires_origin_gas' + : 'insufficient_native_gas', + }; + } + } + + return { usable: true }; +} + +function getNativeRequirements( + messenger: TransactionPayControllerMessenger, + quotes: TransactionPayQuote[], +): Map { + const requirements = new Map(); + + for (const quote of quotes) { + const { from, sourceChainId, sourceTokenAddress } = quote.request; + const nativeTokenAddress = getNativeToken(sourceChainId); + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + const isSourceNative = + normalizedSourceTokenAddress.toLowerCase() === + nativeTokenAddress.toLowerCase(); + + const nativeGasRaw = quote.fees.isSourceGasFeeToken + ? new BigNumber(0) + : toBigNumber(quote.fees.sourceNetwork.max.raw); + const sourceAmountRaw = isSourceNative + ? toBigNumber(quote.sourceAmount.raw) + : new BigNumber(0); + const totalRaw = nativeGasRaw.plus(sourceAmountRaw); + + if (totalRaw.isLessThanOrEqualTo(0)) { + continue; + } + + const key = `${from.toLowerCase()}:${sourceChainId.toLowerCase()}`; + const existing = requirements.get(key); + const requirement = existing ?? { + from, + nativeGasRaw: new BigNumber(0), + nativeTokenAddress, + sourceChainId, + totalRaw: new BigNumber(0), + }; + + requirement.nativeGasRaw = requirement.nativeGasRaw.plus(nativeGasRaw); + requirement.totalRaw = requirement.totalRaw.plus(totalRaw); + + if (isSourceNative) { + requirement.balanceRaw = quote.request.sourceBalanceRaw; + } else { + requirement.balanceRaw ??= getTokenBalance( + messenger, + from, + sourceChainId, + nativeTokenAddress, + ); + } + + requirements.set(key, requirement); + } + + return requirements; +} + +function requiresAuthorizationList(quote: TransactionPayQuote): boolean { + const { original } = quote; + + if (!isRecord(original)) { + return false; + } + + const { metamask } = original; + + return isRecord(metamask) && metamask.requiresAuthorizationList === true; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toBigNumber(value: BigNumber.Value | undefined): BigNumber { + const result = new BigNumber(value ?? 0); + + return result.isFinite() ? result : new BigNumber(0); +} diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 2d2030acff4..1f741b7d3b5 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -22,7 +22,12 @@ import { getStrategiesByName, getStrategyByName, } from './strategy'; -import { getLiveTokenBalance, getTokenFiatRate } from './token'; +import { + getNativeToken, + getLiveTokenBalance, + getTokenBalance, + getTokenFiatRate, +} from './token'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; @@ -33,6 +38,13 @@ jest.mock('./token', () => ({ ...jest.createMockFromModule('./token'), computeTokenAmounts: jest.requireActual('./token').computeTokenAmounts, + getNativeToken: + jest.requireActual('./token').getNativeToken, + normalizeTokenAddress: + jest.requireActual('./token') + .normalizeTokenAddress, + TokenAddressTarget: + jest.requireActual('./token').TokenAddressTarget, })); jest.useFakeTimers(); @@ -70,7 +82,57 @@ const QUOTE_MOCK = { usd: '1.23', fiat: '2.34', }, + estimatedDuration: 1, + fees: { + metaMask: { + fiat: '0', + usd: '0', + }, + provider: { + fiat: '0', + usd: '0', + }, + sourceNetwork: { + estimate: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + max: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: {}, + request: { + from: TRANSACTION_META_MOCK.txParams.from, + sourceBalanceRaw: '5000000', + sourceChainId: '0x123' as Hex, + sourceTokenAddress: '0x123' as Hex, + sourceTokenAmount: '1000000', + targetAmountMinimum: '1000000', + targetChainId: '0x456' as Hex, + targetTokenAddress: '0x456' as Hex, + }, + sourceAmount: { + fiat: '0', + human: '1', + raw: '1000000', + usd: '0', + }, strategy: TransactionPayStrategy.Test, + targetAmount: { + fiat: '0', + usd: '0', + }, } as TransactionPayQuote; const TOTALS_MOCK = { @@ -111,6 +173,7 @@ describe('Quotes Utils', () => { const updateTransactionMock = jest.mocked(updateTransaction); const calculateTotalsMock = jest.mocked(calculateTotals); const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); + const getTokenBalanceMock = jest.mocked(getTokenBalance); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getStrategiesMock = jest.fn(); const getQuotesMock = jest.fn(); @@ -179,6 +242,7 @@ describe('Quotes Utils', () => { calculateTotalsMock.mockReturnValue(TOTALS_MOCK); getLiveTokenBalanceMock.mockResolvedValue('5000000'); + getTokenBalanceMock.mockReturnValue('1000000000000000000'); getTokenFiatRateMock.mockReturnValue({ usdRate: '1.0', fiatRate: '0.85', @@ -438,6 +502,146 @@ describe('Quotes Utils', () => { expect(supportedStrategy.getQuotes).toHaveBeenCalled(); }); + it('falls back to next strategy when quote requires origin gas the account cannot pay', async () => { + const acrossQuote = { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + sourceNetwork: { + ...QUOTE_MOCK.fees.sourceNetwork, + max: { + fiat: '0.01', + human: '0.01', + raw: '10000000000000000', + usd: '0.01', + }, + }, + }, + strategy: TransactionPayStrategy.Across, + } as TransactionPayQuote; + const relayQuote = { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + isSourceGasFeeToken: true, + }, + strategy: TransactionPayStrategy.Relay, + } as TransactionPayQuote; + + const acrossStrategy = { + supports: jest.fn().mockReturnValue(true), + checkQuoteSupport: jest.fn().mockResolvedValue(true), + getQuotes: jest.fn().mockResolvedValue([acrossQuote]), + getBatchTransactions: jest.fn(), + execute: jest.fn(), + }; + + const relayStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([relayQuote]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getTokenBalanceMock.mockReturnValue('0'); + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Across, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Across) { + return acrossStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return relayStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + const transactionDataMock: Record = {}; + updateTransactionDataMock.mock.calls.forEach( + (call: [string, (data: Record) => void]) => + call[1](transactionDataMock), + ); + + expect(acrossStrategy.getQuotes).toHaveBeenCalled(); + expect(acrossStrategy.getBatchTransactions).not.toHaveBeenCalled(); + expect(relayStrategy.getQuotes).toHaveBeenCalled(); + expect(transactionDataMock.quotes).toStrictEqual([relayQuote]); + expect(getTokenBalanceMock).toHaveBeenCalledWith( + messenger, + TRANSACTION_META_MOCK.txParams.from, + acrossQuote.request.sourceChainId, + getNativeToken(acrossQuote.request.sourceChainId), + ); + }); + + it('uses first strategy when quote requires origin gas the account can pay', async () => { + const acrossQuote = { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + sourceNetwork: { + ...QUOTE_MOCK.fees.sourceNetwork, + max: { + fiat: '0.01', + human: '0.01', + raw: '10000000000000000', + usd: '0.01', + }, + }, + }, + strategy: TransactionPayStrategy.Across, + } as TransactionPayQuote; + + const acrossStrategy = { + supports: jest.fn().mockReturnValue(true), + checkQuoteSupport: jest.fn().mockResolvedValue(true), + getQuotes: jest.fn().mockResolvedValue([acrossQuote]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + const relayStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + execute: jest.fn(), + }; + + getTokenBalanceMock.mockReturnValue('10000000000000000'); + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Across, + TransactionPayStrategy.Relay, + ]); + getStrategyByNameMock.mockImplementation((name) => { + if (name === TransactionPayStrategy.Across) { + return acrossStrategy as never; + } + + if (name === TransactionPayStrategy.Relay) { + return relayStrategy as never; + } + + throw new Error(`Unknown strategy: ${name}`); + }); + + await run(); + + const transactionDataMock: Record = {}; + updateTransactionDataMock.mock.calls.forEach( + (call: [string, (data: Record) => void]) => + call[1](transactionDataMock), + ); + + expect(acrossStrategy.getQuotes).toHaveBeenCalled(); + expect(relayStrategy.getQuotes).not.toHaveBeenCalled(); + expect(transactionDataMock.quotes).toStrictEqual([acrossQuote]); + }); + it('continues to next strategy if supports throws', async () => { const brokenStrategy = { supports: jest.fn().mockImplementation(() => { diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index ec0b7bf927a..0e1d4719a05 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -17,6 +17,7 @@ import type { TransactionPaymentToken, UpdateTransactionDataCallback, } from '../types'; +import { checkQuoteUsability } from './quote-usability'; import { checkStrategyQuoteSupport, checkStrategySupport, @@ -517,6 +518,8 @@ async function getQuotes( for (const { name, strategy } of strategies) { try { + log('Trying quote strategy', { strategy: name, transactionId }); + const support = await checkStrategySupport(strategy, request); if (!support) { @@ -550,6 +553,17 @@ async function getQuotes( continue; } + const quoteUsability = checkQuoteUsability({ messenger, quotes }); + + if (!quoteUsability.usable) { + log('Strategy quote unusable', { + reason: quoteUsability.reason, + strategy: name, + transactionId, + }); + continue; + } + log('Updated', { transactionId, quotes }); const batchTransactions = strategy.getBatchTransactions @@ -560,6 +574,7 @@ async function getQuotes( : []; log('Batch transactions', { transactionId, batchTransactions }); + log('Using quote strategy', { strategy: name, transactionId }); return { batchTransactions,