diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 547f1a9521..2b6da05a58 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getPaymentOverrideData` callback to `TransactionPayControllerOptions`, when `paymentOverride` is defined on a transaction, this callback is invoked the resulting transactions are injected into the relay quote steps ([#8870](https://github.com/MetaMask/core/pull/8870)) + ### Changed - Bump `@metamask/assets-controllers` from `^108.1.0` to `^108.2.0` ([#8911](https://github.com/MetaMask/core/pull/8911)) diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 8afd1b0559..3eb0b69758 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -66,6 +66,21 @@ export type TransactionPayControllerGetDelegationTransactionAction = { handler: TransactionPayController['getDelegationTransaction']; }; +/** + * Returns additional transactions for the paymentOverride flow. + * + * Delegates to the client-supplied {@link GetPaymentOverrideDataCallback}. + * Called during quote execution when `paymentOverride` is defined on the transaction. + * Returns an empty array when no callback is configured. + * + * @param args - The arguments forwarded to the {@link GetPaymentOverrideDataCallback}. + * @returns A promise resolving to the additional transactions array. + */ +export type TransactionPayControllerGetPaymentOverrideDataAction = { + type: `TransactionPayController:getPaymentOverrideData`; + handler: TransactionPayController['getPaymentOverrideData']; +}; + /** * Gets the preferred strategy for a transaction. * @@ -113,6 +128,7 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction | TransactionPayControllerGetDelegationTransactionAction + | TransactionPayControllerGetPaymentOverrideDataAction | TransactionPayControllerGetStrategyAction | TransactionPayControllerPolymarketGetDepositWalletAddressAction | TransactionPayControllerPolymarketSubmitDepositWalletBatchAction; diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index a8a93c81cc..b7d2fc2b9c 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -469,6 +469,55 @@ describe('TransactionPayController', () => { }); }); + describe('getPaymentOverrideData', () => { + it('delegates to the callback', async () => { + const resultMock = { + calls: [{ to: '0xdef' as const, data: '0xabc' as const }], + }; + const getPaymentOverrideDataMock = jest + .fn() + .mockResolvedValue(resultMock); + + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getPaymentOverrideData: getPaymentOverrideDataMock, + messenger, + }); + + const requestMock = { + amount: '1.5', + transaction: TRANSACTION_META_MOCK, + transactionData: { isLoading: false, tokens: [] }, + }; + + const result = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + requestMock, + ); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith(requestMock); + expect(result).toStrictEqual(resultMock); + }); + + it('returns empty array when no callback is configured', async () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + messenger, + }); + + const result = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: '1.5', + transaction: TRANSACTION_META_MOCK, + transactionData: { isLoading: false, tokens: [] }, + }, + ); + + expect(result).toStrictEqual({ calls: [] }); + }); + }); + describe('polymarket callbacks', () => { const EOA_MOCK = '0x1111111111111111111111111111111111111111' as Hex; const DEPOSIT_WALLET_MOCK = diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 567bfedabc..cc6e9f7a3f 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -15,6 +15,7 @@ import { QuoteRefresher } from './helpers/QuoteRefresher'; import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import type { GetDelegationTransactionCallback, + GetPaymentOverrideDataCallback, PolymarketCallbacks, TransactionConfigCallback, TransactionData, @@ -36,6 +37,7 @@ import { const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', + 'getPaymentOverrideData', 'getStrategy', 'polymarketGetDepositWalletAddress', 'polymarketSubmitDepositWalletBatch', @@ -64,6 +66,8 @@ export class TransactionPayController extends BaseController< > { readonly #getDelegationTransaction: GetDelegationTransactionCallback; + readonly #getPaymentOverrideData?: GetPaymentOverrideDataCallback; + readonly #getStrategy?: ( transaction: TransactionMeta, ) => TransactionPayStrategy; @@ -76,6 +80,7 @@ export class TransactionPayController extends BaseController< constructor({ getDelegationTransaction, + getPaymentOverrideData, getStrategy, getStrategies, messenger, @@ -90,6 +95,7 @@ export class TransactionPayController extends BaseController< }); this.#getDelegationTransaction = getDelegationTransaction; + this.#getPaymentOverrideData = getPaymentOverrideData; this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; this.#polymarket = polymarket; @@ -217,6 +223,24 @@ export class TransactionPayController extends BaseController< return this.#getDelegationTransaction(...args); } + /** + * Returns additional transactions for the paymentOverride flow. + * + * Delegates to the client-supplied {@link GetPaymentOverrideDataCallback}. + * Called during quote execution when `paymentOverride` is defined on the transaction. + * Returns an empty array when no callback is configured. + * + * @param args - The arguments forwarded to the {@link GetPaymentOverrideDataCallback}. + * @returns A promise resolving to the additional transactions array. + */ + getPaymentOverrideData( + ...args: Parameters + ): ReturnType { + return ( + this.#getPaymentOverrideData?.(...args) ?? Promise.resolve({ calls: [] }) + ); + } + /** * Gets the preferred strategy for a transaction. * diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 44cd88e82a..9b0b113627 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,4 +1,6 @@ export type { + GetPaymentOverrideDataRequest, + GetPaymentOverrideDataResponse, TransactionConfig, TransactionConfigCallback, TransactionData, 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 9439faf4cc..c20aa399dc 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 @@ -14,6 +14,7 @@ import { CHAIN_ID_HYPERCORE, CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS, + PaymentOverride, POLYGON_USDCE_ADDRESS, } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; @@ -2648,6 +2649,78 @@ describe('Relay Quotes Utils', () => { expect(result[0].original.metamask.gasLimits).toStrictEqual([]); }); + + it('adds extra gas when paymentOverride is set', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ gas: 21000 + 75000 }), + ); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 75000, 21000, + ]); + }); + + it('adds extra gas to combined 7702 limit when paymentOverride is 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], + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 51000 + 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 08077cd823..4d1bb5fca2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -65,6 +65,9 @@ import type { const log = createModuleLogger(projectLogger, 'relay-strategy'); +// Hardcoded gas allowance for the prepended payment override transaction(s). +const PAYMENT_OVERRIDE_GAS = 75_000; + /** * Fetches Relay quotes. * @@ -697,9 +700,7 @@ async function calculateSourceNetworkCost( ); const { gasLimits, is7702, totalGasEstimate, totalGasLimit } = - request.isPostQuote - ? combinePostQuoteGas(relayOnlyGas, transaction) - : relayOnlyGas; + combinePrependedGas(relayOnlyGas, request, transaction); log('Gas limit', { is7702, @@ -897,6 +898,25 @@ function toRelayQuoteGasTransaction( }; } +type RelayGasResult = { + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; + is7702: boolean; +}; + +function combinePrependedGas( + relayOnlyGas: RelayGasResult, + request: QuoteRequest, + transaction: TransactionMeta, +): RelayGasResult { + const gas = request.isPostQuote + ? combinePostQuoteGas(relayOnlyGas, transaction) + : relayOnlyGas; + + return request.paymentOverride ? addPaymentOverrideGas(gas) : gas; +} + /** * Combine the original transaction's gas with relay gas for post-quote flows. * @@ -913,19 +933,9 @@ function toRelayQuoteGasTransaction( * @returns Combined gas estimates including the original transaction. */ function combinePostQuoteGas( - relayGas: { - totalGasEstimate: number; - totalGasLimit: number; - gasLimits: number[]; - is7702: boolean; - }, + relayGas: RelayGasResult, transaction: TransactionMeta, -): { - totalGasEstimate: number; - totalGasLimit: number; - gasLimits: number[]; - is7702: boolean; -} { +): RelayGasResult { const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; const rawGas = nestedGas ?? transaction.txParams.gas; const originalTxGas = rawGas ? new BigNumber(rawGas).toNumber() : undefined; @@ -964,6 +974,19 @@ function combinePostQuoteGas( }; } +function addPaymentOverrideGas(relayGas: RelayGasResult): RelayGasResult { + const gasLimits = relayGas.is7702 + ? [relayGas.gasLimits[0] + PAYMENT_OVERRIDE_GAS] + : [PAYMENT_OVERRIDE_GAS, ...relayGas.gasLimits]; + + 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 ae08364ae8..c43dfb22e6 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 @@ -1,9 +1,13 @@ import { ORIGIN_METAMASK } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; +import { PaymentOverride } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { PayStrategyExecuteRequest, @@ -140,8 +144,10 @@ describe('Relay Submit Utils', () => { const { addTransactionMock, addTransactionBatchMock, + getControllerStateMock, getDelegationTransactionMock, findNetworkClientIdByChainIdMock, + getPaymentOverrideDataMock, messenger, } = getMessengerMock(); @@ -857,6 +863,158 @@ describe('Relay Submit Utils', () => { ]); }); + describe('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(() => { + getControllerStateMock.mockReturnValue({ + transactionData: { + [ORIGINAL_TRANSACTION_ID_MOCK]: TRANSACTION_DATA_MOCK, + }, + }); + }); + + it('prepends override tx params to submit batch', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith({ + amount: request.quotes[0].sourceAmount.human, + transaction: request.transaction, + transactionData: TRANSACTION_DATA_MOCK, + }); + + const batchCall = addTransactionBatchMock.mock.calls[0][0]; + expect(batchCall.transactions[0].params).toStrictEqual( + expect.objectContaining({ + data: PAYMENT_OVERRIDE_TX_MOCK.data, + to: PAYMENT_OVERRIDE_TX_MOCK.to, + value: PAYMENT_OVERRIDE_TX_MOCK.value, + }), + ); + }); + + it('does not call getPaymentOverrideData when paymentOverride is not defined', async () => { + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + }); + + it('does not prepend when callback returns empty array', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).not.toHaveBeenCalled(); + expect(addTransactionMock).toHaveBeenCalledTimes(1); + }); + + it('skips source balance validation', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + getLiveTokenBalanceMock.mockResolvedValue('0'); + + await submitRelayQuotes(request); + + expect(getLiveTokenBalanceMock).not.toHaveBeenCalled(); + }); + + it('assigns correct gas limits with override tx', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + request.quotes[0].original.metamask.gasLimits = [10000, 30000, 50000]; + + 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, + }, + }); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + + 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('0x2710'); + expect(transactions[1].params.gas).toBe('0x7530'); + expect(transactions[2].params.gas).toBe('0xc350'); + }); + + it('assigns correct transaction types with multi-step relay (approve + deposit)', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + request.transaction = { + ...request.transaction, + type: TransactionType.simpleSend, + } as TransactionMeta; + + 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, + }, + }); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + + 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.tokenMethodApprove, + }), + ); + expect(transactions[2]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.relayDeposit, + }), + ); + }); + }); + describe('post-quote flow', () => { beforeEach(() => { request.quotes[0].request.isPostQuote = true; 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 b6f0fc5431..dfece5307f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -380,7 +380,7 @@ async function submitTransactions( // In post-quote flows (e.g. Predict withdraw), the source tokens are held in // the Safe — not the EOA — and only become available after the original tx // executes as part of the batch. Skip the EOA balance check here. - if (!quote.request.isPostQuote) { + if (!quote.request.isPostQuote && !quote.request.paymentOverride) { await validateSourceBalance(quote, messenger); } @@ -402,7 +402,27 @@ async function submitTransactions( let allParams = normalizedParams; - if (isPostQuote && transaction.txParams.to) { + if (quote.request.paymentOverride) { + const { transactionData } = messenger.call( + 'TransactionPayController:getState', + ); + + const { calls: overrideTxs } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: quote.sourceAmount.human, + transaction, + transactionData: transactionData[transaction.id], + }, + ); + + if (overrideTxs.length > 0) { + allParams = [ + ...(overrideTxs as TransactionParams[]), + ...normalizedParams, + ]; + } + } else if (isPostQuote && transaction.txParams.to) { const prependedParams = hasAccountOverride ? await buildDelegatedOriginalParams(transaction, messenger) : ({ @@ -664,6 +684,8 @@ async function submitViaTransactionController( ? toHex(metamask.gasLimits[0]) : undefined; + const prependCount = allParams.length - normalizedParams.length; + const transactions = allParams.map((singleParams, index) => { const gasLimit = gasLimits[index]; const gas = @@ -679,7 +701,7 @@ async function submitViaTransactionController( value: singleParams.value as Hex, }, type: getTransactionType( - isPostQuote, + prependCount, index, getEffectiveTransactionType(transaction), normalizedParams.length, @@ -726,25 +748,23 @@ async function submitViaTransactionController( /** * Determine the transaction type for a given index in the batch. * - * @param isPostQuote - Whether this is a post-quote flow. + * @param prependCount - Number of non-relay txs prepended to the batch. * @param index - Index of the transaction in the batch. - * @param originalType - Type of the original transaction (used for post-quote index 0). - * @param relayParamCount - Number of relay-only params (excludes prepended original tx). + * @param originalType - Type of the original transaction (used for prepended indices). + * @param relayParamCount - Number of relay-only params (excludes prepended txs). * @returns The transaction type. */ function getTransactionType( - isPostQuote: boolean | undefined, + prependCount: number, index: number, originalType: TransactionMeta['type'], relayParamCount: number, ): TransactionMeta['type'] { - // Post-quote index 0 is the original transaction - if (isPostQuote && index === 0) { + if (prependCount > 0 && index < prependCount) { return originalType; } - // Adjust index for post-quote flows where original tx is prepended - const relayIndex = isPostQuote ? index - 1 : index; + const relayIndex = index - prependCount; const depositType = getRelayDepositType(originalType); diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index 1931aa202a..294cb1bc2b 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -29,6 +29,7 @@ import type { TransactionControllerUpdateTransactionAction } from '@metamask/tra import type { TransactionPayControllerMessenger } from '..'; import type { TransactionPayControllerGetDelegationTransactionAction, + TransactionPayControllerGetPaymentOverrideDataAction, TransactionPayControllerGetStrategyAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, @@ -120,6 +121,10 @@ export function getMessengerMock({ TransactionPayControllerGetDelegationTransactionAction['handler'] > = jest.fn(); + const getPaymentOverrideDataMock: jest.MockedFn< + TransactionPayControllerGetPaymentOverrideDataAction['handler'] + > = jest.fn().mockResolvedValue({ calls: [] }); + const polymarketGetDepositWalletAddressMock: jest.MockedFn< TransactionPayControllerPolymarketGetDepositWalletAddressAction['handler'] > = jest.fn(); @@ -255,6 +260,11 @@ export function getMessengerMock({ getDelegationTransactionMock, ); + messenger.registerActionHandler( + 'TransactionPayController:getPaymentOverrideData', + getPaymentOverrideDataMock, + ); + messenger.registerActionHandler( 'TransactionPayController:polymarketGetDepositWalletAddress', polymarketGetDepositWalletAddressMock, @@ -306,6 +316,7 @@ export function getMessengerMock({ getControllerStateMock, getCurrencyRateControllerStateMock, getDelegationTransactionMock, + getPaymentOverrideDataMock, getGasFeeControllerStateMock, getGasFeeTokensMock, getKeyringControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index d6795dccdf..8dad3a1a4b 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -48,6 +48,7 @@ import type { } from '@metamask/transaction-controller'; import type { BatchTransaction, + BatchTransactionParams, TransactionControllerAddTransactionAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, @@ -109,6 +110,13 @@ export type TransactionPayControllerGetStateAction = ControllerGetStateAction< /** Configurable properties of a transaction. */ export type TransactionConfig = { + /** + * Optional address to override the default account used by the transaction. + * When `isPostQuote` is true, used as the recipient of the MM Pay transfer. + * When `isPostQuote` is false, it provides the funds and pays for gas. + */ + accountOverride?: Hex; + /** * Whether the source of funds is HyperLiquid (HyperCore). * When true, the Relay strategy uses the HyperLiquid 2-step withdrawal @@ -129,6 +137,9 @@ export type TransactionConfig = { */ isPostQuote?: boolean; + /** Overrides the payment source for the transaction. */ + paymentOverride?: PaymentOverride; + /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -137,21 +148,37 @@ export type TransactionConfig = { * go back to that account rather than the EOA. */ refundTo?: Hex; - - /** - * Optional address to override the default account used by the transaction. - * When `isPostQuote` is true, used as the recipient of the MM Pay transfer. - * When `isPostQuote` is false, it provides the funds and pays for gas. - */ - accountOverride?: Hex; - - /** Overrides the payment source for the transaction. */ - paymentOverride?: PaymentOverride; }; /** Callback to update transaction config. */ export type TransactionConfigCallback = (config: TransactionConfig) => void; +/** Request passed to {@link GetPaymentOverrideDataCallback}. */ +export type GetPaymentOverrideDataRequest = { + /** Amount of the source token in human-readable format. */ + amount: string; + + /** Metadata of the original transaction. */ + transaction: TransactionMeta; + + /** Pay-controller state for the transaction. */ + transactionData: TransactionData; +}; + +/** Response returned by {@link GetPaymentOverrideDataCallback}. */ +export type GetPaymentOverrideDataResponse = { + /** Batch transaction params to prepend to the submit batch. */ + calls: BatchTransactionParams[]; +}; + +/** + * Callback invoked during submit when `paymentOverride` is defined. + * Returns batch transaction params to prepend to the submit batch. + */ +export type GetPaymentOverrideDataCallback = ( + request: GetPaymentOverrideDataRequest, +) => Promise; + /** Callback to update fiat payment state. */ export type TransactionFiatPaymentCallback = ( fiatPayment: TransactionFiatPayment, @@ -191,6 +218,12 @@ export type TransactionPayControllerOptions = { /** Callback to convert a transaction into a redeem delegation. */ getDelegationTransaction: GetDelegationTransactionCallback; + /** + * Optional callback invoked during quote execution when `paymentOverride` is defined. + * Returns additional transactions to be submitted alongside the quote batch. + */ + getPaymentOverrideData?: GetPaymentOverrideDataCallback; + /** Callback to select the PayStrategy for a transaction. */ getStrategy?: (transaction: TransactionMeta) => TransactionPayStrategy; @@ -215,6 +248,13 @@ export type TransactionPayControllerState = { /** State relating to a single transaction. */ export type TransactionData = { + /** + * Optional address to override the default account used by the transaction. + * When `isPostQuote` is true, used as the recipient of the MM Pay transfer. + * When `isPostQuote` is false, it provides the funds and pays for gas. + */ + accountOverride?: Hex; + /** Fiat payment method state. */ fiatPayment?: TransactionFiatPayment; @@ -239,6 +279,9 @@ export type TransactionData = { /** Whether the source of funds is a Polymarket deposit wallet. */ isPolymarketDepositWallet?: boolean; + /** Overrides the payment source for the transaction. */ + paymentOverride?: PaymentOverride; + /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -246,16 +289,6 @@ export type TransactionData = { */ refundTo?: Hex; - /** - * Optional address to override the default account used by the transaction. - * When `isPostQuote` is true, used as the recipient of the MM Pay transfer. - * When `isPostQuote` is false, it provides the funds and pays for gas. - */ - accountOverride?: Hex; - - /** Overrides the payment source for the transaction. */ - paymentOverride?: PaymentOverride; - /** * Token selected for the transaction. * - For standard flows (isPostQuote=false): This is the SOURCE/payment token @@ -424,6 +457,9 @@ export type QuoteRequest = { /** Whether the source of funds is a Polymarket deposit wallet. */ isPolymarketDepositWallet?: boolean; + /** Overrides the payment source for the transaction. */ + paymentOverride?: PaymentOverride; + /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index ba2ec2afd7..e332b63285 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -4,7 +4,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; -import { TransactionPayStrategy } from '../constants'; +import { PaymentOverride, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import type { QuoteRequest, @@ -86,6 +86,7 @@ export async function updateQuotes( isPostQuote, isHyperliquidSource, isPolymarketDepositWallet, + paymentOverride, paymentToken: originalPaymentToken, refundTo, sourceAmounts, @@ -122,6 +123,7 @@ export async function updateQuotes( isPostQuote, isHyperliquidSource, isPolymarketDepositWallet, + paymentOverride, paymentToken, refundTo, sourceAmounts, @@ -326,6 +328,7 @@ function clearControllerIfCurrent( * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.isPostQuote - Whether this is a post-quote flow. + * @param request.paymentOverride - Optional payment override type for the transaction. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction. @@ -339,6 +342,7 @@ function buildQuoteRequests({ isPostQuote, isHyperliquidSource, isPolymarketDepositWallet, + paymentOverride, paymentToken, refundTo, sourceAmounts, @@ -350,6 +354,7 @@ function buildQuoteRequests({ isPostQuote?: boolean; isHyperliquidSource?: boolean; isPolymarketDepositWallet?: boolean; + paymentOverride?: PaymentOverride; paymentToken: TransactionPaymentToken | undefined; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -366,6 +371,7 @@ function buildQuoteRequests({ isMaxAmount, isHyperliquidSource, isPolymarketDepositWallet, + paymentOverride, destinationToken: paymentToken, refundTo, sourceAmounts, @@ -382,6 +388,7 @@ function buildQuoteRequests({ return { from, isMaxAmount, + paymentOverride, sourceBalanceRaw: paymentToken.balanceRaw, sourceTokenAmount: sourceAmount.sourceAmountRaw, sourceChainId: paymentToken.chainId, @@ -409,6 +416,7 @@ function buildQuoteRequests({ * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. + * @param request.paymentOverride - Optional payment override type for the transaction. * @param request.destinationToken - Destination token (paymentToken in post-quote mode). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction (includes source token info). @@ -420,6 +428,7 @@ function buildPostQuoteRequests({ isMaxAmount, isHyperliquidSource, isPolymarketDepositWallet, + paymentOverride, destinationToken, refundTo, sourceAmounts, @@ -429,6 +438,7 @@ function buildPostQuoteRequests({ isMaxAmount: boolean; isHyperliquidSource?: boolean; isPolymarketDepositWallet?: boolean; + paymentOverride?: PaymentOverride; destinationToken: TransactionPaymentToken; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; @@ -459,6 +469,7 @@ function buildPostQuoteRequests({ isPostQuote: true, isHyperliquidSource, isPolymarketDepositWallet, + paymentOverride, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, sourceTokenAmount: sourceAmount.sourceAmountRaw,