From d12b7062e48790b280b080e5eaf12f5c74b886d7 Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:11:21 +0100 Subject: [PATCH 1/2] feat: Withdrawal to any token (Predict) --- packages/transaction-controller/CHANGELOG.md | 4 + packages/transaction-controller/src/types.ts | 6 + .../transaction-pay-controller/CHANGELOG.md | 5 + .../src/TransactionPayController.ts | 22 ++- .../src/actions/update-payment-token.test.ts | 39 +++- .../src/actions/update-payment-token.ts | 2 +- .../transaction-pay-controller/src/index.ts | 1 + .../src/strategy/relay/relay-quotes.test.ts | 31 +++- .../src/strategy/relay/relay-quotes.ts | 20 ++- .../src/strategy/relay/relay-submit.ts | 168 +++++++++++++++++- .../transaction-pay-controller/src/types.ts | 24 ++- .../src/utils/quotes.test.ts | 133 ++++++++++++++ .../src/utils/quotes.ts | 107 ++++++++++- .../src/utils/required-tokens.ts | 2 +- .../src/utils/source-amounts.test.ts | 74 ++++++++ .../src/utils/source-amounts.ts | 30 +++- .../src/utils/token.test.ts | 1 + .../src/utils/token.ts | 14 +- 18 files changed, 652 insertions(+), 31 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f9c15e0f526..86a8fd440d2 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 `isPostQuote` field to `MetamaskPayMetadata` type for withdrawal flows ([#7773](https://github.com/MetaMask/core/pull/7773)) + ## [62.11.0] ### Added diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 413bb591164..bc73c705608 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2082,6 +2082,12 @@ export type MetamaskPayMetadata = { /** Chain ID of the payment token. */ chainId?: Hex; + /** + * Whether this is a post-quote transaction (e.g., withdrawal flow). + * When true, the token represents the destination rather than source. + */ + isPostQuote?: boolean; + /** Total network fee in fiat currency, including the original and bridge transactions. */ networkFeeFiat?: string; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 3254ccfca74..4f2378fd363 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `isPostQuote` field to `TransactionData` type for withdrawal flows ([#7773](https://github.com/MetaMask/core/pull/7773)) +- Add `setIsPostQuote` action type ([#7773](https://github.com/MetaMask/core/pull/7773)) + ## [12.0.2] ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 9bba57d98d8..6a279d39c88 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -80,6 +80,12 @@ export class TransactionPayController extends BaseController< }); } + setIsPostQuote(transactionId: string, isPostQuote: boolean): void { + this.#updateTransactionData(transactionId, (transactionData) => { + transactionData.isPostQuote = isPostQuote; + }); + } + updatePaymentToken(request: UpdatePaymentTokenRequest): void { updatePaymentToken(request, { messenger: this.messenger, @@ -105,6 +111,7 @@ export class TransactionPayController extends BaseController< const originalPaymentToken = current?.paymentToken; const originalTokens = current?.tokens; const originalIsMaxAmount = current?.isMaxAmount; + const originalIsPostQuote = current?.isPostQuote; if (!current) { transactionData[transactionId] = { @@ -122,8 +129,14 @@ export class TransactionPayController extends BaseController< const isTokensUpdated = current.tokens !== originalTokens; const isIsMaxUpdated = current.isMaxAmount !== originalIsMaxAmount; - - if (isPaymentTokenUpdated || isIsMaxUpdated || isTokensUpdated) { + const isPostQuoteUpdated = current.isPostQuote !== originalIsPostQuote; + + if ( + isPaymentTokenUpdated || + isIsMaxUpdated || + isTokensUpdated || + isPostQuoteUpdated + ) { updateSourceAmounts(transactionId, current as never, this.messenger); shouldUpdateQuotes = true; @@ -157,6 +170,11 @@ export class TransactionPayController extends BaseController< this.setIsMaxAmount.bind(this), ); + this.messenger.registerActionHandler( + 'TransactionPayController:setIsPostQuote', + this.setIsPostQuote.bind(this), + ); + this.messenger.registerActionHandler( 'TransactionPayController:updatePaymentToken', this.updatePaymentToken.bind(this), diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts index 8b0d24dfc9e..68bb614d678 100644 --- a/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts @@ -2,11 +2,11 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { noop } from 'lodash'; import { updatePaymentToken } from './update-payment-token'; -import type { TransactionData } from '../types'; +import type { TransactionData, TransactionPaymentToken } from '../types'; import { getTokenBalance, - getTokenInfo, getTokenFiatRate, + getTokenInfo, } from '../utils/token'; import { getTransaction } from '../utils/transaction'; @@ -18,18 +18,37 @@ const CHAIN_ID_MOCK = '0x1'; const FROM_MOCK = '0x456'; const TRANSACTION_ID_MOCK = '123-456'; +const PAYMENT_TOKEN_MOCK: TransactionPaymentToken = { + address: TOKEN_ADDRESS_MOCK, + balanceFiat: '2.46', + balanceHuman: '1.23', + balanceRaw: '1230000', + balanceUsd: '3.69', + chainId: CHAIN_ID_MOCK, + decimals: 6, + symbol: 'TST', +}; + describe('Update Payment Token Action', () => { - const getTokenBalanceMock = jest.mocked(getTokenBalance); const getTokenInfoMock = jest.mocked(getTokenInfo); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getTokenBalanceMock = jest.mocked(getTokenBalance); const getTransactionMock = jest.mocked(getTransaction); beforeEach(() => { jest.resetAllMocks(); - getTokenInfoMock.mockReturnValue({ decimals: 6, symbol: 'TST' }); + getTokenInfoMock.mockReturnValue({ + decimals: PAYMENT_TOKEN_MOCK.decimals, + symbol: PAYMENT_TOKEN_MOCK.symbol, + }); + + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '2', + usdRate: '3', + }); + getTokenBalanceMock.mockReturnValue('1230000'); - getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' }); getTransactionMock.mockReturnValue({ id: TRANSACTION_ID_MOCK, @@ -52,6 +71,12 @@ describe('Update Payment Token Action', () => { }, ); + expect(getTokenInfoMock).toHaveBeenCalledWith( + {}, + TOKEN_ADDRESS_MOCK, + CHAIN_ID_MOCK, + ); + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); const transactionDataMock = {} as TransactionData; @@ -69,7 +94,7 @@ describe('Update Payment Token Action', () => { }); }); - it('throws if decimals not found', () => { + it('throws if token info not found', () => { getTokenInfoMock.mockReturnValue(undefined); expect(() => @@ -87,7 +112,7 @@ describe('Update Payment Token Action', () => { ).toThrow('Payment token not found'); }); - it('throws if token fiat rate not found', () => { + it('throws if fiat rate not found', () => { getTokenFiatRateMock.mockReturnValue(undefined); expect(() => diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.ts index e9c7108af01..7cb8381a550 100644 --- a/packages/transaction-pay-controller/src/actions/update-payment-token.ts +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.ts @@ -26,7 +26,7 @@ export type UpdatePaymentTokenOptions = { /** * Update the payment token for a specific transaction. * - * @param request - Request parameters. + * @param request - Request parameters. * @param options - Options bag. */ export function updatePaymentToken( diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index d3cb83c35f3..1b63b8cf086 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -7,6 +7,7 @@ export type { TransactionPayControllerMessenger, TransactionPayControllerOptions, TransactionPayControllerSetIsMaxAmountAction, + TransactionPayControllerSetIsPostQuoteAction, TransactionPayControllerState, TransactionPayControllerStateChangeEvent, TransactionPayControllerUpdatePaymentTokenAction, 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 34dc3d22de6..43cf3b4522f 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 @@ -638,20 +638,47 @@ describe('Relay Quotes Utils', () => { ); }); - it('ignores requests with no target minimum', async () => { + it('ignores gas fee token requests (target=0 and source=0)', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as never); await getRelayQuotes({ messenger, - requests: [{ ...QUOTE_REQUEST_MOCK, targetAmountMinimum: '0' }], + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + sourceTokenAmount: '0', + }, + ], transaction: TRANSACTION_META_MOCK, }); expect(successfulFetchMock).not.toHaveBeenCalled(); }); + it('processes post-quote requests (target=0 but source>0)', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, targetAmountMinimum: '0' }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalled(); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXACT_INPUT'); + expect(body.amount).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); + }); + it('includes duration in quote', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, 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 730957ff492..405aef5159c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -59,8 +59,13 @@ export async function getRelayQuotes( try { const normalizedRequests = requests - // Ignore gas fee token requests - .filter((singleRequest) => singleRequest.targetAmountMinimum !== '0') + // Ignore gas fee token requests (which have both target=0 and source=0) + // but keep post-quote requests (which have target=0 but source>0) + .filter( + (singleRequest) => + singleRequest.targetAmountMinimum !== '0' || + singleRequest.sourceTokenAmount !== '0', + ) .map((singleRequest) => normalizeRequest(singleRequest)); log('Normalized requests', normalizedRequests); @@ -111,15 +116,20 @@ async function getSingleQuote( ); try { + // For post-quote (withdrawal) flows, use EXACT_INPUT - user specifies how much + // to withdraw, and we show them how much they'll receive after fees. + // For regular flows with a target amount, use EXPECTED_OUTPUT. + const useExactInput = isMaxAmount === true || targetAmountMinimum === '0'; + const body: RelayQuoteRequest = { - amount: isMaxAmount ? sourceTokenAmount : targetAmountMinimum, + amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, destinationChainId: Number(targetChainId), destinationCurrency: targetTokenAddress, originChainId: Number(sourceChainId), originCurrency: sourceTokenAddress, recipient: from, slippageTolerance, - tradeType: isMaxAmount ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', + tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', user: from, }; @@ -140,7 +150,7 @@ async function getSingleQuote( log('Fetched relay quote', quote); - return normalizeQuote(quote, request, fullRequest); + return await normalizeQuote(quote, request, fullRequest); } catch (error) { log('Error fetching relay quote', error); throw error; 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 2586fe0ac8f..0a1b0abc083 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -73,6 +73,8 @@ async function executeSingleQuote( ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); + const isPostQuote = transaction.metamaskPay?.isPostQuote; + updateTransaction( { transactionId: transaction.id, @@ -84,7 +86,14 @@ async function executeSingleQuote( }, ); - await submitTransactions(quote, transaction.id, messenger); + // For post-quote (withdrawal) flows, create an atomic batch with: + // 1. Original transaction (e.g., Safe withdrawal) + // 2. Relay deposit transaction + if (isPostQuote) { + await submitPostQuoteTransactions(quote, transaction, messenger); + } else { + await submitTransactions(quote, transaction.id, messenger); + } const targetHash = await waitForRelayCompletion(quote.original); @@ -104,6 +113,163 @@ async function executeSingleQuote( return { transactionHash: targetHash }; } +/** + * Submit transactions for a post-quote (withdrawal) flow. + * Creates an atomic 7702 batch containing: + * 1. Original transaction (e.g., Safe withdrawal) + * 2. Relay deposit transaction + * + * @param quote - Relay quote. + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + */ +async function submitPostQuoteTransactions( + quote: TransactionPayQuote, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const { steps } = quote.original; + const params = steps.flatMap((step) => step.items).map((item) => item.data); + const invalidKind = steps.find((step) => step.kind !== 'transaction')?.kind; + + if (invalidKind) { + throw new Error(`Unsupported step kind: ${invalidKind}`); + } + + const normalizedRelayParams = params.map((singleParams) => + normalizeParams(singleParams, messenger), + ); + + const { from, sourceChainId, sourceTokenAddress } = quote.request; + const { gasLimits } = quote.original.metamask; + const { txParams, nestedTransactions, type: originalType } = transaction; + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + sourceChainId, + ); + + log('Submitting post-quote batch', { + originalTxParams: txParams, + nestedTransactions, + relayParams: normalizedRelayParams, + sourceChainId, + from, + networkClientId, + }); + + const transactionIds: string[] = []; + + const { end } = collectTransactionIds( + sourceChainId, + from, + messenger, + (transactionId) => { + transactionIds.push(transactionId); + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Add required transaction ID from post-quote batch', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(transactionId); + }, + ); + }, + ); + + const gasFeeToken = quote.fees.isSourceGasFeeToken + ? sourceTokenAddress + : undefined; + + // Build the batch transactions: + // 1. Original transaction(s) - e.g., Safe withdrawal + // 2. Relay deposit transaction(s) + + const batchTransactions: { + params: { + data?: Hex; + gas?: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + to: Hex; + value?: Hex; + }; + type?: TransactionType; + }[] = []; + + // Add original transaction (e.g., Safe execTransaction call) + // Note: We use txParams (the outer call) not nestedTransactions (internal calls) + // because nestedTransactions are executed BY the Safe, not by the user's EOA + if (txParams.to) { + batchTransactions.push({ + params: { + data: txParams.data as Hex | undefined, + to: txParams.to as Hex, + value: txParams.value as Hex | undefined, + }, + type: originalType, + }); + } + + // Add relay deposit transaction(s) + for (let i = 0; i < normalizedRelayParams.length; i++) { + const relayParams = normalizedRelayParams[i]; + batchTransactions.push({ + params: { + data: relayParams.data as Hex, + gas: gasLimits[i] ? toHex(gasLimits[i]) : undefined, + maxFeePerGas: relayParams.maxFeePerGas as Hex, + maxPriorityFeePerGas: relayParams.maxPriorityFeePerGas as Hex, + to: relayParams.to as Hex, + value: relayParams.value as Hex, + }, + type: TransactionType.relayDeposit, + }); + } + + log('Post-quote batch transactions', { batchTransactions }); + + await messenger.call('TransactionController:addTransactionBatch', { + from, + gasFeeToken, + networkClientId, + origin: ORIGIN_METAMASK, + overwriteUpgrade: true, + requireApproval: false, + transactions: batchTransactions, + }); + + end(); + + // Mark original transaction as handled by nested batch + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Mark as dummy - handled by post-quote batch', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + + log('Post-quote batch submitted', transactionIds); + + if (transactionIds.length > 0) { + await Promise.all( + transactionIds.map((txId) => + waitForTransactionConfirmed(txId, messenger), + ), + ); + } + + log('Post-quote batch confirmed', transactionIds); +} + /** * Wait for a Relay request to complete. * diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 8539a6e15ff..e6a43c8206e 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -2,7 +2,6 @@ import type { CurrencyRateControllerActions, TokenBalancesControllerGetStateAction, } from '@metamask/assets-controllers'; -import type { TokenListControllerActions } from '@metamask/assets-controllers'; import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; import type { TokensControllerGetStateAction } from '@metamask/assets-controllers'; import type { AccountTrackerControllerGetStateAction } from '@metamask/assets-controllers'; @@ -47,7 +46,6 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction - | TokenListControllerActions | TokenRatesControllerGetStateAction | TokensControllerGetStateAction | TransactionControllerAddTransactionAction @@ -85,6 +83,12 @@ export type TransactionPayControllerUpdatePaymentTokenAction = { handler: (request: UpdatePaymentTokenRequest) => void; }; +/** Action to set the post-quote flag for a transaction. */ +export type TransactionPayControllerSetIsPostQuoteAction = { + type: `${typeof CONTROLLER_NAME}:setIsPostQuote`; + handler: (transactionId: string, isPostQuote: boolean) => void; +}; + /** Action to set the max amount flag for a transaction. */ export type TransactionPayControllerSetIsMaxAmountAction = { type: `${typeof CONTROLLER_NAME}:setIsMaxAmount`; @@ -102,6 +106,7 @@ export type TransactionPayControllerActions = | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction | TransactionPayControllerSetIsMaxAmountAction + | TransactionPayControllerSetIsPostQuoteAction | TransactionPayControllerUpdatePaymentTokenAction; export type TransactionPayControllerEvents = @@ -142,7 +147,20 @@ export type TransactionData = { /** Whether the user has selected the maximum amount. */ isMaxAmount?: boolean; - /** Source token selected for the transaction. */ + /** + * Whether this is a post-quote transaction (e.g., withdrawal flow). + * When true, the paymentToken represents the destination token, + * and the quote source is derived from the transaction's output token. + * Used for Predict/Perps withdrawals where funds flow: + * withdrawal → bridge/swap → destination token + */ + isPostQuote?: boolean; + + /** + * Token selected for the transaction. + * - For deposits (isPostQuote=false): This is the SOURCE/payment token + * - For withdrawals (isPostQuote=true): This is the DESTINATION token + */ paymentToken?: TransactionPaymentToken; /** Quotes retrieved for the transaction. */ diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index a247cef45d1..eb822ba6b93 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -413,4 +413,137 @@ describe('Quotes Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(0); }); }); + + describe('post-quote (withdrawal) flow', () => { + const DESTINATION_TOKEN_MOCK: TransactionPaymentToken = { + address: '0xdef' as Hex, + balanceFiat: '100.00', + balanceHuman: '1.00', + balanceRaw: '1000000000000000000', + balanceUsd: '100.00', + chainId: '0x38', + decimals: 18, + symbol: 'BNB', + }; + + const SOURCE_TOKEN_MOCK: TransactionPayRequiredToken = { + address: '0x456' as Hex, + amountHuman: '10', + amountRaw: '10000000', + balanceRaw: '50000000', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC.e', + skipIfBalance: false, + } as TransactionPayRequiredToken; + + const POST_QUOTE_TRANSACTION_DATA: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + sourceAmounts: [ + { + sourceAmountHuman: '10', + sourceAmountRaw: '10000000', + targetTokenAddress: '0x456' as Hex, + } as TransactionPaySourceAmount, + ], + tokens: [SOURCE_TOKEN_MOCK], + }; + + it('builds post-quote request with paymentToken as target', async () => { + await run({ + transactionData: POST_QUOTE_TRANSACTION_DATA, + }); + + expect(getQuotesMock).toHaveBeenCalledWith({ + messenger, + requests: [ + { + from: TRANSACTION_META_MOCK.txParams.from, + isMaxAmount: false, + sourceBalanceRaw: SOURCE_TOKEN_MOCK.balanceRaw, + sourceChainId: SOURCE_TOKEN_MOCK.chainId, + sourceTokenAddress: SOURCE_TOKEN_MOCK.address, + sourceTokenAmount: '10000000', + targetAmountMinimum: '0', + targetChainId: DESTINATION_TOKEN_MOCK.chainId, + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + }); + + it('does not fetch quotes for same-token-same-chain withdrawal', async () => { + const sameTokenData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + paymentToken: { + ...DESTINATION_TOKEN_MOCK, + address: SOURCE_TOKEN_MOCK.address, + chainId: SOURCE_TOKEN_MOCK.chainId, + }, + }; + + await run({ + transactionData: sameTokenData, + }); + + // When requests array is empty, getQuotes is not called + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + + it('does not fetch quotes if no source token found', async () => { + const noSourceTokenData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + tokens: [{ ...SOURCE_TOKEN_MOCK, skipIfBalance: true }], + }; + + await run({ + transactionData: noSourceTokenData, + }); + + // When requests array is empty, getQuotes is not called + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty requests if no paymentToken', async () => { + const noPaymentTokenData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + paymentToken: undefined, + }; + + await run({ + transactionData: noPaymentTokenData, + }); + + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + + it('uses sourceToken.amountRaw when no matching sourceAmount', async () => { + const noMatchingSourceAmountData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + sourceAmounts: [ + { + sourceAmountRaw: '99999', + targetTokenAddress: '0xdifferent' as Hex, + } as TransactionPaySourceAmount, + ], + }; + + await run({ + transactionData: noMatchingSourceAmountData, + }); + + expect(getQuotesMock).toHaveBeenCalledWith({ + messenger, + requests: [ + expect.objectContaining({ + sourceTokenAmount: SOURCE_TOKEN_MOCK.amountRaw, + }), + ], + transaction: TRANSACTION_META_MOCK, + }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index a242607fbf7..3f01a0968b4 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -55,11 +55,13 @@ export async function updateQuotes( log('Updating quotes', { transactionId }); - const { isMaxAmount, paymentToken, sourceAmounts, tokens } = transactionData; + const { isMaxAmount, isPostQuote, paymentToken, sourceAmounts, tokens } = + transactionData; const requests = buildQuoteRequests({ from: transaction.txParams.from as Hex, isMaxAmount: isMaxAmount ?? false, + isPostQuote, paymentToken, sourceAmounts, tokens, @@ -89,6 +91,7 @@ export async function updateQuotes( syncTransaction({ batchTransactions, + isPostQuote, messenger: messenger as never, paymentToken, totals, @@ -114,19 +117,22 @@ export async function updateQuotes( * * @param request - Request object. * @param request.batchTransactions - Batch transactions to sync. + * @param request.isPostQuote - Whether this is a post-quote (withdrawal) flow. * @param request.messenger - Messenger instance. - * @param request.paymentToken - Payment token used. + * @param request.paymentToken - Payment token (source for deposits, destination for withdrawals). * @param request.totals - Calculated totals. * @param request.transactionId - ID of the transaction to sync. */ function syncTransaction({ batchTransactions, + isPostQuote, messenger, paymentToken, totals, transactionId, }: { batchTransactions: BatchTransaction[]; + isPostQuote?: boolean; messenger: TransactionPayControllerMessenger; paymentToken: TransactionPaymentToken | undefined; totals: TransactionPayTotals; @@ -149,6 +155,7 @@ function syncTransaction({ tx.metamaskPay = { bridgeFeeFiat: totals.fees.provider.usd, chainId: paymentToken.chainId, + isPostQuote, networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, targetFiat: totals.targetAmount.usd, tokenAddress: paymentToken.address, @@ -213,7 +220,8 @@ export async function refreshQuotes( * @param request - Request parameters. * @param request.from - Address from which the transaction is sent. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. - * @param request.paymentToken - Payment token used for the transaction. + * @param request.isPostQuote - Whether this is a post-quote (withdrawal) flow. + * @param request.paymentToken - Payment token (source for deposits, destination for withdrawals). * @param request.sourceAmounts - Source amounts for the transaction. * @param request.tokens - Required tokens for the transaction. * @param request.transactionId - ID of the transaction. @@ -222,6 +230,7 @@ export async function refreshQuotes( function buildQuoteRequests({ from, isMaxAmount, + isPostQuote, paymentToken, sourceAmounts, tokens, @@ -229,6 +238,7 @@ function buildQuoteRequests({ }: { from: Hex; isMaxAmount: boolean; + isPostQuote?: boolean; paymentToken: TransactionPaymentToken | undefined; sourceAmounts: TransactionPaySourceAmount[] | undefined; tokens: TransactionPayRequiredToken[]; @@ -238,6 +248,20 @@ function buildQuoteRequests({ return []; } + if (isPostQuote) { + // Post-quote flow: source = transaction's required token, target = paymentToken (destination) + // The user is withdrawing and wants to receive funds in paymentToken + return buildPostQuoteRequests({ + from, + isMaxAmount, + destinationToken: paymentToken, + sourceAmounts, + tokens, + transactionId, + }); + } + + // Standard flow: source = paymentToken, target = required tokens const requests = (sourceAmounts ?? []).map((sourceAmount) => { const token = tokens.find( (singleToken) => singleToken.address === sourceAmount.targetTokenAddress, @@ -263,6 +287,83 @@ function buildQuoteRequests({ return requests; } +/** + * Build quote requests for post-quote (withdrawal) flows. + * In this flow, the source is the transaction's required token (e.g., Polygon USDC.e), + * and the target is the user's selected destination token (paymentToken). + * + * @param request - Request parameters. + * @param request.from - Address from which the transaction is sent. + * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. + * @param request.destinationToken - Destination token for withdrawal (paymentToken in post-quote mode). + * @param request.sourceAmounts - Source amounts for the transaction. + * @param request.tokens - Required tokens for the transaction. + * @param request.transactionId - ID of the transaction. + * @returns Array of quote requests for withdrawal flow. + */ +function buildPostQuoteRequests({ + from, + isMaxAmount, + destinationToken, + sourceAmounts, + tokens, + transactionId, +}: { + from: Hex; + isMaxAmount: boolean; + destinationToken: TransactionPaymentToken; + sourceAmounts: TransactionPaySourceAmount[] | undefined; + tokens: TransactionPayRequiredToken[]; + transactionId: string; +}): QuoteRequest[] { + // For withdrawals, the required token (e.g., Polygon USDC.e) becomes the SOURCE + // and the destinationToken (paymentToken) becomes the TARGET/destination + const sourceToken = tokens.find((token) => !token.skipIfBalance); + + if (!sourceToken) { + log('No source token found for post-quote request', { transactionId }); + return []; + } + + // Find the source amount for this token + const sourceAmount = sourceAmounts?.find( + (amount) => + amount.targetTokenAddress.toLowerCase() === + sourceToken.address.toLowerCase(), + ); + + // Check if source and target are the same token on the same chain + const isSameToken = + sourceToken.address.toLowerCase() === + destinationToken.address.toLowerCase() && + sourceToken.chainId === destinationToken.chainId; + + if (isSameToken) { + // For same-token-same-chain, no quote is needed - the withdrawal goes directly to user + // Return empty to indicate no bridging required + log('Same token same chain - no bridge needed', { transactionId }); + return []; + } + + const request: QuoteRequest = { + from, + isMaxAmount, + sourceBalanceRaw: sourceToken.balanceRaw, + sourceTokenAmount: sourceAmount?.sourceAmountRaw ?? sourceToken.amountRaw, + sourceChainId: sourceToken.chainId, + sourceTokenAddress: sourceToken.address, + // For post-quote withdrawals, use EXACT_INPUT - user specifies how much to withdraw, + // and we show them how much they'll receive after fees + targetAmountMinimum: '0', + targetChainId: destinationToken.chainId, + targetTokenAddress: destinationToken.address, + }; + + log('Post-quote request built', { transactionId, request }); + + return [request]; +} + /** * Retrieve quotes for a transaction. * diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.ts b/packages/transaction-pay-controller/src/utils/required-tokens.ts index c42426c918f..ca7863026a0 100644 --- a/packages/transaction-pay-controller/src/utils/required-tokens.ts +++ b/packages/transaction-pay-controller/src/utils/required-tokens.ts @@ -270,7 +270,7 @@ function getTokenTransferData(transactionMeta: TransactionMeta): ); const nestedCall = - nestedCallIndex === undefined + nestedCallIndex === undefined || nestedCallIndex === -1 ? undefined : nestedTransactions?.[nestedCallIndex]; diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index 9612a79e032..8eb9b00dc20 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -209,5 +209,79 @@ describe('Source Amounts Utils', () => { it('does nothing if no transaction data', () => { updateSourceAmounts(TRANSACTION_ID_MOCK, undefined, messenger); }); + + describe('post-quote (withdrawal) flow', () => { + it('calculates source amounts from tokens for post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: { + address: '0xdef', + balanceFiat: '100.00', + balanceHuman: '1.00', + balanceRaw: '1000000000000000000', + balanceUsd: '100.00', + chainId: '0x38', + decimals: 18, + symbol: 'BNB', + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + skipIfBalance: false, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: TRANSACTION_TOKEN_MOCK.amountHuman, + sourceAmountRaw: TRANSACTION_TOKEN_MOCK.amountRaw, + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('filters out skipIfBalance tokens in post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: { + address: '0xdef', + balanceFiat: '100.00', + balanceHuman: '1.00', + balanceRaw: '1000000000000000000', + balanceUsd: '100.00', + chainId: '0x38', + decimals: 18, + symbol: 'BNB', + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + skipIfBalance: true, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('does nothing for post-quote if no paymentToken', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toBeUndefined(); + }); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index f98cd422c37..0603880daca 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -35,12 +35,21 @@ export function updateSourceAmounts( return; } - const { isMaxAmount, paymentToken, tokens } = transactionData; + const { isMaxAmount, isPostQuote, paymentToken, tokens } = transactionData; if (!tokens.length || !paymentToken) { return; } + // For post-quote (withdrawal) flows, source amounts are calculated differently + // The source is the transaction's required token, not the selected token + if (isPostQuote) { + const sourceAmounts = calculatePostQuoteSourceAmounts(tokens); + log('Updated post-quote source amounts', { transactionId, sourceAmounts }); + transactionData.sourceAmounts = sourceAmounts; + return; + } + const sourceAmounts = tokens .map((singleToken) => calculateSourceAmount( @@ -58,6 +67,25 @@ export function updateSourceAmounts( transactionData.sourceAmounts = sourceAmounts; } +/** + * Calculate source amounts for post-quote (withdrawal) flows. + * In this flow, the required tokens ARE the source tokens. + * + * @param tokens - Required tokens from the transaction. + * @returns Array of source amounts. + */ +function calculatePostQuoteSourceAmounts( + tokens: TransactionPayRequiredToken[], +): TransactionPaySourceAmount[] { + return tokens + .filter((token) => !token.skipIfBalance) + .map((token) => ({ + sourceAmountHuman: token.amountHuman, + sourceAmountRaw: token.amountRaw, + targetTokenAddress: token.address, + })); +} + /** * Calculate the required source amount for a payment token to cover a target token. * diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts index ffdaa5fc353..cc0f20d91d6 100644 --- a/packages/transaction-pay-controller/src/utils/token.test.ts +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -471,4 +471,5 @@ describe('Token Utils', () => { ]); }); }); + }); diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts index b05f0a5f32c..b5229f1fa4d 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -10,7 +10,10 @@ import { NATIVE_TOKEN_ADDRESS, POLYGON_USDCE_ADDRESS, } from '../constants'; -import type { FiatRates, TransactionPayControllerMessenger } from '../types'; +import type { + FiatRates, + TransactionPayControllerMessenger, +} from '../types'; const STABLECOINS: Record = { [CHAIN_ID_ARBITRUM]: [ARBITRUM_USDC_ADDRESS.toLowerCase() as Hex], @@ -141,14 +144,14 @@ export function getTokenInfo( singleToken.address.toLowerCase() === normalizedTokenAddress, ); - if (!token && !isNative) { - return undefined; - } - if (token && !isNative) { return { decimals: Number(token.decimals), symbol: token.symbol }; } + if (!token && !isNative) { + return undefined; + } + const ticker = getTicker(chainId, messenger); if (!ticker) { @@ -263,3 +266,4 @@ function getTicker( return undefined; } } + From 5e9eb50c4bb43837bbbed8e160657c43ff6bea69 Mon Sep 17 00:00:00 2001 From: dan437 <80175477+dan437@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:32:29 +0100 Subject: [PATCH 2/2] Abstract shared code Signed-off-by: dan437 <80175477+dan437@users.noreply.github.com> --- .../src/strategy/relay/relay-submit.test.ts | 92 ++++- .../src/strategy/relay/relay-submit.ts | 330 +++++++++++------- 2 files changed, 292 insertions(+), 130 deletions(-) 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 1974a0bb01a..2bd02f558a9 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 @@ -5,7 +5,11 @@ import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; import { RELAY_STATUS_URL } from './constants'; -import { submitRelayQuotes } from './relay-submit'; +import { + getSubmitContext, + setupTransactionCollection, + submitRelayQuotes, +} from './relay-submit'; import type { RelayQuote } from './types'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { @@ -535,4 +539,90 @@ describe('Relay Submit Utils', () => { ); }); }); + + describe('getSubmitContext', () => { + it('extracts and normalizes params from quote', () => { + const quote = REQUEST_MOCK.quotes[0]; + const context = getSubmitContext(quote, messenger); + + expect(context).toStrictEqual({ + from: FROM_MOCK, + gasFeeToken: undefined, + gasLimits: [21000, 21000], + networkClientId: NETWORK_CLIENT_ID_MOCK, + normalizedParams: [ + { + data: '0x1234', + from: FROM_MOCK, + gas: '0x5208', + maxFeePerGas: '0x5d21dba00', + maxPriorityFeePerGas: '0x3b9aca00', + to: '0xfedcb', + value: '0x4d2', + }, + ], + sourceChainId: CHAIN_ID_MOCK, + sourceTokenAddress: TOKEN_ADDRESS_MOCK, + }); + }); + + it('returns gas fee token if isSourceGasFeeToken', () => { + const quote = { + ...REQUEST_MOCK.quotes[0], + fees: { ...REQUEST_MOCK.quotes[0].fees, isSourceGasFeeToken: true }, + }; + const context = getSubmitContext(quote, messenger); + + expect(context.gasFeeToken).toBe(TOKEN_ADDRESS_MOCK); + }); + + it('throws if step kind is unsupported', () => { + const quote = cloneDeep(REQUEST_MOCK.quotes[0]); + quote.original.steps[0].kind = 'unsupported' as never; + + expect(() => getSubmitContext(quote, messenger)).toThrow( + 'Unsupported step kind: unsupported', + ); + }); + }); + + describe('setupTransactionCollection', () => { + it('collects transaction IDs and updates parent transaction', () => { + const { transactionIds, end } = setupTransactionCollection({ + sourceChainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger, + parentTransactionId: ORIGINAL_TRANSACTION_ID_MOCK, + note: 'Test note', + }); + + expect(transactionIds).toStrictEqual([TRANSACTION_META_MOCK.id]); + expect(updateTransactionMock).toHaveBeenCalledWith( + { + transactionId: ORIGINAL_TRANSACTION_ID_MOCK, + messenger, + note: 'Test note', + }, + expect.any(Function), + ); + expect(typeof end).toBe('function'); + }); + + it('adds transaction ID to requiredTransactionIds', () => { + setupTransactionCollection({ + sourceChainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger, + parentTransactionId: ORIGINAL_TRANSACTION_ID_MOCK, + note: 'Test note', + }); + + const txDraft = { txParams: {} } as TransactionMeta; + updateTransactionMock.mock.calls[0][1](txDraft); + + expect(txDraft.requiredTransactionIds).toStrictEqual([ + TRANSACTION_META_MOCK.id, + ]); + }); + }); }); 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 0a1b0abc083..b3b3b848b83 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -32,6 +32,112 @@ const FALLBACK_HASH = '0x0' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); +type SubmitContext = { + from: Hex; + gasFeeToken: Hex | undefined; + gasLimits: number[]; + networkClientId: string; + normalizedParams: TransactionParams[]; + sourceChainId: Hex; + sourceTokenAddress: Hex; +}; + +/** + * Extract and validate relay params from a quote. + * + * @param quote - Relay quote. + * @param messenger - Controller messenger. + * @returns Submit context with normalized params and metadata. + */ +export function getSubmitContext( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, +): SubmitContext { + const { steps } = quote.original; + const params = steps.flatMap((step) => step.items).map((item) => item.data); + const invalidKind = steps.find((step) => step.kind !== 'transaction')?.kind; + + if (invalidKind) { + throw new Error(`Unsupported step kind: ${invalidKind}`); + } + + const normalizedParams = params.map((singleParams) => + normalizeParams(singleParams, messenger), + ); + + const { from, sourceChainId, sourceTokenAddress } = quote.request; + const { gasLimits } = quote.original.metamask; + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + sourceChainId, + ); + + const gasFeeToken = quote.fees.isSourceGasFeeToken + ? sourceTokenAddress + : undefined; + + return { + from, + gasFeeToken, + gasLimits, + networkClientId, + normalizedParams, + sourceChainId, + sourceTokenAddress, + }; +} + +/** + * Setup transaction ID collection with parent transaction updates. + * + * @param options - Options object. + * @param options.sourceChainId - Source chain ID. + * @param options.from - From address. + * @param options.messenger - Controller messenger. + * @param options.parentTransactionId - Parent transaction ID to update. + * @param options.note - Note for the transaction update. + * @returns Object with transactionIds array and end function. + */ +export function setupTransactionCollection({ + sourceChainId, + from, + messenger, + parentTransactionId, + note, +}: { + sourceChainId: Hex; + from: Hex; + messenger: TransactionPayControllerMessenger; + parentTransactionId: string; + note: string; +}): { transactionIds: string[]; end: () => void } { + const transactionIds: string[] = []; + + const { end } = collectTransactionIds( + sourceChainId, + from, + messenger, + (transactionId) => { + transactionIds.push(transactionId); + + updateTransaction( + { + transactionId: parentTransactionId, + messenger, + note, + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(transactionId); + }, + ); + }, + ); + + return { transactionIds, end }; +} + /** * Submits Relay quotes. * @@ -128,67 +234,99 @@ async function submitPostQuoteTransactions( transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, ): Promise { - const { steps } = quote.original; - const params = steps.flatMap((step) => step.items).map((item) => item.data); - const invalidKind = steps.find((step) => step.kind !== 'transaction')?.kind; - - if (invalidKind) { - throw new Error(`Unsupported step kind: ${invalidKind}`); - } - - const normalizedRelayParams = params.map((singleParams) => - normalizeParams(singleParams, messenger), - ); - - const { from, sourceChainId, sourceTokenAddress } = quote.request; - const { gasLimits } = quote.original.metamask; - const { txParams, nestedTransactions, type: originalType } = transaction; - - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', + const { + from, + gasFeeToken, + gasLimits, + networkClientId, + normalizedParams, sourceChainId, - ); + } = getSubmitContext(quote, messenger); + + const { txParams, type: originalType } = transaction; - log('Submitting post-quote batch', { - originalTxParams: txParams, - nestedTransactions, - relayParams: normalizedRelayParams, + const { transactionIds, end } = setupTransactionCollection({ sourceChainId, from, - networkClientId, + messenger, + parentTransactionId: transaction.id, + note: 'Add required transaction ID from post-quote batch', }); - const transactionIds: string[] = []; + // Build the batch transactions: + // 1. Original transaction (e.g., Safe execTransaction call) + // 2. Relay deposit transaction(s) + const batchTransactions = buildPostQuoteBatchTransactions({ + txParams, + originalType, + normalizedParams, + gasLimits, + }); - const { end } = collectTransactionIds( - sourceChainId, + await messenger.call('TransactionController:addTransactionBatch', { from, - messenger, - (transactionId) => { - transactionIds.push(transactionId); + gasFeeToken, + networkClientId, + origin: ORIGIN_METAMASK, + overwriteUpgrade: true, + requireApproval: false, + transactions: batchTransactions, + }); - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Add required transaction ID from post-quote batch', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(transactionId); - }, - ); + end(); + + // Mark original transaction as handled by nested batch + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Mark as dummy - handled by post-quote batch', + }, + (tx) => { + tx.isIntentComplete = true; }, ); - const gasFeeToken = quote.fees.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; - - // Build the batch transactions: - // 1. Original transaction(s) - e.g., Safe withdrawal - // 2. Relay deposit transaction(s) + if (transactionIds.length > 0) { + await Promise.all( + transactionIds.map((txId) => + waitForTransactionConfirmed(txId, messenger), + ), + ); + } +} +/** + * Build batch transactions for post-quote flow. + * + * @param options - Options object. + * @param options.txParams - Original transaction params. + * @param options.originalType - Original transaction type. + * @param options.normalizedParams - Normalized relay params. + * @param options.gasLimits - Gas limits for each transaction. + * @returns Array of batch transactions. + */ +function buildPostQuoteBatchTransactions({ + txParams, + originalType, + normalizedParams, + gasLimits, +}: { + txParams: TransactionMeta['txParams']; + originalType: TransactionMeta['type']; + normalizedParams: TransactionParams[]; + gasLimits: number[]; +}): { + params: { + data?: Hex; + gas?: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + to: Hex; + value?: Hex; + }; + type?: TransactionType; +}[] { const batchTransactions: { params: { data?: Hex; @@ -216,8 +354,8 @@ async function submitPostQuoteTransactions( } // Add relay deposit transaction(s) - for (let i = 0; i < normalizedRelayParams.length; i++) { - const relayParams = normalizedRelayParams[i]; + for (let i = 0; i < normalizedParams.length; i++) { + const relayParams = normalizedParams[i]; batchTransactions.push({ params: { data: relayParams.data as Hex, @@ -231,43 +369,7 @@ async function submitPostQuoteTransactions( }); } - log('Post-quote batch transactions', { batchTransactions }); - - await messenger.call('TransactionController:addTransactionBatch', { - from, - gasFeeToken, - networkClientId, - origin: ORIGIN_METAMASK, - overwriteUpgrade: true, - requireApproval: false, - transactions: batchTransactions, - }); - - end(); - - // Mark original transaction as handled by nested batch - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Mark as dummy - handled by post-quote batch', - }, - (tx) => { - tx.isIntentComplete = true; - }, - ); - - log('Post-quote batch submitted', transactionIds); - - if (transactionIds.length > 0) { - await Promise.all( - transactionIds.map((txId) => - waitForTransactionConfirmed(txId, messenger), - ), - ); - } - - log('Post-quote batch confirmed', transactionIds); + return batchTransactions; } /** @@ -344,25 +446,14 @@ async function submitTransactions( parentTransactionId: string, messenger: TransactionPayControllerMessenger, ): Promise { - const { steps } = quote.original; - const params = steps.flatMap((step) => step.items).map((item) => item.data); - const invalidKind = steps.find((step) => step.kind !== 'transaction')?.kind; - - if (invalidKind) { - throw new Error(`Unsupported step kind: ${invalidKind}`); - } - - const normalizedParams = params.map((singleParams) => - normalizeParams(singleParams, messenger), - ); - - const transactionIds: string[] = []; - const { from, sourceChainId, sourceTokenAddress } = quote.request; - - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', + const { + from, + gasFeeToken, + gasLimits, + networkClientId, + normalizedParams, sourceChainId, - ); + } = getSubmitContext(quote, messenger); log('Adding transactions', { normalizedParams, @@ -371,33 +462,16 @@ async function submitTransactions( networkClientId, }); - const { end } = collectTransactionIds( + const { transactionIds, end } = setupTransactionCollection({ sourceChainId, from, messenger, - (transactionId) => { - transactionIds.push(transactionId); - - updateTransaction( - { - transactionId: parentTransactionId, - messenger, - note: 'Add required transaction ID from Relay submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(transactionId); - }, - ); - }, - ); + parentTransactionId, + note: 'Add required transaction ID from Relay submission', + }); let result: { result: Promise } | undefined; - const gasFeeToken = quote.fees.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; - const isSameChain = quote.original.details.currencyIn.currency.chainId === quote.original.details.currencyOut.currency.chainId; @@ -410,9 +484,7 @@ async function submitTransactions( })) : undefined; - const { gasLimits } = quote.original.metamask; - - if (params.length === 1) { + if (normalizedParams.length === 1) { const transactionParams = { ...normalizedParams[0], authorizationList,