From a65c5492356942190fffc74a8cb416836a604393 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 11 May 2026 12:13:28 +0100 Subject: [PATCH] Add Across Predict withdraw quote support --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../strategy/across/AcrossStrategy.test.ts | 93 ++++ .../src/strategy/across/AcrossStrategy.ts | 19 +- .../src/strategy/across/across-quotes.test.ts | 459 ++++++++++++++++++ .../src/strategy/across/across-quotes.ts | 283 +++++++++-- .../src/strategy/across/types.ts | 1 + 6 files changed, 821 insertions(+), 38 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index fbe8309fb6..676ca73759 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 Across quote support for post-quote Predict withdraw flows ([#8760](https://github.com/MetaMask/core/pull/8760)) + ### Changed - Derive fiat order source amount from on-chain transaction data (`order.txHash`) with fallback to `order.cryptoAmount` ([#8694](https://github.com/MetaMask/core/pull/8694)) diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index 6539a87c93..c63d7ba7a8 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -7,6 +7,7 @@ import type { Hex } from '@metamask/utils'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../../constants'; import type { + PayStrategyCheckQuoteSupportRequest, PayStrategyExecuteRequest, PayStrategyGetQuotesRequest, TransactionPayQuote, @@ -56,6 +57,25 @@ describe('AcrossStrategy', () => { ], } as PayStrategyGetQuotesRequest; + const quoteWithAuthorizationList = { + request: { + ...baseRequest.requests[0], + }, + original: { + metamask: { + gasLimits: [], + is7702: true, + requiresAuthorizationList: true, + }, + quote: {}, + request: { + actions: [], + amount: '100', + tradeType: 'exactInput', + }, + }, + } as TransactionPayQuote; + beforeEach(() => { jest.resetAllMocks(); getPayStrategiesConfigMock.mockReturnValue({ @@ -351,6 +371,79 @@ describe('AcrossStrategy', () => { expect(result).toBe(false); }); + it('supports source 7702 authorization lists for Predict withdraw post-quote quotes without Across actions', () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [ + { + ...quoteWithAuthorizationList, + request: { + ...quoteWithAuthorizationList.request, + isPostQuote: true, + }, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + } as TransactionMeta, + } as PayStrategyCheckQuoteSupportRequest; + + expect(strategy.checkQuoteSupport(request)).toBe(true); + }); + + it('does not support first-time 7702 authorization lists for non-post-quote Across quotes', () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [quoteWithAuthorizationList], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + } as TransactionMeta, + } as PayStrategyCheckQuoteSupportRequest; + + expect(strategy.checkQuoteSupport(request)).toBe(false); + }); + + it('does not support first-time 7702 authorization lists when the Across quote has embedded destination actions', () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [ + { + ...quoteWithAuthorizationList, + request: { + ...quoteWithAuthorizationList.request, + isPostQuote: true, + }, + original: { + ...quoteWithAuthorizationList.original, + request: { + ...quoteWithAuthorizationList.original.request, + actions: [ + { + args: [], + functionSignature: 'function transfer(address,uint256)', + isNativeTransfer: false, + target: '0xdef' as Hex, + value: '0', + }, + ], + }, + }, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + } as TransactionMeta, + } as PayStrategyCheckQuoteSupportRequest; + + expect(strategy.checkQuoteSupport(request)).toBe(false); + }); + it('supports 7702 quotes that do not require an authorization list', () => { const strategy = new AcrossStrategy(); const quote = { diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index fc141acfa0..0c942dd7d0 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -83,9 +83,26 @@ export class AcrossStrategy implements PayStrategy { // Gas planning can discover that TransactionController would add an // authorization list for a first-time 7702 upgrade. `is7702` alone is not a // blocker because it also covers already-upgraded accounts. - return !request.quotes.some( + const requiresAuthorizationList = request.quotes.some( (quote) => quote.original.metamask.requiresAuthorizationList, ); + + if (!requiresAuthorizationList) { + return true; + } + + if (!isPredictWithdrawTransaction(request.transaction)) { + return false; + } + + // A first-time 7702 authorization list is acceptable here only because it is + // attached to MetaMask's source-chain batch transaction. It must not be + // smuggled into Across destination post-swap actions. + return request.quotes.every( + (quote) => + quote.request.isPostQuote === true && + quote.original.request.actions.length === 0, + ); } async getQuotes( diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 2a872d6611..50e56e589c 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -54,6 +54,12 @@ const TRANSACTION_META_MOCK = { from: FROM_MOCK, }, } as TransactionMeta; +const PREDICT_WITHDRAW_TRANSACTION_MOCK = { + txParams: { + from: FROM_MOCK, + }, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], +} as TransactionMeta; const QUOTE_REQUEST_MOCK: QuoteRequest = { from: FROM_MOCK, @@ -329,6 +335,203 @@ describe('Across Quotes', () => { expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); }); + it('uses exactInput trade type without destination actions for post-quote predict withdraws with source-chain authorization lists', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + data: '0x12345678' as Hex, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('tradeType')).toBe('exactInput'); + expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); + expect(params.get('refundAddress')).toBe(refundTo); + expect(getRequestBody().actions).toStrictEqual([]); + }); + + it('ignores invalid original transaction gas for post-quote predict withdraws', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x0', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 21000, + max: 21000, + }, + ]); + }); + + it('adds original transaction gas to EIP-7702 gas limits for post-quote predict withdraws', async () => { + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [51000], + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 72000, + max: 72000, + }, + ]); + expect(result[0].original.metamask.is7702).toBe(true); + }); + + it('preserves Across per-leg gas pricing when adding original post-quote gas fees', async () => { + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [21000, 21000], + }); + + const amountByMaxFeePerGas = { + '0x2': { estimate: '20', max: '200' }, + '0x3': { estimate: '30', max: '300' }, + '0x5': { estimate: '50', max: '500' }, + }; + + calculateGasCostMock.mockImplementation(({ isMax, maxFeePerGas }) => { + const amounts = + amountByMaxFeePerGas[ + maxFeePerGas as keyof typeof amountByMaxFeePerGas + ]; + const raw = isMax ? amounts.max : amounts.estimate; + + return { + fiat: raw, + human: raw, + raw, + usd: raw, + }; + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => + ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + to: '0xapprove1' as Hex, + }, + ], + swapTx: { + ...QUOTE_MOCK.swapTx, + maxFeePerGas: '0x3', + maxPriorityFeePerGas: '0x1', + }, + }) as unknown as AcrossSwapApprovalResponse, + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + chainId: '0x1', + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + maxFeePerGas: '0x5', + maxPriorityFeePerGas: '0x1', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].fees.sourceNetwork.estimate.raw).toBe('100'); + expect(result[0].fees.sourceNetwork.max.raw).toBe('1000'); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: 21000, + maxFeePerGas: '0x5', + }), + ); + }); + it('re-quotes max amount quotes after reserving source token for gas fee token', async () => { const adjustedSourceAmount = '999999999999999900'; @@ -361,6 +564,7 @@ describe('Across Quotes', () => { } as Response); const result = await getAcrossQuotes({ + accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, @@ -381,6 +585,261 @@ describe('Across Quotes', () => { expect(result[0].fees.isSourceGasFeeToken).toBe(true); }); + it('re-quotes post-quote predict withdraws after reserving source token for gas fee token', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const adjustedSourceAmount = '900'; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: adjustedSourceAmount, + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '0', + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + + const [phase1Url] = successfulFetchMock.mock.calls[0]; + const [phase2Url] = successfulFetchMock.mock.calls[1]; + expect(new URL(phase1Url as string).searchParams.get('amount')).toBe( + '1000', + ); + expect(new URL(phase2Url as string).searchParams.get('amount')).toBe( + adjustedSourceAmount, + ); + expect(result[0].sourceAmount.raw).toBe(adjustedSourceAmount); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + + it('does not return unsafe post-quote predict withdraw source gas quotes when gas consumes the source amount', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '100', + }), + } as Response); + + await expect( + getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '0', + sourceTokenAmount: '100', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/cannot cover source gas fee token/u); + + expect(successfulFetchMock).toHaveBeenCalledTimes(1); + }); + + it('rejects post-quote predict withdraws when phase 2 loses gas fee token eligibility', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const sourceFiatRate = { + fiatRate: '4.0', + usdRate: '2.0', + }; + + getTokenFiatRateMock + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock + .mockResolvedValueOnce([GAS_FEE_TOKEN_MOCK]) + .mockResolvedValueOnce([]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '900', + }), + } as Response); + + await expect( + getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/lost source gas fee token eligibility/u); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects post-quote predict withdraws when phase 2 exceeds the source amount', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + calculateGasFeeTokenCostMock + .mockReturnValueOnce({ + fiat: '0.0004', + human: '0.0001', + raw: '100', + usd: '0.0002', + }) + .mockReturnValueOnce({ + fiat: '0.000404', + human: '0.000101', + raw: '101', + usd: '0.000202', + }); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '1000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '900', + }), + } as Response); + + await expect( + getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }), + ).rejects.toThrow(/exceeds source amount/u); + + expect(successfulFetchMock).toHaveBeenCalledTimes(2); + }); + + it('estimates post-quote predict withdraw Across transactions from the EOA', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + targetAmountMinimum: '0', + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(estimateGasMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: QUOTE_MOCK.swapTx.data, + from: FROM_MOCK, + to: QUOTE_MOCK.swapTx.to, + }), + 'mainnet', + ); + }); + it('falls back to phase 1 max amount quote when adjusted quote is not affordable', async () => { getTokenBalanceMock.mockReturnValue('0'); isEIP7702ChainMock.mockReturnValue(true); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index 8e3ddd5c22..d7ff9627bd 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -1,4 +1,5 @@ import { successfulFetch, toHex } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -26,7 +27,10 @@ import { getTokenBalance, getTokenFiatRate, } from '../../utils/token'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; +import type { AcrossDestination } from './across-actions'; import { getAcrossDestination } from './across-actions'; +import { hasUnsupportedTransactionAuthorizationList } from './authorization-list'; import { normalizeAcrossRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; import { getAcrossOrderedTransactions } from './transactions'; @@ -65,7 +69,12 @@ export async function getAcrossQuotes( return []; } - if (request.transaction.txParams?.authorizationList?.length) { + if ( + hasUnsupportedTransactionAuthorizationList( + request.transaction, + normalizedRequests, + ) + ) { throw new Error(UNSUPPORTED_AUTHORIZATION_LIST_ERROR); } @@ -104,9 +113,17 @@ async function getSingleQuote( sourceTokenAddress, ); - const amount = isMaxAmount ? sourceTokenAmount : targetAmountMinimum; - const tradeType = isMaxAmount ? 'exactInput' : 'exactOutput'; - const destination = getAcrossDestination(transaction, request); + const useExactInput = isMaxAmount + ? true + : normalizedRequest.isPostQuote === true; + const amount = useExactInput ? sourceTokenAmount : targetAmountMinimum; + const tradeType = useExactInput ? 'exactInput' : 'exactOutput'; + const destination = getAcrossDestinationForRequest( + transaction, + request, + from, + ); + const quote = await requestAcrossApproval({ actions: destination.actions, amount, @@ -118,6 +135,7 @@ async function getSingleQuote( outputToken: targetTokenAddress, recipient: destination.recipient, signal, + refundAddress: normalizedRequest.refundTo, slippage: slippageDecimal, tradeType, }); @@ -125,6 +143,7 @@ async function getSingleQuote( const originalQuote: AcrossQuoteWithoutMetaMask = { quote, request: { + actions: destination.actions, amount, tradeType, }, @@ -133,26 +152,57 @@ async function getSingleQuote( return await normalizeQuote(originalQuote, normalizedRequest, fullRequest); } +function getAcrossDestinationForRequest( + transaction: TransactionMeta, + request: QuoteRequest, + recipient: Hex, +): AcrossDestination { + if (request.isPostQuote) { + return { + actions: [], + recipient, + }; + } + + return getAcrossDestination(transaction, request); +} + async function getQuoteWithGasStationHandling( request: QuoteRequest, fullRequest: PayStrategyGetQuotesRequest, ): Promise> { + // Phase 1 uses the requested source amount to discover whether Across will + // pay source-chain gas with the source token, and what the max gas cost is. + // Phase 2 repeats the quote with that max gas cost reserved from the source + // amount, so execution can fund both the Across deposit and token-paid gas. const phase1Quote = await getSingleQuote(request, fullRequest); - if (!request.isMaxAmount || !phase1Quote.fees.isSourceGasFeeToken) { + if ( + (!request.isMaxAmount && !request.isPostQuote) || + !phase1Quote.fees.isSourceGasFeeToken + ) { return phase1Quote; } + const requiresSourceGasReservation = + request.isPostQuote === true && + isPredictWithdrawTransaction(fullRequest.transaction); + const adjustedSourceAmount = new BigNumber(request.sourceTokenAmount) .minus(phase1Quote.fees.sourceNetwork.max.raw) .integerValue(BigNumber.ROUND_DOWN); if (!adjustedSourceAmount.isGreaterThan(0)) { - log('Insufficient balance after gas subtraction for Across max quote'); + log('Insufficient balance after gas subtraction for Across quote'); + if (requiresSourceGasReservation) { + throw new Error( + 'Across Predict withdraw source amount cannot cover source gas fee token', + ); + } return phase1Quote; } - log('Subtracting gas from source for Across max quote', { + log('Subtracting gas from source for Across quote', { adjustedSourceAmount: adjustedSourceAmount.toString(10), gasCostRaw: phase1Quote.fees.sourceNetwork.max.raw, originalSourceAmount: request.sourceTokenAmount, @@ -171,7 +221,12 @@ async function getQuoteWithGasStationHandling( ); if (!phase2Quote.fees.isSourceGasFeeToken) { - log('Across max phase 2 lost gas fee token eligibility'); + log('Across phase 2 lost gas fee token eligibility'); + if (requiresSourceGasReservation) { + throw new Error( + 'Across Predict withdraw quote lost source gas fee token eligibility', + ); + } return phase1Quote; } @@ -182,17 +237,30 @@ async function getQuoteWithGasStationHandling( .plus(phase2GasCost) .isGreaterThan(request.sourceTokenAmount) ) { - log('Across max phase 2 quote exceeds original source amount', { + log('Across phase 2 quote exceeds original source amount', { adjustedSourceAmount: adjustedSourceAmount.toString(10), gasCostRaw: phase2GasCost.toString(10), originalSourceAmount: request.sourceTokenAmount, }); + if (requiresSourceGasReservation) { + throw new Error( + 'Across Predict withdraw source gas fee token quote exceeds source amount', + ); + } return phase1Quote; } return phase2Quote; } catch (error) { - log('Across max phase 2 quote failed, falling back to phase 1', { error }); + log( + requiresSourceGasReservation + ? 'Across phase 2 quote failed after source gas reservation' + : 'Across phase 2 quote failed, falling back to phase 1', + { error }, + ); + if (requiresSourceGasReservation) { + throw error; + } return phase1Quote; } } @@ -207,6 +275,7 @@ type AcrossApprovalRequest = { originChainId: Hex; outputToken: Hex; recipient: Hex; + refundAddress?: Hex; signal?: AbortSignal; slippage?: number; tradeType: 'exactInput' | 'exactOutput'; @@ -225,6 +294,7 @@ async function requestAcrossApproval( originChainId, outputToken, recipient, + refundAddress, signal, slippage, tradeType, @@ -240,6 +310,10 @@ async function requestAcrossApproval( params.set('depositor', depositor); params.set('recipient', recipient); + if (refundAddress !== undefined) { + params.set('refundAddress', refundAddress); + } + if (slippage !== undefined) { params.set('slippage', String(slippage)); } @@ -255,6 +329,7 @@ async function requestAcrossApproval( method: 'POST', signal, }; + const response = await successfulFetch(url, options); return (await response.json()) as AcrossSwapApprovalResponse; @@ -282,7 +357,12 @@ async function normalizeQuote( isGasFeeToken: isSourceGasFeeToken, requiresAuthorizationList, sourceNetwork, - } = await calculateSourceNetworkCost(quote, messenger, request); + } = await calculateSourceNetworkCost( + quote, + messenger, + request, + fullRequest.transaction, + ); const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); @@ -466,6 +546,7 @@ async function calculateSourceNetworkCost( quote: AcrossSwapApprovalResponse, messenger: TransactionPayControllerMessenger, request: QuoteRequest, + transaction: TransactionMeta, ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; @@ -482,15 +563,16 @@ async function calculateSourceNetworkCost( const gasEstimates = await estimateQuoteGasLimits({ fallbackGas: acrossFallbackGas, messenger, - transactions: orderedTransactions.map((transaction) => ({ - chainId: toHex(transaction.chainId), - data: transaction.data, + transactions: orderedTransactions.map((orderedTransaction) => ({ + chainId: toHex(orderedTransaction.chainId), + data: orderedTransaction.data, from, - gas: transaction.gas, - to: transaction.to, - value: transaction.value ?? '0x0', + gas: orderedTransaction.gas, + to: orderedTransaction.to, + value: orderedTransaction.value ?? '0x0', })), }); + const { batchGasLimit, is7702, requiresAuthorizationList, totalGasEstimate } = gasEstimates; @@ -530,32 +612,32 @@ async function calculateSourceNetworkCost( ]; } else { const transactionGasLimits = orderedTransactions.map( - (transaction, index) => ({ + (orderedTransaction, index) => ({ gasEstimate: gasEstimates.gasLimits[index], - transaction, + orderedTransaction, }), ); const estimate = sumAmounts( - transactionGasLimits.map(({ gasEstimate, transaction }) => + transactionGasLimits.map(({ gasEstimate, orderedTransaction }) => calculateGasCost({ - chainId: toHex(transaction.chainId), + chainId: toHex(orderedTransaction.chainId), gas: gasEstimate.estimate, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + maxFeePerGas: orderedTransaction.maxFeePerGas, + maxPriorityFeePerGas: orderedTransaction.maxPriorityFeePerGas, messenger, }), ), ); const max = sumAmounts( - transactionGasLimits.map(({ gasEstimate, transaction }) => + transactionGasLimits.map(({ gasEstimate, orderedTransaction }) => calculateGasCost({ - chainId: toHex(transaction.chainId), + chainId: toHex(orderedTransaction.chainId), gas: gasEstimate.max, isMax: true, - maxFeePerGas: transaction.maxFeePerGas, - maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + maxFeePerGas: orderedTransaction.maxFeePerGas, + maxPriorityFeePerGas: orderedTransaction.maxPriorityFeePerGas, messenger, }), ), @@ -576,19 +658,26 @@ async function calculateSourceNetworkCost( is7702, ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), gasLimits, + totalGasEstimate, + totalGasLimit: gasEstimates.totalGasLimit, }; + const finalResult = request.isPostQuote + ? combinePostQuoteGas(result, transaction, swapTx, messenger) + : result; + const nativeBalance = getTokenBalance( messenger, from, sourceChainId, getNativeToken(sourceChainId), ); + const hasNativeBalance = new BigNumber(nativeBalance).isGreaterThanOrEqualTo( + finalResult.sourceNetwork.max.raw, + ); - if ( - new BigNumber(nativeBalance).isGreaterThanOrEqualTo(sourceNetwork.max.raw) - ) { - return result; + if (hasNativeBalance) { + return finalResult; } const gasStationEligibility = getGasStationEligibility( @@ -598,14 +687,14 @@ async function calculateSourceNetworkCost( if (gasStationEligibility.isDisabledChain) { log('Skipping Across gas station as disabled chain', { sourceChainId }); - return result; + return finalResult; } if (!gasStationEligibility.chainSupportsGasStation) { log('Skipping Across gas station as chain does not support EIP-7702', { sourceChainId, }); - return result; + return finalResult; } const firstTransaction = orderedTransactions[0]; @@ -622,12 +711,15 @@ async function calculateSourceNetworkCost( sourceChainId, sourceTokenAddress, }, - totalGasEstimate, - totalItemCount: Math.max(orderedTransactions.length, gasLimits.length), + totalGasEstimate: finalResult.totalGasEstimate, + totalItemCount: Math.max( + orderedTransactions.length + (request.isPostQuote ? 1 : 0), + finalResult.gasLimits.length, + ), }); if (!gasFeeTokenCost) { - return result; + return finalResult; } log('Using gas fee token for Across source network', { @@ -640,8 +732,125 @@ async function calculateSourceNetworkCost( estimate: gasFeeTokenCost, max: gasFeeTokenCost, }, - is7702, + is7702: finalResult.is7702, ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), + gasLimits: finalResult.gasLimits, + }; +} + +function combinePostQuoteGas( + gasResult: { + sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; + gasLimits: AcrossGasLimits; + is7702: boolean; + requiresAuthorizationList?: true; + totalGasEstimate: number; + totalGasLimit: number; + }, + transaction: TransactionMeta, + swapTx: AcrossSwapApprovalResponse['swapTx'], + messenger: TransactionPayControllerMessenger, +): typeof gasResult { + const originalTxGas = getOriginalTransactionGas(transaction); + + if (originalTxGas === undefined) { + return gasResult; + } + + const gasLimits = gasResult.is7702 + ? [ + { + estimate: gasResult.gasLimits[0].estimate + originalTxGas, + max: gasResult.gasLimits[0].max + originalTxGas, + }, + ] + : [ + { + estimate: originalTxGas, + max: originalTxGas, + }, + ...gasResult.gasLimits, + ]; + + const totalGasEstimate = gasResult.totalGasEstimate + originalTxGas; + const totalGasLimit = gasResult.totalGasLimit + originalTxGas; + const originalSourceNetwork = calculateOriginalSourceNetworkCost({ + gas: originalTxGas, + messenger, + swapTx, + transaction, + }); + + return { + ...gasResult, + sourceNetwork: { + estimate: sumAmounts([ + gasResult.sourceNetwork.estimate, + originalSourceNetwork.estimate, + ]), + max: sumAmounts([gasResult.sourceNetwork.max, originalSourceNetwork.max]), + }, gasLimits, + totalGasEstimate, + totalGasLimit, + }; +} + +function getOriginalTransactionGas( + transaction: TransactionMeta, +): number | undefined { + const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; + const rawGas = nestedGas ?? transaction.txParams.gas; + + if (rawGas === undefined) { + return undefined; + } + + const gas = new BigNumber(rawGas); + + if (!gas.isFinite() || gas.isNaN() || !gas.isInteger() || gas.lte(0)) { + return undefined; + } + + return gas.toNumber(); +} + +function calculateOriginalSourceNetworkCost({ + gas, + messenger, + swapTx, + transaction, +}: { + gas: number; + messenger: TransactionPayControllerMessenger; + swapTx: AcrossSwapApprovalResponse['swapTx']; + transaction: TransactionMeta; +}): TransactionPayQuote['fees']['sourceNetwork'] { + const originalTransactionWithGas = transaction.nestedTransactions?.find( + (tx) => tx.gas, + ); + const maxFeePerGas = + originalTransactionWithGas?.maxFeePerGas ?? + transaction.txParams.maxFeePerGas; + const maxPriorityFeePerGas = + originalTransactionWithGas?.maxPriorityFeePerGas ?? + transaction.txParams.maxPriorityFeePerGas; + + return { + estimate: calculateGasCost({ + chainId: transaction.chainId ?? toHex(swapTx.chainId), + gas, + maxFeePerGas, + maxPriorityFeePerGas, + messenger, + }), + max: calculateGasCost({ + chainId: transaction.chainId ?? toHex(swapTx.chainId), + gas, + isMax: true, + maxFeePerGas, + maxPriorityFeePerGas, + messenger, + }), }; } diff --git a/packages/transaction-pay-controller/src/strategy/across/types.ts b/packages/transaction-pay-controller/src/strategy/across/types.ts index af0f66a3f6..9e11895633 100644 --- a/packages/transaction-pay-controller/src/strategy/across/types.ts +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -91,6 +91,7 @@ export type AcrossQuote = { }; quote: AcrossSwapApprovalResponse; request: { + actions: AcrossAction[]; amount: string; tradeType: 'exactOutput' | 'exactInput'; };