Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `transactionHistoryLimit` feature flag to configure the maximum number of transactions stored in state ([#7648](https://github.com/MetaMask/core/pull/7648))
- Defaults to 40 if not provided.
- Add optional `callTraceErrors` to `simulationData` ([#7641](https://github.com/MetaMask/core/pull/7641))
- Add `acrossDeposit` transaction type and `MetamaskPayMetadata.executionLatencyMs` for MetaMask Pay tracking ([#7806](https://github.com/MetaMask/core/pull/7806))

### Changed

Expand Down
8 changes: 8 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,11 @@ export enum TransactionType {
*/
relayDeposit = 'relayDeposit',

/**
* Deposit funds for Across quote.
*/
acrossDeposit = 'acrossDeposit',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually have a pending task to make relayDeposit more granular, so perpsRelayDeposit and predictRelayDeposit, so should do the same for Across also.


/**
* When a transaction is failed it can be retried by
* resubmitting the same transaction with a higher gas fee. This type is also used
Expand Down Expand Up @@ -2099,6 +2104,9 @@ export type MetamaskPayMetadata = {

/** Total cost of the transaction in fiat currency, including gas, fees, and the funds themselves. */
totalFiat?: string;

/** Total time spent executing the MetaMask Pay flow, in milliseconds. */
executionLatencyMs?: number;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, alphabetical.

};

/**
Expand Down
17 changes: 17 additions & 0 deletions packages/transaction-pay-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ Quotes are retrieved from the [Relay API](https://docs.relay.link/what-is-relay)

The resulting transaction deposits the necessary funds (on the source network), then a Relayer on the target chain immediately transfers the necessary funds and optionally executes any requested call data.

### TokenPay (provider routing)

The `TokenPayStrategy` routes quote and execution requests through provider adapters (currently Relay and Across).

Provider selection is determined by feature flags:

- `tokenPay.providerOrder` controls priority (default: `[primaryProvider, 'relay', 'across']`).
- Each provider can be enabled/disabled via `tokenPay.providers.<id>.enabled`.
- Providers may also implement capability gating in `supports(...)` (e.g., Across rejects same-chain swaps).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like a good mechanism, but can we abstract to PayStrategy in general so we can accommodate fiat in future also and any strategy?

That way, we could isolate this into it's own dedicated PR for risk and easier review?


Routing behavior:

- The strategy selects the **first** provider in order that is enabled and returns `supports(...) === true`.
- If no provider supports the request, the strategy throws, and the controller returns no quotes.
- There is **no automatic fallback** if the selected provider throws during quote retrieval or execution; errors surface and quotes are left empty. (Future work could introduce fallback on specific error types.)
- Current limitation: provider-specific capability checks that happen during quote building (e.g., Across rejecting type-4/EIP-7702 transactions) do not fall back to lower-priority providers. If we add a third provider after Across that supports type-4, we should consider adding fallback logic or moving that check into `supports(...)` to avoid returning no quotes.

## Lifecycle

The high level interaction with the `TransactionPayController` is as follows:
Expand Down
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 TokenPay strategy with new Across provider and existing Relay provider, including gating and latency metrics ([#7806](https://github.com/MetaMask/core/pull/7806))

### Changed

- Bump `@metamask/transaction-controller` from `^62.11.0` to `^62.13.0` ([#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802))
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export const POLYGON_USDCE_ADDRESS =
export enum TransactionPayStrategy {
Bridge = 'bridge',
Relay = 'relay',
TokenPay = 'tokenPay',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was assuming this would just be an abstract internal class to remove duplication.

Ideally the client could specify multiple preferences in priority order, and we pick the first that is supported or throw?

Thereby combining the new fallback mechanism and getStrategy?

Test = 'test',
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
PublishHookResult,
TransactionMeta,
TransactionControllerState,
} from '@metamask/transaction-controller';

import { TransactionPayPublishHook } from './TransactionPayPublishHook';
Expand All @@ -27,8 +28,13 @@ describe('TransactionPayPublishHook', () => {
const isSmartTransactionMock = jest.fn();
const executeMock = jest.fn();

const { messenger, getControllerStateMock, getStrategyMock } =
getMessengerMock();
const {
messenger,
getControllerStateMock,
getStrategyMock,
getTransactionControllerStateMock,
updateTransactionMock,
} = getMessengerMock();

let hook: TransactionPayPublishHook;

Expand Down Expand Up @@ -65,6 +71,10 @@ describe('TransactionPayPublishHook', () => {
} as TransactionPayControllerState);

getStrategyMock.mockReturnValue(TransactionPayStrategy.Test);

getTransactionControllerStateMock.mockReturnValue({
transactions: [TRANSACTION_META_MOCK],
} as TransactionControllerState);
});

it('executes strategy with quotes', async () => {
Expand Down Expand Up @@ -92,4 +102,32 @@ describe('TransactionPayPublishHook', () => {

await expect(runHook()).rejects.toThrow('Test error');
});

it('stores execution latency in metadata', async () => {
const nowSpy = jest.spyOn(Date, 'now');
nowSpy
.mockReturnValueOnce(1000)
.mockReturnValueOnce(1400)
.mockReturnValue(1400);

await runHook();

expect(updateTransactionMock).toHaveBeenCalled();
const updatedTx = updateTransactionMock.mock.calls[0][0];
expect(updatedTx.metamaskPay?.executionLatencyMs).toBe(400);

nowSpy.mockRestore();
});

it('swallows errors when updating execution metrics', async () => {
updateTransactionMock.mockImplementation(() => {
throw new Error('Update failed');
});
executeMock.mockResolvedValue({ transactionHash: '0xhash' });

await expect(runHook()).resolves.toStrictEqual({
transactionHash: '0xhash',
});
expect(updateTransactionMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
TransactionPayQuote,
} from '../types';
import { getStrategy } from '../utils/strategy';
import { updateTransaction } from '../utils/transaction';

const log = createModuleLogger(projectLogger, 'pay-publish-hook');

Expand Down Expand Up @@ -70,11 +71,34 @@ export class TransactionPayPublishHook {

const strategy = getStrategy(this.#messenger, transactionMeta);

return await strategy.execute({
const start = Date.now();
const result = await strategy.execute({
isSmartTransaction: this.#isSmartTransaction,
quotes,
messenger: this.#messenger,
transaction: transactionMeta,
});

const executionLatencyMs = Date.now() - start;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, is Performance.now more accurate?

Do we definitely want to test the entire execution that includes transaction creation, and gas fee token requests etc?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know this, thanks!


try {
updateTransaction(
{
transactionId,
messenger: this.#messenger,
note: 'Update MetaMask Pay execution metrics',
},
(tx) => {
tx.metamaskPay = {
...tx.metamaskPay,
executionLatencyMs,
};
},
);
} catch (error) {
log('Failed to update execution metrics', error);
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { TransactionType } from '@metamask/transaction-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';

import { AcrossProvider } from './AcrossProvider';
import { getAcrossQuotes } from './across-quotes';
import { submitAcrossQuotes } from './across-submit';
import type { AcrossQuote } from './types';
import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller';
import { TransactionPayStrategy } from '../../constants';
import { getMessengerMock } from '../../tests/messenger-mock';
import type {
PayStrategyGetQuotesRequest,
QuoteRequest,
TransactionPayQuote,
} from '../../types';
import type { TokenPayProviderQuote } from '../token-pay/types';

jest.mock('./across-quotes');
jest.mock('./across-submit');

const QUOTE_REQUEST_MOCK: QuoteRequest = {
from: '0x1234567890123456789012345678901234567891' as Hex,
sourceBalanceRaw: '10000000000000000000',
sourceChainId: '0x1',
sourceTokenAddress: '0xabc',
sourceTokenAmount: '1000000000000000000',
targetAmountMinimum: '123',
targetChainId: '0x2',
targetTokenAddress: '0xdef',
};

function buildRequest(
overrides: Partial<PayStrategyGetQuotesRequest> = {},
): PayStrategyGetQuotesRequest {
return {
messenger: overrides.messenger as PayStrategyGetQuotesRequest['messenger'],
requests: [QUOTE_REQUEST_MOCK],
transaction: { type: TransactionType.simpleSend } as TransactionMeta,
...overrides,
};
}

describe('AcrossProvider', () => {
const getAcrossQuotesMock = jest.mocked(getAcrossQuotes);
const submitAcrossQuotesMock = jest.mocked(submitAcrossQuotes);
const { messenger, getRemoteFeatureFlagControllerStateMock } =
getMessengerMock();

beforeEach(() => {
jest.resetAllMocks();

getRemoteFeatureFlagControllerStateMock.mockReturnValue({
...getDefaultRemoteFeatureFlagControllerState(),
remoteFeatureFlags: {
confirmations_pay: {
tokenPay: {
providers: {
across: {
enabled: true,
},
},
},
},
},
});
});

it('returns false for perps deposit transactions', () => {
const provider = new AcrossProvider();
const request = buildRequest({
messenger,
transaction: { type: TransactionType.perpsDeposit } as TransactionMeta,
});

expect(provider.supports(request)).toBe(false);
});

it('returns false for same-chain requests', () => {
const provider = new AcrossProvider();
const request = buildRequest({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
targetChainId: QUOTE_REQUEST_MOCK.sourceChainId,
},
],
});

expect(provider.supports(request)).toBe(false);
});

it('returns false when Across is disabled', () => {
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
...getDefaultRemoteFeatureFlagControllerState(),
remoteFeatureFlags: {
confirmations_pay: {
tokenPay: {
providers: {
across: {
enabled: false,
},
},
},
},
},
});

const provider = new AcrossProvider();
const request = buildRequest({ messenger });

expect(provider.supports(request)).toBe(false);
});

it('returns true for same-chain requests when allowed', () => {
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
...getDefaultRemoteFeatureFlagControllerState(),
remoteFeatureFlags: {
confirmations_pay: {
tokenPay: {
providers: {
across: {
allowSameChain: true,
enabled: true,
},
},
},
},
},
});

const provider = new AcrossProvider();
const request = buildRequest({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
targetChainId: QUOTE_REQUEST_MOCK.sourceChainId,
},
],
});

expect(provider.supports(request)).toBe(true);
});

it('returns true for cross-chain requests', () => {
const provider = new AcrossProvider();
const request = buildRequest({ messenger });

expect(provider.supports(request)).toBe(true);
});

it('maps quotes with provider metadata', async () => {
const quote = {
original: {
request: { amount: '1', tradeType: 'exactOutput' },
quote: {} as AcrossQuote['quote'],
},
} as TransactionPayQuote<AcrossQuote>;

getAcrossQuotesMock.mockResolvedValue([quote]);

const provider = new AcrossProvider();
const result = await provider.getQuotes(buildRequest({ messenger }));

expect(result[0].original).toStrictEqual({
providerId: 'across',
quote: quote.original,
});
expect(result[0].strategy).toBe(TransactionPayStrategy.TokenPay);
});

it('executes by unwrapping provider quotes', async () => {
submitAcrossQuotesMock.mockResolvedValue({ transactionHash: '0xhash' });

const provider = new AcrossProvider();
const wrappedQuote = {
original: {
providerId: 'across',
quote: {
request: { amount: '1', tradeType: 'exactOutput' },
quote: {} as AcrossQuote['quote'],
},
},
} as TransactionPayQuote<TokenPayProviderQuote<AcrossQuote>>;

await provider.execute({
quotes: [wrappedQuote],
messenger,
transaction: { id: '1', txParams: { from: '0x1' } } as TransactionMeta,
isSmartTransaction: jest.fn(),
});

expect(submitAcrossQuotesMock).toHaveBeenCalledWith(
expect.objectContaining({
quotes: [
expect.objectContaining({
original: wrappedQuote.original.quote,
}),
],
}),
);
});
});
Loading
Loading