diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2d2da9c929..ef5a42374f 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8593](https://github.com/MetaMask/core/pull/8593)) + ### Changed - Trigger the first-time-interaction warning correctly for `safeTransferFrom` token transfers by including `TransactionType.tokenMethodSafeTransferFrom` in the effective-recipient decoding logic ([#8723](https://github.com/MetaMask/core/pull/8723)) diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 0f07ae1443..541dd360d1 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -845,6 +845,11 @@ export enum TransactionType { */ predictAcrossDeposit = 'predictAcrossDeposit', + /** + * Withdraw funds for Across quote via Predict. + */ + predictAcrossWithdraw = 'predictAcrossWithdraw', + /** * Buy a position via Predict. * diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts index 077b7e358a..8a6418375d 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts @@ -480,6 +480,36 @@ describe('Gas Fee Tokens Utils', () => { expect(request.transaction.txParams.nonce).toBeUndefined(); }); + it('sets external sign when native token is excluded for fees', async () => { + request.transaction.excludeNativeTokenForFee = true; + request.transaction.isGasFeeTokenIgnoredIfBalance = false; + request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; + request.transaction.gasFeeTokens = []; + request.transaction.isExternalSign = false; + request.transaction.txParams.nonce = '0x1'; + + jest.mocked(request.fetchGasFeeTokens).mockResolvedValueOnce([ + { + tokenAddress: TOKEN_ADDRESS_1_MOCK, + } as GasFeeToken, + ]); + + await checkGasFeeTokenBeforePublish(request); + + jest + .mocked(request.updateTransaction) + .mock.calls[0][1](request.transaction); + + expect(isNativeBalanceSufficientForGasMock).not.toHaveBeenCalled(); + expect(request.fetchGasFeeTokens).toHaveBeenCalledWith( + expect.objectContaining({ + isExternalSign: true, + }), + ); + expect(request.transaction.isExternalSign).toBe(true); + expect(request.transaction.txParams.nonce).toBeUndefined(); + }); + it('removes selected gas fee token if native balance sufficient', async () => { request.transaction.isGasFeeTokenIgnoredIfBalance = true; request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; @@ -503,7 +533,7 @@ describe('Gas Fee Tokens Utils', () => { expect(request.updateTransaction).not.toHaveBeenCalled(); }); - it('does nothing if not ignoring gas fee token when native balance sufficient', async () => { + it('does nothing if not ignoring gas fee token and native token is allowed for fees', async () => { request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; request.transaction.isGasFeeTokenIgnoredIfBalance = false; diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts index 399d460a23..ce4205aa18 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts @@ -146,31 +146,40 @@ export async function checkGasFeeTokenBeforePublish({ fn: (tx: TransactionMeta) => void, ) => void; }): Promise { - const { isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken } = transaction; + const { + excludeNativeTokenForFee, + isGasFeeTokenIgnoredIfBalance, + selectedGasFeeToken, + } = transaction; - if (!selectedGasFeeToken || !isGasFeeTokenIgnoredIfBalance) { + if ( + !selectedGasFeeToken || + (!isGasFeeTokenIgnoredIfBalance && !excludeNativeTokenForFee) + ) { return; } log('Checking gas fee token before publish', { selectedGasFeeToken }); - const hasNativeBalance = await isNativeBalanceSufficientForGas( - transaction, - messenger, - networkClientId, - ); - - if (hasNativeBalance) { - log( - 'Ignoring gas fee token before publish due to sufficient native balance', + if (!excludeNativeTokenForFee) { + const hasNativeBalance = await isNativeBalanceSufficientForGas( + transaction, + messenger, + networkClientId, ); - updateTransaction(transaction.id, (tx) => { - tx.isExternalSign = false; - tx.selectedGasFeeToken = undefined; - }); + if (hasNativeBalance) { + log( + 'Ignoring gas fee token before publish due to sufficient native balance', + ); - return; + updateTransaction(transaction.id, (tx) => { + tx.isExternalSign = false; + tx.selectedGasFeeToken = undefined; + }); + + return; + } } const gasFeeTokens = await fetchGasFeeTokens({ diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 44e6e40463..98d9482907 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 support for post-quote Predict withdraw flows ([#8593](https://github.com/MetaMask/core/pull/8593)) + ## [22.0.0] ### Changed 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 efe6eb6234..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({ @@ -197,6 +217,60 @@ describe('AcrossStrategy', () => { ).toBe(true); }); + it('supports post-quote predict withdraw requests with source-chain authorization lists', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + data: '0x12345678' as Hex, + to: '0xdef' as Hex, + }, + } as TransactionMeta, + requests: [ + { + from: '0xabc' as Hex, + isPostQuote: true, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(true); + }); + + it('does not support post-quote requests outside predict withdraw', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + requests: [ + { + from: '0xabc' as Hex, + isPostQuote: true, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(false); + }); + it('returns false for unsupported perps deposits', () => { const strategy = new AcrossStrategy(); expect( @@ -297,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 c54cde6187..0c942dd7d0 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -8,9 +8,11 @@ import type { TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { getAcrossDestination } from './across-actions'; import { getAcrossQuotes } from './across-quotes'; import { submitAcrossQuotes } from './across-submit'; +import { hasUnsupportedTransactionAuthorizationList } from './authorization-list'; import { isSupportedAcrossPerpsDepositRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; import type { AcrossQuote } from './types'; @@ -52,15 +54,20 @@ export class AcrossStrategy implements PayStrategy { } } - // Across cannot submit EIP-7702 authorization lists. This pre-quote check - // catches transactions where the authorization list is already present. - // First-time 7702 upgrades discovered during gas planning are handled in - // `checkQuoteSupport` below. - if (request.transaction.txParams?.authorizationList?.length) { + if ( + hasUnsupportedTransactionAuthorizationList( + request.transaction, + actionableRequests, + ) + ) { return false; } return actionableRequests.every((singleRequest) => { + if (singleRequest.isPostQuote) { + return isPredictWithdrawTransaction(request.transaction); + } + try { getAcrossDestination(request.transaction, singleRequest); return true; @@ -76,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..a21ae52c49 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,300 @@ 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('caps excessive prefunded post-quote batch gas at Across fallback gas', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + apiBase: 'https://test.across.to/api', + fallbackGas: { + estimate: 900001, + max: 1500001, + }, + }, + }, + }, + }, + }); + + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [42000000], + }); + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + inputAmount: '1000000000000000000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + }, + ], + inputAmount: '999999999999999900', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '0', + 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: 921001, + max: 1521001, + }, + ]); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: 900001, + }), + ); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + gas: 1500001, + isMax: 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 +661,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 +682,587 @@ 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('prefers source gas fee token pricing for post-quote predict withdraws even when native balance is available', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + 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: '900', + }), + } as Response); + + const result = await 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, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].fees.sourceNetwork.max.raw).toBe('100'); + }); + + 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('keeps native gas for post-quote predict withdraws when the account cannot use 7702', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: false, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(getGasFeeTokensMock).not.toHaveBeenCalled(); + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('3450000000000000000'); + }); + + it('uses fiat-derived source gas fee token pricing for post-quote predict withdraws when gas station cannot price the source token', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '10000000000000000000', + }), + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '6550000000000000000', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '10000000000000000000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + expect(result[0].fees.sourceNetwork.max).toStrictEqual({ + fiat: '13.8', + human: '3.45', + raw: '3450000000000000000', + usd: '6.9', + }); + }); + + it('keeps native gas for post-quote predict withdraws when fallback source fiat rate is unavailable', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const sourceFiatRate = { + fiatRate: '4.0', + usdRate: '2.0', + }; + + getTokenBalanceMock.mockReturnValue('0'); + getTokenFiatRateMock + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '10000000000000000000', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '10000000000000000000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('3450000000000000000'); + }); + + it('keeps native gas for post-quote predict withdraws when fallback gas token amount is invalid', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + const sourceFiatRate = { + fiatRate: '4.0', + usdRate: '2.0', + }; + + getTokenBalanceMock.mockReturnValue('0'); + getTokenFiatRateMock + .mockReturnValueOnce(sourceFiatRate) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ + fiatRate: '4.0', + usdRate: '0', + }); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([ + { + ...GAS_FEE_TOKEN_MOCK, + tokenAddress: '0xdifferent' as Hex, + }, + ]); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: '10000000000000000000', + }), + } as Response); + + const result = await getAcrossQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceTokenAmount: '10000000000000000000', + targetAmountMinimum: '0', + }, + ], + transaction: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK, + txParams: { + ...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams, + gas: '0x5208', + to: '0x000000000000000000000000000000000000dEaD' as Hex, + }, + } as TransactionMeta, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBeUndefined(); + expect(result[0].fees.sourceNetwork.max.raw).toBe('3450000000000000000'); + }); + + 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('uses relaxed per-transaction gas estimates for prefunded post-quote predict withdraws', async () => { + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + estimateGasBatchMock.mockRejectedValueOnce( + new Error('Batch estimation failed'), + ); + estimateGasMock + .mockResolvedValueOnce({ + gas: '0x1000', + simulationFails: undefined, + }) + .mockResolvedValueOnce({ + gas: '0x2000', + simulationFails: true, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0x095ea7b3' as Hex, + to: '0xapprove1' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + sourceBalanceRaw: '100', + sourceTokenAmount: '1000', + targetAmountMinimum: '0', + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: QUOTE_REQUEST_MOCK.sourceChainId, + from: FROM_MOCK, + transactions: expect.any(Array), + }); + expect(estimateGasMock).toHaveBeenCalledTimes(2); + expect(result[0].original.metamask.is7702).toBe(false); + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + { + estimate: 4096, + max: 4096, + }, + expect.objectContaining({ + estimate: expect.any(Number), + max: expect.any(Number), + }), + ]); + expect(result[0].original.metamask.gasLimits[1].estimate).toBeGreaterThan( + 8192, + ); + }); + 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..bd5eec27cb 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'; @@ -21,15 +22,22 @@ import { getGasStationEligibility, } from '../../utils/gas-station'; import { estimateQuoteGasLimits } from '../../utils/quote-gas'; +import type { QuoteGasTransaction } from '../../utils/quote-gas'; import { getNativeToken, 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'; +import { + getAcrossOrderedTransactions, + getOriginalTransactionGas, +} from './transactions'; import type { AcrossAction, AcrossActionRequestBody, @@ -65,7 +73,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 +117,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 +139,7 @@ async function getSingleQuote( outputToken: targetTokenAddress, recipient: destination.recipient, signal, + refundAddress: normalizedRequest.refundTo, slippage: slippageDecimal, tradeType, }); @@ -125,6 +147,7 @@ async function getSingleQuote( const originalQuote: AcrossQuoteWithoutMetaMask = { quote, request: { + actions: destination.actions, amount, tradeType, }, @@ -133,26 +156,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 +225,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 +241,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 +279,7 @@ type AcrossApprovalRequest = { originChainId: Hex; outputToken: Hex; recipient: Hex; + refundAddress?: Hex; signal?: AbortSignal; slippage?: number; tradeType: 'exactInput' | 'exactOutput'; @@ -225,6 +298,7 @@ async function requestAcrossApproval( originChainId, outputToken, recipient, + refundAddress, signal, slippage, tradeType, @@ -240,6 +314,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 +333,7 @@ async function requestAcrossApproval( method: 'POST', signal, }; + const response = await successfulFetch(url, options); return (await response.json()) as AcrossSwapApprovalResponse; @@ -282,7 +361,13 @@ async function normalizeQuote( isGasFeeToken: isSourceGasFeeToken, requiresAuthorizationList, sourceNetwork, - } = await calculateSourceNetworkCost(quote, messenger, request); + } = await calculateSourceNetworkCost( + quote, + messenger, + request, + fullRequest.transaction, + fullRequest.accountSupports7702, + ); const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); @@ -466,6 +551,8 @@ async function calculateSourceNetworkCost( quote: AcrossSwapApprovalResponse, messenger: TransactionPayControllerMessenger, request: QuoteRequest, + transaction: TransactionMeta, + accountSupports7702: boolean, ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; @@ -479,18 +566,29 @@ async function calculateSourceNetworkCost( const orderedTransactions = getAcrossOrderedTransactions({ quote }); const { swapTx } = quote; const swapChainId = toHex(swapTx.chainId); - const gasEstimates = await estimateQuoteGasLimits({ + const isPredictWithdraw = + request.isPostQuote === true && isPredictWithdrawTransaction(transaction); + const relaxPrefundedSourceEstimate = + isPredictWithdraw && + new BigNumber(request.sourceTokenAmount).gt(request.sourceBalanceRaw); + const gasEstimateTransactions = orderedTransactions.map( + (orderedTransaction) => ({ + chainId: toHex(orderedTransaction.chainId), + data: orderedTransaction.data, + from, + gas: orderedTransaction.gas, + to: orderedTransaction.to, + value: orderedTransaction.value ?? '0x0', + }), + ); + + const gasEstimates = await estimateAcrossQuoteGasLimits({ fallbackGas: acrossFallbackGas, + fallbackOnSimulationFailure: relaxPrefundedSourceEstimate, messenger, - transactions: orderedTransactions.map((transaction) => ({ - chainId: toHex(transaction.chainId), - data: transaction.data, - from, - gas: transaction.gas, - to: transaction.to, - value: transaction.value ?? '0x0', - })), + transactions: gasEstimateTransactions, }); + const { batchGasLimit, is7702, requiresAuthorizationList, totalGasEstimate } = gasEstimates; @@ -530,32 +628,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 +674,30 @@ 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 (isPredictWithdraw && !accountSupports7702) { + return finalResult; + } + + if (hasNativeBalance && !isPredictWithdraw) { + return finalResult; } const gasStationEligibility = getGasStationEligibility( @@ -598,14 +707,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,26 +731,294 @@ 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; + let gasFeeTokenNetwork: + | TransactionPayQuote['fees']['sourceNetwork'] + | undefined; + + if (gasFeeTokenCost) { + gasFeeTokenNetwork = { + estimate: gasFeeTokenCost, + max: gasFeeTokenCost, + }; + } else if (isPredictWithdraw) { + gasFeeTokenNetwork = calculateSourceGasFeeTokenNetworkFallback({ + messenger, + nativeSourceNetwork: finalResult.sourceNetwork, + quote, + request, + }); + } + + if (!gasFeeTokenNetwork) { + return finalResult; } log('Using gas fee token for Across source network', { - gasFeeTokenCost, + gasFeeTokenCost: gasFeeTokenNetwork.max, }); return { isGasFeeToken: true, + sourceNetwork: gasFeeTokenNetwork, + is7702: finalResult.is7702, + ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), + gasLimits: finalResult.gasLimits, + }; +} + +function calculateSourceGasFeeTokenNetworkFallback({ + messenger, + nativeSourceNetwork, + quote, + request, +}: { + messenger: TransactionPayControllerMessenger; + nativeSourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; + quote: AcrossSwapApprovalResponse; + request: QuoteRequest; +}): TransactionPayQuote['fees']['sourceNetwork'] | undefined { + const sourceFiatRate = getTokenFiatRate( + messenger, + request.sourceTokenAddress, + request.sourceChainId, + ); + + if (!sourceFiatRate) { + return undefined; + } + + const estimate = calculateSourceGasFeeTokenAmountFallback({ + decimals: quote.inputToken.decimals, + fiatRate: sourceFiatRate, + nativeGasCost: nativeSourceNetwork.estimate, + }); + const max = calculateSourceGasFeeTokenAmountFallback({ + decimals: quote.inputToken.decimals, + fiatRate: sourceFiatRate, + nativeGasCost: nativeSourceNetwork.max, + }); + + if (!estimate || !max) { + return undefined; + } + + return { estimate, max }; +} + +function calculateSourceGasFeeTokenAmountFallback({ + decimals, + fiatRate, + nativeGasCost, +}: { + decimals: number; + fiatRate: FiatRates; + nativeGasCost: Amount; +}): Amount | undefined { + const usdRate = new BigNumber(fiatRate.usdRate); + const nativeGasUsd = new BigNumber(nativeGasCost.usd); + + if ( + !usdRate.isFinite() || + !usdRate.isGreaterThan(0) || + !nativeGasUsd.isFinite() || + !nativeGasUsd.isGreaterThan(0) + ) { + return undefined; + } + + const amountRaw = nativeGasUsd + .dividedBy(usdRate) + .shiftedBy(decimals) + .integerValue(BigNumber.ROUND_CEIL) + .toFixed(0); + + return getAmountFromTokenAmount({ + amountRaw, + decimals, + fiatRate, + }); +} + +async function estimateAcrossQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transactions, +}: { + fallbackGas?: { + estimate: number; + max: number; + }; + fallbackOnSimulationFailure: boolean; + messenger: TransactionPayControllerMessenger; + transactions: QuoteGasTransaction[]; +}): Promise>> { + try { + const gasEstimates = await estimateQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure, + messenger, + transactions, + }); + + if ( + fallbackOnSimulationFailure && + fallbackGas !== undefined && + gasEstimates.is7702 && + gasEstimates.batchGasLimit !== undefined && + gasEstimates.batchGasLimit.max > fallbackGas.max + ) { + // Prefunded Predict withdraws can produce inflated 7702 batch estimates + // because the source account does not yet hold the funds. Keep the gas + // reservation bounded by the configured Across fallback for this path. + return { + ...gasEstimates, + batchGasLimit: fallbackGas, + gasLimits: [fallbackGas], + totalGasEstimate: fallbackGas.estimate, + totalGasLimit: fallbackGas.max, + }; + } + + return gasEstimates; + } catch (error) { + if (!fallbackOnSimulationFailure || transactions.length <= 1) { + throw error; + } + + const perTransactionGasEstimates = await Promise.all( + transactions.map((transaction) => + estimateQuoteGasLimits({ + fallbackGas, + fallbackOnSimulationFailure: true, + messenger, + transactions: [transaction], + }), + ), + ); + const gasLimits = perTransactionGasEstimates.map( + (estimate) => estimate.gasLimits[0], + ); + const totalGasEstimate = gasLimits.reduce( + (total, gasLimit) => total + gasLimit.estimate, + 0, + ); + const totalGasLimit = gasLimits.reduce( + (total, gasLimit) => total + gasLimit.max, + 0, + ); + + return { + gasLimits, + is7702: false, + totalGasEstimate, + totalGasLimit, + usedBatch: false, + }; + } +} + +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: gasFeeTokenCost, - max: gasFeeTokenCost, + estimate: sumAmounts([ + gasResult.sourceNetwork.estimate, + originalSourceNetwork.estimate, + ]), + max: sumAmounts([gasResult.sourceNetwork.max, originalSourceNetwork.max]), }, - is7702, - ...(requiresAuthorizationList ? { requiresAuthorizationList } : {}), gasLimits, + totalGasEstimate, + totalGasLimit, + }; +} + +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/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts index 074ee59760..dd453928c1 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -79,6 +79,7 @@ const QUOTE_MOCK: TransactionPayQuote = { }, }, request: { + actions: [], amount: '100', tradeType: 'exactOutput', }, @@ -104,8 +105,10 @@ describe('Across Submit', () => { const { addTransactionBatchMock, addTransactionMock, + estimateGasBatchMock, estimateGasMock, findNetworkClientIdByChainIdMock, + getKeyringControllerStateMock, getRemoteFeatureFlagControllerStateMock, getTransactionControllerStateMock, messenger, @@ -126,6 +129,16 @@ describe('Across Submit', () => { }, }, }); + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: [FROM_MOCK], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); estimateGasMock.mockResolvedValue({ gas: '0x5208', @@ -231,6 +244,7 @@ describe('Across Submit', () => { expect(addTransactionBatchMock).toHaveBeenCalledWith( expect.objectContaining({ + excludeNativeTokenForFee: true, gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress, }), ); @@ -285,6 +299,237 @@ describe('Across Submit', () => { ); }); + it('estimates 7702 batch gas when a post-quote original transaction was not priced in the quote', async () => { + const postQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [{ estimate: 43000, max: 64000 }], + is7702: true, + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'HD Key Tree', + accounts: [FROM_MOCK], + metadata: { id: 'hd-keyring', name: 'HD Key Tree' }, + }, + ], + }); + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [123456], + }); + + await submitAcrossQuotes({ + accountSupports7702: true, + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(estimateGasBatchMock).toHaveBeenCalledWith({ + chainId: QUOTE_MOCK.request.sourceChainId, + from: FROM_MOCK, + transactions: [ + expect.objectContaining({ + data: '0x12345678', + gas: undefined, + to: '0x000000000000000000000000000000000000dEaD', + }), + expect.objectContaining({ + data: QUOTE_MOCK.original.quote.approvalTxns[0].data, + gas: undefined, + to: QUOTE_MOCK.original.quote.approvalTxns[0].to, + }), + expect.objectContaining({ + data: QUOTE_MOCK.original.quote.swapTx.data, + gas: undefined, + to: QUOTE_MOCK.original.quote.swapTx.to, + }), + ], + }); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: false, + disableHook: true, + disableSequential: true, + gasLimit7702: toHex(123456), + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + data: '0x12345678', + gas: undefined, + to: '0x000000000000000000000000000000000000dEaD', + }), + type: TransactionType.predictWithdraw, + }), + expect.objectContaining({ + params: expect.objectContaining({ + data: QUOTE_MOCK.original.quote.approvalTxns[0].data, + gas: undefined, + to: QUOTE_MOCK.original.quote.approvalTxns[0].to, + }), + type: TransactionType.tokenMethodApprove, + }), + expect.objectContaining({ + params: expect.objectContaining({ + data: QUOTE_MOCK.original.quote.swapTx.data, + gas: undefined, + to: QUOTE_MOCK.original.quote.swapTx.to, + }), + type: TransactionType.predictAcrossWithdraw, + }), + ], + }), + ); + }); + + it('reuses quoted 7702 batch gas when the post-quote original transaction already has gas', async () => { + const postQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [{ estimate: 43000, max: 64000 }], + is7702: true, + }, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + accountSupports7702: true, + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.swap, + txParams: { + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + gas: '0x5208', + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: false, + gasLimit7702: toHex(64000), + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + data: '0x12345678', + gas: undefined, + to: '0x000000000000000000000000000000000000dEaD', + }), + type: TransactionType.swap, + }), + expect.objectContaining({ + params: expect.objectContaining({ + data: QUOTE_MOCK.original.quote.swapTx.data, + gas: undefined, + to: QUOTE_MOCK.original.quote.swapTx.to, + }), + type: TransactionType.swap, + }), + ], + }), + ); + }); + + it('submits 7702 batches without estimated gas when the account cannot sign authorizations', async () => { + getKeyringControllerStateMock.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + type: 'Ledger Hardware', + accounts: [FROM_MOCK], + metadata: { id: 'ledger-keyring', name: 'Ledger Hardware' }, + }, + ], + }); + + await submitAcrossQuotes({ + accountSupports7702: true, + messenger, + quotes: [QUOTE_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: false, + gasLimit7702: undefined, + }), + ); + }); + + it('submits 7702 batches without estimated gas when estimation returns multiple gas limits', async () => { + estimateGasBatchMock.mockResolvedValue({ + gasLimits: [123456, 234567], + }); + + await submitAcrossQuotes({ + accountSupports7702: true, + messenger, + quotes: [QUOTE_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(estimateGasBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: false, + gasLimit7702: undefined, + }), + ); + }); + it('submits a single transaction when no approvals', async () => { const noApprovalQuote = { ...QUOTE_MOCK, @@ -340,6 +585,7 @@ describe('Across Submit', () => { expect(addTransactionMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ + excludeNativeTokenForFee: true, gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress, }), ); @@ -401,6 +647,297 @@ describe('Across Submit', () => { ); }); + it('prepends the original transaction and uses predict withdraw type for post-quote predict withdraws', async () => { + const postQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [ + { estimate: 50000, max: 50000 }, + { estimate: 22000, max: 22000 }, + ], + is7702: false, + }, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + value: '0x1' as Hex, + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + transactions: [ + { + params: expect.objectContaining({ + data: '0x12345678', + gas: toHex(50000), + to: '0x000000000000000000000000000000000000dEaD', + value: '0x1', + }), + type: TransactionType.predictWithdraw, + }, + { + params: expect.objectContaining({ + data: QUOTE_MOCK.original.quote.swapTx.data, + gas: toHex(22000), + to: QUOTE_MOCK.original.quote.swapTx.to, + }), + type: TransactionType.predictAcrossWithdraw, + }, + ], + }), + ); + }); + + it('keeps Across gas limits aligned when post-quote original gas is absent', async () => { + const postQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [{ estimate: 22000, max: 22000 }], + is7702: false, + }, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + value: '0x1' as Hex, + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: [ + { + params: expect.objectContaining({ + data: '0x12345678', + gas: undefined, + to: '0x000000000000000000000000000000000000dEaD', + value: '0x1', + }), + type: TransactionType.predictWithdraw, + }, + { + params: expect.objectContaining({ + data: QUOTE_MOCK.original.quote.swapTx.data, + gas: toHex(22000), + to: QUOTE_MOCK.original.quote.swapTx.to, + }), + type: TransactionType.predictAcrossWithdraw, + }, + ], + }), + ); + }); + + it('passes gas fee token for post-quote predict withdraw batches', async () => { + const postQuote = { + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + isSourceGasFeeToken: true, + }, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [ + { estimate: 50000, max: 50000 }, + { estimate: 22000, max: 22000 }, + ], + is7702: false, + }, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictWithdraw, + txParams: { + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + excludeNativeTokenForFee: true, + gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress, + }), + ); + }); + + it('submits post-quote predict withdraw parent authorization lists as 7702 batches', async () => { + const postQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [{ estimate: 22000, max: 22000 }], + is7702: false, + }, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + authorizationList: [{ address: '0xabc' as Hex }], + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: false, + disableHook: true, + disableSequential: true, + transactions: [ + { + params: expect.objectContaining({ + data: '0x12345678', + gas: undefined, + to: '0x000000000000000000000000000000000000dEaD', + }), + type: TransactionType.predictWithdraw, + }, + { + params: expect.objectContaining({ + data: QUOTE_MOCK.original.quote.swapTx.data, + gas: undefined, + to: QUOTE_MOCK.original.quote.swapTx.to, + }), + type: TransactionType.predictAcrossWithdraw, + }, + ], + }), + ); + }); + + it('uses the original transaction type for non-predict post-quote batches', async () => { + const postQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + metamask: { + gasLimits: [undefined as never, { estimate: 22000, max: 22000 }], + is7702: false, + }, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + request: { + ...QUOTE_MOCK.request, + isPostQuote: true, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [postQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.swap, + txParams: { + from: FROM_MOCK, + to: '0x000000000000000000000000000000000000dEaD' as Hex, + data: '0x12345678' as Hex, + }, + } as TransactionMeta, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + gas: undefined, + }), + type: TransactionType.swap, + }), + ]), + }), + ); + }); + it('preserves transaction type when not perps or predict', async () => { const noApprovalQuote = { ...QUOTE_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts index cb09bac1cb..3c52f2395d 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -18,14 +18,20 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, } from '../../types'; +import { accountSupports7702 } from '../../utils/7702'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { getGasBuffer } from '../../utils/feature-flags'; import { collectTransactionIds, getTransaction, updateTransaction, + isPredictWithdrawTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; -import { getAcrossOrderedTransactions } from './transactions'; +import { + getAcrossOrderedTransactions, + getOriginalTransactionGas, +} from './transactions'; import type { AcrossQuote } from './types'; const log = createModuleLogger(projectLogger, 'across-strategy'); @@ -33,7 +39,7 @@ const ACROSS_STATUS_POLL_INTERVAL = 1000; type PreparedAcrossTransaction = { params: TransactionParams; - type: TransactionType; + type: TransactionMeta['type']; }; /** @@ -79,10 +85,10 @@ async function executeSingleQuote( }, ); - const acrossDepositType = getAcrossDepositType(transaction.type); + const acrossDepositType = getAcrossDepositType(transaction); const transactionHash = await submitTransactions( quote, - transaction.id, + transaction, acrossDepositType, messenger, ); @@ -105,14 +111,14 @@ async function executeSingleQuote( * Submit transactions for an Across quote. * * @param quote - Across quote. - * @param parentTransactionId - ID of the parent transaction. + * @param parentTransaction - Parent transaction. * @param acrossDepositType - Transaction type used for the swap/deposit step. * @param messenger - Controller messenger. * @returns Hash of the last submitted transaction, if available. */ async function submitTransactions( quote: TransactionPayQuote, - parentTransactionId: string, + parentTransaction: TransactionMeta, acrossDepositType: TransactionType, messenger: TransactionPayControllerMessenger, ): Promise { @@ -124,32 +130,81 @@ async function submitTransactions( quote: quote.original.quote, swapType: acrossDepositType, }); + const shouldPrependOriginalTransaction = + quote.request.isPostQuote === true && + parentTransaction.txParams.to !== undefined; + const hasPrependedOriginalGasLimit = + shouldPrependOriginalTransaction && + !is7702 && + quoteGasLimits.length > orderedTransactions.length; + const gasLimitOffset = hasPrependedOriginalGasLimit ? 1 : 0; + const transactionCount = + orderedTransactions.length + (shouldPrependOriginalTransaction ? 1 : 0); const networkClientId = messenger.call( 'NetworkController:findNetworkClientIdByChainId', chainId, ); - const batchGasLimit = - is7702 && orderedTransactions.length > 1 - ? quoteGasLimits[0]?.max - : undefined; + const is7702Batch = is7702 && transactionCount > 1; + const canUseQuotedBatchGasLimit = + is7702Batch && + (!shouldPrependOriginalTransaction || + hasOriginalTransactionGas(parentTransaction)); + const batchGasLimit = canUseQuotedBatchGasLimit + ? quoteGasLimits[0]?.max + : undefined; - if (is7702 && orderedTransactions.length > 1 && batchGasLimit === undefined) { + if (canUseQuotedBatchGasLimit && batchGasLimit === undefined) { throw new Error('Missing quote gas limit for Across 7702 batch'); } - const gasLimit7702 = + const quotedGasLimit7702 = batchGasLimit === undefined ? undefined : toHex(batchGasLimit); + const parentHasAuthorizationList = Boolean( + parentTransaction.txParams.authorizationList?.length, + ); + + const shouldUseGasFeeToken7702Submit = shouldEstimate7702SubmitBatch( + parentTransaction, + quote, + ) + ? accountSupports7702(messenger, from) + : false; + const shouldUse7702Submit = [ + Boolean(quotedGasLimit7702), + is7702Batch, + parentHasAuthorizationList, + shouldUseGasFeeToken7702Submit, + ].some(Boolean); + + const shouldEstimateGasLimit7702 = !quotedGasLimit7702 && shouldUse7702Submit; + + const estimatedGasLimit7702 = shouldEstimateGasLimit7702 + ? await estimateSubmitBatchGasLimit7702({ + chainId, + from, + messenger, + orderedTransactions, + parentTransaction, + shouldPrependOriginalTransaction, + }) + : undefined; + + const gasLimit7702 = quotedGasLimit7702 ?? estimatedGasLimit7702; + const submitAs7702 = shouldUse7702Submit || Boolean(gasLimit7702); - const transactions: PreparedAcrossTransaction[] = orderedTransactions.map( - (transaction, index) => { - const gasLimit = gasLimit7702 ? undefined : quoteGasLimits[index]?.max; + const acrossTransactions: PreparedAcrossTransaction[] = + orderedTransactions.map((transaction, index) => { + const gasLimit = submitAs7702 + ? undefined + : quoteGasLimits[index + gasLimitOffset]?.max; - if (gasLimit === undefined && !gasLimit7702) { + if (gasLimit === undefined && !submitAs7702) { + const quoteGasIndex = index + gasLimitOffset; const errorMessage = transaction.kind === 'approval' - ? `Missing quote gas limit for Across approval transaction at index ${index}` + ? `Missing quote gas limit for Across approval transaction at index ${quoteGasIndex}` : 'Missing quote gas limit for Across swap transaction'; throw new Error(errorMessage); @@ -167,8 +222,18 @@ async function submitTransactions( }), type: transaction.type ?? acrossDepositType, }; - }, - ); + }); + const originalTransaction = shouldPrependOriginalTransaction + ? [ + buildOriginalTransaction( + parentTransaction, + submitAs7702 || !hasPrependedOriginalGasLimit + ? undefined + : quoteGasLimits[0]?.max, + ), + ] + : []; + const transactions = [...originalTransaction, ...acrossTransactions]; const transactionIds: string[] = []; @@ -181,7 +246,7 @@ async function submitTransactions( updateTransaction( { - transactionId: parentTransactionId, + transactionId: parentTransaction.id, messenger, note: 'Add required transaction ID from Across submission', }, @@ -197,6 +262,7 @@ async function submitTransactions( const gasFeeToken = quote.fees.isSourceGasFeeToken ? quote.request.sourceTokenAddress : undefined; + const excludeNativeTokenForFee = gasFeeToken ? true : undefined; try { if (transactions.length === 1) { @@ -204,6 +270,7 @@ async function submitTransactions( 'TransactionController:addTransaction', transactions[0].params, { + excludeNativeTokenForFee, gasFeeToken, networkClientId, origin: ORIGIN_METAMASK, @@ -218,9 +285,10 @@ async function submitTransactions( })); await messenger.call('TransactionController:addTransactionBatch', { - disable7702: !gasLimit7702, - disableHook: Boolean(gasLimit7702), - disableSequential: Boolean(gasLimit7702), + disable7702: !submitAs7702, + disableHook: submitAs7702, + disableSequential: submitAs7702, + excludeNativeTokenForFee, from, gasFeeToken, gasLimit7702, @@ -260,6 +328,13 @@ type AcrossStatusResponse = { txHash?: Hex; }; +/** + * Poll Across until a submitted deposit reaches a terminal status. + * + * @param transactionHash - Source-chain deposit transaction hash. + * @param messenger - Controller messenger. + * @returns Destination/fill transaction hash when available, otherwise the source hash. + */ async function waitForAcrossCompletion( transactionHash: Hex | undefined, messenger: TransactionPayControllerMessenger, @@ -335,10 +410,168 @@ async function waitForAcrossCompletion( } } -function getAcrossDepositType( - transactionType?: TransactionType, -): TransactionType { - switch (transactionType) { +/** + * Check whether submit should estimate a 7702 batch gas limit. + * + * This is needed for Predict withdraw post-quote flows that pay source-chain + * gas with the source token, because the final submit batch can differ from the + * batch shape that Across quoted. + * + * @param parentTransaction - Original transaction metadata. + * @param quote - Across quote selected for execution. + * @returns Whether submit should try to estimate the final 7702 batch gas. + */ +function shouldEstimate7702SubmitBatch( + parentTransaction: TransactionMeta, + quote: TransactionPayQuote, +): boolean { + return ( + isPredictWithdrawTransaction(parentTransaction) && + quote.request.isPostQuote === true && + quote.fees.isSourceGasFeeToken === true + ); +} + +/** + * Estimate the 7702 batch gas limit for the actual submit payload. + * + * Quotes can contain a combined 7702 gas limit that only covered the Across + * approval/swap legs. When submit prepends the original transaction, estimate + * the final batch shape so the gas limit covers every submitted leg. + * + * @param args - Estimation arguments. + * @param args.chainId - Source chain ID. + * @param args.from - Sender address. + * @param args.messenger - Controller messenger. + * @param args.orderedTransactions - Across approval/swap legs in submission order. + * @param args.parentTransaction - Original transaction that may be prepended. + * @param args.shouldPrependOriginalTransaction - Whether to include the original transaction in the estimate. + * @returns Hex gas limit, or `undefined` when estimation is unavailable. + */ +async function estimateSubmitBatchGasLimit7702({ + chainId, + from, + messenger, + orderedTransactions, + parentTransaction, + shouldPrependOriginalTransaction, +}: { + chainId: Hex; + from: Hex; + messenger: TransactionPayControllerMessenger; + orderedTransactions: ReturnType; + parentTransaction: TransactionMeta; + shouldPrependOriginalTransaction: boolean; +}): Promise { + if (!accountSupports7702(messenger, from)) { + return undefined; + } + + const originalTransaction = shouldPrependOriginalTransaction + ? [buildOriginalTransaction(parentTransaction)] + : []; + + const acrossTransactions = orderedTransactions.map((transaction) => ({ + params: buildTransactionParams(from, { + chainId: transaction.chainId, + data: transaction.data, + maxFeePerGas: transaction.maxFeePerGas, + maxPriorityFeePerGas: transaction.maxPriorityFeePerGas, + to: transaction.to, + value: transaction.value, + }), + type: transaction.type, + })); + + const transactions = [...originalTransaction, ...acrossTransactions]; + + try { + const result = await messenger.call( + 'TransactionController:estimateGasBatch', + { + chainId, + from, + transactions: transactions.map(({ params }) => + toBatchTransactionParams(params), + ), + }, + ); + + if (result.gasLimits.length !== 1) { + return undefined; + } + + const gasLimit = Math.ceil( + result.gasLimits[0] * getGasBuffer(messenger, chainId), + ); + + return toHex(gasLimit); + } catch { + return undefined; + } +} + +/** + * Build the original parent transaction as a prepared batch leg. + * + * @param transaction - Original transaction metadata. + * @param gasLimit - Optional gas limit to pin on the original leg. + * @returns Prepared transaction params and transaction type for the original leg. + */ +function buildOriginalTransaction( + transaction: TransactionMeta, + gasLimit?: number, +): PreparedAcrossTransaction { + return { + params: { + data: transaction.txParams.data, + from: transaction.txParams.from, + gas: gasLimit === undefined ? undefined : toHex(gasLimit), + to: transaction.txParams.to, + value: transaction.txParams.value, + } as TransactionParams, + type: getOriginalTransactionType(transaction), + }; +} + +/** + * Get the transaction type to use for the original batch leg. + * + * @param transaction - Original transaction metadata. + * @returns `predictWithdraw` for Predict withdrawals; otherwise the original type. + */ +function getOriginalTransactionType( + transaction: TransactionMeta, +): TransactionMeta['type'] { + if (isPredictWithdrawTransaction(transaction)) { + return TransactionType.predictWithdraw; + } + + return transaction.type; +} + +/** + * Check whether the original transaction already has a usable gas limit. + * + * @param transaction - Original transaction metadata. + * @returns Whether the original or nested transaction gas is a positive integer. + */ +function hasOriginalTransactionGas(transaction: TransactionMeta): boolean { + return getOriginalTransactionGas(transaction) !== undefined; +} + +/** + * Get the transaction type for the Across bridge/deposit leg. + * + * @param transaction - Original parent transaction. + * @returns Across-specific transaction type for known flows, or the original type. + */ +function getAcrossDepositType(transaction: TransactionMeta): TransactionType { + if (isPredictWithdrawTransaction(transaction)) { + return TransactionType.predictAcrossWithdraw; + } + + switch (transaction.type) { case TransactionType.perpsDeposit: return TransactionType.perpsAcrossDeposit; case TransactionType.predictDeposit: @@ -346,10 +579,24 @@ function getAcrossDepositType( case undefined: return TransactionType.perpsAcrossDeposit; default: - return transactionType; + return transaction.type as TransactionType; } } +/** + * Build TransactionController params for an Across approval or swap leg. + * + * @param from - Sender address. + * @param params - Across transaction fields. + * @param params.chainId - Source chain ID. + * @param params.data - Transaction calldata. + * @param params.gasLimit - Optional gas limit. + * @param params.to - Recipient contract address. + * @param params.value - Optional native value. + * @param params.maxFeePerGas - Optional EIP-1559 max fee. + * @param params.maxPriorityFeePerGas - Optional EIP-1559 priority fee. + * @returns TransactionController params. + */ function buildTransactionParams( from: Hex, params: { @@ -375,6 +622,12 @@ function buildTransactionParams( }; } +/** + * Normalize an optional numeric string or hex string into a hex value. + * + * @param value - Optional value to normalize. + * @returns Hex value, or `undefined` when no value is provided. + */ function normalizeOptionalHex(value?: string): Hex | undefined { if (value === undefined) { return undefined; @@ -383,6 +636,12 @@ function normalizeOptionalHex(value?: string): Hex | undefined { return toHex(value); } +/** + * Convert full TransactionController params into batch transaction params. + * + * @param params - Transaction params. + * @returns Batch-compatible transaction params. + */ function toBatchTransactionParams( params: TransactionParams, ): BatchTransactionParams { diff --git a/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts b/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts new file mode 100644 index 0000000000..68af4c5dd6 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts @@ -0,0 +1,29 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { QuoteRequest } from '../../types'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; + +/** + * Check whether an authorization list on the original transaction is unsupported by Across. + * + * Predict withdraw post-quote requests have no Across destination actions, so + * the authorization list applies to MetaMask's source-chain batch instead of an + * Across post-swap action. + * + * @param transaction - Original transaction metadata. + * @param requests - Across quote requests. + * @returns `true` if the authorization list should block Across. + */ +export function hasUnsupportedTransactionAuthorizationList( + transaction: TransactionMeta, + requests: QuoteRequest[], +): boolean { + if (!transaction.txParams?.authorizationList?.length) { + return false; + } + + return ( + !isPredictWithdrawTransaction(transaction) || + requests.some((request) => request.isPostQuote !== true) + ); +} diff --git a/packages/transaction-pay-controller/src/strategy/across/requests.ts b/packages/transaction-pay-controller/src/strategy/across/requests.ts index 77b967af56..f662dc7ae6 100644 --- a/packages/transaction-pay-controller/src/strategy/across/requests.ts +++ b/packages/transaction-pay-controller/src/strategy/across/requests.ts @@ -3,6 +3,7 @@ import type { QuoteRequest } from '../../types'; export function isAcrossQuoteRequest(request: QuoteRequest): boolean { return ( request.isMaxAmount === true || + request.isPostQuote === true || (request.targetAmountMinimum !== undefined && request.targetAmountMinimum !== '0') ); diff --git a/packages/transaction-pay-controller/src/strategy/across/transactions.ts b/packages/transaction-pay-controller/src/strategy/across/transactions.ts index f5cb6ef302..38d04fff10 100644 --- a/packages/transaction-pay-controller/src/strategy/across/transactions.ts +++ b/packages/transaction-pay-controller/src/strategy/across/transactions.ts @@ -1,4 +1,6 @@ import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; import type { AcrossSwapApprovalResponse } from './types'; @@ -38,3 +40,28 @@ export function getAcrossOrderedTransactions({ }, ]; } + +/** + * Get a usable gas limit from the original or nested transaction. + * + * @param transaction - Original transaction metadata. + * @returns Positive integer gas limit if present, otherwise undefined. + */ +export 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(); +} 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'; }; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index d3ecc63e47..723e2eefea 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -123,8 +123,8 @@ export type TransactionConfig = { isPostQuote?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. Use this for post-quote flows where the user's funds originate * from a smart contract account (e.g. Predict Safe proxy) so that refunds * go back to that account rather than the EOA. @@ -224,8 +224,8 @@ export type TransactionData = { isHyperliquidSource?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. */ refundTo?: Hex; @@ -400,8 +400,8 @@ export type QuoteRequest = { isHyperliquidSource?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. */ refundTo?: Hex; diff --git a/packages/transaction-pay-controller/src/utils/gas-station.ts b/packages/transaction-pay-controller/src/utils/gas-station.ts index 4a52b30310..6398a1365f 100644 --- a/packages/transaction-pay-controller/src/utils/gas-station.ts +++ b/packages/transaction-pay-controller/src/utils/gas-station.ts @@ -59,7 +59,7 @@ export async function getGasStationCostInSourceTokenRaw({ const { data, to, value } = firstStepData; const { from, sourceChainId, sourceTokenAddress } = request; - let gasFeeTokens: GasFeeToken[]; + let gasFeeTokens: GasFeeToken[] | undefined; try { gasFeeTokens = await messenger.call( @@ -80,7 +80,7 @@ export async function getGasStationCostInSourceTokenRaw({ return undefined; } - const gasFeeToken = gasFeeTokens.find( + const gasFeeToken = gasFeeTokens?.find( (singleGasFeeToken) => singleGasFeeToken.tokenAddress.toLowerCase() === sourceTokenAddress.toLowerCase(),