diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 96b301bed6..404a39f14b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -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)) @@ -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] diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 03487050b8..40ec7d81ab 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -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', () => ({ @@ -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({ @@ -262,6 +394,7 @@ describe('Relay Quotes Utils', () => { expect(result).toStrictEqual([ expect.objectContaining({ original: QUOTE_MOCK, + requiredTransactionCount: 1, }), ]); }); @@ -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); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 01e684698a..5f9908aa6e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -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, @@ -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({ + 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. * @@ -765,6 +810,7 @@ async function normalizeQuote( messenger, request, fullRequest.transaction, + fullRequest.accountSupports7702, ); const targetNetwork = { @@ -796,6 +842,12 @@ async function normalizeQuote( is7702, }; + const requiredTransactionCount = getRequiredTransactionCount({ + is7702, + quote, + request, + }); + return { dust, estimatedDuration: details.timeEstimate, @@ -811,6 +863,9 @@ async function normalizeQuote( metamask, }, request, + ...(requiredTransactionCount === undefined + ? {} + : { requiredTransactionCount }), sourceAmount, targetAmount, strategy: TransactionPayStrategy.Relay, @@ -901,6 +956,7 @@ 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( @@ -908,6 +964,7 @@ async function calculateSourceNetworkCost( messenger: TransactionPayControllerMessenger, request: QuoteRequest, transaction: TransactionMeta, + accountSupports7702: boolean | undefined, ): Promise< TransactionPayQuote['fees']['sourceNetwork'] & { gasLimits: number[]; @@ -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, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 32c3dfa3ab..b1fe950e60 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -678,10 +678,10 @@ async function submitTransactions( ); } -function getRelayTransactionStepData( - quote: TransactionPayQuote, +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), @@ -712,7 +712,7 @@ async function buildRelaySubmitParams({ quote: TransactionPayQuote; transaction: TransactionMeta; }): Promise { - const params = getRelayTransactionStepData(quote); + const params = getRelayTransactionStepData(quote.original); const normalizedParams = params.map((singleParams) => normalizeParams(singleParams, messenger), ); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index d940c0dbcd..c6b4041c9f 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -679,6 +679,12 @@ export type TransactionPayQuote = { /** 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;