diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 47fcb23325..18d89044ba 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add Across submit support for post-quote Predict withdraw flows ([#8761](https://github.com/MetaMask/core/pull/8761)) - Add Across quote support for post-quote Predict withdraw flows ([#8760](https://github.com/MetaMask/core/pull/8760)) ### Changed 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 d7ff9627bd..8db721b870 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -33,7 +33,10 @@ 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, @@ -796,25 +799,6 @@ function combinePostQuoteGas( }; } -function getOriginalTransactionGas( - transaction: TransactionMeta, -): number | undefined { - const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; - const rawGas = nestedGas ?? transaction.txParams.gas; - - if (rawGas === undefined) { - return undefined; - } - - const gas = new BigNumber(rawGas); - - if (!gas.isFinite() || gas.isNaN() || !gas.isInteger() || gas.lte(0)) { - return undefined; - } - - return gas.toNumber(); -} - function calculateOriginalSourceNetworkCost({ gas, messenger, 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/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(); +}