Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ([#8858](https://github.com/MetaMask/core/pull/8858))

## [22.7.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ export type TransactionPayControllerUpdateFiatPaymentAction = {
handler: TransactionPayController['updateFiatPayment'];
};

/**
* Returns additional transactions for the paymentOverride flow.
*
* Delegates to the client-supplied callback. Called during quote execution
* when paymentOverride is true. Returns an empty array when no callback
* is configured.
*
* @param args - The arguments forwarded to the {@link GetPaymentOverrideDataCallback},
* containing the transaction ID.
* @returns A promise resolving to the additional transactions array.
*/
export type TransactionPayControllerGetPaymentOverrideDataAction = {
type: `TransactionPayController:getPaymentOverrideData`;
handler: TransactionPayController['getPaymentOverrideData'];
};

/**
* Gets the delegation transaction for a given transaction.
*
Expand Down Expand Up @@ -113,6 +129,7 @@ export type TransactionPayControllerMethodActions =
| TransactionPayControllerUpdatePaymentTokenAction
| TransactionPayControllerUpdateFiatPaymentAction
| TransactionPayControllerGetDelegationTransactionAction
| TransactionPayControllerGetPaymentOverrideDataAction
| TransactionPayControllerGetStrategyAction
| TransactionPayControllerPolymarketGetDepositWalletAddressAction
| TransactionPayControllerPolymarketSubmitDepositWalletBatchAction;
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,43 @@ describe('TransactionPayController', () => {
});
});

describe('getPaymentOverrideData', () => {
it('delegates to the callback', async () => {
const txMock = { from: '0xabc', to: '0xdef' };
const getPaymentOverrideDataMock = jest.fn().mockResolvedValue([txMock]);

new TransactionPayController({
getDelegationTransaction: jest.fn(),
getPaymentOverrideData: getPaymentOverrideDataMock,
messenger,
});

const result = await messenger.call(
'TransactionPayController:getPaymentOverrideData',
TRANSACTION_ID_MOCK,
);

expect(getPaymentOverrideDataMock).toHaveBeenCalledWith(
TRANSACTION_ID_MOCK,
);
expect(result).toStrictEqual([txMock]);
});

it('returns empty array when no callback is configured', async () => {
new TransactionPayController({
getDelegationTransaction: jest.fn(),
messenger,
});

const result = await messenger.call(
'TransactionPayController:getPaymentOverrideData',
TRANSACTION_ID_MOCK,
);

expect(result).toStrictEqual([]);
});
});

describe('polymarket callbacks', () => {
const EOA_MOCK = '0x1111111111111111111111111111111111111111' as Hex;
const DEPOSIT_WALLET_MOCK =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { QuoteRefresher } from './helpers/QuoteRefresher';
import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils';
import type {
GetDelegationTransactionCallback,
GetPaymentOverrideDataCallback,
PolymarketCallbacks,
TransactionConfigCallback,
TransactionData,
Expand All @@ -36,6 +37,7 @@ import {

const MESSENGER_EXPOSED_METHODS = [
'getDelegationTransaction',
'getPaymentOverrideData',
'getStrategy',
'polymarketGetDepositWalletAddress',
'polymarketSubmitDepositWalletBatch',
Expand Down Expand Up @@ -64,6 +66,8 @@ export class TransactionPayController extends BaseController<
> {
readonly #getDelegationTransaction: GetDelegationTransactionCallback;

readonly #getPaymentOverrideData?: GetPaymentOverrideDataCallback;

readonly #getStrategy?: (
transaction: TransactionMeta,
) => TransactionPayStrategy;
Expand All @@ -76,6 +80,7 @@ export class TransactionPayController extends BaseController<

constructor({
getDelegationTransaction,
getPaymentOverrideData,
getStrategy,
getStrategies,
messenger,
Expand All @@ -90,6 +95,7 @@ export class TransactionPayController extends BaseController<
});

this.#getDelegationTransaction = getDelegationTransaction;
this.#getPaymentOverrideData = getPaymentOverrideData;
this.#getStrategy = getStrategy;
this.#getStrategies = getStrategies;
this.#polymarket = polymarket;
Expand Down Expand Up @@ -217,6 +223,23 @@ 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},
* containing the transaction ID.
* @returns A promise resolving to the additional transactions array.
*/
getPaymentOverrideData(
...args: Parameters<GetPaymentOverrideDataCallback>
): ReturnType<GetPaymentOverrideDataCallback> {
return this.#getPaymentOverrideData?.(...args) ?? Promise.resolve([]);
}

/**
* Gets the preferred strategy for a transaction.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,6 +184,7 @@ describe('Relay Quotes Utils', () => {
getDelegationTransactionMock,
getGasFeeTokensMock,
getKeyringControllerStateMock,
getPaymentOverrideDataMock,
getRemoteFeatureFlagControllerStateMock,
polymarketGetDepositWalletAddressMock,
} = getMessengerMock();
Expand Down Expand Up @@ -3375,6 +3377,159 @@ describe('Relay Quotes Utils', () => {
});
});

describe('paymentOverride step injection (paymentOverride defined)', () => {
const PAYMENT_OVERRIDE_TX_MOCK = {
from: FROM_MOCK,
to: '0xpaymentoverride' as Hex,
data: '0xpaymentoverride' as Hex,
value: '0x0',
maxFeePerGas: '1000000000',
maxPriorityFeePerGas: '2000000000',
};

beforeEach(() => {
successfulFetchMock.mockResolvedValue({
ok: true,
json: async () => QUOTE_MOCK,
} as never);

estimateGasBatchMock.mockResolvedValue({
gasLimits: [30000, 21000],
totalGasLimit: 51000,
});
});

it('prepends paymentOverride step before relay steps for standard (non-post-quote) flow', async () => {
getPaymentOverrideDataMock.mockResolvedValue([
PAYMENT_OVERRIDE_TX_MOCK,
]);

const [quote] = await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

const txSteps = quote.original.steps.filter(
(step): step is RelayTransactionStep => step.kind === 'transaction',
);
expect(txSteps[0].id).toBe('payment-override');
expect(txSteps[0].items[0].data.to).toBe(PAYMENT_OVERRIDE_TX_MOCK.to);
expect(txSteps[1].id).toBe(STEP_MOCK.id);
});

it('appends paymentOverride step after relay steps for post-quote flow', async () => {
getPaymentOverrideDataMock.mockResolvedValue([
PAYMENT_OVERRIDE_TX_MOCK,
]);

const [quote] = await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
paymentOverride: PaymentOverride.MoneyAccount,
isPostQuote: true,
},
],
transaction: TRANSACTION_META_MOCK,
});

const txSteps = quote.original.steps.filter(
(step): step is RelayTransactionStep => step.kind === 'transaction',
);
expect(txSteps[0].id).toBe(STEP_MOCK.id);
expect(txSteps[1].id).toBe('payment-override');
});

it('does not inject when paymentOverride is not defined', async () => {
const [quote] = await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: TRANSACTION_META_MOCK,
});

expect(
quote.original.steps.every((step) => step.id !== 'payment-override'),
).toBe(true);
expect(getPaymentOverrideDataMock).not.toHaveBeenCalled();
});

it('does not inject when callback returns empty array', async () => {
getPaymentOverrideDataMock.mockResolvedValue([]);

const [quote] = await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(quote.original.steps).toHaveLength(1);
expect(quote.original.steps[0].id).toBe(STEP_MOCK.id);
});

it('uses the transaction chainId when present', async () => {
getPaymentOverrideDataMock.mockResolvedValue([
{ ...PAYMENT_OVERRIDE_TX_MOCK, chainId: '0xa' },
]);

const [quote] = await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

const overrideStep = quote.original.steps.find(
(step) => step.id === 'payment-override',
) as RelayTransactionStep;
expect(overrideStep.items[0].data.chainId).toBe(10);
});

it('falls back to sourceChainId when transaction chainId is absent', async () => {
getPaymentOverrideDataMock.mockResolvedValue([
PAYMENT_OVERRIDE_TX_MOCK,
]);

const [quote] = await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
paymentOverride: PaymentOverride.MoneyAccount,
},
],
transaction: TRANSACTION_META_MOCK,
});

const overrideStep = quote.original.steps.find(
(step) => step.id === 'payment-override',
) as RelayTransactionStep;
// QUOTE_REQUEST_MOCK.sourceChainId is '0x1' → chainId 1
expect(overrideStep.items[0].data.chainId).toBe(1);
});
});

describe('gas buffer support', () => {
it('applies buffer to single transaction gas estimate', async () => {
const quoteMock = cloneDeep(QUOTE_MOCK);
Expand Down
Loading
Loading