-
-
Notifications
You must be signed in to change notification settings - Fork 273
feat(transaction-pay): add TokenPay strategy with Across provider + metrics #7806
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -830,6 +830,11 @@ export enum TransactionType { | |
| */ | ||
| relayDeposit = 'relayDeposit', | ||
|
|
||
| /** | ||
| * Deposit funds for Across quote. | ||
| */ | ||
| acrossDeposit = 'acrossDeposit', | ||
|
|
||
| /** | ||
| * When a transaction is failed it can be retried by | ||
| * resubmitting the same transaction with a higher gas fee. This type is also used | ||
|
|
@@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor, alphabetical. |
||
| }; | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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). | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This sounds like a good mechanism, but can we abstract to 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,5 +16,6 @@ export const POLYGON_USDCE_ADDRESS = | |
| export enum TransactionPayStrategy { | ||
| Bridge = 'bridge', | ||
| Relay = 'relay', | ||
| TokenPay = 'tokenPay', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| Test = 'test', | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'); | ||
|
|
||
|
|
@@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor, is Do we definitely want to test the entire execution that includes transaction creation, and gas fee token requests etc?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }), | ||
| ], | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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
relayDepositmore granular, soperpsRelayDepositandpredictRelayDeposit, so should do the same for Across also.