diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index c20aa399dc..faf4a89f01 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -2721,6 +2721,102 @@ describe('Relay Quotes Utils', () => { ]); expect(result[0].original.metamask.is7702).toBe(true); }); + + it('appends extra gas when both postQuote and paymentOverride are set', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 79000, 21000, 75000, + ]); + }); + + it('appends extra gas to combined 7702 limit when both postQuote and paymentOverride are set', async () => { + const multiStepQuote = { + ...QUOTE_MOCK, + steps: [ + { + ...STEP_MOCK, + items: [ + STEP_MOCK.items[0], + { + ...STEP_MOCK.items[0], + data: { ...STEP_MOCK.items[0].data, gas: '30000' }, + }, + ], + }, + ], + }; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => multiStepQuote, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + } as TransactionMeta, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 51000 + 79000 + 75000, + ]); + expect(result[0].original.metamask.is7702).toBe(true); + }); }); describe('HyperLiquid source (isHyperliquidSource)', () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 4d1bb5fca2..5294004a1e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -914,7 +914,15 @@ function combinePrependedGas( ? combinePostQuoteGas(relayOnlyGas, transaction) : relayOnlyGas; - return request.paymentOverride ? addPaymentOverrideGas(gas) : gas; + if (!request.paymentOverride) { + return gas; + } + + // Combined: override gas goes last (appended after relay + original tx) + // Override-only: override gas goes first (prepended before relay txs) + return request.isPostQuote + ? appendPaymentOverrideGas(gas) + : prependPaymentOverrideGas(gas); } /** @@ -974,7 +982,7 @@ function combinePostQuoteGas( }; } -function addPaymentOverrideGas(relayGas: RelayGasResult): RelayGasResult { +function prependPaymentOverrideGas(relayGas: RelayGasResult): RelayGasResult { const gasLimits = relayGas.is7702 ? [relayGas.gasLimits[0] + PAYMENT_OVERRIDE_GAS] : [PAYMENT_OVERRIDE_GAS, ...relayGas.gasLimits]; @@ -987,6 +995,19 @@ function addPaymentOverrideGas(relayGas: RelayGasResult): RelayGasResult { }; } +function appendPaymentOverrideGas(relayGas: RelayGasResult): RelayGasResult { + const gasLimits = relayGas.is7702 + ? [relayGas.gasLimits[0] + PAYMENT_OVERRIDE_GAS] + : [...relayGas.gasLimits, PAYMENT_OVERRIDE_GAS]; + + return { + totalGasEstimate: relayGas.totalGasEstimate + PAYMENT_OVERRIDE_GAS, + totalGasLimit: relayGas.totalGasLimit + PAYMENT_OVERRIDE_GAS, + gasLimits, + is7702: relayGas.is7702, + }; +} + /** * Calculate the provider fee for a Relay quote. * diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index c43dfb22e6..26c7b489f7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1275,6 +1275,185 @@ describe('Relay Submit Utils', () => { }); }); + describe('combined post-quote + paymentOverride flow', () => { + const TRANSACTION_DATA_MOCK = { + isLoading: false, + tokens: [], + }; + + const PAYMENT_OVERRIDE_TX_MOCK: BatchTransactionParams = { + to: '0xpaymentoverride' as Hex, + data: '0xpaymentoverride' as Hex, + value: '0x0' as Hex, + }; + + beforeEach(() => { + request.quotes[0].request.isPostQuote = true; + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + request.transaction = { + id: ORIGINAL_TRANSACTION_ID_MOCK, + txParams: { + from: FROM_MOCK, + to: '0xrecipient' as Hex, + data: '0xorigdata' as Hex, + value: '0x100' as Hex, + }, + type: TransactionType.simpleSend, + } as TransactionMeta; + + getControllerStateMock.mockReturnValue({ + transactionData: { + [ORIGINAL_TRANSACTION_ID_MOCK]: TRANSACTION_DATA_MOCK, + }, + }); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + }); + + it('prepends original tx and appends override tx', async () => { + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + + const { transactions } = addTransactionBatchMock.mock + .calls[0][0] as unknown as Record; + + expect(transactions).toHaveLength(3); + expect(transactions[0]).toStrictEqual( + expect.objectContaining({ + params: expect.objectContaining({ + data: '0xorigdata', + to: '0xrecipient', + value: '0x100', + }), + }), + ); + expect(transactions[2]).toStrictEqual( + expect.objectContaining({ + params: expect.objectContaining({ + data: PAYMENT_OVERRIDE_TX_MOCK.data, + to: PAYMENT_OVERRIDE_TX_MOCK.to, + value: PAYMENT_OVERRIDE_TX_MOCK.value, + }), + }), + ); + }); + + it('assigns correct transaction types', async () => { + await submitRelayQuotes(request); + + const { transactions } = addTransactionBatchMock.mock + .calls[0][0] as unknown as Record; + + expect(transactions).toHaveLength(3); + expect(transactions[0]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.simpleSend, + }), + ); + expect(transactions[1]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.relayDeposit, + }), + ); + expect(transactions[2]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.simpleSend, + }), + ); + }); + + it('assigns correct types with multi-step relay (approve + deposit)', async () => { + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + data: { + ...request.quotes[0].original.steps[0].items[0].data, + data: '0xapprove' as Hex, + to: '0xapproveTarget' as Hex, + }, + }); + + request.quotes[0].original.metamask.gasLimits = [ + 21000, 30000, 50000, 75000, + ]; + + await submitRelayQuotes(request); + + const { transactions } = addTransactionBatchMock.mock + .calls[0][0] as unknown as Record; + + expect(transactions).toHaveLength(4); + expect(transactions[0]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.simpleSend, + }), + ); + expect(transactions[1]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.tokenMethodApprove, + }), + ); + expect(transactions[2]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.relayDeposit, + }), + ); + expect(transactions[3]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.simpleSend, + }), + ); + }); + + it('assigns correct gas limits', async () => { + request.quotes[0].original.metamask.gasLimits = [ + 21000, 30000, 75000, + ]; + + await submitRelayQuotes(request); + + const { transactions } = addTransactionBatchMock.mock + .calls[0][0] as unknown as Record< + string, + { params: { gas?: string } }[] + >; + + expect(transactions).toHaveLength(3); + expect(transactions[0].params.gas).toBe('0x5208'); + expect(transactions[1].params.gas).toBe('0x7530'); + expect(transactions[2].params.gas).toBe('0x124f8'); + }); + + it('skips source balance validation', async () => { + getLiveTokenBalanceMock.mockResolvedValue('0'); + + await submitRelayQuotes(request); + + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + }); + + it('does not append when callback returns empty array', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); + + await submitRelayQuotes(request); + + const { transactions } = addTransactionBatchMock.mock + .calls[0][0] as unknown as Record; + + expect(transactions).toHaveLength(2); + expect(transactions[0]).toStrictEqual( + expect.objectContaining({ + params: expect.objectContaining({ + data: '0xorigdata', + }), + }), + ); + }); + }); + it('adds transaction batch with single gasLimit7702', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index dfece5307f..8a5ecbe59d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -401,7 +401,23 @@ async function submitTransactions( (transaction.txParams.from as Hex).toLowerCase(); let allParams = normalizedParams; + let appendedParams: TransactionParams[] = []; + // Post-quote: prepend the original tx so it executes first in the batch + if (isPostQuote && transaction.txParams.to) { + const prependedParams = hasAccountOverride + ? await buildDelegatedOriginalParams(transaction, messenger) + : ({ + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams); + + allParams = [prependedParams, ...normalizedParams]; + } + + // Payment override: invoke callback for additional txs if (quote.request.paymentOverride) { const { transactionData } = messenger.call( 'TransactionPayController:getState', @@ -417,22 +433,18 @@ async function submitTransactions( ); if (overrideTxs.length > 0) { - allParams = [ - ...(overrideTxs as TransactionParams[]), - ...normalizedParams, - ]; + if (isPostQuote) { + // Combined: append override txs after relay txs + appendedParams = overrideTxs as TransactionParams[]; + allParams = [...allParams, ...appendedParams]; + } else { + // Override-only: prepend override txs before relay txs + allParams = [ + ...(overrideTxs as TransactionParams[]), + ...normalizedParams, + ]; + } } - } else if (isPostQuote && transaction.txParams.to) { - const prependedParams = hasAccountOverride - ? await buildDelegatedOriginalParams(transaction, messenger) - : ({ - data: transaction.txParams.data as Hex | undefined, - from: transaction.txParams.from, - to: transaction.txParams.to, - value: transaction.txParams.value as Hex | undefined, - } as TransactionParams); - - allParams = [prependedParams, ...normalizedParams]; } if (quote.original.metamask.isExecute) { @@ -450,6 +462,7 @@ async function submitTransactions( messenger, normalizedParams, allParams, + appendedParams.length, ); } @@ -586,7 +599,8 @@ async function submitViaRelayExecute( * @param transaction - Original transaction meta. * @param messenger - Controller messenger. * @param normalizedParams - Normalized relay-only params (without prepended original tx). - * @param allParams - All params including any prepended original tx for post-quote flows. + * @param allParams - All params including any prepended/appended txs. + * @param appendCount - Number of override txs appended after relay params. * @returns Hash of the last submitted transaction. */ async function submitViaTransactionController( @@ -595,6 +609,7 @@ async function submitViaTransactionController( messenger: TransactionPayControllerMessenger, normalizedParams: TransactionParams[], allParams: TransactionParams[], + appendCount: number, ): Promise { const transactionIds: string[] = []; const { from, sourceChainId, sourceTokenAddress } = quote.request; @@ -684,7 +699,7 @@ async function submitViaTransactionController( ? toHex(metamask.gasLimits[0]) : undefined; - const prependCount = allParams.length - normalizedParams.length; + const prependCount = allParams.length - normalizedParams.length - appendCount; const transactions = allParams.map((singleParams, index) => { const gasLimit = gasLimits[index]; @@ -702,6 +717,7 @@ async function submitViaTransactionController( }, type: getTransactionType( prependCount, + appendCount, index, getEffectiveTransactionType(transaction), normalizedParams.length, @@ -749,13 +765,15 @@ async function submitViaTransactionController( * Determine the transaction type for a given index in the batch. * * @param prependCount - Number of non-relay txs prepended to the batch. + * @param appendCount - Number of non-relay txs appended after relay params. * @param index - Index of the transaction in the batch. - * @param originalType - Type of the original transaction (used for prepended indices). - * @param relayParamCount - Number of relay-only params (excludes prepended txs). + * @param originalType - Type of the original transaction (used for prepended/appended indices). + * @param relayParamCount - Number of relay-only params (excludes prepended/appended txs). * @returns The transaction type. */ function getTransactionType( prependCount: number, + appendCount: number, index: number, originalType: TransactionMeta['type'], relayParamCount: number, @@ -764,6 +782,10 @@ function getTransactionType( return originalType; } + if (appendCount > 0 && index >= prependCount + relayParamCount) { + return originalType; + } + const relayIndex = index - prependCount; const depositType = getRelayDepositType(originalType);