Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `requiredTransactionCount` to Relay-backed `TransactionPayQuote` values so clients can predict how many TransactionController child transactions a quote will create ([#9897](https://github.com/MetaMask/core/pull/9897))

### Changed

- Bump `@metamask/sentinel-api-service` from `^1.0.0` to `^1.0.1` ([#9972](https://github.com/MetaMask/core/pull/9972))
Expand Down Expand Up @@ -51,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Prevent Relay quotes from offering EIP-7702 gas fee token sponsorship when the source account does not support EIP-7702 ([#9897](https://github.com/MetaMask/core/pull/9897))
- Read the `stableTokens` remote feature flag in `getStablecoins` instead of `stable-tokens` ([#9885](https://github.com/MetaMask/core/pull/9885))

## [26.3.0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
getTokenBalance,
getTokenFiatRate,
} from '../../utils/token.js';
import { getRelayQuotes } from './relay-quotes.js';
import { getRelayQuotes, getRequiredTransactionCount } from './relay-quotes.js';
import type { RelayQuote, RelayTransactionStep } from './types.js';

jest.mock('../../utils/token', () => ({
Expand Down Expand Up @@ -245,6 +245,138 @@ describe('Relay Quotes Utils', () => {
successfulFetchMock.mockRestore();
});

describe('getRequiredTransactionCount', () => {
it('returns one for a sequential quote with one local transaction', () => {
expect(
getRequiredTransactionCount({
is7702: false,
quote: QUOTE_MOCK,
request: QUOTE_REQUEST_MOCK,
}),
).toBe(1);
});

it('counts each local transaction in a sequential quote', () => {
const quote = cloneDeep(QUOTE_MOCK);
quote.steps[0].items.push(cloneDeep(quote.steps[0].items[0]));

expect(
getRequiredTransactionCount({
is7702: false,
quote,
request: QUOTE_REQUEST_MOCK,
}),
).toBe(2);
});

it('returns one for a 7702 batch with multiple local transactions', () => {
const quote = cloneDeep(QUOTE_MOCK);
quote.steps[0].items.push(cloneDeep(quote.steps[0].items[0]));

expect(
getRequiredTransactionCount({
is7702: true,
quote,
request: QUOTE_REQUEST_MOCK,
}),
).toBe(1);
});

it('returns undefined for Relay execute quotes', () => {
const quote = cloneDeep(QUOTE_MOCK);
quote.metamask.isExecute = true;

expect(
getRequiredTransactionCount({
is7702: false,
quote,
request: QUOTE_REQUEST_MOCK,
}),
).toBeUndefined();
});

it('returns undefined for post-quote requests', () => {
expect(
getRequiredTransactionCount({
is7702: false,
quote: QUOTE_MOCK,
request: { ...QUOTE_REQUEST_MOCK, isPostQuote: true },
}),
).toBeUndefined();
});

it('returns undefined for payment override requests', () => {
expect(
getRequiredTransactionCount({
is7702: false,
quote: QUOTE_MOCK,
request: {
...QUOTE_REQUEST_MOCK,
paymentOverride: PaymentOverride.MoneyAccount,
},
}),
).toBeUndefined();
});

it('returns undefined for HyperLiquid source requests', () => {
expect(
getRequiredTransactionCount({
is7702: false,
quote: QUOTE_MOCK,
request: { ...QUOTE_REQUEST_MOCK, isHyperliquidSource: true },
}),
).toBeUndefined();
});

it('returns undefined for Polymarket deposit wallet requests', () => {
expect(
getRequiredTransactionCount({
is7702: false,
quote: QUOTE_MOCK,
request: {
...QUOTE_REQUEST_MOCK,
isPolymarketDepositWallet: true,
},
}),
).toBeUndefined();
});

it('returns undefined when the quote has no local transactions', () => {
const quote = cloneDeep(QUOTE_MOCK);
quote.steps = [];

expect(
getRequiredTransactionCount({
is7702: false,
quote,
request: QUOTE_REQUEST_MOCK,
}),
).toBeUndefined();
});

it('returns undefined when local transaction data is malformed', () => {
const quote = {
...QUOTE_MOCK,
steps: [
{
id: 'deposit',
items: [{ status: 'incomplete' }],
kind: 'transaction',
requestId: '0x2',
},
],
} as unknown as RelayQuote;

expect(
getRequiredTransactionCount({
is7702: false,
quote,
request: QUOTE_REQUEST_MOCK,
}),
).toBeUndefined();
});
});

describe('getRelayQuotes', () => {
it('returns quotes from Relay', async () => {
successfulFetchMock.mockResolvedValue({
Expand All @@ -262,6 +394,7 @@ describe('Relay Quotes Utils', () => {
expect(result).toStrictEqual([
expect.objectContaining({
original: QUOTE_MOCK,
requiredTransactionCount: 1,
}),
]);
});
Expand Down Expand Up @@ -2682,6 +2815,26 @@ describe('Relay Quotes Utils', () => {
});
});

it('does not use a gas fee token if account does not support EIP-7702', async () => {
successfulFetchMock.mockResolvedValue({
ok: true,
json: async () => QUOTE_MOCK,
} as never);

getTokenBalanceMock.mockReturnValue('1724999999999999');
getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]);

const result = await getRelayQuotes({
accountSupports7702: false,
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(result[0].fees.isSourceGasFeeToken).toBeUndefined();
expect(getGasFeeTokensMock).not.toHaveBeenCalled();
});

it('using estimated gas fee token cost if insufficient native balance and batch', async () => {
const quote = cloneDeep(QUOTE_MOCK);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
} from './polymarket/withdraw.js';
import { fetchRelayQuote } from './relay-api.js';
import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js';
import { getRelayTransactionStepData } from './relay-submit.js';
import { validateRelayQuotes } from './relay-validation.js';
import type {
RelayQuote,
Expand Down Expand Up @@ -714,6 +715,50 @@ function normalizeRequest(
return newRequest;
}

/**
* Gets the number of TransactionController child transactions created by a
* Relay quote.
*
* @param options - Count options.
* @param options.is7702 - Whether the local transactions use a 7702 batch.
* @param options.quote - Raw Relay quote.
* @param options.request - Associated quote request.
* @returns The expected child transaction count, or `undefined` when unknown.
*/
export function getRequiredTransactionCount({

@matthewwalsh0 matthewwalsh0 Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pay controller is just one instance where we call addTransactionBatch and may sequentially sign if EIP-7702 is supported.

So rather than coupling the modal to every instance that does this, should we instead provide this information at the source within the TransactionController?

Maybe some transient state such as signatureCounts that is keyed on batch ID that is set within addTransactionBatch?

Then the hardware modal can check using the batchId of the transaction it's signing?

is7702,
quote,
request,
}: {
is7702: boolean;
quote: RelayQuote;
request: QuoteRequest;
}): number | undefined {
if (
quote.metamask?.isExecute ||
request.isPostQuote ||
request.paymentOverride ||
request.isHyperliquidSource ||
request.isPolymarketDepositWallet
) {
return undefined;
}

let transactionCount: number;

try {
transactionCount = getRelayTransactionStepData(quote).length;
} catch {
return undefined;
}

if (!transactionCount) {
return undefined;
}

return is7702 ? 1 : transactionCount;
}

/**
* Normalizes a Relay quote into a TransactionPayQuote.
*
Expand Down Expand Up @@ -765,6 +810,7 @@ async function normalizeQuote(
messenger,
request,
fullRequest.transaction,
fullRequest.accountSupports7702,
);

const targetNetwork = {
Expand Down Expand Up @@ -796,6 +842,12 @@ async function normalizeQuote(
is7702,
};

const requiredTransactionCount = getRequiredTransactionCount({
is7702,
quote,
request,
});

return {
dust,
estimatedDuration: details.timeEstimate,
Expand All @@ -811,6 +863,9 @@ async function normalizeQuote(
metamask,
},
request,
...(requiredTransactionCount === undefined
? {}
: { requiredTransactionCount }),
sourceAmount,
targetAmount,
strategy: TransactionPayStrategy.Relay,
Expand Down Expand Up @@ -901,13 +956,15 @@ function getFiatRates(
* @param messenger - Controller messenger.
* @param request - Quote request.
* @param transaction - Original transaction metadata.
* @param accountSupports7702 - Whether the source account supports EIP-7702.
* @returns Total source network cost in USD and fiat.
*/
async function calculateSourceNetworkCost(
quote: RelayQuote,
messenger: TransactionPayControllerMessenger,
request: QuoteRequest,
transaction: TransactionMeta,
accountSupports7702: boolean | undefined,
): Promise<
TransactionPayQuote<RelayQuote>['fees']['sourceNetwork'] & {
gasLimits: number[];
Expand Down Expand Up @@ -1045,6 +1102,14 @@ async function calculateSourceNetworkCost(
return result;
}

if (accountSupports7702 === false) {
log('Skipping gas station as account does not support EIP-7702', {
from,
});

return result;
}

const gasStationEligibility = getGasStationEligibility(
messenger,
sourceChainId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -678,10 +678,10 @@ async function submitTransactions(
);
}

function getRelayTransactionStepData(
quote: TransactionPayQuote<RelayQuote>,
export function getRelayTransactionStepData(
quote: RelayQuote,
): RelayTransactionStep['items'][0]['data'][] {
const { steps } = quote.original;
const { steps } = quote;
const supportedStepKinds = ['transaction', 'signature'];
const invalidKind = steps.find(
(step) => !supportedStepKinds.includes(step.kind),
Expand Down Expand Up @@ -712,7 +712,7 @@ async function buildRelaySubmitParams({
quote: TransactionPayQuote<RelayQuote>;
transaction: TransactionMeta;
}): Promise<RelaySubmitParams> {
const params = getRelayTransactionStepData(quote);
const params = getRelayTransactionStepData(quote.original);
const normalizedParams = params.map((singleParams) =>
normalizeParams(singleParams, messenger),
);
Expand Down
6 changes: 6 additions & 0 deletions packages/transaction-pay-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,12 @@ export type TransactionPayQuote<OriginalQuote> = {
/** Associated quote request. */
request: QuoteRequest;

/**
* Expected number of TransactionController child transactions created when
* this quote is executed. Undefined when the count cannot be predicted.
*/
requiredTransactionCount?: number;

/** Amount of source token required. */
sourceAmount: Amount;

Expand Down