Skip to content

Commit 047509a

Browse files
feat(transaction-pay): add TokenPay strategy with Across provider + metrics
- Introduce TokenPay strategy with provider adapter interface and registry - Add Across provider (quotes + submit) and Relay adapter wrapper - Implement Across quote normalization (fees, dust, durations, fiat) and actions API payloads for delegated calls - Add feature flags for tokenPay providers and Across API config - Add Across submit flow (approvals + swap tx), intent completion, and confirmation waits - Gate unsupported cases (same-chain, perps deposits) and block type‑4 authorizationList until Across supports it - Add Across quote latency metrics and execution latency recording in metamaskPay metadata - Add/extend unit tests for Across quotes/submit/supports and publish hook metrics
1 parent 5d9dec2 commit 047509a

27 files changed

+3911
-3
lines changed

packages/transaction-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242
- Add support for `transactionHistoryLimit` feature flag to configure the maximum number of transactions stored in state ([#7648](https://github.com/MetaMask/core/pull/7648))
4343
- Defaults to 40 if not provided.
4444
- Add optional `callTraceErrors` to `simulationData` ([#7641](https://github.com/MetaMask/core/pull/7641))
45+
- Add `acrossDeposit` transaction type and `MetamaskPayMetadata.executionLatencyMs` for MetaMask Pay tracking ([#7806](https://github.com/MetaMask/core/pull/7806))
4546

4647
### Changed
4748

packages/transaction-controller/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,11 @@ export enum TransactionType {
830830
*/
831831
relayDeposit = 'relayDeposit',
832832

833+
/**
834+
* Deposit funds for Across quote.
835+
*/
836+
acrossDeposit = 'acrossDeposit',
837+
833838
/**
834839
* When a transaction is failed it can be retried by
835840
* resubmitting the same transaction with a higher gas fee. This type is also used
@@ -2099,6 +2104,9 @@ export type MetamaskPayMetadata = {
20992104

21002105
/** Total cost of the transaction in fiat currency, including gas, fees, and the funds themselves. */
21012106
totalFiat?: string;
2107+
2108+
/** Total time spent executing the MetaMask Pay flow, in milliseconds. */
2109+
executionLatencyMs?: number;
21022110
};
21032111

21042112
/**

packages/transaction-pay-controller/ARCHITECTURE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ Quotes are retrieved from the [Relay API](https://docs.relay.link/what-is-relay)
4444

4545
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.
4646

47+
### TokenPay (provider routing)
48+
49+
The `TokenPayStrategy` routes quote and execution requests through provider adapters (currently Relay and Across).
50+
51+
Provider selection is determined by feature flags:
52+
53+
- `tokenPay.providerOrder` controls priority (default: `[primaryProvider, 'relay', 'across']`).
54+
- Each provider can be enabled/disabled via `tokenPay.providers.<id>.enabled`.
55+
- Providers may also implement capability gating in `supports(...)` (e.g., Across rejects same-chain swaps).
56+
57+
Routing behavior:
58+
59+
- The strategy selects the **first** provider in order that is enabled and returns `supports(...) === true`.
60+
- If no provider supports the request, the strategy throws, and the controller returns no quotes.
61+
- 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.)
62+
- 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.
63+
4764
## Lifecycle
4865

4966
The high level interaction with the `TransactionPayController` is as follows:

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add TokenPay strategy with new Across provider and existing Relay provider, including gating and latency metrics ([#7806](https://github.com/MetaMask/core/pull/7806))
13+
1014
### Changed
1115

1216
- 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))

packages/transaction-pay-controller/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@ export const POLYGON_USDCE_ADDRESS =
1616
export enum TransactionPayStrategy {
1717
Bridge = 'bridge',
1818
Relay = 'relay',
19+
TokenPay = 'tokenPay',
1920
Test = 'test',
2021
}

packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
PublishHookResult,
33
TransactionMeta,
4+
TransactionControllerState,
45
} from '@metamask/transaction-controller';
56

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

30-
const { messenger, getControllerStateMock, getStrategyMock } =
31-
getMessengerMock();
31+
const {
32+
messenger,
33+
getControllerStateMock,
34+
getStrategyMock,
35+
getTransactionControllerStateMock,
36+
updateTransactionMock,
37+
} = getMessengerMock();
3238

3339
let hook: TransactionPayPublishHook;
3440

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

6773
getStrategyMock.mockReturnValue(TransactionPayStrategy.Test);
74+
75+
getTransactionControllerStateMock.mockReturnValue({
76+
transactions: [TRANSACTION_META_MOCK],
77+
} as TransactionControllerState);
6878
});
6979

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

93103
await expect(runHook()).rejects.toThrow('Test error');
94104
});
105+
106+
it('stores execution latency in metadata', async () => {
107+
const nowSpy = jest.spyOn(Date, 'now');
108+
nowSpy
109+
.mockReturnValueOnce(1000)
110+
.mockReturnValueOnce(1400)
111+
.mockReturnValue(1400);
112+
113+
await runHook();
114+
115+
expect(updateTransactionMock).toHaveBeenCalled();
116+
const updatedTx = updateTransactionMock.mock.calls[0][0];
117+
expect(updatedTx.metamaskPay?.executionLatencyMs).toBe(400);
118+
119+
nowSpy.mockRestore();
120+
});
121+
122+
it('swallows errors when updating execution metrics', async () => {
123+
updateTransactionMock.mockImplementation(() => {
124+
throw new Error('Update failed');
125+
});
126+
executeMock.mockResolvedValue({ transactionHash: '0xhash' });
127+
128+
await expect(runHook()).resolves.toStrictEqual({
129+
transactionHash: '0xhash',
130+
});
131+
expect(updateTransactionMock).toHaveBeenCalled();
132+
});
95133
});

packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
TransactionPayQuote,
1111
} from '../types';
1212
import { getStrategy } from '../utils/strategy';
13+
import { updateTransaction } from '../utils/transaction';
1314

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

@@ -70,11 +71,34 @@ export class TransactionPayPublishHook {
7071

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

73-
return await strategy.execute({
74+
const start = Date.now();
75+
const result = await strategy.execute({
7476
isSmartTransaction: this.#isSmartTransaction,
7577
quotes,
7678
messenger: this.#messenger,
7779
transaction: transactionMeta,
7880
});
81+
82+
const executionLatencyMs = Date.now() - start;
83+
84+
try {
85+
updateTransaction(
86+
{
87+
transactionId,
88+
messenger: this.#messenger,
89+
note: 'Update MetaMask Pay execution metrics',
90+
},
91+
(tx) => {
92+
tx.metamaskPay = {
93+
...tx.metamaskPay,
94+
executionLatencyMs,
95+
};
96+
},
97+
);
98+
} catch (error) {
99+
log('Failed to update execution metrics', error);
100+
}
101+
102+
return result;
79103
}
80104
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { TransactionType } from '@metamask/transaction-controller';
2+
import type { TransactionMeta } from '@metamask/transaction-controller';
3+
import type { Hex } from '@metamask/utils';
4+
5+
import { AcrossProvider } from './AcrossProvider';
6+
import { getAcrossQuotes } from './across-quotes';
7+
import { submitAcrossQuotes } from './across-submit';
8+
import type { AcrossQuote } from './types';
9+
import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller';
10+
import { TransactionPayStrategy } from '../../constants';
11+
import { getMessengerMock } from '../../tests/messenger-mock';
12+
import type {
13+
PayStrategyGetQuotesRequest,
14+
QuoteRequest,
15+
TransactionPayQuote,
16+
} from '../../types';
17+
import type { TokenPayProviderQuote } from '../token-pay/types';
18+
19+
jest.mock('./across-quotes');
20+
jest.mock('./across-submit');
21+
22+
const QUOTE_REQUEST_MOCK: QuoteRequest = {
23+
from: '0x1234567890123456789012345678901234567891' as Hex,
24+
sourceBalanceRaw: '10000000000000000000',
25+
sourceChainId: '0x1',
26+
sourceTokenAddress: '0xabc',
27+
sourceTokenAmount: '1000000000000000000',
28+
targetAmountMinimum: '123',
29+
targetChainId: '0x2',
30+
targetTokenAddress: '0xdef',
31+
};
32+
33+
function buildRequest(
34+
overrides: Partial<PayStrategyGetQuotesRequest> = {},
35+
): PayStrategyGetQuotesRequest {
36+
return {
37+
messenger: overrides.messenger as PayStrategyGetQuotesRequest['messenger'],
38+
requests: [QUOTE_REQUEST_MOCK],
39+
transaction: { type: TransactionType.simpleSend } as TransactionMeta,
40+
...overrides,
41+
};
42+
}
43+
44+
describe('AcrossProvider', () => {
45+
const getAcrossQuotesMock = jest.mocked(getAcrossQuotes);
46+
const submitAcrossQuotesMock = jest.mocked(submitAcrossQuotes);
47+
const { messenger, getRemoteFeatureFlagControllerStateMock } =
48+
getMessengerMock();
49+
50+
beforeEach(() => {
51+
jest.resetAllMocks();
52+
53+
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
54+
...getDefaultRemoteFeatureFlagControllerState(),
55+
remoteFeatureFlags: {
56+
confirmations_pay: {
57+
tokenPay: {
58+
providers: {
59+
across: {
60+
enabled: true,
61+
},
62+
},
63+
},
64+
},
65+
},
66+
});
67+
});
68+
69+
it('returns false for perps deposit transactions', () => {
70+
const provider = new AcrossProvider();
71+
const request = buildRequest({
72+
messenger,
73+
transaction: { type: TransactionType.perpsDeposit } as TransactionMeta,
74+
});
75+
76+
expect(provider.supports(request)).toBe(false);
77+
});
78+
79+
it('returns false for same-chain requests', () => {
80+
const provider = new AcrossProvider();
81+
const request = buildRequest({
82+
messenger,
83+
requests: [
84+
{
85+
...QUOTE_REQUEST_MOCK,
86+
targetChainId: QUOTE_REQUEST_MOCK.sourceChainId,
87+
},
88+
],
89+
});
90+
91+
expect(provider.supports(request)).toBe(false);
92+
});
93+
94+
it('returns false when Across is disabled', () => {
95+
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
96+
...getDefaultRemoteFeatureFlagControllerState(),
97+
remoteFeatureFlags: {
98+
confirmations_pay: {
99+
tokenPay: {
100+
providers: {
101+
across: {
102+
enabled: false,
103+
},
104+
},
105+
},
106+
},
107+
},
108+
});
109+
110+
const provider = new AcrossProvider();
111+
const request = buildRequest({ messenger });
112+
113+
expect(provider.supports(request)).toBe(false);
114+
});
115+
116+
it('returns true for same-chain requests when allowed', () => {
117+
getRemoteFeatureFlagControllerStateMock.mockReturnValue({
118+
...getDefaultRemoteFeatureFlagControllerState(),
119+
remoteFeatureFlags: {
120+
confirmations_pay: {
121+
tokenPay: {
122+
providers: {
123+
across: {
124+
allowSameChain: true,
125+
enabled: true,
126+
},
127+
},
128+
},
129+
},
130+
},
131+
});
132+
133+
const provider = new AcrossProvider();
134+
const request = buildRequest({
135+
messenger,
136+
requests: [
137+
{
138+
...QUOTE_REQUEST_MOCK,
139+
targetChainId: QUOTE_REQUEST_MOCK.sourceChainId,
140+
},
141+
],
142+
});
143+
144+
expect(provider.supports(request)).toBe(true);
145+
});
146+
147+
it('returns true for cross-chain requests', () => {
148+
const provider = new AcrossProvider();
149+
const request = buildRequest({ messenger });
150+
151+
expect(provider.supports(request)).toBe(true);
152+
});
153+
154+
it('maps quotes with provider metadata', async () => {
155+
const quote = {
156+
original: {
157+
request: { amount: '1', tradeType: 'exactOutput' },
158+
quote: {} as AcrossQuote['quote'],
159+
},
160+
} as TransactionPayQuote<AcrossQuote>;
161+
162+
getAcrossQuotesMock.mockResolvedValue([quote]);
163+
164+
const provider = new AcrossProvider();
165+
const result = await provider.getQuotes(buildRequest({ messenger }));
166+
167+
expect(result[0].original).toStrictEqual({
168+
providerId: 'across',
169+
quote: quote.original,
170+
});
171+
expect(result[0].strategy).toBe(TransactionPayStrategy.TokenPay);
172+
});
173+
174+
it('executes by unwrapping provider quotes', async () => {
175+
submitAcrossQuotesMock.mockResolvedValue({ transactionHash: '0xhash' });
176+
177+
const provider = new AcrossProvider();
178+
const wrappedQuote = {
179+
original: {
180+
providerId: 'across',
181+
quote: {
182+
request: { amount: '1', tradeType: 'exactOutput' },
183+
quote: {} as AcrossQuote['quote'],
184+
},
185+
},
186+
} as TransactionPayQuote<TokenPayProviderQuote<AcrossQuote>>;
187+
188+
await provider.execute({
189+
quotes: [wrappedQuote],
190+
messenger,
191+
transaction: { id: '1', txParams: { from: '0x1' } } as TransactionMeta,
192+
isSmartTransaction: jest.fn(),
193+
});
194+
195+
expect(submitAcrossQuotesMock).toHaveBeenCalledWith(
196+
expect.objectContaining({
197+
quotes: [
198+
expect.objectContaining({
199+
original: wrappedQuote.original.quote,
200+
}),
201+
],
202+
}),
203+
);
204+
});
205+
});

0 commit comments

Comments
 (0)