diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 5d9709eb97c..25cd785373c 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Lock `@metamask/core-backend` to `5.0.0` ([#7852](https://github.com/MetaMask/core/pull/7852)) +- Add `perpsAcrossDeposit` and `predictAcrossDeposit` transaction types ([#7806](https://github.com/MetaMask/core/pull/7806)) ## [62.14.0] @@ -56,6 +57,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 diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 0012653529f..894e6d8a3f5 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -835,6 +835,16 @@ export enum TransactionType { */ relayDeposit = 'relayDeposit', + /** + * Deposit funds for Across quote via Perps. + */ + perpsAcrossDeposit = 'perpsAcrossDeposit', + + /** + * Deposit funds for Across quote via Predict. + */ + predictAcrossDeposit = 'predictAcrossDeposit', + /** * When a transaction is failed it can be retried by * resubmitting the same transaction with a higher gas fee. This type is also used @@ -2096,6 +2106,9 @@ export type MetamaskPayMetadata = { /** Chain ID of the payment token. */ chainId?: Hex; + /** Total time spent submitting the MetaMask Pay flow, in milliseconds. */ + executionLatencyMs?: number; + /** Total network fee in fiat currency, including the original and bridge transactions. */ networkFeeFiat?: string; diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index c44e17a11eb..73015a982b1 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -44,6 +44,21 @@ 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. +### Across + +The `AcrossStrategy` retrieves quotes from the Across API and submits approvals and deposit transactions via the `TransactionController`. + +### Strategy Selection and Fallback + +Strategy order and configuration are determined by feature flags: + +- `payStrategies.strategyOrder` controls the ordered strategy list (default: `[Relay, Across]`). +- Each strategy can be enabled/disabled via `payStrategies..enabled`. +- Strategies may implement capability gating in `supports(...)` (e.g., Across rejects same-chain swaps). + +- The controller selects the **first** strategy in order that returns `supports(...) === true`. +- If the selected strategy fails during quote retrieval or execution, the next compatible strategy is attempted. + ## Lifecycle The high level interaction with the `TransactionPayController` is as follows: @@ -54,11 +69,11 @@ The high level interaction with the `TransactionPayController` is as follows: 4. Controller identifies any required tokens and adds them to its state. 5. If a client confirmation is using `MetaMask Pay`, the user selects a payment token (or it is done automatically) which invokes the `updatePaymentToken` action. - The below steps are also triggered if the transaction `data` is updated. -6. Controller selects an appropriate `PayStrategy` using the `getStrategy` action. -7. Controller requests quotes from the `PayStrategy` and persists them in state, including associated totals. +6. Controller requests an ordered list of strategies via the `getStrategies` action. +7. Controller selects the first compatible strategy and requests quotes, falling back to the next strategy if quote retrieval fails. 8. Resulting fees and totals are presented in the client transaction confirmation. 9. If approved by the user, the target transaction is signed and published. -10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the same `PayStrategy`. +10. The `TransactionPayPublishHook` is invoked and submits the relevant quotes via the strategy indicated by the quotes, with fallback on execution errors. 11. The hook waits for any transactions and quotes to complete. 12. Depending on the pay strategy and required tokens, the original target transaction is also published as the required funds are now in place on the user's account on the target chain. 13. Target transaction is finalized and any related controller state is removed. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index e2abdf4b4bf..efe29df9fb3 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Generate required tokens using `requiredAssets` from transaction metadata ([#7820](https://github.com/MetaMask/core/pull/7820)) +- Add standalone Across pay strategy with core-level selection and fallback ([#7806](https://github.com/MetaMask/core/pull/7806)) ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 82232be1a13..2c0733ecca6 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -112,6 +112,54 @@ describe('TransactionPayController', () => { ), ).toBe(TransactionPayStrategy.Test); }); + + it('returns relay if getStrategies callback returns empty', async () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategies: (): TransactionPayStrategy[] => [], + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Relay); + }); + }); + + describe('getStrategies Action', () => { + it('returns relay by default', async () => { + createController(); + + expect( + messenger.call( + 'TransactionPayController:getStrategies', + TRANSACTION_META_MOCK, + ), + ).toStrictEqual([ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, + ]); + }); + + it('returns callback list if provided', async () => { + new TransactionPayController({ + getDelegationTransaction: jest.fn(), + getStrategies: (): TransactionPayStrategy[] => [ + TransactionPayStrategy.Test, + ], + messenger, + }); + + expect( + messenger.call( + 'TransactionPayController:getStrategies', + TRANSACTION_META_MOCK, + ), + ).toStrictEqual([TransactionPayStrategy.Test]); + }); }); describe('transaction data update', () => { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 9bba57d98d8..fbd4f4e1484 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -15,6 +15,7 @@ import type { TransactionPayControllerState, UpdatePaymentTokenRequest, } from './types'; +import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; import { pollTransactionChanges } from './utils/transaction'; @@ -43,9 +44,14 @@ export class TransactionPayController extends BaseController< transaction: TransactionMeta, ) => TransactionPayStrategy; + readonly #getStrategies?: ( + transaction: TransactionMeta, + ) => TransactionPayStrategy[]; + constructor({ getDelegationTransaction, getStrategy, + getStrategies, messenger, state, }: TransactionPayControllerOptions) { @@ -58,6 +64,7 @@ export class TransactionPayController extends BaseController< this.#getDelegationTransaction = getDelegationTransaction; this.#getStrategy = getStrategy; + this.#getStrategies = getStrategies; this.#registerActionHandlers(); @@ -141,6 +148,16 @@ export class TransactionPayController extends BaseController< } #registerActionHandlers(): void { + const getStrategies = + this.#getStrategies ?? + ((transaction: TransactionMeta): TransactionPayStrategy[] => { + if (this.#getStrategy) { + return [this.#getStrategy(transaction)]; + } + + return getStrategyOrder(this.messenger); + }); + this.messenger.registerActionHandler( 'TransactionPayController:getDelegationTransaction', this.#getDelegationTransaction.bind(this), @@ -148,8 +165,14 @@ export class TransactionPayController extends BaseController< this.messenger.registerActionHandler( 'TransactionPayController:getStrategy', - this.#getStrategy ?? - ((): TransactionPayStrategy => TransactionPayStrategy.Relay), + (transaction: TransactionMeta): TransactionPayStrategy => + getStrategies(transaction)[0] ?? TransactionPayStrategy.Relay, + ); + + this.messenger.registerActionHandler( + 'TransactionPayController:getStrategies', + (transaction: TransactionMeta): TransactionPayStrategy[] => + getStrategies(transaction), ); this.messenger.registerActionHandler( diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 0b6d75ff06e..8e8da61889b 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -14,6 +14,7 @@ export const POLYGON_USDCE_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Hex; export enum TransactionPayStrategy { + Across = 'across', Bridge = 'bridge', Relay = 'relay', Test = 'test', diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index 91daa76d6b7..92ef63f01f7 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -1,18 +1,19 @@ import type { PublishHookResult, TransactionMeta, + TransactionControllerState, } from '@metamask/transaction-controller'; import { TransactionPayPublishHook } from './TransactionPayPublishHook'; import { TransactionPayStrategy } from '..'; -import { TestStrategy } from '../strategy/test/TestStrategy'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionPayControllerState, TransactionPayQuote, } from '../types'; +import { getStrategies, getStrategyByName } from '../utils/strategy'; -jest.mock('../strategy/test/TestStrategy'); +jest.mock('../utils/strategy'); const TRANSACTION_META_MOCK = { id: '123-456', @@ -21,14 +22,22 @@ const TRANSACTION_META_MOCK = { }, } as TransactionMeta; -const QUOTE_MOCK = {} as TransactionPayQuote; +const QUOTE_MOCK = { + strategy: TransactionPayStrategy.Test, +} as TransactionPayQuote; describe('TransactionPayPublishHook', () => { const isSmartTransactionMock = jest.fn(); const executeMock = jest.fn(); + const getStrategiesUtilMock = jest.mocked(getStrategies); + const getStrategyByNameMock = jest.mocked(getStrategyByName); - const { messenger, getControllerStateMock, getStrategyMock } = - getMessengerMock(); + const { + messenger, + getControllerStateMock, + getTransactionControllerStateMock, + updateTransactionMock, + } = getMessengerMock(); let hook: TransactionPayPublishHook; @@ -49,10 +58,15 @@ describe('TransactionPayPublishHook', () => { messenger, }); - jest.mocked(TestStrategy).mockReturnValue({ + getStrategyByNameMock.mockReturnValue({ execute: executeMock, getQuotes: jest.fn(), - } as unknown as TestStrategy); + } as never); + + executeMock.mockImplementation(async (request) => { + request.onSubmitted?.(400); + return { transactionHash: '0xhash' }; + }); isSmartTransactionMock.mockReturnValue(false); @@ -64,7 +78,11 @@ describe('TransactionPayPublishHook', () => { }, } as TransactionPayControllerState); - getStrategyMock.mockReturnValue(TransactionPayStrategy.Test); + getStrategiesUtilMock.mockReturnValue([]); + + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); }); it('executes strategy with quotes', async () => { @@ -92,4 +110,201 @@ describe('TransactionPayPublishHook', () => { await expect(runHook()).rejects.toThrow('Test error'); }); + + it('stores execution latency in metadata', async () => { + await runHook(); + + expect(updateTransactionMock).toHaveBeenCalled(); + const updatedTx = updateTransactionMock.mock.calls[0][0]; + expect(updatedTx.metamaskPay?.executionLatencyMs).toBe(400); + }); + + it('records execution latency only once', async () => { + executeMock.mockImplementation(async (request) => { + request.onSubmitted?.(400); + request.onSubmitted?.(900); + return { transactionHash: '0xhash' }; + }); + + await runHook(); + + expect(updateTransactionMock).toHaveBeenCalledTimes(1); + const updatedTx = updateTransactionMock.mock.calls[0][0]; + expect(updatedTx.metamaskPay?.executionLatencyMs).toBe(400); + }); + + it('swallows errors when updating execution metrics', async () => { + updateTransactionMock.mockImplementation(() => { + throw new Error('Update failed'); + }); + executeMock.mockImplementation(async (request) => { + request.onSubmitted?.(123); + return { transactionHash: '0xhash' }; + }); + + const result = await runHook(); + + expect(result).toStrictEqual({ + transactionHash: '0xhash', + }); + expect(updateTransactionMock).toHaveBeenCalled(); + }); + + it('falls back to the next compatible strategy when primary fails', async () => { + class PrimaryStrategy {} + class UnsupportedStrategy {} + class EmptyStrategy {} + class ErrorStrategy {} + class FallbackStrategy {} + + const primaryStrategy = { + constructor: PrimaryStrategy, + execute: jest.fn().mockRejectedValue(new Error('Primary error')), + }; + + const unsupportedStrategy = { + constructor: UnsupportedStrategy, + supports: jest.fn().mockReturnValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + const emptyStrategy = { + constructor: EmptyStrategy, + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([]), + execute: jest.fn(), + }; + + const errorStrategy = { + constructor: ErrorStrategy, + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue(new Error('Quote error')), + execute: jest.fn(), + }; + + const fallbackStrategy = { + constructor: FallbackStrategy, + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + execute: jest.fn().mockImplementation(async (request) => { + request.onSubmitted?.(250); + return { transactionHash: '0xfallback' }; + }), + }; + + getStrategyByNameMock.mockReturnValue(primaryStrategy as never); + getStrategiesUtilMock.mockReturnValue([ + primaryStrategy as never, + unsupportedStrategy as never, + emptyStrategy as never, + errorStrategy as never, + fallbackStrategy as never, + ]); + + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_META_MOCK.id]: { + isLoading: false, + quotes: [QUOTE_MOCK], + isMaxAmount: false, + paymentToken: { + address: '0x123', + balanceRaw: '100', + chainId: '0x1', + }, + sourceAmounts: [ + { + sourceAmountRaw: '100', + targetTokenAddress: '0x456', + }, + ], + tokens: [ + { + address: '0x456', + allowUnderMinimum: false, + amountRaw: '100', + chainId: '0x2', + }, + ], + }, + }, + } as unknown as TransactionPayControllerState); + + const result = await runHook(); + + expect(result).toStrictEqual({ + transactionHash: '0xfallback', + }); + + expect(unsupportedStrategy.getQuotes).not.toHaveBeenCalled(); + expect(emptyStrategy.getQuotes).toHaveBeenCalled(); + expect(errorStrategy.getQuotes).toHaveBeenCalled(); + expect(fallbackStrategy.execute).toHaveBeenCalled(); + expect(updateTransactionMock).toHaveBeenCalled(); + }); + + it('throws the original error when no fallback succeeds', async () => { + class PrimaryStrategy {} + class UnsupportedStrategy {} + class EmptyStrategy {} + + const primaryError = new Error('Primary error'); + const primaryStrategy = { + constructor: PrimaryStrategy, + execute: jest.fn().mockRejectedValue(primaryError), + }; + + const unsupportedStrategy = { + constructor: UnsupportedStrategy, + supports: jest.fn().mockReturnValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + const emptyStrategy = { + constructor: EmptyStrategy, + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([]), + execute: jest.fn(), + }; + + getStrategyByNameMock.mockReturnValue(primaryStrategy as never); + getStrategiesUtilMock.mockReturnValue([ + primaryStrategy as never, + unsupportedStrategy as never, + emptyStrategy as never, + ]); + + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_META_MOCK.id]: { + isLoading: false, + quotes: [QUOTE_MOCK], + isMaxAmount: false, + paymentToken: { + address: '0x123', + balanceRaw: '100', + chainId: '0x1', + }, + sourceAmounts: [ + { + sourceAmountRaw: '100', + targetTokenAddress: '0x456', + }, + ], + tokens: [ + { + address: '0x456', + allowUnderMinimum: false, + amountRaw: '100', + chainId: '0x2', + }, + ], + }, + }, + } as unknown as TransactionPayControllerState); + + await expect(runHook()).rejects.toThrow(primaryError); + }); }); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index c49cbcf252f..10ba8c02563 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -7,9 +7,12 @@ import { createModuleLogger } from '@metamask/utils'; import { projectLogger } from '../logger'; import type { TransactionPayControllerMessenger, + TransactionData, TransactionPayQuote, } from '../types'; -import { getStrategy } from '../utils/strategy'; +import { buildQuoteRequests } from '../utils/quotes'; +import { getStrategies, getStrategyByName } from '../utils/strategy'; +import { updateTransaction } from '../utils/transaction'; const log = createModuleLogger(projectLogger, 'pay-publish-hook'); @@ -59,22 +62,151 @@ export class TransactionPayPublishHook { 'TransactionPayController:getState', ); + const transactionData = controllerState.transactionData?.[transactionId]; + const quotes = - (controllerState.transactionData?.[transactionId] - ?.quotes as TransactionPayQuote[]) ?? []; + (transactionData?.quotes as TransactionPayQuote[]) ?? []; if (!quotes?.length) { log('Skipping as no quotes found'); return EMPTY_RESULT; } - const strategy = getStrategy(this.#messenger, transactionMeta); + const primaryStrategyName = quotes[0].strategy; + const primaryStrategy = getStrategyByName(primaryStrategyName); + let hasRecordedLatency = false; + + const recordExecutionLatency = (latencyMs: number): void => { + if (hasRecordedLatency) { + return; + } + + hasRecordedLatency = true; + this.#recordExecutionMetrics(transactionId, latencyMs); + }; + + try { + const result = await primaryStrategy.execute({ + isSmartTransaction: this.#isSmartTransaction, + quotes, + messenger: this.#messenger, + onSubmitted: recordExecutionLatency, + transaction: transactionMeta, + }); + + return result; + } catch (error) { + log('Primary strategy failed, attempting fallback', { + error, + strategy: primaryStrategyName, + }); + + const result = await this.#executeFallback({ + transactionMeta, + transactionData, + primaryStrategy, + originalError: error, + onSubmitted: recordExecutionLatency, + }); + + return result; + } + } + + #recordExecutionMetrics( + transactionId: string, + executionLatencyMs: number, + ): void { + 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); + } + } + + async #executeFallback({ + transactionMeta, + transactionData, + primaryStrategy, + originalError, + onSubmitted, + }: { + transactionMeta: TransactionMeta; + transactionData: TransactionData | undefined; + primaryStrategy: ReturnType; + originalError: unknown; + onSubmitted: (latencyMs: number) => void; + }): Promise { + /* istanbul ignore next */ + if (!transactionData) { + throw originalError; + } + + const { isMaxAmount, paymentToken, sourceAmounts, tokens } = + transactionData; + + const requests = buildQuoteRequests({ + from: transactionMeta.txParams.from as Hex, + isMaxAmount: isMaxAmount ?? false, + paymentToken, + sourceAmounts, + tokens: tokens ?? [], + transactionId: transactionMeta.id, + }); + + if (!requests.length) { + throw originalError; + } - return await strategy.execute({ - isSmartTransaction: this.#isSmartTransaction, - quotes, + const strategies = getStrategies(this.#messenger, transactionMeta); + const request = { messenger: this.#messenger, + onSubmitted, + requests, transaction: transactionMeta, - }); + }; + + for (const strategy of strategies) { + if (strategy.constructor === primaryStrategy.constructor) { + continue; + } + + if (strategy.supports && !strategy.supports(request)) { + continue; + } + + try { + const quotes = await strategy.getQuotes(request); + + if (!quotes?.length) { + continue; + } + + return await strategy.execute({ + isSmartTransaction: this.#isSmartTransaction, + quotes, + messenger: this.#messenger, + onSubmitted, + transaction: transactionMeta, + }); + } catch (error) { + log('Strategy failed, trying next', { error }); + continue; + } + } + + throw originalError; } } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index d3cb83c35f3..82607141a8b 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -4,12 +4,14 @@ export type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStateAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerGetStrategiesAction, TransactionPayControllerMessenger, TransactionPayControllerOptions, TransactionPayControllerSetIsMaxAmountAction, TransactionPayControllerState, TransactionPayControllerStateChangeEvent, TransactionPayControllerUpdatePaymentTokenAction, + TransactionPayAction, TransactionPaymentToken, TransactionPayQuote, TransactionPayRequiredToken, diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts new file mode 100644 index 00000000000..f9243a57d2f --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -0,0 +1,191 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { getAcrossQuotes } from './across-quotes'; +import { submitAcrossQuotes } from './across-submit'; +import { AcrossStrategy } from './AcrossStrategy'; +import type { AcrossQuote } from './types'; +import type { + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, + TransactionPayQuote, +} from '../../types'; +import { getPayStrategiesConfig } from '../../utils/feature-flags'; + +jest.mock('./across-quotes'); +jest.mock('./across-submit'); +jest.mock('../../utils/feature-flags'); + +describe('AcrossStrategy', () => { + const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig); + const getAcrossQuotesMock = jest.mocked(getAcrossQuotes); + const submitAcrossQuotesMock = jest.mocked(submitAcrossQuotes); + + const messenger = {} as never; + + const TRANSACTION_META_MOCK = { + id: 'tx-1', + chainId: '0x1', + networkClientId: 'mainnet', + status: TransactionStatus.unapproved, + time: Date.now(), + txParams: { + from: '0xabc', + }, + } as TransactionMeta; + + const baseRequest = { + messenger, + transaction: TRANSACTION_META_MOCK, + requests: [ + { + from: '0xabc' as Hex, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '100', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + } as PayStrategyGetQuotesRequest; + + beforeEach(() => { + jest.resetAllMocks(); + getPayStrategiesConfigMock.mockReturnValue({ + across: { + allowSameChain: false, + apiBase: 'https://across.test', + enabled: true, + }, + relay: { + enabled: true, + relayQuoteUrl: 'https://relay.test', + }, + }); + }); + + it('returns false when across is disabled', () => { + getPayStrategiesConfigMock.mockReturnValue({ + across: { + allowSameChain: false, + apiBase: 'https://across.test', + enabled: false, + }, + relay: { + enabled: true, + relayQuoteUrl: 'https://relay.test', + }, + }); + + const strategy = new AcrossStrategy(); + expect(strategy.supports(baseRequest)).toBe(false); + }); + + it('returns false for perps deposits', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.perpsDeposit, + } as TransactionMeta, + }), + ).toBe(false); + }); + + it('returns true when same-chain swaps are allowed', () => { + getPayStrategiesConfigMock.mockReturnValue({ + across: { + allowSameChain: true, + apiBase: 'https://across.test', + enabled: true, + }, + relay: { + enabled: true, + relayQuoteUrl: 'https://relay.test', + }, + }); + + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + requests: [ + { + from: '0xabc' as Hex, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '100', + targetChainId: '0x1' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(true); + }); + + it('returns false when same-chain swaps are not allowed', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + requests: [ + { + from: '0xabc' as Hex, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '100', + targetChainId: '0x1' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(false); + }); + + it('returns true when all requests are cross-chain', () => { + const strategy = new AcrossStrategy(); + expect(strategy.supports(baseRequest)).toBe(true); + }); + + it('delegates getQuotes to across quotes', async () => { + const strategy = new AcrossStrategy(); + const quote = { strategy: 'across' } as TransactionPayQuote; + getAcrossQuotesMock.mockResolvedValue([quote]); + + const result = await strategy.getQuotes(baseRequest); + + expect(result).toStrictEqual([quote]); + expect(getAcrossQuotesMock).toHaveBeenCalledWith(baseRequest); + }); + + it('delegates execute to across submit', async () => { + const strategy = new AcrossStrategy(); + const request = { + messenger, + quotes: [], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + } as PayStrategyExecuteRequest; + + submitAcrossQuotesMock.mockResolvedValue({ transactionHash: '0xhash' }); + + const result = await strategy.execute(request); + + expect(result).toStrictEqual({ + transactionHash: '0xhash', + }); + expect(submitAcrossQuotesMock).toHaveBeenCalledWith(request); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts new file mode 100644 index 00000000000..3da4bc7eaa5 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -0,0 +1,49 @@ +import { TransactionType } from '@metamask/transaction-controller'; + +import { getAcrossQuotes } from './across-quotes'; +import { submitAcrossQuotes } from './across-submit'; +import type { AcrossQuote } from './types'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, + TransactionPayQuote, +} from '../../types'; +import { getPayStrategiesConfig } from '../../utils/feature-flags'; + +export class AcrossStrategy implements PayStrategy { + supports(request: PayStrategyGetQuotesRequest): boolean { + const config = getPayStrategiesConfig(request.messenger); + + if (!config.across.enabled) { + return false; + } + + if (request.transaction?.type === TransactionType.perpsDeposit) { + // TODO: Enable Across for perps deposits once Hypercore USDC-PERPs is supported. + return false; + } + + if (config.across.allowSameChain) { + return true; + } + + // Across doesn't support same-chain swaps (e.g. mUSD conversions). + return request.requests.every( + (singleRequest) => + singleRequest.sourceChainId !== singleRequest.targetChainId, + ); + } + + async getQuotes( + request: PayStrategyGetQuotesRequest, + ): Promise[]> { + return getAcrossQuotes(request); + } + + async execute( + request: PayStrategyExecuteRequest, + ): ReturnType['execute']> { + return submitAcrossQuotes(request); + } +} diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts new file mode 100644 index 00000000000..4e9432a7fdf --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -0,0 +1,1086 @@ +import { Interface } from '@ethersproject/abi'; +import { successfulFetch } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { getAcrossQuotes } from './across-quotes'; +import type { AcrossSwapApprovalResponse } from './types'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; +import { NATIVE_TOKEN_ADDRESS, TransactionPayStrategy } from '../../constants'; +import { getMessengerMock } from '../../tests/messenger-mock'; +import type { QuoteRequest } from '../../types'; +import { getGasBuffer, getSlippage } from '../../utils/feature-flags'; +import { calculateGasCost } from '../../utils/gas'; +import { getTokenFiatRate } from '../../utils/token'; + +jest.mock('../../utils/token'); +jest.mock('../../utils/gas', () => ({ + ...jest.requireActual('../../utils/gas'), + calculateGasCost: jest.fn(), +})); +jest.mock('../../utils/feature-flags', () => ({ + ...jest.requireActual('../../utils/feature-flags'), + getGasBuffer: jest.fn(), + getSlippage: jest.fn(), +})); + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: jest.fn(), +})); + +const FROM_MOCK = '0x1234567890123456789012345678901234567891' as Hex; + +const TRANSACTION_META_MOCK = { + txParams: { + from: FROM_MOCK, + }, +} as TransactionMeta; + +const QUOTE_REQUEST_MOCK: QuoteRequest = { + from: FROM_MOCK, + sourceBalanceRaw: '10000000000000000000', + sourceChainId: '0x1', + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '123', + targetChainId: '0x2', + targetTokenAddress: '0xdef' as Hex, +}; + +const QUOTE_MOCK: AcrossSwapApprovalResponse = { + approvalTxns: [], + expectedFillTime: 300, + expectedOutputAmount: '200', + fees: { + total: { amountUsd: '1.23' }, + originGas: { amountUsd: '0.45' }, + destinationGas: { amountUsd: '0.67' }, + }, + inputAmount: '1000000000000000000', + inputToken: { + address: '0xabc' as Hex, + chainId: 1, + decimals: 18, + symbol: 'ETH', + }, + minOutputAmount: '150', + outputToken: { + address: '0xdef' as Hex, + chainId: 2, + decimals: 6, + symbol: 'USDC', + }, + swapTx: { + chainId: 1, + to: '0xswap' as Hex, + data: '0xdeadbeef' as Hex, + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }, +}; + +const TOKEN_TRANSFER_INTERFACE = new Interface([ + 'function transfer(address to, uint256 amount)', +]); + +const TRANSFER_RECIPIENT = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const DELEGATION_ACTION_MOCK = { + target: '0xde1e9a7e' as Hex, + functionSignature: + 'function redeemDelegations(bytes[] delegations, bytes32[] modes, bytes[] executions)', + args: [ + { + value: ['0xdead'], + populateDynamically: false, + }, + { + value: ['0x00'], + populateDynamically: false, + }, + { + value: ['0xbeef'], + populateDynamically: false, + }, + ], + value: '0x0', + isNativeTransfer: false, +}; + +function buildTransferData( + recipient: string = TRANSFER_RECIPIENT, + amount = 1, +): Hex { + return TOKEN_TRANSFER_INTERFACE.encodeFunctionData('transfer', [ + recipient, + amount, + ]) as Hex; +} + +describe('Across Quotes', () => { + const successfulFetchMock = jest.mocked(successfulFetch); + const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getGasBufferMock = jest.mocked(getGasBuffer); + const getSlippageMock = jest.mocked(getSlippage); + const calculateGasCostMock = jest.mocked(calculateGasCost); + + const { + messenger, + estimateGasMock, + findNetworkClientIdByChainIdMock, + getDelegationTransactionMock, + getRemoteFeatureFlagControllerStateMock, + } = getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + apiBase: 'https://test.across.to/api', + }, + }, + }, + }, + }); + + getTokenFiatRateMock.mockReturnValue({ + usdRate: '2.0', + fiatRate: '4.0', + }); + + calculateGasCostMock.mockReturnValue({ + fiat: '4.56', + human: '1.725', + raw: '1725000000000000000', + usd: '3.45', + }); + + getGasBufferMock.mockReturnValue(1.0); + getSlippageMock.mockReturnValue(0.005); + + findNetworkClientIdByChainIdMock.mockReturnValue('mainnet'); + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: undefined, + }); + }); + + describe('getAcrossQuotes', () => { + it('fetches and normalizes quotes from Across', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result).toHaveLength(1); + expect(result[0].strategy).toBe(TransactionPayStrategy.Across); + expect(result[0].estimatedDuration).toBe(300); + expect(result[0].fees.provider.usd).toBe('0.0001'); + expect(result[0].fees.impact?.usd).toBe('0.0001'); + expect(result[0].fees.impactRatio).toBe('0.25'); + }); + + it('attaches quote latency metrics', async () => { + const nowSpy = jest.spyOn(Date, 'now'); + nowSpy + .mockReturnValueOnce(1000) + .mockReturnValueOnce(1250) + .mockReturnValue(1250); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.metrics?.latency).toBe(250); + + nowSpy.mockRestore(); + }); + + it('filters out requests with zero target amount', async () => { + const result = await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result).toStrictEqual([]); + expect(successfulFetchMock).not.toHaveBeenCalled(); + }); + + it('throws wrapped error when quote fetching fails', async () => { + successfulFetchMock.mockRejectedValue(new Error('Network error')); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow(/Failed to fetch Across quotes/u); + }); + + it('uses exactInput trade type for max amount quotes', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('tradeType')).toBe('exactInput'); + expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); + }); + + it('uses exactOutput trade type for non-max amount quotes', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('tradeType')).toBe('exactOutput'); + expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.targetAmountMinimum); + }); + + it('includes slippage when available', async () => { + getSlippageMock.mockReturnValue(0.02); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('slippage')).toBe('0.02'); + }); + + it('includes integrator ID and app fee when configured', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + integratorId: 'test-integrator', + appFee: '0.01', + appFeeRecipient: '0xfee', + }, + }, + }, + }, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('integratorId')).toBe('test-integrator'); + expect(params.get('appFee')).toBe('0.01'); + expect(params.get('appFeeRecipient')).toBe('0xfee'); + }); + + it('includes API key header when configured', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + apiKey: 'test-api-key', + apiKeyHeader: 'X-Api-Key', + apiKeyPrefix: 'Bearer', + }, + }, + }, + }, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [, options] = successfulFetchMock.mock.calls[0]; + + expect(options?.headers).toStrictEqual( + expect.objectContaining({ + 'X-Api-Key': 'Bearer test-api-key', + }), + ); + }); + + it('uses default Authorization header for API key when no header specified', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + enabled: true, + apiKey: 'test-api-key', + }, + }, + }, + }, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [, options] = successfulFetchMock.mock.calls[0]; + + expect(options?.headers).toStrictEqual( + expect.objectContaining({ + Authorization: 'test-api-key', + }), + ); + }); + + it('uses transfer recipient for token transfer transactions', async () => { + const transferData = buildTransferData(TRANSFER_RECIPIENT); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: transferData, + }, + }, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + }); + + it('uses transfer recipient from nested transactions', async () => { + const transferData = buildTransferData(TRANSFER_RECIPIENT); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ data: transferData }], + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + }); + + it('includes actions for delegation transactions', async () => { + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }); + + const [, options] = successfulFetchMock.mock.calls[0]; + const body = JSON.parse(options?.body as string); + + expect(options?.method).toBe('POST'); + expect(body.actions).toHaveLength(2); + }); + + it('builds native token transfer action for native target token', async () => { + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetTokenAddress: NATIVE_TOKEN_ADDRESS, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }); + + const [, options] = successfulFetchMock.mock.calls[0]; + const body = JSON.parse(options?.body as string); + + expect(body.actions[0]).toStrictEqual( + expect.objectContaining({ + isNativeTransfer: true, + value: QUOTE_REQUEST_MOCK.targetAmountMinimum, + }), + ); + }); + + it('builds ERC20 transfer action for token target', async () => { + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }); + + const [, options] = successfulFetchMock.mock.calls[0]; + const body = JSON.parse(options?.body as string); + + expect(body.actions[0]).toStrictEqual( + expect.objectContaining({ + isNativeTransfer: false, + target: QUOTE_REQUEST_MOCK.targetTokenAddress, + functionSignature: 'function transfer(address to, uint256 amount)', + }), + ); + }); + + it('throws when delegation requires authorization list', async () => { + getDelegationTransactionMock.mockResolvedValue({ + authorizationList: [{ address: '0xabc' as Hex }], + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }), + ).rejects.toThrow(/Across does not support type-4\/EIP-7702/u); + }); + + it('throws when actions present for max amount quotes', async () => { + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + await expect( + getAcrossQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }), + ).rejects.toThrow( + /Max amount quotes do not support included transactions/u, + ); + }); + + it('calculates dust from expected vs minimum output', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: '200', + minOutputAmount: '150', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(parseFloat(result[0].dust.usd)).toBeGreaterThan(0); + }); + + it('uses swap impact fee when provided', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + swapImpact: { amountUsd: '0.5' }, + }, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact?.usd).toBe('0.5'); + expect(result[0].fees.impact?.fiat).toBe('1'); + expect(result[0].fees.provider.usd).toBe('0.5'); + expect(result[0].fees.provider.fiat).toBe('1'); + }); + + it('returns undefined impact when expected output is zero', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: '0', + minOutputAmount: '0', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact).toBeUndefined(); + expect(result[0].fees.impactRatio).toBeUndefined(); + }); + + it('normalizes negative impact to zero', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: '100', + minOutputAmount: '150', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact?.usd).toBe('0'); + expect(result[0].fees.impact?.fiat).toBe('0'); + expect(result[0].fees.impactRatio).toBe('0'); + }); + + it('uses zero for estimated duration when not provided', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedFillTime: undefined, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].estimatedDuration).toBe(0); + }); + + it('handles missing destination gas fee', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + fees: { + total: { amountUsd: '1.23' }, + }, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.targetNetwork.usd).toBe('0'); + }); + + it('handles missing input amount', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + inputAmount: undefined, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].sourceAmount.raw).toBe('0'); + }); + + it('uses fallback gas estimate when estimation fails', async () => { + estimateGasMock.mockRejectedValue(new Error('Gas estimation failed')); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result).toHaveLength(1); + }); + + it('applies gas buffer to estimated gas', async () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + gasBuffer: { + default: 1.5, + }, + payStrategies: { + across: { enabled: true }, + }, + }, + }, + }); + + getGasBufferMock.mockReturnValue(1.5); + + estimateGasMock.mockResolvedValue({ + gas: '0x10000', + simulationFails: undefined, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result).toHaveLength(1); + }); + + it('handles missing expected output amount', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: undefined, + minOutputAmount: '150', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount.raw).toBe('150'); + }); + + it('handles missing min output amount', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: undefined, + minOutputAmount: undefined, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount.raw).toBe( + QUOTE_REQUEST_MOCK.targetAmountMinimum, + ); + }); + + it('handles missing target amount minimum', async () => { + const request = { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: undefined, + } as unknown as QuoteRequest; + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: undefined, + minOutputAmount: undefined, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [request], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount.raw).toBe('0'); + expect(result[0].fees.impact).toBeUndefined(); + }); + + it('uses from address as recipient when no transfer data', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(FROM_MOCK); + }); + + it('includes delegation value in action when present', async () => { + getDelegationTransactionMock.mockResolvedValue({ + action: { + ...DELEGATION_ACTION_MOCK, + value: '0x123', + }, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x123' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }); + + const [, options] = successfulFetchMock.mock.calls[0]; + const body = JSON.parse(options?.body as string); + + expect(body.actions[1].value).toBe('0x123'); + }); + + it('throws when delegation action is missing', async () => { + getDelegationTransactionMock.mockResolvedValue({ + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }), + ).rejects.toThrow(/Delegation action missing/u); + }); + + it('uses nested transaction transfer recipient when available', async () => { + const transferData = buildTransferData(TRANSFER_RECIPIENT); + + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { data: transferData }, + { data: '0xbeef' as Hex }, + ], + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + }); + + it('omits slippage param when slippage is undefined', async () => { + getSlippageMock.mockReturnValue(undefined as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.has('slippage')).toBe(false); + }); + + it('throws when source token fiat rate not found', async () => { + getTokenFiatRateMock.mockReturnValue(undefined as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }), + ).rejects.toThrow(/Failed to fetch Across quotes/u); + }); + + it('uses source fiat rate as fallback for target when not found', async () => { + getTokenFiatRateMock + .mockReturnValueOnce({ + usdRate: '2.0', + fiatRate: '4.0', + }) + .mockReturnValueOnce(undefined as never); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result).toHaveLength(1); + }); + + it('extracts recipient from token transfer in nested transactions array', async () => { + const transferData = buildTransferData(TRANSFER_RECIPIENT); + + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { data: '0xother' as Hex }, + { data: transferData }, + ], + txParams: { + from: FROM_MOCK, + data: '0xnonTransferData' as Hex, + }, + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + }); + + it('handles nested transactions with undefined data', async () => { + const transferData = buildTransferData(TRANSFER_RECIPIENT); + + getDelegationTransactionMock.mockResolvedValue({ + action: DELEGATION_ACTION_MOCK, + data: '0xdead' as Hex, + to: '0xde1e9a7e' as Hex, + value: '0x0' as Hex, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as Response); + + await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ to: '0xabc' as Hex }, { data: transferData }], + txParams: { + from: FROM_MOCK, + data: '0xnonTransferData' as Hex, + }, + } as TransactionMeta, + }); + + const [url] = successfulFetchMock.mock.calls[0]; + const params = new URL(url as string).searchParams; + + expect(params.get('recipient')).toBe(TRANSFER_RECIPIENT.toLowerCase()); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts new file mode 100644 index 00000000000..bb69ea24cea --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -0,0 +1,541 @@ +import { Interface } from '@ethersproject/abi'; +import { successfulFetch, toHex } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + AcrossAction, + AcrossActionRequestBody, + AcrossQuote, + AcrossSwapApprovalResponse, +} from './types'; +import { NATIVE_TOKEN_ADDRESS, TransactionPayStrategy } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { + Amount, + FiatRates, + FiatValue, + PayStrategyGetQuotesRequest, + QuoteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getSlippage, getPayStrategiesConfig } from '../../utils/feature-flags'; +import { + calculateGasCost, + estimateGasWithBufferOrFallback, +} from '../../utils/gas'; +import { getTokenFiatRate } from '../../utils/token'; +import { TOKEN_TRANSFER_FOUR_BYTE } from '../relay/constants'; + +const log = createModuleLogger(projectLogger, 'across-strategy'); + +const TOKEN_TRANSFER_INTERFACE = new Interface([ + 'function transfer(address to, uint256 amount)', +]); + +/** + * Fetch Across quotes. + * + * @param request - Request object. + * @returns Array of quotes. + */ +export async function getAcrossQuotes( + request: PayStrategyGetQuotesRequest, +): Promise[]> { + const { requests } = request; + + log('Fetching quotes', requests); + + try { + const normalizedRequests = requests.filter( + (singleRequest) => singleRequest.targetAmountMinimum !== '0', + ); + + return await Promise.all( + normalizedRequests.map((singleRequest) => + getSingleQuote(singleRequest, request), + ), + ); + } catch (error) { + log('Error fetching quotes', { error }); + throw new Error(`Failed to fetch Across quotes: ${String(error)}`); + } +} + +async function getSingleQuote( + request: QuoteRequest, + fullRequest: PayStrategyGetQuotesRequest, +): Promise> { + const start = Date.now(); + const { messenger, transaction } = fullRequest; + const { + from, + isMaxAmount, + sourceChainId, + sourceTokenAddress, + sourceTokenAmount, + targetAmountMinimum, + targetChainId, + targetTokenAddress, + } = request; + + const config = getPayStrategiesConfig(messenger); + const slippageDecimal = getSlippage( + messenger, + sourceChainId, + sourceTokenAddress, + ); + + const amount = isMaxAmount ? sourceTokenAmount : targetAmountMinimum; + const tradeType = isMaxAmount ? 'exactInput' : 'exactOutput'; + + const { actions, recipient } = await buildAcrossActions( + transaction, + request, + messenger, + ); + + if (actions?.length && isMaxAmount) { + throw new Error('Max amount quotes do not support included transactions'); + } + + const params = new URLSearchParams(); + params.set('tradeType', tradeType); + params.set('amount', amount); + params.set('inputToken', sourceTokenAddress); + params.set('outputToken', targetTokenAddress); + params.set('originChainId', String(parseInt(sourceChainId, 16))); + params.set('destinationChainId', String(parseInt(targetChainId, 16))); + params.set('depositor', from); + params.set('recipient', recipient); + + if (slippageDecimal !== undefined) { + params.set('slippage', String(slippageDecimal)); + } + + if (config.across.integratorId) { + params.set('integratorId', config.across.integratorId); + } + + if (config.across.appFee) { + params.set('appFee', config.across.appFee); + if (config.across.appFeeRecipient) { + params.set('appFeeRecipient', config.across.appFeeRecipient); + } + } + + const url = `${config.across.apiBase}/swap/approval?${params.toString()}`; + + const headers: Record = { + Accept: 'application/json', + }; + + if (config.across.apiKey) { + const header = config.across.apiKeyHeader ?? 'Authorization'; + const value = config.across.apiKeyPrefix + ? `${config.across.apiKeyPrefix} ${config.across.apiKey}` + : config.across.apiKey; + headers[header] = value; + } + + const hasActions = Boolean(actions?.length); + + const response = await successfulFetch(url, { + method: hasActions ? 'POST' : 'GET', + headers: { + ...headers, + ...(hasActions ? { 'Content-Type': 'application/json' } : undefined), + }, + ...(hasActions + ? { body: JSON.stringify({ actions } as AcrossActionRequestBody) } + : undefined), + }); + + const quote = (await response.json()) as AcrossSwapApprovalResponse; + + const originalQuote: AcrossQuote = { + quote, + request: { + amount, + tradeType, + }, + }; + + const normalized = await normalizeQuote(originalQuote, request, fullRequest); + normalized.original.metrics = { + latency: Date.now() - start, + }; + + return normalized; +} + +async function buildAcrossActions( + transaction: TransactionMeta, + request: QuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise<{ actions?: AcrossAction[]; recipient: Hex }> { + const { nestedTransactions, txParams } = transaction; + const { targetTokenAddress, targetAmountMinimum, from } = request; + + const data = txParams?.data as Hex | undefined; + const singleData = + nestedTransactions?.length === 1 ? nestedTransactions[0].data : data; + + const isTokenTransfer = Boolean( + singleData?.startsWith(TOKEN_TRANSFER_FOUR_BYTE), + ); + + const tokenTransferData = nestedTransactions?.find((nestedTx) => + nestedTx.data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE), + )?.data; + + let recipient = from; + + if (isTokenTransfer && singleData) { + recipient = getTransferRecipient(singleData); + } + + if (tokenTransferData) { + recipient = getTransferRecipient(tokenTransferData); + } + + const hasNoData = singleData === undefined || singleData === '0x'; + + if (hasNoData || isTokenTransfer) { + return { recipient }; + } + + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction }, + ); + + if (delegation.authorizationList?.length) { + // TODO: Enable type-4/EIP-7702 authorization lists when Across supports first-time Polymarket deposits. + throw new Error( + 'Across does not support type-4/EIP-7702 authorization lists yet', + ); + } + + const tokenTransferAction = buildTokenTransferAction({ + amountRaw: targetAmountMinimum, + recipient: from, + tokenAddress: targetTokenAddress, + }); + + if (!delegation.action) { + throw new Error('Delegation action missing from client callback'); + } + + return { + actions: [tokenTransferAction, delegation.action], + recipient, + }; +} + +function buildTokenTransferAction({ + amountRaw, + recipient, + tokenAddress, +}: { + amountRaw: string; + recipient: Hex; + tokenAddress: Hex; +}): AcrossAction { + if (tokenAddress === NATIVE_TOKEN_ADDRESS) { + return { + target: recipient, + functionSignature: '', + args: [], + value: amountRaw, + isNativeTransfer: true, + populateCallValueDynamically: false, + }; + } + + return { + target: tokenAddress, + functionSignature: 'function transfer(address to, uint256 amount)', + args: [ + { + value: recipient, + populateDynamically: false, + }, + { + value: amountRaw, + populateDynamically: false, + }, + ], + value: '0', + isNativeTransfer: false, + }; +} + +function getTransferRecipient(data: Hex): Hex { + return TOKEN_TRANSFER_INTERFACE.decodeFunctionData( + 'transfer', + data, + ).to.toLowerCase() as Hex; +} + +async function normalizeQuote( + original: AcrossQuote, + request: QuoteRequest, + fullRequest: PayStrategyGetQuotesRequest, +): Promise> { + const { messenger } = fullRequest; + const { quote } = original; + + const { usdToFiatRate, sourceFiatRate, targetFiatRate } = getFiatRates( + messenger, + quote, + ); + + const dustUsd = calculateDustUsd(quote, request, targetFiatRate); + const dust = getFiatValueFromUsd(dustUsd, usdToFiatRate); + + const sourceNetwork = await calculateSourceNetworkCost( + quote, + messenger, + request, + ); + + const targetNetworkUsd = new BigNumber( + quote.fees?.destinationGas?.amountUsd ?? '0', + ); + + const targetNetwork = getFiatValueFromUsd(targetNetworkUsd, usdToFiatRate); + + const inputAmountRaw = quote.inputAmount ?? '0'; + const expectedOutputRaw = new BigNumber( + quote.expectedOutputAmount ?? + quote.minOutputAmount ?? + request.targetAmountMinimum ?? + '0', + ); + const minOutputRaw = new BigNumber( + quote.minOutputAmount ?? request.targetAmountMinimum ?? '0', + ); + const outputAmountRaw = expectedOutputRaw.toString(10); + + const sourceAmount = getAmountFromTokenAmount({ + amountRaw: inputAmountRaw, + decimals: quote.inputToken.decimals, + fiatRate: sourceFiatRate, + }); + + const expectedOutputUsd = expectedOutputRaw.gt(0) + ? expectedOutputRaw + .shiftedBy(-quote.outputToken.decimals) + .multipliedBy(targetFiatRate.usdRate) + : new BigNumber(0); + + const impactUsd = calculateImpactUsd( + quote, + expectedOutputRaw, + minOutputRaw, + targetFiatRate, + ); + + let impact: ReturnType | undefined; + + if (impactUsd !== undefined) { + impact = getFiatValueFromUsd(impactUsd, usdToFiatRate); + } + + let impactRatio: string | undefined; + + if (impactUsd !== undefined && expectedOutputUsd.gt(0)) { + impactRatio = impactUsd.dividedBy(expectedOutputUsd).toString(10); + } + + const providerUsd = impactUsd ?? new BigNumber(0); + const provider = getFiatValueFromUsd(providerUsd, usdToFiatRate); + + const targetAmount = getAmountFromTokenAmount({ + amountRaw: outputAmountRaw, + decimals: quote.outputToken.decimals, + fiatRate: targetFiatRate, + }); + + return { + dust, + estimatedDuration: quote.expectedFillTime ?? 0, + fees: { + impact, + impactRatio, + provider, + sourceNetwork, + targetNetwork, + }, + original: { + ...original, + }, + request, + sourceAmount, + targetAmount, + strategy: TransactionPayStrategy.Across, + } as TransactionPayQuote; +} + +function getFiatRates( + messenger: TransactionPayControllerMessenger, + quote: AcrossSwapApprovalResponse, +): { + sourceFiatRate: FiatRates; + targetFiatRate: FiatRates; + usdToFiatRate: BigNumber; +} { + const sourceFiatRate = getTokenFiatRate( + messenger, + quote.inputToken.address, + toHex(quote.inputToken.chainId), + ); + + if (!sourceFiatRate) { + throw new Error('Source token fiat rate not found'); + } + + const targetFiatRate = + getTokenFiatRate( + messenger, + quote.outputToken.address, + toHex(quote.outputToken.chainId), + ) ?? sourceFiatRate; + + const usdToFiatRate = new BigNumber(sourceFiatRate.fiatRate).dividedBy( + sourceFiatRate.usdRate, + ); + + return { sourceFiatRate, targetFiatRate, usdToFiatRate }; +} + +function calculateDustUsd( + quote: AcrossSwapApprovalResponse, + request: QuoteRequest, + targetFiatRate: FiatRates, +): BigNumber { + const expectedOutput = new BigNumber(quote.expectedOutputAmount ?? '0'); + const minimumOutput = new BigNumber( + quote.minOutputAmount ?? request.targetAmountMinimum, + ); + + const dustRaw = expectedOutput.minus(minimumOutput); + const dustHuman = dustRaw.shiftedBy(-quote.outputToken.decimals); + + return dustHuman.multipliedBy(targetFiatRate.usdRate); +} + +function calculateImpactUsd( + quote: AcrossSwapApprovalResponse, + expectedOutputRaw: BigNumber, + minOutputRaw: BigNumber, + targetFiatRate: FiatRates, +): BigNumber | undefined { + const swapImpactUsd = quote.fees?.swapImpact?.amountUsd; + + if (swapImpactUsd !== undefined) { + return new BigNumber(swapImpactUsd).abs(); + } + + if (expectedOutputRaw.lte(0)) { + return undefined; + } + + const rawImpact = expectedOutputRaw.minus(minOutputRaw); + const normalizedRawImpact = rawImpact.isNegative() + ? new BigNumber(0) + : rawImpact; + + const impactHuman = normalizedRawImpact.shiftedBy( + -quote.outputToken.decimals, + ); + + return impactHuman.multipliedBy(targetFiatRate.usdRate); +} + +function getFiatValueFromUsd( + usdValue: BigNumber, + usdToFiatRate: BigNumber, +): FiatValue { + const fiatValue = usdValue.multipliedBy(usdToFiatRate); + + return { + usd: usdValue.toString(10), + fiat: fiatValue.toString(10), + }; +} + +function getAmountFromTokenAmount({ + amountRaw, + decimals, + fiatRate, +}: { + amountRaw: string; + decimals: number; + fiatRate: FiatRates; +}): Amount { + const rawValue = new BigNumber(amountRaw); + const raw = rawValue.toString(10); + + const humanValue = rawValue.shiftedBy(-decimals); + const human = humanValue.toString(10); + + const usd = humanValue.multipliedBy(fiatRate.usdRate).toString(10); + const fiat = humanValue.multipliedBy(fiatRate.fiatRate).toString(10); + + return { + fiat, + human, + raw, + usd, + }; +} + +async function calculateSourceNetworkCost( + quote: AcrossSwapApprovalResponse, + messenger: TransactionPayControllerMessenger, + request: QuoteRequest, +): Promise['fees']['sourceNetwork']> { + const { from } = request; + const { swapTx } = quote; + const chainId = toHex(swapTx.chainId); + + const gasResult = await estimateGasWithBufferOrFallback({ + chainId, + data: swapTx.data, + from, + messenger, + to: swapTx.to, + value: swapTx.value ?? '0x0', + }); + + if (gasResult.usedFallback) { + log('Gas estimate failed, using fallback', { error: gasResult.error }); + } + + const gasLimit = gasResult.estimate; + + const estimate = calculateGasCost({ + chainId, + gas: gasLimit, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }); + + const max = calculateGasCost({ + chainId, + gas: gasLimit, + isMax: true, + messenger, + }); + + return { + estimate, + max, + }; +} diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts new file mode 100644 index 00000000000..2a3e7f84084 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -0,0 +1,628 @@ +import { toHex } from '@metamask/controller-utils'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { + TransactionControllerState, + TransactionMeta, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { + getAcrossOriginalQuote, + isAcrossQuote, + 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 { TransactionPayQuote } from '../../types'; +import { getGasBuffer } from '../../utils/feature-flags'; + +jest.mock('../../utils/feature-flags', () => ({ + ...jest.requireActual('../../utils/feature-flags'), + getGasBuffer: jest.fn(), +})); + +const FROM_MOCK = '0x1234567890123456789012345678901234567891' as Hex; + +const TRANSACTION_META_MOCK = { + id: 'tx-1', + type: TransactionType.perpsDeposit, + txParams: { + from: FROM_MOCK, + }, +} as TransactionMeta; + +const QUOTE_MOCK: TransactionPayQuote = { + dust: { usd: '0', fiat: '0' }, + estimatedDuration: 0, + fees: { + provider: { usd: '0', fiat: '0' }, + sourceNetwork: { + estimate: { usd: '0', fiat: '0', human: '0', raw: '0' }, + max: { usd: '0', fiat: '0', human: '0', raw: '0' }, + }, + targetNetwork: { usd: '0', fiat: '0' }, + }, + original: { + quote: { + approvalTxns: [ + { + chainId: 1, + to: '0xapprove' as Hex, + data: '0xdeadbeef' as Hex, + }, + ], + inputToken: { + address: '0xabc' as Hex, + chainId: 1, + decimals: 18, + }, + outputToken: { + address: '0xdef' as Hex, + chainId: 2, + decimals: 6, + }, + swapTx: { + chainId: 1, + to: '0xswap' as Hex, + data: '0xfeed' as Hex, + maxFeePerGas: '0x100', + maxPriorityFeePerGas: '0x10', + }, + }, + request: { + amount: '100', + tradeType: 'exactOutput', + }, + }, + request: { + from: FROM_MOCK, + sourceBalanceRaw: '100', + sourceChainId: '0x1', + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '100', + targetChainId: '0x2', + targetTokenAddress: '0xdef' as Hex, + }, + sourceAmount: { usd: '0', fiat: '0', human: '0', raw: '0' }, + targetAmount: { usd: '0', fiat: '0', human: '0', raw: '0' }, + strategy: TransactionPayStrategy.Across, +}; + +describe('Across Submit', () => { + const getGasBufferMock = jest.mocked(getGasBuffer); + + const { + addTransactionBatchMock, + addTransactionMock, + estimateGasMock, + findNetworkClientIdByChainIdMock, + getRemoteFeatureFlagControllerStateMock, + getTransactionControllerStateMock, + messenger, + publish, + updateTransactionMock, + } = getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + gasBuffer: { + default: 1.0, + }, + }, + }, + }); + + getGasBufferMock.mockReturnValue(1.0); + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: undefined, + }); + findNetworkClientIdByChainIdMock.mockReturnValue('networkClientId'); + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState); + addTransactionMock.mockResolvedValue({ + result: Promise.resolve('0xhash'), + transactionMeta: TRANSACTION_META_MOCK, + }); + }); + + describe('submitAcrossQuotes', () => { + it('submits a batch when approvals exist', async () => { + await submitAcrossQuotes({ + messenger, + quotes: [QUOTE_MOCK], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: [ + expect.objectContaining({ + type: TransactionType.tokenMethodApprove, + }), + expect.objectContaining({ + type: TransactionType.perpsAcrossDeposit, + }), + ], + }), + ); + }); + + it('submits a single transaction when no approvals', async () => { + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalledTimes(1); + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: TransactionType.perpsAcrossDeposit, + }), + ); + }); + + it('uses predict deposit type when transaction is predict deposit', async () => { + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.predictDeposit, + }, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: TransactionType.predictAcrossDeposit, + }), + ); + }); + + it('defaults to perps across deposit when transaction type is not perps or predict', async () => { + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: { + ...TRANSACTION_META_MOCK, + type: TransactionType.swap, + }, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + type: TransactionType.perpsAcrossDeposit, + }), + ); + }); + + it('removes nonce from skipped transaction', async () => { + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(updateTransactionMock).toHaveBeenCalledWith( + expect.anything(), + 'Remove nonce from skipped transaction', + ); + }); + + it('collects transaction IDs and adds to required transactions', async () => { + const confirmedTransaction = { + id: 'new-tx', + chainId: '0x1', + networkClientId: 'mainnet', + time: Date.now(), + status: TransactionStatus.confirmed, + hash: '0xconfirmed', + txParams: { + from: FROM_MOCK, + }, + } as unknown as TransactionMeta; + + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK, confirmedTransaction], + } as TransactionControllerState); + + addTransactionMock.mockImplementation(async () => { + publish('TransactionController:unapprovedTransactionAdded', { + id: confirmedTransaction.id, + chainId: confirmedTransaction.chainId, + txParams: confirmedTransaction.txParams, + } as TransactionMeta); + + return { + result: Promise.resolve('0xhash'), + transactionMeta: TRANSACTION_META_MOCK, + }; + }); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + const result = await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(updateTransactionMock).toHaveBeenCalledWith( + expect.anything(), + 'Add required transaction ID from Across submission', + ); + expect(result.transactionHash).toBe('0xconfirmed'); + }); + + it('marks intent as complete after submission', async () => { + const confirmedTransaction = { + id: 'new-tx', + chainId: '0x1', + networkClientId: 'mainnet', + time: Date.now(), + status: TransactionStatus.confirmed, + hash: '0xconfirmed', + txParams: { + from: FROM_MOCK, + }, + } as unknown as TransactionMeta; + + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK, confirmedTransaction], + } as TransactionControllerState); + + addTransactionMock.mockImplementation(async () => { + publish('TransactionController:unapprovedTransactionAdded', { + id: confirmedTransaction.id, + chainId: confirmedTransaction.chainId, + txParams: confirmedTransaction.txParams, + } as TransactionMeta); + + return { + result: Promise.resolve('0xhash'), + transactionMeta: TRANSACTION_META_MOCK, + }; + }); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(updateTransactionMock).toHaveBeenCalledWith( + expect.anything(), + 'Intent complete after Across submission', + ); + }); + + it('uses fallback gas value when estimation fails', async () => { + estimateGasMock.mockRejectedValue(new Error('Gas estimation failed')); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + const params = addTransactionMock.mock.calls[0][0] as { gas: Hex }; + expect(params.gas).toBe(toHex(900000)); + }); + + it('applies gas buffer to estimated gas', async () => { + getGasBufferMock.mockReturnValue(1.5); + + estimateGasMock.mockResolvedValue({ + gas: '0x10000', + simulationFails: undefined, + }); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + const params = addTransactionMock.mock.calls[0][0] as { gas: Hex }; + const gasValue = parseInt(params.gas, 16); + const expectedGas = Math.ceil(0x10000 * 1.5); + expect(gasValue).toBe(expectedGas); + }); + + it('includes maxFeePerGas and maxPriorityFeePerGas in swap transaction', async () => { + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + const params = addTransactionMock.mock.calls[0][0] as { + maxFeePerGas: Hex; + maxPriorityFeePerGas: Hex; + }; + + expect(params.maxFeePerGas).toBe('0x100'); + expect(params.maxPriorityFeePerGas).toBe('0x10'); + }); + + it('handles approval transactions without value', async () => { + const quoteWithApproval = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [ + { + chainId: 1, + to: '0xapprove' as Hex, + data: '0xdeadbeef' as Hex, + }, + ], + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [quoteWithApproval], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionBatchMock).toHaveBeenCalled(); + }); + + it('handles swap transaction without value', async () => { + const quoteWithoutValue = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + swapTx: { + chainId: QUOTE_MOCK.original.quote.swapTx.chainId, + to: QUOTE_MOCK.original.quote.swapTx.to, + data: QUOTE_MOCK.original.quote.swapTx.data, + maxFeePerGas: QUOTE_MOCK.original.quote.swapTx.maxFeePerGas, + maxPriorityFeePerGas: + QUOTE_MOCK.original.quote.swapTx.maxPriorityFeePerGas, + // value intentionally omitted to test the ?? '0x0' fallback + }, + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [quoteWithoutValue], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalled(); + const params = addTransactionMock.mock.calls[0][0] as { value: Hex }; + expect(params.value).toBe('0x0'); + }); + + it('processes multiple quotes sequentially', async () => { + const quote1 = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + const quote2 = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + swapTx: { + ...QUOTE_MOCK.original.quote.swapTx, + to: '0xswap2' as Hex, + }, + }, + }, + } as TransactionPayQuote; + + const confirmedTransaction = { + id: 'new-tx', + chainId: '0x1', + networkClientId: 'mainnet', + time: Date.now(), + status: TransactionStatus.confirmed, + hash: '0xconfirmed', + txParams: { + from: FROM_MOCK, + }, + } as unknown as TransactionMeta; + + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK, confirmedTransaction], + } as TransactionControllerState); + + addTransactionMock.mockImplementation(async () => { + publish('TransactionController:unapprovedTransactionAdded', { + id: confirmedTransaction.id, + chainId: confirmedTransaction.chainId, + txParams: confirmedTransaction.txParams, + } as TransactionMeta); + + return { + result: Promise.resolve('0xhash'), + transactionMeta: TRANSACTION_META_MOCK, + }; + }); + + await submitAcrossQuotes({ + messenger, + quotes: [quote1, quote2], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(addTransactionMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('isAcrossQuote', () => { + it('returns true when quote has original.quote', () => { + expect(isAcrossQuote(QUOTE_MOCK)).toBe(true); + }); + + it('returns false when original.quote is missing', () => { + const missingQuote = { + original: {}, + } as TransactionPayQuote; + + expect(isAcrossQuote(missingQuote)).toBe(false); + }); + + it('returns false when original is undefined', () => { + const noOriginal = { + original: undefined, + } as unknown as TransactionPayQuote; + + expect(isAcrossQuote(noOriginal)).toBe(false); + }); + }); + + describe('getAcrossOriginalQuote', () => { + it('returns the original quote object', () => { + const result = getAcrossOriginalQuote(QUOTE_MOCK); + expect(result).toBe(QUOTE_MOCK.original.quote); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/across/across-submit.ts b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts new file mode 100644 index 00000000000..da217a5d48f --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -0,0 +1,234 @@ +import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + TransactionParams, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { AcrossQuote, AcrossSwapApprovalResponse } from './types'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyExecuteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getGasBuffer } from '../../utils/feature-flags'; +import { + SourceTransaction, + submitSourceTransactions, +} from '../../utils/strategy-helpers'; + +const log = createModuleLogger(projectLogger, 'across-strategy'); + +/** + * Submit Across quotes. + * + * @param request - Request object. + * @returns An object containing the transaction hash if available. + */ +export async function submitAcrossQuotes( + request: PayStrategyExecuteRequest, +): Promise<{ transactionHash?: Hex }> { + log('Executing quotes', request); + + const acrossDepositType = getAcrossDepositType(request.transaction.type); + const { messenger } = request; + + return submitSourceTransactions({ + request, + requiredTransactionNote: + 'Add required transaction ID from Across submission', + intentCompleteNote: 'Intent complete after Across submission', + buildTransactions: async (quote) => { + log('Executing single quote', quote); + + const { swapTx, approvalTxns } = quote.original.quote; + const { from } = quote.request; + const chainId = toHex(swapTx.chainId); + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + const transactions: SourceTransaction[] = []; + + if (approvalTxns?.length) { + for (const approval of approvalTxns) { + transactions.push({ + params: await buildTransactionParams(messenger, from, { + chainId: approval.chainId, + data: approval.data, + to: approval.to, + value: approval.value, + }), + type: TransactionType.tokenMethodApprove, + }); + } + } + + transactions.push({ + params: await buildTransactionParams(messenger, from, { + chainId: swapTx.chainId, + data: swapTx.data, + to: swapTx.to, + value: swapTx.value, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + }), + type: acrossDepositType, + }); + + return { + chainId, + from, + transactions, + submit: async (preparedTransactions): Promise => { + if (preparedTransactions.length === 1) { + const result = await messenger.call( + 'TransactionController:addTransaction', + preparedTransactions[0].params, + { + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + type: preparedTransactions[0].type, + }, + ); + + const txHash = await result.result; + log('Submitted transaction', txHash); + return; + } + + const batchTransactions = preparedTransactions.map( + ({ params, type }) => ({ + params: toBatchTransactionParams(params), + type, + }), + ); + + await messenger.call('TransactionController:addTransactionBatch', { + from, + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + transactions: batchTransactions, + }); + }, + }; + }, + }); +} + +function getAcrossDepositType( + transactionType?: TransactionType, +): TransactionType { + switch (transactionType) { + case TransactionType.perpsDeposit: + return TransactionType.perpsAcrossDeposit; + case TransactionType.predictDeposit: + return TransactionType.predictAcrossDeposit; + default: + return TransactionType.perpsAcrossDeposit; + } +} + +async function buildTransactionParams( + messenger: TransactionPayControllerMessenger, + from: Hex, + params: { + chainId: number; + data: Hex; + to: Hex; + value?: Hex; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + }, +): Promise { + const chainId = toHex(params.chainId); + const value = toHex(params.value ?? '0x0'); + + const gas = await estimateGasWithBuffer( + messenger, + { + chainId, + data: params.data, + to: params.to, + value, + }, + from, + ); + + return { + data: params.data, + from, + gas: toHex(gas), + maxFeePerGas: params.maxFeePerGas as Hex | undefined, + maxPriorityFeePerGas: params.maxPriorityFeePerGas as Hex | undefined, + to: params.to, + value, + }; +} + +function toBatchTransactionParams( + params: TransactionParams, +): BatchTransactionParams { + return { + data: params.data as Hex | undefined, + gas: params.gas as Hex | undefined, + maxFeePerGas: params.maxFeePerGas as Hex | undefined, + maxPriorityFeePerGas: params.maxPriorityFeePerGas as Hex | undefined, + to: params.to as Hex | undefined, + value: params.value as Hex | undefined, + }; +} + +async function estimateGasWithBuffer( + messenger: TransactionPayControllerMessenger, + params: { + chainId: Hex; + data: Hex; + to: Hex; + value: Hex; + }, + from: Hex, +): Promise { + const { chainId, data, to, value } = params; + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + try { + const { gas: gasHex } = await messenger.call( + 'TransactionController:estimateGas', + { from, data, to, value }, + networkClientId, + ); + + const gasBuffer = getGasBuffer(messenger, chainId); + const estimatedGas = new BigNumber(gasHex).toNumber(); + + return Math.ceil(estimatedGas * gasBuffer); + } catch (error) { + log('Gas estimate failed, using fallback', { error }); + return 900000; + } +} + +export function isAcrossQuote( + quote: TransactionPayQuote, +): quote is TransactionPayQuote { + return Boolean(quote.original?.quote); +} + +export function getAcrossOriginalQuote( + quote: TransactionPayQuote, +): AcrossSwapApprovalResponse { + return quote.original.quote; +} diff --git a/packages/transaction-pay-controller/src/strategy/across/types.ts b/packages/transaction-pay-controller/src/strategy/across/types.ts new file mode 100644 index 00000000000..35bc555c4b7 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -0,0 +1,86 @@ +import type { Hex } from '@metamask/utils'; + +export type AcrossToken = { + address: Hex; + chainId: number; + decimals: number; + name?: string; + symbol?: string; +}; + +export type AcrossFeeComponent = { + amount?: string; + amountUsd?: string; + pct?: string | null; + token?: AcrossToken; +}; + +export type AcrossFees = { + total?: AcrossFeeComponent; + originGas?: AcrossFeeComponent; + destinationGas?: AcrossFeeComponent; + relayerCapital?: AcrossFeeComponent; + relayerTotal?: AcrossFeeComponent; + lpFee?: AcrossFeeComponent; + app?: AcrossFeeComponent; + swapImpact?: AcrossFeeComponent; +}; + +export type AcrossApprovalTransaction = { + chainId: number; + to: Hex; + data: Hex; + value?: Hex; +}; + +export type AcrossSwapTransaction = { + chainId: number; + to: Hex; + data: Hex; + value?: Hex; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +}; + +export type AcrossSwapApprovalResponse = { + approvalTxns?: AcrossApprovalTransaction[]; + expectedFillTime?: number; + expectedOutputAmount?: string; + fees?: AcrossFees; + id?: string; + inputAmount?: string; + inputToken: AcrossToken; + minOutputAmount?: string; + outputToken: AcrossToken; + swapTx: AcrossSwapTransaction; +}; + +export type AcrossActionArg = { + value: string | string[] | string[][]; + populateDynamically: boolean; + balanceSourceToken?: string; +}; + +export type AcrossAction = { + target: Hex; + functionSignature: string; + args: AcrossActionArg[]; + value: string; + isNativeTransfer: boolean; + populateCallValueDynamically?: boolean; +}; + +export type AcrossActionRequestBody = { + actions: AcrossAction[]; +}; + +export type AcrossQuote = { + quote: AcrossSwapApprovalResponse; + request: { + amount: string; + tradeType: 'exactOutput' | 'exactInput'; + }; + metrics?: { + latency: number; + }; +}; diff --git a/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts index 2e269a2478b..47cce9db644 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts @@ -39,13 +39,15 @@ export class BridgeStrategy implements PayStrategy { async execute( request: PayStrategyExecuteRequest, ): ReturnType['execute']> { - const { isSmartTransaction, quotes, messenger, transaction } = request; + const { isSmartTransaction, onSubmitted, quotes, messenger, transaction } = + request; const from = transaction.txParams.from as Hex; await submitBridgeQuotes({ from, isSmartTransaction, messenger, + onSubmitted, quotes, transaction, }); diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts index d5226188521..45f593fe0dc 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts @@ -168,6 +168,61 @@ describe('Bridge Submit Utils', () => { ); }); + it('records submit latency once', async () => { + const originalPerformance = globalThis.performance; + const nowMock = jest + .fn() + .mockReturnValueOnce(1000) + .mockReturnValueOnce(1400); + Object.defineProperty(globalThis, 'performance', { + value: { now: nowMock }, + configurable: true, + }); + + const onSubmittedMock = jest.fn(); + + request.onSubmitted = onSubmittedMock; + request.quotes = [cloneDeep(QUOTE_MOCK)]; + + await submitBridgeQuotes(request); + + expect(onSubmittedMock).toHaveBeenCalledTimes(1); + expect(onSubmittedMock).toHaveBeenCalledWith(400); + Object.defineProperty(globalThis, 'performance', { + value: originalPerformance, + configurable: true, + }); + }); + + it('falls back to Date.now when performance.now is unavailable', async () => { + const originalPerformance = globalThis.performance; + Object.defineProperty(globalThis, 'performance', { + value: { now: undefined }, + configurable: true, + }); + + const onSubmittedMock = jest.fn(); + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy + .mockReturnValueOnce(2000) + .mockReturnValueOnce(2400) + .mockReturnValue(2400); + + request.onSubmitted = onSubmittedMock; + request.quotes = [cloneDeep(QUOTE_MOCK)]; + + await submitBridgeQuotes(request); + + expect(onSubmittedMock).toHaveBeenCalledTimes(1); + expect(onSubmittedMock).toHaveBeenCalledWith(400); + + dateSpy.mockRestore(); + Object.defineProperty(globalThis, 'performance', { + value: originalPerformance, + configurable: true, + }); + }); + it('indicates if smart transactions is enabled', async () => { request.isSmartTransaction = (): boolean => true; diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts index 382cc1fbb40..e6b97311121 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts @@ -23,10 +23,16 @@ import { const log = createModuleLogger(projectLogger, 'bridge-strategy'); +const getNow = (): number => + typeof globalThis.performance?.now === 'function' + ? globalThis.performance.now() + : Date.now(); + export type SubmitBridgeQuotesRequest = { from: Hex; isSmartTransaction: (chainId: Hex) => boolean; messenger: TransactionPayControllerMessenger; + onSubmitted?: (latencyMs: number) => void; quotes: TransactionPayQuote[]; transaction: TransactionMeta; }; @@ -61,6 +67,15 @@ export async function submitBridgeQuotes( } let index = 0; + let hasReportedLatency = false; + const onSubmitted = (latencyMs: number): void => { + if (hasReportedLatency) { + return; + } + + hasReportedLatency = true; + request.onSubmitted?.(latencyMs); + }; for (const quote of quotes) { log('Submitting bridge', index, quote); @@ -75,7 +90,13 @@ export async function submitBridgeQuotes( } } - await submitBridgeTransaction(request, finalQuote); + await submitBridgeTransaction( + { + ...request, + onSubmitted, + }, + finalQuote, + ); index += 1; } @@ -138,12 +159,15 @@ async function submitBridgeTransaction( cost: tokenAmountValues, }; + const submitStart = getNow(); const result = await messenger.call( 'BridgeStatusController:submitTx', from, { ...quote, ...metadata }, isSTX, ); + // Guard against negative duration when clocks or mocks move backward. + request.onSubmitted?.(Math.max(getNow() - submitStart, 0)); bridgeTransactionIdCollector.end(); diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts index 66acd6b50b0..269a5c78e09 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -7,8 +7,14 @@ import type { PayStrategyGetQuotesRequest, TransactionPayQuote, } from '../../types'; +import { getPayStrategiesConfig } from '../../utils/feature-flags'; export class RelayStrategy implements PayStrategy { + supports(request: PayStrategyGetQuotesRequest): boolean { + const config = getPayStrategiesConfig(request.messenger); + return config.relay.enabled; + } + async getQuotes( request: PayStrategyGetQuotesRequest, ): Promise[]> { 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 cd1d6667354..3a2444152e8 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 @@ -4,6 +4,7 @@ import type { TransactionMeta, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { cloneDeep } from 'lodash'; import { CHAIN_ID_HYPERCORE } from './constants'; @@ -40,7 +41,12 @@ import { } from '../../utils/token'; jest.mock('../../utils/token'); -jest.mock('../../utils/gas'); +jest.mock('../../utils/gas', () => ({ + ...jest.requireActual('../../utils/gas'), + calculateGasCost: jest.fn(), + calculateGasFeeTokenCost: jest.fn(), + calculateTransactionGasCost: jest.fn(), +})); jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), getEIP7702SupportedChains: jest.fn(), @@ -684,6 +690,147 @@ describe('Relay Quotes Utils', () => { }); }); + it('includes impact metrics', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + const expectedRatio = new BigNumber('1.11') + .dividedBy('1.23') + .toString(10); + + expect(result[0].fees.impact).toStrictEqual({ + usd: '1.11', + fiat: '2.22', + }); + expect(result[0].fees.impactRatio).toBe(expectedRatio); + }); + + it('calculates impact when total impact is missing', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.details.totalImpact = undefined as never; + quote.details.currencyOut.amount = '200'; + quote.details.currencyOut.amountFormatted = '2'; + quote.details.currencyOut.amountUsd = '2'; + quote.details.currencyOut.minimumAmount = '100'; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact).toStrictEqual({ + usd: '1', + fiat: '2', + }); + }); + + it('normalizes negative impact to zero when total impact is missing', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.details.totalImpact = undefined as never; + quote.details.currencyOut.amount = '100'; + quote.details.currencyOut.amountFormatted = '1'; + quote.details.currencyOut.amountUsd = '1'; + quote.details.currencyOut.minimumAmount = '125'; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact?.usd).toBe('0'); + }); + + it('returns undefined impact when amount is missing', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.details.totalImpact = undefined as never; + quote.details.currencyOut.amount = '' as never; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact).toBeUndefined(); + }); + + it('returns undefined impact when formatted amount is zero', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.details.totalImpact = undefined as never; + quote.details.currencyOut.amountFormatted = '0'; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact).toBeUndefined(); + }); + + it('returns undefined impact when amountUsd is non-finite and total impact is missing', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.details.totalImpact = undefined as never; + quote.details.currencyOut.amount = '200'; + quote.details.currencyOut.amountFormatted = '2'; + quote.details.currencyOut.amountUsd = 'NaN'; + quote.details.currencyOut.minimumAmount = '100'; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impact).toBeUndefined(); + }); + + it('returns undefined impact ratio when amountUsd is missing', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.details.currencyOut.amountUsd = undefined as never; + + successfulFetchMock.mockResolvedValue({ + json: async () => quote, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.impactRatio).toBeUndefined(); + }); + it('includes dust in quote', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, 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 730957ff492..3ef5d1e8032 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -33,9 +33,15 @@ import { getEIP7702SupportedChains, getFeatureFlags, getGasBuffer, + getPayStrategiesConfig, getSlippage, } from '../../utils/feature-flags'; -import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; +import { + calculateGasCost, + calculateGasFeeTokenCost, + estimateGasWithBufferOrFallback, + getFallbackGas, +} from '../../utils/gas'; import { getNativeToken, getTokenBalance, @@ -125,7 +131,7 @@ async function getSingleQuote( await processTransactions(transaction, request, body, messenger); - const url = getFeatureFlags(messenger).relayQuoteUrl; + const url = getPayStrategiesConfig(messenger).relay.relayQuoteUrl; log('Request body', { body, url }); @@ -304,6 +310,22 @@ async function normalizeQuote( usdToFiatRate, ); + const expectedOutputUsd = new BigNumber(currencyOut.amountUsd ?? '0').abs(); + + const impactUsd = calculateImpactUsd(quote); + + let impact: ReturnType | undefined; + + if (impactUsd !== undefined) { + impact = getFiatValueFromUsd(impactUsd, usdToFiatRate); + } + + let impactRatio: string | undefined; + + if (impactUsd !== undefined && expectedOutputUsd.gt(0)) { + impactRatio = impactUsd.dividedBy(expectedOutputUsd).toString(10); + } + const { gasLimits, isGasFeeToken: isSourceGasFeeToken, @@ -336,6 +358,8 @@ async function normalizeQuote( estimatedDuration: details.timeEstimate, fees: { isSourceGasFeeToken, + impact, + impactRatio, provider, sourceNetwork, targetNetwork, @@ -372,6 +396,43 @@ function calculateDustUsd(quote: RelayQuote, request: QuoteRequest): BigNumber { return dustRaw.shiftedBy(-targetDecimals).multipliedBy(targetUsdRate); } +function calculateImpactUsd(quote: RelayQuote): BigNumber | undefined { + const totalImpactUsd = quote.details.totalImpact?.usd; + + if (totalImpactUsd !== undefined) { + return new BigNumber(totalImpactUsd).abs(); + } + + const { currencyOut } = quote.details; + const { amount, amountFormatted, amountUsd, minimumAmount } = currencyOut; + + if (!minimumAmount || !amount) { + return undefined; + } + + const formattedAmount = new BigNumber(amountFormatted); + + if (!formattedAmount.isFinite() || formattedAmount.isZero()) { + return undefined; + } + + const amountUsdValue = new BigNumber(amountUsd); + + if (!amountUsdValue.isFinite()) { + return undefined; + } + + const targetUsdRate = amountUsdValue.dividedBy(formattedAmount); + const rawImpact = new BigNumber(amount).minus(minimumAmount); + const normalizedRawImpact = rawImpact.isNegative() + ? new BigNumber(0) + : rawImpact; + + return normalizedRawImpact + .shiftedBy(-currencyOut.currency.decimals) + .multipliedBy(targetUsdRate); +} + /** * Converts USD value to fiat value. * @@ -626,7 +687,7 @@ async function calculateSourceNetworkGasLimit( * @returns - Provider fee in USD. */ function calculateProviderFee(quote: RelayQuote): BigNumber { - return new BigNumber(quote.details.totalImpact.usd).abs(); + return calculateImpactUsd(quote) ?? new BigNumber(0); } /** @@ -676,59 +737,41 @@ async function calculateSourceNetworkGasLimitSingle( }; } - try { - const { - chainId: chainIdNumber, - data, - from, - to, - value: valueString, - } = params; + const { chainId: chainIdNumber, data, from, to, value: valueString } = params; + const chainId = toHex(chainIdNumber); + const value = toHex(valueString ?? '0'); - const chainId = toHex(chainIdNumber); - const value = toHex(valueString ?? '0'); - const gasBuffer = getGasBuffer(messenger, chainId); + const result = await estimateGasWithBufferOrFallback({ + chainId, + data, + from, + messenger, + to, + value, + }); - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', + if (!result.usedFallback) { + log('Estimated gas limit for single transaction', { chainId, - ); - - const { gas: gasHex, simulationFails } = await messenger.call( - 'TransactionController:estimateGas', - { from, data, to, value }, - networkClientId, - ); - - const estimatedGas = new BigNumber(gasHex).toNumber(); - const bufferedGas = Math.ceil(estimatedGas * gasBuffer); + bufferedGas: result.estimate, + }); - if (!simulationFails) { - log('Estimated gas limit for single transaction', { - chainId, - estimatedGas, - bufferedGas, - gasBuffer, - }); - - return { - totalGasEstimate: bufferedGas, - totalGasLimit: bufferedGas, - gasLimits: [bufferedGas], - }; - } - } catch (error) { - log('Failed to estimate gas limit for single transaction', error); + return { + totalGasEstimate: result.estimate, + totalGasLimit: result.estimate, + gasLimits: [result.estimate], + }; } - const fallbackGas = getFeatureFlags(messenger).relayFallbackGas; - - log('Using fallback gas for single transaction', { fallbackGas }); + log('Using fallback gas for single transaction', { + estimate: result.estimate, + max: result.max, + }); return { - totalGasEstimate: fallbackGas.estimate, - totalGasLimit: fallbackGas.max, - gasLimits: [fallbackGas.max], + totalGasEstimate: result.estimate, + totalGasLimit: result.max, + gasLimits: [result.max], }; } @@ -807,7 +850,7 @@ async function calculateSourceNetworkGasLimitBatch( log('Failed to estimate gas limit for batch', error); } - const fallbackGas = getFeatureFlags(messenger).relayFallbackGas; + const fallbackGas = getFallbackGas(messenger); const totalGasEstimate = params.reduce((acc, singleParams) => { const gas = singleParams.gas ?? fallbackGas.estimate; 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 2b8b1c8d96f..9402e5a3212 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -5,10 +5,7 @@ import { } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionParams } from '@metamask/transaction-controller'; -import type { - AuthorizationList, - TransactionMeta, -} from '@metamask/transaction-controller'; +import type { AuthorizationList } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -22,11 +19,10 @@ import type { } from '../../types'; import { getFeatureFlags } from '../../utils/feature-flags'; import { - collectTransactionIds, - getTransaction, - updateTransaction, - waitForTransactionConfirmed, -} from '../../utils/transaction'; + SourceTransaction, + submitSourceTransactions, +} from '../../utils/strategy-helpers'; +import { updateTransaction } from '../../utils/transaction'; const FALLBACK_HASH = '0x0' as Hex; @@ -43,16 +39,12 @@ export async function submitRelayQuotes( ): Promise<{ transactionHash?: Hex }> { log('Executing quotes', request); - const { quotes, messenger, transaction } = request; + const { quotes } = request; let transactionHash: Hex | undefined; for (const quote of quotes) { - ({ transactionHash } = await executeSingleQuote( - quote, - messenger, - transaction, - )); + ({ transactionHash } = await executeSingleQuote(quote, request)); } return { transactionHash }; @@ -62,29 +54,26 @@ export async function submitRelayQuotes( * Executes a single Relay quote. * * @param quote - Relay quote to execute. - * @param messenger - Controller messenger. - * @param transaction - Original transaction meta. + * @param request - Original request. * @returns An object containing the transaction hash if available. */ async function executeSingleQuote( quote: TransactionPayQuote, - messenger: TransactionPayControllerMessenger, - transaction: TransactionMeta, + request: PayStrategyExecuteRequest, ): Promise<{ transactionHash?: Hex }> { log('Executing single quote', quote); - updateTransaction( - { - transactionId: transaction.id, - messenger, - note: 'Remove nonce from skipped transaction', + await submitSourceTransactions({ + request: { + ...request, + quotes: [quote], }, - (tx) => { - tx.txParams.nonce = undefined; - }, - ); - - await submitTransactions(quote, transaction.id, messenger); + requiredTransactionNote: + 'Add required transaction ID from Relay submission', + markIntentComplete: false, + buildTransactions: async (singleQuote) => + buildTransactions(singleQuote, request.messenger), + }); const targetHash = await waitForRelayCompletion(quote.original); @@ -92,8 +81,8 @@ async function executeSingleQuote( updateTransaction( { - transactionId: transaction.id, - messenger, + transactionId: request.transaction.id, + messenger: request.messenger, note: 'Intent complete after Relay completion', }, (tx) => { @@ -170,18 +159,21 @@ function normalizeParams( } /** - * Submit transactions for a relay quote. + * Build transactions for a relay quote. * * @param quote - Relay quote. - * @param parentTransactionId - ID of the parent transaction. * @param messenger - Controller messenger. - * @returns Hash of the last submitted transaction. + * @returns Prepared transactions and submission callback. */ -async function submitTransactions( +async function buildTransactions( quote: TransactionPayQuote, - parentTransactionId: string, messenger: TransactionPayControllerMessenger, -): Promise { +): Promise<{ + chainId: Hex; + from: Hex; + transactions: SourceTransaction[]; + submit: (transactions: SourceTransaction[]) => Promise; +}> { const { steps } = quote.original; const params = steps.flatMap((step) => step.items).map((item) => item.data); const invalidKind = steps.find((step) => step.kind !== 'transaction')?.kind; @@ -194,47 +186,8 @@ async function submitTransactions( normalizeParams(singleParams, messenger), ); - const transactionIds: string[] = []; const { from, sourceChainId, sourceTokenAddress } = quote.request; - - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', - sourceChainId, - ); - - log('Adding transactions', { - normalizedParams, - sourceChainId, - from, - networkClientId, - }); - - const { end } = collectTransactionIds( - sourceChainId, - from, - messenger, - (transactionId) => { - transactionIds.push(transactionId); - - updateTransaction( - { - transactionId: parentTransactionId, - messenger, - note: 'Add required transaction ID from Relay submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(transactionId); - }, - ); - }, - ); - - let result: { result: Promise } | undefined; - - const gasFeeToken = quote.fees.isSourceGasFeeToken - ? sourceTokenAddress - : undefined; + const chainId = sourceChainId; const isSameChain = quote.original.details.currencyIn.currency.chainId === @@ -248,76 +201,97 @@ async function submitTransactions( })) : undefined; + const gasFeeToken = quote.fees.isSourceGasFeeToken + ? sourceTokenAddress + : undefined; + const { gasLimits } = quote.original.metamask; - if (params.length === 1) { - const transactionParams = { - ...normalizedParams[0], - authorizationList, - gas: toHex(gasLimits[0]), - }; - - result = await messenger.call( - 'TransactionController:addTransaction', - transactionParams, - { - gasFeeToken, - networkClientId, - origin: ORIGIN_METAMASK, - requireApproval: false, - type: TransactionType.relayDeposit, - }, - ); - } else { - const gasLimit7702 = - gasLimits.length === 1 ? toHex(gasLimits[0]) : undefined; - - const transactions = normalizedParams.map((singleParams, index) => ({ - params: { - data: singleParams.data as Hex, - gas: gasLimit7702 ? undefined : toHex(gasLimits[index]), - maxFeePerGas: singleParams.maxFeePerGas as Hex, - maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, - to: singleParams.to as Hex, - value: singleParams.value as Hex, - }, + const transactions: SourceTransaction[] = normalizedParams.map( + (singleParams, index) => ({ + params: singleParams, type: - index === 0 - ? TransactionType.tokenMethodApprove - : TransactionType.relayDeposit, - })); - - await messenger.call('TransactionController:addTransactionBatch', { - from, - disable7702: !gasLimit7702, - disableHook: Boolean(gasLimit7702), - disableSequential: Boolean(gasLimit7702), - gasFeeToken, - gasLimit7702, - networkClientId, - origin: ORIGIN_METAMASK, - overwriteUpgrade: true, - requireApproval: false, - transactions, - }); - } - - end(); - - log('Added transactions', transactionIds); - - if (result) { - const txHash = await result.result; - log('Submitted transaction', txHash); - } - - await Promise.all( - transactionIds.map((txId) => waitForTransactionConfirmed(txId, messenger)), + params.length === 1 || index > 0 + ? TransactionType.relayDeposit + : TransactionType.tokenMethodApprove, + }), ); - log('All transactions confirmed', transactionIds); + return { + chainId, + from, + transactions, + submit: async (preparedTransactions): Promise => { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + sourceChainId, + ); - const hash = getTransaction(transactionIds.slice(-1)[0], messenger)?.hash; + log('Adding transactions', { + normalizedParams, + sourceChainId, + from, + networkClientId, + }); + + if (preparedTransactions.length === 1) { + const transactionParams = { + ...preparedTransactions[0].params, + authorizationList, + gas: toHex(gasLimits[0]), + }; + + const result = await messenger.call( + 'TransactionController:addTransaction', + transactionParams, + { + gasFeeToken, + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + type: TransactionType.relayDeposit, + }, + ); + + const txHash = await result.result; + log('Submitted transaction', txHash); + return; + } + + const gasLimit7702 = + gasLimits.length === 1 ? toHex(gasLimits[0]) : undefined; + + const batchTransactions = preparedTransactions.map( + (singleParams, index) => ({ + params: { + data: singleParams.params.data as Hex, + gas: gasLimit7702 ? undefined : toHex(gasLimits[index]), + maxFeePerGas: singleParams.params.maxFeePerGas as Hex, + maxPriorityFeePerGas: singleParams.params + .maxPriorityFeePerGas as Hex, + to: singleParams.params.to as Hex, + value: singleParams.params.value as Hex, + }, + type: + index === 0 + ? TransactionType.tokenMethodApprove + : TransactionType.relayDeposit, + }), + ); - return hash as Hex; + await messenger.call('TransactionController:addTransactionBatch', { + from, + disable7702: !gasLimit7702, + disableHook: Boolean(gasLimit7702), + disableSequential: Boolean(gasLimit7702), + gasFeeToken, + gasLimit7702, + networkClientId, + origin: ORIGIN_METAMASK, + overwriteUpgrade: true, + requireApproval: false, + transactions: batchTransactions, + }); + }, + }; } diff --git a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts index 06b470bcb8e..9804d2d44d5 100644 --- a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts @@ -68,10 +68,12 @@ export class TestStrategy implements PayStrategy { async execute( request: PayStrategyExecuteRequest, ): ReturnType['execute']> { - const { quotes } = request; + const { onSubmitted, quotes } = request; log('Executing', quotes); + onSubmitted?.(0); + await this.#timeout(5000); return { transactionHash: undefined }; diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index b6d69d4f22d..e32763e7892 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -27,6 +27,7 @@ import type { BridgeStatusControllerSubmitTxAction } from '../../../bridge-statu import type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerGetStrategiesAction, } from '../types'; import type { TransactionPayControllerGetStateAction } from '../types'; @@ -53,6 +54,10 @@ export function getMessengerMock({ TransactionPayControllerGetStrategyAction['handler'] > = jest.fn(); + const getStrategiesMock: jest.MockedFn< + TransactionPayControllerGetStrategiesAction['handler'] + > = jest.fn(); + const getTransactionControllerStateMock: jest.MockedFn< TransactionControllerGetStateAction['handler'] > = jest.fn(); @@ -142,6 +147,11 @@ export function getMessengerMock({ getStrategyMock, ); + messenger.registerActionHandler( + 'TransactionPayController:getStrategies', + getStrategiesMock, + ); + messenger.registerActionHandler( 'TransactionController:getState', getTransactionControllerStateMock, @@ -262,6 +272,7 @@ export function getMessengerMock({ getNetworkClientByIdMock, getRemoteFeatureFlagControllerStateMock, getStrategyMock, + getStrategiesMock, getTokenBalanceControllerStateMock, getTokenRatesControllerStateMock, getTokensControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 8539a6e15ff..aacef592ce4 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -79,6 +79,12 @@ export type TransactionPayControllerGetStrategyAction = { handler: (transaction: TransactionMeta) => TransactionPayStrategy; }; +/** Action to get the ordered pay strategies for a transaction. */ +export type TransactionPayControllerGetStrategiesAction = { + type: `${typeof CONTROLLER_NAME}:getStrategies`; + handler: (transaction: TransactionMeta) => TransactionPayStrategy[]; +}; + /** Action to update the payment token for a transaction. */ export type TransactionPayControllerUpdatePaymentTokenAction = { type: `${typeof CONTROLLER_NAME}:updatePaymentToken`; @@ -101,6 +107,7 @@ export type TransactionPayControllerActions = | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction + | TransactionPayControllerGetStrategiesAction | TransactionPayControllerSetIsMaxAmountAction | TransactionPayControllerUpdatePaymentTokenAction; @@ -121,6 +128,9 @@ export type TransactionPayControllerOptions = { /** Callback to select the PayStrategy for a transaction. */ getStrategy?: (transaction: TransactionMeta) => TransactionPayStrategy; + /** Callback to select ordered PayStrategies for a transaction. */ + getStrategies?: (transaction: TransactionMeta) => TransactionPayStrategy[]; + /** Controller messenger. */ messenger: TransactionPayControllerMessenger; @@ -300,6 +310,16 @@ export type TransactionPayFees = { /** Whether a gas fee token is used to pay target network fees. */ isTargetGasFeeToken?: boolean; + /** + * Estimated impact of the quote compared to the expected output amount. + */ + impact?: FiatValue; + + /** + * Impact normalized against expected output (decimal string, e.g. "0.05"). + */ + impactRatio?: string; + /** Fee charged by the quote provider. */ provider: FiatValue; @@ -360,6 +380,9 @@ export type PayStrategyExecuteRequest = { /** Controller messenger. */ messenger: TransactionPayControllerMessenger; + /** Callback invoked after transactions are submitted to record latency. */ + onSubmitted?: (latencyMs: number) => void; + /** Quotes to be submitted. */ quotes: TransactionPayQuote[]; @@ -387,6 +410,12 @@ export type PayStrategyGetRefreshIntervalRequest = { /** Strategy used to obtain required tokens for a transaction. */ export type PayStrategy = { + /** + * Check if the strategy supports the given request. + * Defaults to true if not implemented. + */ + supports?: (request: PayStrategyGetQuotesRequest) => boolean; + /** Retrieve quotes for required tokens. */ getQuotes: ( request: PayStrategyGetQuotesRequest, @@ -450,6 +479,31 @@ export type UpdatePaymentTokenRequest = { chainId: Hex; }; +/** Action data to be included with a transaction pay quote. */ +export type TransactionPayAction = { + /** Target contract for the action call. */ + target: Hex; + + /** Function signature describing the action call. */ + functionSignature: string; + + /** Arguments for the action call. */ + args: { + value: string | string[] | string[][]; + populateDynamically: boolean; + balanceSourceToken?: string; + }[]; + + /** Value to send with the action call. */ + value: string; + + /** Whether the action is a native token transfer. */ + isNativeTransfer: boolean; + + /** Whether to populate the call value dynamically. */ + populateCallValueDynamically?: boolean; +}; + /** Callback to convert a transaction to a redeem delegation. */ export type GetDelegationTransactionCallback = ({ transaction, @@ -457,6 +511,7 @@ export type GetDelegationTransactionCallback = ({ transaction: TransactionMeta; }) => Promise<{ authorizationList?: AuthorizationList; + action?: TransactionPayAction; data: Hex; to: Hex; value: Hex; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 566092cad03..06878be9d4d 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -1,6 +1,7 @@ import type { Hex } from '@metamask/utils'; import { + DEFAULT_ACROSS_API_BASE, DEFAULT_GAS_BUFFER, DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, DEFAULT_RELAY_FALLBACK_GAS_MAX, @@ -9,6 +10,7 @@ import { getEIP7702SupportedChains, getFeatureFlags, getGasBuffer, + getPayStrategiesConfig, getSlippage, } from './feature-flags'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; @@ -83,6 +85,24 @@ describe('Feature Flags Utils', () => { slippage: SLIPPAGE_MOCK, }); }); + + it('returns defaults when feature flag controller throws', () => { + getRemoteFeatureFlagControllerStateMock.mockImplementation(() => { + throw new Error('boom'); + }); + + const featureFlags = getFeatureFlags(messenger); + + expect(featureFlags).toStrictEqual({ + relayDisabledGasStationChains: [], + relayFallbackGas: { + estimate: DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, + max: DEFAULT_RELAY_FALLBACK_GAS_MAX, + }, + relayQuoteUrl: DEFAULT_RELAY_QUOTE_URL, + slippage: DEFAULT_SLIPPAGE, + }); + }); }); describe('getGasBuffer', () => { @@ -384,5 +404,35 @@ describe('Feature Flags Utils', () => { expect(supportedChains).toStrictEqual([]); }); + + it('returns empty array when feature flag controller throws', () => { + getRemoteFeatureFlagControllerStateMock.mockImplementation(() => { + throw new Error('Boom'); + }); + + const supportedChains = getEIP7702SupportedChains(messenger); + + expect(supportedChains).toStrictEqual([]); + }); + }); + + describe('getPayStrategiesConfig', () => { + it('returns defaults when pay strategies config is missing', () => { + const config = getPayStrategiesConfig(messenger); + + expect(config.across).toStrictEqual( + expect.objectContaining({ + allowSameChain: false, + apiBase: DEFAULT_ACROSS_API_BASE, + enabled: true, + }), + ); + expect(config.relay).toStrictEqual( + expect.objectContaining({ + enabled: true, + relayQuoteUrl: DEFAULT_RELAY_QUOTE_URL, + }), + ); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 54fe05c0f68..4e66a7e28ae 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -2,6 +2,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import type { TransactionPayControllerMessenger } from '..'; +import { TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; import { RELAY_URL_BASE } from '../strategy/relay/constants'; @@ -12,6 +13,11 @@ export const DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE = 900000; export const DEFAULT_RELAY_FALLBACK_GAS_MAX = 1500000; export const DEFAULT_RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; export const DEFAULT_SLIPPAGE = 0.005; +export const DEFAULT_ACROSS_API_BASE = 'https://app.across.to/api'; +export const DEFAULT_STRATEGY_ORDER = [ + TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, +]; type FeatureFlagsRaw = { gasBuffer?: { @@ -32,6 +38,7 @@ type FeatureFlagsRaw = { relayQuoteUrl?: string; slippage?: number; slippageTokens?: Record>; + payStrategies?: PayStrategiesConfigRaw; }; export type FeatureFlags = { @@ -44,6 +51,38 @@ export type FeatureFlags = { slippage: number; }; +export type PayStrategyConfigRaw = { + allowSameChain?: boolean; + apiBase?: string; + apiKey?: string; + apiKeyHeader?: string; + apiKeyPrefix?: string; + appFee?: string; + appFeeRecipient?: string; + enabled?: boolean; + integratorId?: string; +}; + +export type PayStrategiesConfigRaw = { + across?: PayStrategyConfigRaw; + relay?: { + enabled?: boolean; + relayQuoteUrl?: string; + }; +}; + +export type PayStrategiesConfig = { + across: PayStrategyConfigRaw & { + allowSameChain: boolean; + apiBase: string; + enabled: boolean; + }; + relay: { + enabled: boolean; + relayQuoteUrl: string; + }; +}; + /** * Get feature flags related to the controller. * @@ -84,6 +123,59 @@ export function getFeatureFlags( return result; } +/** + * Get Pay Strategies configuration. + * + * @param messenger - Controller messenger. + * @returns Pay Strategies configuration. + */ +export function getPayStrategiesConfig( + messenger: TransactionPayControllerMessenger, +): PayStrategiesConfig { + const featureFlags = getFeatureFlagsRaw(messenger); + const payStrategies = featureFlags.payStrategies ?? {}; + + const acrossRaw = payStrategies.across ?? {}; + const relayRaw = payStrategies.relay ?? {}; + + const across = { + allowSameChain: acrossRaw.allowSameChain ?? false, + apiBase: acrossRaw.apiBase ?? DEFAULT_ACROSS_API_BASE, + apiKey: acrossRaw.apiKey, + apiKeyHeader: acrossRaw.apiKeyHeader, + apiKeyPrefix: acrossRaw.apiKeyPrefix, + appFee: acrossRaw.appFee, + appFeeRecipient: acrossRaw.appFeeRecipient, + enabled: acrossRaw.enabled ?? true, + integratorId: acrossRaw.integratorId, + }; + + const relay = { + enabled: relayRaw.enabled ?? true, + relayQuoteUrl: + relayRaw.relayQuoteUrl ?? + featureFlags.relayQuoteUrl ?? + DEFAULT_RELAY_QUOTE_URL, + }; + + return { + across, + relay, + }; +} + +/** + * Get ordered list of strategies to try. + * + * @param _messenger - Controller messenger. + * @returns Ordered strategy list. + */ +export function getStrategyOrder( + _messenger: TransactionPayControllerMessenger, +): TransactionPayStrategy[] { + return [...DEFAULT_STRATEGY_ORDER]; +} + /** * Get the gas buffer value for a specific chain ID. * @@ -170,12 +262,18 @@ function getCaseInsensitive( export function getEIP7702SupportedChains( messenger: TransactionPayControllerMessenger, ): Hex[] { - const state = messenger.call('RemoteFeatureFlagController:getState'); - const eip7702Flags = state.remoteFeatureFlags.confirmations_eip_7702 as - | { supportedChains?: Hex[] } - | undefined; + try { + const state = messenger.call('RemoteFeatureFlagController:getState') as + | { remoteFeatureFlags?: Record } + | undefined; + const eip7702Flags = state?.remoteFeatureFlags?.confirmations_eip_7702 as + | { supportedChains?: Hex[] } + | undefined; - return eip7702Flags?.supportedChains ?? []; + return eip7702Flags?.supportedChains ?? []; + } catch { + return []; + } } /** @@ -187,6 +285,13 @@ export function getEIP7702SupportedChains( function getFeatureFlagsRaw( messenger: TransactionPayControllerMessenger, ): FeatureFlagsRaw { - const state = messenger.call('RemoteFeatureFlagController:getState'); - return (state.remoteFeatureFlags.confirmations_pay as FeatureFlagsRaw) ?? {}; + try { + const state = messenger.call('RemoteFeatureFlagController:getState') as + | { remoteFeatureFlags?: Record } + | undefined; + + return state?.remoteFeatureFlags?.confirmations_pay ?? {}; + } catch { + return {}; + } } diff --git a/packages/transaction-pay-controller/src/utils/gas.test.ts b/packages/transaction-pay-controller/src/utils/gas.test.ts index 9f505008e57..905e355a237 100644 --- a/packages/transaction-pay-controller/src/utils/gas.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas.test.ts @@ -2,13 +2,20 @@ import { toHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { clone, cloneDeep } from 'lodash'; +import { + DEFAULT_GAS_BUFFER, + DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, + DEFAULT_RELAY_FALLBACK_GAS_MAX, +} from './feature-flags'; import { calculateGasCost, calculateGasFeeTokenCost, calculateTransactionGasCost, + estimateGasWithBufferOrFallback, } from './gas'; import { getTokenBalance, getTokenFiatRate } from './token'; import type { GasFeeEstimates } from '../../../gas-fee-controller/src'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import type { GasFeeToken, TransactionMeta, @@ -57,12 +64,22 @@ const GAS_FEE_CONTROLLER_STATE_MOCK = { describe('Gas Utils', () => { const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getTokenBalanceMock = jest.mocked(getTokenBalance); - const { messenger, getGasFeeControllerStateMock } = getMessengerMock(); + const { + messenger, + estimateGasMock, + findNetworkClientIdByChainIdMock, + getGasFeeControllerStateMock, + getRemoteFeatureFlagControllerStateMock, + } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); getGasFeeControllerStateMock.mockReturnValue(GAS_FEE_CONTROLLER_STATE_MOCK); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + }); + findNetworkClientIdByChainIdMock.mockReturnValue('mainnet'); getTokenBalanceMock.mockReturnValue('147000000000000'); getTokenFiatRateMock.mockReturnValue({ @@ -395,4 +412,88 @@ describe('Gas Utils', () => { expect(result).toBeUndefined(); }); }); + + describe('estimateGasWithBufferOrFallback', () => { + const FROM_MOCK = '0x123' as Hex; + const TO_MOCK = '0x456' as Hex; + const DATA_MOCK = '0x' as Hex; + + it('returns buffered estimate when simulation succeeds', async () => { + estimateGasMock.mockResolvedValue({ + gas: GAS_USED_MOCK, + simulationFails: undefined, + }); + + const result = await estimateGasWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: DATA_MOCK, + from: FROM_MOCK, + messenger, + to: TO_MOCK, + }); + + const bufferedGas = Math.ceil( + parseInt(GAS_USED_MOCK, 16) * DEFAULT_GAS_BUFFER, + ); + + expect(result).toStrictEqual({ + estimate: bufferedGas, + max: bufferedGas, + usedFallback: false, + }); + }); + + it('uses fallback when simulation fails', async () => { + estimateGasMock.mockResolvedValue({ + gas: GAS_USED_MOCK, + simulationFails: { debug: {} }, + }); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + relayFallbackGas: { + estimate: 111, + max: 222, + }, + }, + }, + }); + + const result = await estimateGasWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: DATA_MOCK, + from: FROM_MOCK, + messenger, + to: TO_MOCK, + }); + + expect(result).toStrictEqual({ + estimate: 111, + max: 222, + usedFallback: true, + error: undefined, + }); + }); + + it('uses fallback when estimateGas throws', async () => { + estimateGasMock.mockRejectedValue(new Error('boom')); + + const result = await estimateGasWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: DATA_MOCK, + from: FROM_MOCK, + messenger, + to: TO_MOCK, + }); + + expect(result).toMatchObject({ + estimate: DEFAULT_RELAY_FALLBACK_GAS_ESTIMATE, + max: DEFAULT_RELAY_FALLBACK_GAS_MAX, + usedFallback: true, + }); + expect(result.error).toBeInstanceOf(Error); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/gas.ts b/packages/transaction-pay-controller/src/utils/gas.ts index 95c2bfb8668..5e22c0d67ca 100644 --- a/packages/transaction-pay-controller/src/utils/gas.ts +++ b/packages/transaction-pay-controller/src/utils/gas.ts @@ -7,6 +7,7 @@ import type { import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { getFeatureFlags, getGasBuffer } from './feature-flags'; import { getNativeToken, getTokenBalance, getTokenFiatRate } from './token'; import type { TransactionPayControllerMessenger } from '..'; import { createModuleLogger, projectLogger } from '../logger'; @@ -198,6 +199,72 @@ export function calculateGasFeeTokenCost({ }; } +export function getFallbackGas(messenger: TransactionPayControllerMessenger): { + estimate: number; + max: number; +} { + return getFeatureFlags(messenger).relayFallbackGas; +} + +export async function estimateGasWithBufferOrFallback({ + chainId, + data, + from, + messenger, + to, + value, +}: { + chainId: Hex; + data: Hex; + from: Hex; + messenger: TransactionPayControllerMessenger; + to: Hex; + value?: Hex; +}): Promise<{ + estimate: number; + max: number; + usedFallback: boolean; + error?: unknown; +}> { + const gasBuffer = getGasBuffer(messenger, chainId); + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + let error: unknown; + + try { + const { gas: gasHex, simulationFails } = await messenger.call( + 'TransactionController:estimateGas', + { from, data, to, value: value ?? '0x0' }, + networkClientId, + ); + + const estimatedGas = new BigNumber(gasHex).toNumber(); + const bufferedGas = Math.ceil(estimatedGas * gasBuffer); + + if (!simulationFails) { + return { + estimate: bufferedGas, + max: bufferedGas, + usedFallback: false, + }; + } + } catch (caughtError) { + error = caughtError; + } + + const fallbackGas = getFallbackGas(messenger); + + return { + estimate: fallbackGas.estimate, + max: fallbackGas.max, + usedFallback: true, + error, + }; +} + /** * Get gas fee estimates for a given chain. * diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index a247cef45d1..18bd71bf82c 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -6,9 +6,10 @@ import { cloneDeep } from 'lodash'; import type { UpdateQuotesRequest } from './quotes'; import { refreshQuotes, updateQuotes } from './quotes'; -import { getStrategy, getStrategyByName } from './strategy'; +import { getStrategies, getStrategyByName } from './strategy'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; +import { TransactionPayStrategy } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionPaySourceAmount, @@ -58,6 +59,7 @@ const QUOTE_MOCK = { usd: '1.23', fiat: '2.34', }, + strategy: TransactionPayStrategy.Test, } as TransactionPayQuote; const TOTALS_MOCK = { @@ -92,7 +94,7 @@ const BATCH_TRANSACTION_MOCK = { describe('Quotes Utils', () => { const { messenger, getControllerStateMock } = getMessengerMock(); const updateTransactionDataMock = jest.fn(); - const getStrategyMock = jest.mocked(getStrategy); + const getStrategiesMock = jest.mocked(getStrategies); const getStrategyByNameMock = jest.mocked(getStrategyByName); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); @@ -120,11 +122,13 @@ describe('Quotes Utils', () => { jest.resetAllMocks(); jest.clearAllTimers(); - getStrategyMock.mockReturnValue({ - execute: jest.fn(), - getQuotes: getQuotesMock, - getBatchTransactions: getBatchTransactionsMock, - }); + getStrategiesMock.mockReturnValue([ + { + execute: jest.fn(), + getQuotes: getQuotesMock, + getBatchTransactions: getBatchTransactionsMock, + }, + ]); getStrategyByNameMock.mockReturnValue({ execute: jest.fn(), @@ -198,6 +202,89 @@ describe('Quotes Utils', () => { }); }); + it('falls back to next strategy when quotes fail', async () => { + const firstStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockRejectedValue(new Error('Strategy error')), + execute: jest.fn(), + }; + + const secondStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([firstStrategy, secondStrategy]); + + await run(); + + expect(firstStrategy.getQuotes).toHaveBeenCalled(); + expect(secondStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('skips strategies that do not support the request', async () => { + const unsupportedStrategy = { + supports: jest.fn().mockReturnValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }; + + const supportedStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([ + unsupportedStrategy, + supportedStrategy, + ]); + + await run(); + + expect(unsupportedStrategy.getQuotes).not.toHaveBeenCalled(); + expect(supportedStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('tries next strategy when quotes are empty', async () => { + const emptyStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([]), + execute: jest.fn(), + }; + + const fallbackStrategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + getBatchTransactions: getBatchTransactionsMock, + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([emptyStrategy, fallbackStrategy]); + + await run(); + + expect(emptyStrategy.getQuotes).toHaveBeenCalled(); + expect(fallbackStrategy.getQuotes).toHaveBeenCalled(); + }); + + it('defaults to no batch transactions when strategy does not provide them', async () => { + const strategy = { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn().mockResolvedValue([QUOTE_MOCK]), + execute: jest.fn(), + }; + + getStrategiesMock.mockReturnValue([strategy]); + + await run(); + + expect(strategy.getQuotes).toHaveBeenCalled(); + }); + it('clears state if no payment token', async () => { await run({ transactionData: { @@ -314,6 +401,27 @@ describe('Quotes Utils', () => { }); }); + it('preserves existing metamask pay metadata', async () => { + await run(); + + const transactionMetaMock = { + metamaskPay: { executionLatencyMs: 1234 }, + } as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock.metamaskPay?.executionLatencyMs).toBe(1234); + expect(transactionMetaMock).toMatchObject({ + metamaskPay: { + bridgeFeeFiat: TOTALS_MOCK.fees.provider.usd, + chainId: TRANSACTION_DATA_MOCK.paymentToken?.chainId, + networkFeeFiat: TOTALS_MOCK.fees.sourceNetwork.estimate.usd, + targetFiat: TOTALS_MOCK.targetAmount.usd, + tokenAddress: TRANSACTION_DATA_MOCK.paymentToken?.address, + totalFiat: TOTALS_MOCK.total.usd, + }, + }); + }); + it('does nothing if transaction is not unapproved', async () => { getTransactionMock.mockReturnValue({ ...TRANSACTION_META_MOCK, diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index a242607fbf7..10d8ce64a22 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 { getStrategy, getStrategyByName } from './strategy'; +import { getStrategies, getStrategyByName } from './strategy'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; import { projectLogger } from '../logger'; @@ -146,7 +146,10 @@ function syncTransaction({ tx.batchTransactions = batchTransactions; tx.batchTransactionsOptions = {}; + const executionLatencyMs = tx.metamaskPay?.executionLatencyMs; + tx.metamaskPay = { + executionLatencyMs, bridgeFeeFiat: totals.fees.provider.usd, chainId: paymentToken.chainId, networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, @@ -219,7 +222,7 @@ export async function refreshQuotes( * @param request.transactionId - ID of the transaction. * @returns Array of quote requests. */ -function buildQuoteRequests({ +export function buildQuoteRequests({ from, isMaxAmount, paymentToken, @@ -280,35 +283,62 @@ async function getQuotes( quotes: TransactionPayQuote[]; }> { const { id: transactionId } = transaction; - const strategy = getStrategy(messenger as never, transaction); - let quotes: TransactionPayQuote[] | undefined = []; + const strategies = getStrategies(messenger as never, transaction); - try { - quotes = requests?.length - ? ((await strategy.getQuotes({ - messenger, - requests, - transaction, - })) as TransactionPayQuote[]) - : []; - } catch (error) { - log('Error fetching quotes', { error, transactionId }); + if (!requests?.length) { + return { + batchTransactions: [], + quotes: [], + }; } - log('Updated', { transactionId, quotes }); + const request = { + messenger, + requests, + transaction, + }; + + for (const strategy of strategies) { + if (strategy.supports && !strategy.supports(request)) { + continue; + } + + let quotes: TransactionPayQuote[] | undefined; + + try { + quotes = (await strategy.getQuotes( + request, + )) as TransactionPayQuote[]; + } catch (error) { + log('Error fetching quotes', { error, transactionId }); + continue; + } + + if (!quotes?.length) { + continue; + } + + log('Updated', { transactionId, quotes }); - const batchTransactions = - quotes?.length && strategy.getBatchTransactions + const batchTransactions = strategy.getBatchTransactions ? await strategy.getBatchTransactions({ messenger, quotes, }) : []; - log('Batch transactions', { transactionId, batchTransactions }); + log('Batch transactions', { transactionId, batchTransactions }); + + return { + batchTransactions, + quotes, + }; + } + + log('No quotes available', { transactionId }); return { - batchTransactions, - quotes, + batchTransactions: [], + quotes: [], }; } diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index 9612a79e032..5ca74d2b5fa 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -43,14 +43,17 @@ const TRANSACTION_ID_MOCK = '123-456'; describe('Source Amounts Utils', () => { const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getTransactionMock = jest.mocked(getTransaction); - const { messenger, getStrategyMock } = getMessengerMock(); + const { messenger, getStrategiesMock } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' }); - getStrategyMock.mockReturnValue(TransactionPayStrategy.Test); - getTransactionMock.mockReturnValue({ id: TRANSACTION_ID_MOCK } as never); + getStrategiesMock.mockReturnValue([TransactionPayStrategy.Test]); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { from: '0xabc' }, + } as never); }); describe('updateSourceAmounts', () => { @@ -91,7 +94,7 @@ describe('Source Amounts Utils', () => { }); it('does not return empty array if payment token matches but hyperliquid deposit and relay strategy', () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Relay); + getStrategiesMock.mockReturnValue([TransactionPayStrategy.Relay]); const transactionData: TransactionData = { isLoading: false, @@ -209,5 +212,92 @@ describe('Source Amounts Utils', () => { it('does nothing if no transaction data', () => { updateSourceAmounts(TRANSACTION_ID_MOCK, undefined, messenger); }); + + it('defaults to relay strategy when transaction is missing', () => { + getTransactionMock.mockReturnValue(undefined); + + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '2', + sourceAmountRaw: '2000000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('falls back to first strategy name when getStrategyByName throws', () => { + getStrategiesMock.mockReturnValue(['UnknownStrategy' as never]); + + const transactionData: TransactionData = { + isLoading: false, + paymentToken: { + ...PAYMENT_TOKEN_MOCK, + address: ARBITRUM_USDC_ADDRESS, + chainId: CHAIN_ID_ARBITRUM, + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + address: ARBITRUM_USDC_ADDRESS, + chainId: CHAIN_ID_ARBITRUM, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('sets targetAmountMinimum to zero when allowUnderMinimum is true', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + allowUnderMinimum: true, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '2', + sourceAmountRaw: '2000000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('falls back to relay when no strategies are configured', () => { + getStrategiesMock.mockReturnValue(undefined as never); + + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '2', + sourceAmountRaw: '2000000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index f98cd422c37..23cd36320c2 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -1,20 +1,23 @@ +import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { getStrategyByName } from './strategy'; import { getTokenFiatRate } from './token'; import { getTransaction } from './transaction'; -import type { - TransactionPayControllerMessenger, - TransactionPaymentToken, -} from '..'; -import { TransactionPayStrategy } from '..'; -import type { TransactionMeta } from '../../../transaction-controller/src'; -import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../constants'; +import { + ARBITRUM_USDC_ADDRESS, + CHAIN_ID_ARBITRUM, + TransactionPayStrategy, +} from '../constants'; import { projectLogger } from '../logger'; import type { - TransactionPaySourceAmount, + PayStrategyGetQuotesRequest, TransactionData, + TransactionPayControllerMessenger, + TransactionPaymentToken, TransactionPayRequiredToken, + TransactionPaySourceAmount, } from '../types'; const log = createModuleLogger(projectLogger, 'source-amounts'); @@ -41,14 +44,22 @@ export function updateSourceAmounts( return; } + const strategy = getStrategyType( + transactionId, + paymentToken, + tokens, + isMaxAmount ?? false, + messenger, + ); + const sourceAmounts = tokens .map((singleToken) => calculateSourceAmount( paymentToken, singleToken, messenger, - transactionId, isMaxAmount ?? false, + strategy, ), ) .filter(Boolean) as TransactionPaySourceAmount[]; @@ -64,16 +75,16 @@ export function updateSourceAmounts( * @param paymentToken - Selected payment token. * @param token - Target token to cover. * @param messenger - Controller messenger. - * @param transactionId - ID of the transaction. * @param isMaxAmount - Whether the transaction is a maximum amount transaction. + * @param strategy - Payment strategy. * @returns The source amount or undefined if calculation failed. */ function calculateSourceAmount( paymentToken: TransactionPaymentToken, token: TransactionPayRequiredToken, messenger: TransactionPayControllerMessenger, - transactionId: string, isMaxAmount: boolean, + strategy: TransactionPayStrategy, ): TransactionPaySourceAmount | undefined { const paymentTokenFiatRate = getTokenFiatRate( messenger, @@ -94,8 +105,6 @@ function calculateSourceAmount( return undefined; } - const strategy = getStrategyType(transactionId, messenger); - const isSameTokenSelected = token.address.toLowerCase() === paymentToken.address.toLowerCase() && token.chainId === paymentToken.chainId; @@ -159,17 +168,60 @@ function isQuoteAlwaysRequired( * Get the strategy type for a transaction. * * @param transactionId - ID of the transaction. + * @param paymentToken - Selected payment token. + * @param tokens - Tokens required by the transaction. + * @param isMaxAmount - Whether the transaction is a maximum amount transaction. * @param messenger - Controller messenger. * @returns Payment strategy type. */ function getStrategyType( transactionId: string, + paymentToken: TransactionPaymentToken, + tokens: TransactionPayRequiredToken[], + isMaxAmount: boolean, messenger: TransactionPayControllerMessenger, ): TransactionPayStrategy { - const transaction = getTransaction( - transactionId, + const transaction = getTransaction(transactionId, messenger); + + if (!transaction) { + return TransactionPayStrategy.Relay; + } + + const from = transaction.txParams.from as Hex; + + const requests = tokens.map((singleToken) => ({ + from, + isMaxAmount, + sourceBalanceRaw: paymentToken.balanceRaw, + sourceChainId: paymentToken.chainId, + sourceTokenAddress: paymentToken.address, + sourceTokenAmount: singleToken.amountRaw, + targetAmountMinimum: singleToken.allowUnderMinimum + ? '0' + : singleToken.amountRaw, + targetChainId: singleToken.chainId, + targetTokenAddress: singleToken.address, + })); + + const request = { messenger, - ) as TransactionMeta; + requests, + transaction, + } as PayStrategyGetQuotesRequest; + + const strategyOrder = + messenger.call('TransactionPayController:getStrategies', transaction) ?? []; + + for (const strategyName of strategyOrder) { + try { + const strategy = getStrategyByName(strategyName); + if (!strategy.supports || strategy.supports(request)) { + return strategyName; + } + } catch { + continue; + } + } - return messenger.call('TransactionPayController:getStrategy', transaction); + return strategyOrder[0] ?? TransactionPayStrategy.Relay; } diff --git a/packages/transaction-pay-controller/src/utils/strategy-helpers.test.ts b/packages/transaction-pay-controller/src/utils/strategy-helpers.test.ts new file mode 100644 index 00000000000..b18933a6eb9 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/strategy-helpers.test.ts @@ -0,0 +1,175 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { submitSourceTransactions } from './strategy-helpers'; +import { + collectTransactionIds, + getTransaction, + updateTransaction, + waitForTransactionConfirmed, +} from './transaction'; +import { TransactionPayStrategy } from '../constants'; +import type { PayStrategyExecuteRequest, TransactionPayQuote } from '../types'; + +jest.mock('./transaction', () => ({ + collectTransactionIds: jest.fn(), + getTransaction: jest.fn(), + updateTransaction: jest.fn(), + waitForTransactionConfirmed: jest.fn(), +})); + +describe('strategy-helpers', () => { + const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getTransactionMock = jest.mocked(getTransaction); + const updateTransactionMock = jest.mocked(updateTransaction); + const waitForTransactionConfirmedMock = jest.mocked( + waitForTransactionConfirmed, + ); + + const messenger = {} as never; + const transaction = { + id: 'tx-1', + chainId: '0x1', + networkClientId: 'mainnet', + status: TransactionStatus.unapproved, + time: Date.now(), + txParams: { + from: '0xabc', + }, + } as TransactionMeta; + const quote: TransactionPayQuote = { + dust: { usd: '0', fiat: '0' }, + estimatedDuration: 0, + fees: { + provider: { usd: '0', fiat: '0' }, + sourceNetwork: { + estimate: { usd: '0', fiat: '0', human: '0', raw: '0' }, + max: { usd: '0', fiat: '0', human: '0', raw: '0' }, + }, + targetNetwork: { usd: '0', fiat: '0' }, + }, + original: {}, + request: { + from: '0xabc' as Hex, + sourceBalanceRaw: '1', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '1', + targetAmountMinimum: '1', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + sourceAmount: { usd: '0', fiat: '0', human: '0', raw: '0' }, + strategy: TransactionPayStrategy.Relay, + targetAmount: { usd: '0', fiat: '0', human: '0', raw: '0' }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + collectTransactionIdsMock.mockImplementation( + (_chainId, _from, _messenger, onTransaction) => { + onTransaction('new-tx'); + return { end: jest.fn() }; + }, + ); + waitForTransactionConfirmedMock.mockResolvedValue(); + getTransactionMock.mockReturnValue({ hash: '0xhash' } as never); + }); + + it('uses default intent complete note when not provided', async () => { + const originalPerformance = globalThis.performance; + const nowMock = jest + .fn() + .mockReturnValueOnce(1000) + .mockReturnValueOnce(1400); + Object.defineProperty(globalThis, 'performance', { + value: { now: nowMock }, + configurable: true, + }); + + const onSubmittedMock = jest.fn(); + + const request = { + messenger, + quotes: [quote], + transaction, + onSubmitted: onSubmittedMock, + isSmartTransaction: jest.fn(), + } as PayStrategyExecuteRequest; + + await submitSourceTransactions({ + request, + requiredTransactionNote: 'Required transaction note', + buildTransactions: async () => ({ + chainId: '0x1' as Hex, + from: '0xabc' as Hex, + transactions: [ + { + params: {} as never, + type: TransactionType.relayDeposit, + }, + ], + submit: jest.fn().mockResolvedValue(undefined), + }), + }); + + const notes = updateTransactionMock.mock.calls.map((call) => call[0].note); + expect(notes).toContain('Intent complete'); + expect(onSubmittedMock).toHaveBeenCalledWith(400); + Object.defineProperty(globalThis, 'performance', { + value: originalPerformance, + configurable: true, + }); + }); + + it('falls back to Date.now when performance.now is unavailable', async () => { + const originalPerformance = globalThis.performance; + Object.defineProperty(globalThis, 'performance', { + value: { now: undefined }, + configurable: true, + }); + + const onSubmittedMock = jest.fn(); + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy + .mockReturnValueOnce(2000) + .mockReturnValueOnce(2400) + .mockReturnValue(2400); + + const request = { + messenger, + quotes: [quote], + transaction, + onSubmitted: onSubmittedMock, + isSmartTransaction: jest.fn(), + } as PayStrategyExecuteRequest; + + await submitSourceTransactions({ + request, + requiredTransactionNote: 'Required transaction note', + buildTransactions: async () => ({ + chainId: '0x1' as Hex, + from: '0xabc' as Hex, + transactions: [ + { + params: {} as never, + type: TransactionType.relayDeposit, + }, + ], + submit: jest.fn().mockResolvedValue(undefined), + }), + }); + + expect(onSubmittedMock).toHaveBeenCalledWith(400); + + dateSpy.mockRestore(); + Object.defineProperty(globalThis, 'performance', { + value: originalPerformance, + configurable: true, + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/strategy-helpers.ts b/packages/transaction-pay-controller/src/utils/strategy-helpers.ts new file mode 100644 index 00000000000..d3a5255c567 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/strategy-helpers.ts @@ -0,0 +1,125 @@ +import type { TransactionParams } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { + collectTransactionIds, + getTransaction, + updateTransaction, + waitForTransactionConfirmed, +} from './transaction'; +import type { PayStrategyExecuteRequest, TransactionPayQuote } from '../types'; + +const getNow = (): number => + typeof globalThis.performance?.now === 'function' + ? globalThis.performance.now() + : Date.now(); + +export type SourceTransaction = { + params: TransactionParams; + type: TransactionType; +}; + +type SubmitSourceTransactionsOptions = { + request: PayStrategyExecuteRequest; + buildTransactions: (quote: TransactionPayQuote) => Promise<{ + chainId: Hex; + from: Hex; + transactions: SourceTransaction[]; + submit: (transactions: SourceTransaction[]) => Promise; + }>; + requiredTransactionNote: string; + intentCompleteNote?: string; + markIntentComplete?: boolean; +}; + +/** + * Submit source transactions for quotes and wait for confirmation. + * + * @param options - Submit options. + * @returns The transaction hash of the last submitted transaction. + */ +export async function submitSourceTransactions( + options: SubmitSourceTransactionsOptions, +): Promise<{ transactionHash?: Hex }> { + const { + request, + buildTransactions, + requiredTransactionNote, + intentCompleteNote, + markIntentComplete = true, + } = options; + const { quotes, messenger, onSubmitted, transaction } = request; + + let transactionHash: Hex | undefined; + let hasReportedLatency = false; + + for (const quote of quotes) { + const prepared = await buildTransactions(quote); + const { chainId, from, transactions, submit } = prepared; + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Remove nonce from skipped transaction', + }, + (tx) => { + tx.txParams.nonce = undefined; + }, + ); + + const transactionIds: string[] = []; + + const { end } = collectTransactionIds(chainId, from, messenger, (id) => { + transactionIds.push(id); + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: requiredTransactionNote, + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(id); + }, + ); + }); + + const submitStart = getNow(); + await submit(transactions); + + if (!hasReportedLatency) { + hasReportedLatency = true; + // Guard against negative duration when clocks or mocks move backward. + onSubmitted?.(Math.max(getNow() - submitStart, 0)); + } + + end(); + + await Promise.all( + transactionIds.map((txId) => + waitForTransactionConfirmed(txId, messenger), + ), + ); + + if (markIntentComplete) { + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: intentCompleteNote ?? 'Intent complete', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + } + + transactionHash = getTransaction(transactionIds.slice(-1)[0], messenger) + ?.hash as Hex; + } + + return { transactionHash }; +} diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index 7b2c65593e2..14f86c5f009 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -1,56 +1,89 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; -import { getStrategy, getStrategyByName } from './strategy'; +import { + getStrategies, + getStrategy, + getStrategyByName, + selectStrategy, +} from './strategy'; import { TransactionPayStrategy } from '../constants'; +import { AcrossStrategy } from '../strategy/across/AcrossStrategy'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; import { getMessengerMock } from '../tests/messenger-mock'; +import type { PayStrategyGetQuotesRequest } from '../types'; const TRANSACTION_META_MOCK = {} as TransactionMeta; describe('Strategy Utils', () => { - const { messenger, getStrategyMock } = getMessengerMock(); + const { messenger, getStrategiesMock } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); }); - describe('getStrategy', () => { - it('returns TestStrategy if strategy name is Test', async () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Test); + describe('getStrategies', () => { + it('returns ordered strategies for provided names', () => { + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Relay, + ]); - const strategy = getStrategy(messenger, TRANSACTION_META_MOCK); + const strategies = getStrategies(messenger, TRANSACTION_META_MOCK); - expect(strategy).toBeInstanceOf(TestStrategy); + expect(strategies[0]).toBeInstanceOf(TestStrategy); + expect(strategies[1]).toBeInstanceOf(RelayStrategy); }); - it('returns BridgeStrategy if strategy name is Bridge', async () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Bridge); + it('filters unknown strategies', () => { + getStrategiesMock.mockReturnValue([ + 'UnknownStrategy' as never, + TransactionPayStrategy.Bridge, + ]); - const strategy = getStrategy(messenger, TRANSACTION_META_MOCK); + const strategies = getStrategies(messenger, TRANSACTION_META_MOCK); - expect(strategy).toBeInstanceOf(BridgeStrategy); + expect(strategies).toHaveLength(1); + expect(strategies[0]).toBeInstanceOf(BridgeStrategy); }); - it('returns RelayStrategy if strategy name is Relay', async () => { - getStrategyMock.mockReturnValue(TransactionPayStrategy.Relay); + it('returns empty list if no strategies are configured', () => { + getStrategiesMock.mockReturnValue(undefined as never); + + const strategies = getStrategies(messenger, TRANSACTION_META_MOCK); + + expect(strategies).toStrictEqual([]); + }); + }); + + describe('getStrategy', () => { + it('returns first strategy from list', async () => { + getStrategiesMock.mockReturnValue([ + TransactionPayStrategy.Test, + TransactionPayStrategy.Relay, + ]); const strategy = getStrategy(messenger, TRANSACTION_META_MOCK); - expect(strategy).toBeInstanceOf(RelayStrategy); + expect(strategy).toBeInstanceOf(TestStrategy); }); - it('throws if strategy name is unknown', async () => { - getStrategyMock.mockReturnValue('UnknownStrategy' as never); + it('throws if no strategies are configured', async () => { + getStrategiesMock.mockReturnValue([]); expect(() => getStrategy(messenger, TRANSACTION_META_MOCK)).toThrow( - 'Unknown strategy: UnknownStrategy', + 'No strategies configured', ); }); }); describe('getStrategyByName', () => { + it('returns AcrossStrategy if strategy name is Across', () => { + const strategy = getStrategyByName(TransactionPayStrategy.Across); + expect(strategy).toBeInstanceOf(AcrossStrategy); + }); + it('returns TestStrategy if strategy name is Test', () => { const strategy = getStrategyByName(TransactionPayStrategy.Test); expect(strategy).toBeInstanceOf(TestStrategy); @@ -72,4 +105,44 @@ describe('Strategy Utils', () => { ); }); }); + + describe('selectStrategy', () => { + const request = { + messenger, + requests: [], + transaction: TRANSACTION_META_MOCK, + } as PayStrategyGetQuotesRequest; + + it('returns first compatible strategy', () => { + const strategies = [ + { + supports: jest.fn().mockReturnValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }, + { + supports: jest.fn().mockReturnValue(true), + getQuotes: jest.fn(), + execute: jest.fn(), + }, + ]; + + const selected = selectStrategy(strategies as never, request); + expect(selected).toBe(strategies[1]); + }); + + it('throws when none are compatible', () => { + const strategies = [ + { + supports: jest.fn().mockReturnValue(false), + getQuotes: jest.fn(), + execute: jest.fn(), + }, + ]; + + expect(() => selectStrategy(strategies as never, request)).toThrow( + 'No compatible strategy found', + ); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index dc505431f4e..08245999852 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -1,11 +1,57 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionPayStrategy } from '../constants'; +import { AcrossStrategy } from '../strategy/across/AcrossStrategy'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; import type { PayStrategy, TransactionPayControllerMessenger } from '../types'; +/** + * Get the ordered list of payment strategy instances. + * + * @param messenger - Controller messenger + * @param transaction - Transaction to get the strategy for. + * @returns The ordered payment strategy instances. + */ +export function getStrategies( + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, +): PayStrategy[] { + const strategyNames = + messenger.call('TransactionPayController:getStrategies', transaction) ?? []; + + return strategyNames + .map((strategyName) => { + try { + return getStrategyByName(strategyName); + } catch { + return undefined; + } + }) + .filter(Boolean) as PayStrategy[]; +} + +/** + * Select the first compatible strategy for a request. + * + * @param strategies - Ordered strategies. + * @param request - Quote request. + * @returns The selected strategy instance. + */ +export function selectStrategy( + strategies: PayStrategy[], + request: Parameters>['getQuotes']>[0], +): PayStrategy { + for (const strategy of strategies) { + if (!strategy.supports || strategy.supports(request)) { + return strategy; + } + } + + throw new Error('No compatible strategy found'); +} + /** * Get the payment strategy instance. * @@ -17,12 +63,13 @@ export function getStrategy( messenger: TransactionPayControllerMessenger, transaction: TransactionMeta, ): PayStrategy { - const strategyName = messenger.call( - 'TransactionPayController:getStrategy', - transaction, - ); + const strategies = getStrategies(messenger, transaction); - return getStrategyByName(strategyName); + if (!strategies.length) { + throw new Error('No strategies configured'); + } + + return strategies[0]; } /** @@ -35,6 +82,9 @@ export function getStrategyByName( strategyName: TransactionPayStrategy, ): PayStrategy { switch (strategyName) { + case TransactionPayStrategy.Across: + return new AcrossStrategy() as never; + case TransactionPayStrategy.Bridge: return new BridgeStrategy() as never;