Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('./token')>('./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<Json>;

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<Json>,
],
});

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<Json>,
],
});

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<Json>,
],
});

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<Json>,
],
});

expect(result).toStrictEqual({ usable: true });
});
});
});
153 changes: 153 additions & 0 deletions packages/transaction-pay-controller/src/utils/quote-usability.ts
Original file line number Diff line number Diff line change
@@ -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<Json>[];
}): 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<Json>[],
): Map<string, NativeRequirement> {
const requirements = new Map<string, NativeRequirement>();

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<Json>): 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<string, unknown> {
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);
}
Loading
Loading