diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 385a6fba845..1901a4e490c 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)) + ## [64.4.0] ### Changed diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 8881fb7b1f7..c1ec1eb9f5e 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -842,6 +842,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-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 8b8febe2c9a..275d76e66e4 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Gas Station support for Across source transactions when native balance is insufficient ([#8588](https://github.com/MetaMask/core/pull/8588)) +- Add Across support for post-quote Predict withdraw flows ([#8593](https://github.com/MetaMask/core/pull/8593)) ### 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 efe6eb6234d..88fccd9af09 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -197,6 +197,59 @@ describe('AcrossStrategy', () => { ).toBe(true); }); + it('supports post-quote predict withdraw requests', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + ...TRANSACTION_META_MOCK.txParams, + 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( diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index c54cde6187a..e5879eeafbb 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -8,6 +8,7 @@ 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'; @@ -61,6 +62,10 @@ export class AcrossStrategy implements PayStrategy { } return actionableRequests.every((singleRequest) => { + if (singleRequest.isPostQuote) { + return isPredictWithdrawTransaction(request.transaction); + } + try { getAcrossDestination(request.transaction, singleRequest); return true; 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 4e09ff65730..17600f035c9 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, @@ -324,6 +330,120 @@ 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', 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, + 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({ + 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({ + 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('re-quotes max amount quotes after reserving source token for gas fee token', async () => { const adjustedSourceAmount = '999999999999999900'; @@ -376,6 +496,60 @@ 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 adjustedSourceAmount = '999999999999999900'; + const refundTo = '0x5afe000000000000000000000000000000000001' as Hex; + + getTokenBalanceMock.mockReturnValue('0'); + isEIP7702ChainMock.mockReturnValue(true); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + successfulFetchMock + .mockResolvedValueOnce({ + json: async () => QUOTE_MOCK, + } as Response) + .mockResolvedValueOnce({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: adjustedSourceAmount, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + refundTo, + 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: refundTo, + }), + ); + + const [phase2Url] = successfulFetchMock.mock.calls[1]; + 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('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 0b12c70c6cd..2a4308acd14 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'; @@ -22,10 +23,12 @@ import { getTokenBalance, getTokenFiatRate, } from '../../utils/token'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { getGasStationCostInSourceTokenRaw, getGasStationEligibility, } from '../gas-station'; +import type { AcrossDestination } from './across-actions'; import { getAcrossDestination } from './across-actions'; import { normalizeAcrossRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; @@ -104,9 +107,16 @@ 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, @@ -117,6 +127,7 @@ async function getSingleQuote( originChainId: sourceChainId, outputToken: targetTokenAddress, recipient: destination.recipient, + refundAddress: normalizedRequest.refundTo, slippage: slippageDecimal, tradeType, }); @@ -132,13 +143,31 @@ 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> { const phase1Quote = await getSingleQuote(request, fullRequest); - if (!request.isMaxAmount || !phase1Quote.fees.isSourceGasFeeToken) { + if ( + (!request.isMaxAmount && !request.isPostQuote) || + !phase1Quote.fees.isSourceGasFeeToken + ) { return phase1Quote; } @@ -147,11 +176,11 @@ async function getQuoteWithGasStationHandling( .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'); 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, @@ -170,7 +199,7 @@ 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'); return phase1Quote; } @@ -181,7 +210,7 @@ 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, @@ -191,7 +220,7 @@ async function getQuoteWithGasStationHandling( return phase2Quote; } catch (error) { - log('Across max phase 2 quote failed, falling back to phase 1', { error }); + log('Across phase 2 quote failed, falling back to phase 1', { error }); return phase1Quote; } } @@ -206,6 +235,7 @@ type AcrossApprovalRequest = { originChainId: Hex; outputToken: Hex; recipient: Hex; + refundAddress?: Hex; slippage?: number; tradeType: 'exactInput' | 'exactOutput'; }; @@ -223,6 +253,7 @@ async function requestAcrossApproval( originChainId, outputToken, recipient, + refundAddress, slippage, tradeType, } = request; @@ -237,6 +268,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)); } @@ -278,7 +313,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); @@ -462,6 +502,7 @@ async function calculateSourceNetworkCost( quote: AcrossSwapApprovalResponse, messenger: TransactionPayControllerMessenger, request: QuoteRequest, + transaction: TransactionMeta, ): Promise<{ sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; gasLimits: AcrossGasLimits; @@ -475,16 +516,19 @@ async function calculateSourceNetworkCost( const orderedTransactions = getAcrossOrderedTransactions({ quote }); const { swapTx } = quote; const swapChainId = toHex(swapTx.chainId); + const isPredictWithdraw = + request.isPostQuote && isPredictWithdrawTransaction(transaction); + const fromOverride = isPredictWithdraw ? request.refundTo : undefined; const gasEstimates = await estimateQuoteGasLimits({ fallbackGas: acrossFallbackGas, messenger, - transactions: orderedTransactions.map((transaction) => ({ - chainId: toHex(transaction.chainId), - data: transaction.data, - from, - gas: transaction.gas, - to: transaction.to, - value: transaction.value ?? '0x0', + transactions: orderedTransactions.map((orderedTransaction) => ({ + chainId: toHex(orderedTransaction.chainId), + data: orderedTransaction.data, + from: fromOverride ?? from, + gas: fromOverride ? undefined : orderedTransaction.gas, + to: orderedTransaction.to, + value: orderedTransaction.value ?? '0x0', })), }); const { batchGasLimit, is7702, requiresAuthorizationList, totalGasEstimate } = @@ -526,32 +570,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, }), ), @@ -572,8 +616,14 @@ 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, @@ -582,9 +632,11 @@ async function calculateSourceNetworkCost( ); if ( - new BigNumber(nativeBalance).isGreaterThanOrEqualTo(sourceNetwork.max.raw) + new BigNumber(nativeBalance).isGreaterThanOrEqualTo( + finalResult.sourceNetwork.max.raw, + ) ) { - return result; + return finalResult; } const gasStationEligibility = getGasStationEligibility( @@ -594,14 +646,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]; @@ -614,16 +666,19 @@ async function calculateSourceNetworkCost( }, messenger, request: { - from, + from: fromOverride ?? from, 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', { @@ -636,8 +691,89 @@ 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; + + return { + ...gasResult, + sourceNetwork: { + estimate: calculateGasCost({ + chainId: toHex(swapTx.chainId), + gas: totalGasEstimate, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }), + max: calculateGasCost({ + chainId: toHex(swapTx.chainId), + gas: totalGasLimit, + isMax: true, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }), + }, 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(); +} 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 4e795dbd7b9..f1291af7ddc 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 @@ -395,6 +395,170 @@ 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('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({ + gasFeeToken: QUOTE_MOCK.request.sourceTokenAddress, + }), + ); + }); + + 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 cb09bac1cb6..2a29bbdd877 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -23,6 +23,7 @@ import { collectTransactionIds, getTransaction, updateTransaction, + isPredictWithdrawTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; import { getAcrossOrderedTransactions } from './transactions'; @@ -33,7 +34,7 @@ const ACROSS_STATUS_POLL_INTERVAL = 1000; type PreparedAcrossTransaction = { params: TransactionParams; - type: TransactionType; + type: TransactionMeta['type']; }; /** @@ -79,10 +80,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 +106,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,6 +125,10 @@ async function submitTransactions( quote: quote.original.quote, swapType: acrossDepositType, }); + const shouldPrependOriginalTransaction = + quote.request.isPostQuote && parentTransaction.txParams.to !== undefined; + const gasLimitOffset = shouldPrependOriginalTransaction ? 1 : 0; + const transactionCount = orderedTransactions.length + gasLimitOffset; const networkClientId = messenger.call( 'NetworkController:findNetworkClientIdByChainId', @@ -131,25 +136,26 @@ async function submitTransactions( ); const batchGasLimit = - is7702 && orderedTransactions.length > 1 - ? quoteGasLimits[0]?.max - : undefined; + is7702 && transactionCount > 1 ? quoteGasLimits[0]?.max : undefined; - if (is7702 && orderedTransactions.length > 1 && batchGasLimit === undefined) { + if (is7702 && transactionCount > 1 && batchGasLimit === undefined) { throw new Error('Missing quote gas limit for Across 7702 batch'); } const gasLimit7702 = batchGasLimit === undefined ? undefined : toHex(batchGasLimit); - const transactions: PreparedAcrossTransaction[] = orderedTransactions.map( - (transaction, index) => { - const gasLimit = gasLimit7702 ? undefined : quoteGasLimits[index]?.max; + const acrossTransactions: PreparedAcrossTransaction[] = + orderedTransactions.map((transaction, index) => { + const gasLimit = gasLimit7702 + ? undefined + : quoteGasLimits[index + gasLimitOffset]?.max; if (gasLimit === undefined && !gasLimit7702) { + 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 +173,11 @@ async function submitTransactions( }), type: transaction.type ?? acrossDepositType, }; - }, - ); + }); + const originalTransaction = shouldPrependOriginalTransaction + ? [buildOriginalTransaction(parentTransaction, quoteGasLimits[0]?.max)] + : []; + const transactions = [...originalTransaction, ...acrossTransactions]; const transactionIds: string[] = []; @@ -181,7 +190,7 @@ async function submitTransactions( updateTransaction( { - transactionId: parentTransactionId, + transactionId: parentTransaction.id, messenger, note: 'Add required transaction ID from Across submission', }, @@ -335,10 +344,38 @@ async function waitForAcrossCompletion( } } -function getAcrossDepositType( - transactionType?: TransactionType, -): TransactionType { - switch (transactionType) { +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), + }; +} + +function getOriginalTransactionType( + transaction: TransactionMeta, +): TransactionMeta['type'] { + if (isPredictWithdrawTransaction(transaction)) { + return TransactionType.predictWithdraw; + } + + return transaction.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,7 +383,7 @@ function getAcrossDepositType( case undefined: return TransactionType.perpsAcrossDeposit; default: - return transactionType; + return transaction.type as TransactionType; } } diff --git a/packages/transaction-pay-controller/src/strategy/across/requests.ts b/packages/transaction-pay-controller/src/strategy/across/requests.ts index 77b967af562..f662dc7ae60 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/types.ts b/packages/transaction-pay-controller/src/types.ts index 175668f3827..c1a558b5228 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -93,8 +93,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. @@ -184,8 +184,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; @@ -354,8 +354,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;