diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 4dbd958fdb2..4a3debfe1b3 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `perpsAcrossDeposit` and `predictAcrossDeposit` transaction types for Across MetaMask Pay submissions ([#7886](https://github.com/MetaMask/core/pull/7886)) + ## [62.18.0] ### Added diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index ae992a298d5..b0d5c229c67 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -785,6 +785,11 @@ export enum TransactionType { */ musdConversion = 'musdConversion', + /** + * Deposit funds for Across quote via Perps. + */ + perpsAcrossDeposit = 'perpsAcrossDeposit', + /** * Deposit funds to be available for trading via Perps. */ @@ -806,6 +811,11 @@ export enum TransactionType { */ personalSign = 'personal_sign', + /** + * Deposit funds for Across quote via Predict. + */ + predictAcrossDeposit = 'predictAcrossDeposit', + /** * Buy a position via Predict. * diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index c5d1534cf5a..b7b8108c990 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add ordered strategy fallback mechanism for quote retrieval ([#7868](https://github.com/MetaMask/core/pull/7868)) +- Add Across pay strategy support, including Across quote retrieval/normalization and Across submission flow with approval + deposit execution paths ([#7886](https://github.com/MetaMask/core/pull/7886)) ## [16.0.0] diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 73dd4540621..c0586a649a5 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -33,6 +33,7 @@ export const STABLECOINS: Record = { }; export enum TransactionPayStrategy { + Across = 'across', Bridge = 'bridge', Relay = 'relay', Test = 'test', 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..0c2bba64643 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -0,0 +1,188 @@ +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, + }, + }); + }); + + it('returns false when across is disabled', () => { + getPayStrategiesConfigMock.mockReturnValue({ + across: { + allowSameChain: false, + apiBase: 'https://across.test', + enabled: false, + }, + relay: { + enabled: true, + }, + }); + + 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, + }, + }); + + 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..e69127b2659 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -0,0 +1,891 @@ +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 { 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'; + +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, + 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('1.23'); + }); + + 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('uses GET approval request with default headers', async () => { + 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).toStrictEqual({ + headers: { + Accept: 'application/json', + }, + method: 'GET', + }); + }); + + 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('throws when destination flow is not transfer-style', async () => { + await expect( + getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + from: FROM_MOCK, + data: '0xabc' as Hex, + }, + }, + }), + ).rejects.toThrow(/Across only supports transfer-style/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 total fee as provider fee when provided', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + fees: { + ...QUOTE_MOCK.fees, + total: { amountUsd: '0.5' }, + }, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.provider.usd).toBe('0.5'); + expect(result[0].fees.provider.fiat).toBe('1'); + }); + + it('uses input-output delta when provider fee is not available', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + fees: { + destinationGas: { amountUsd: '0.67' }, + originGas: { amountUsd: '0.45' }, + }, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.provider.usd).toBe('1.9996'); + expect(result[0].fees.provider.fiat).toBe('3.9992'); + }); + + it('uses zero provider fee when expected output is zero and total fee is missing', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: '0', + fees: { + destinationGas: { amountUsd: '0.67' }, + originGas: { amountUsd: '0.45' }, + }, + minOutputAmount: '150', + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.provider.usd).toBe('0'); + expect(result[0].fees.provider.fiat).toBe('0'); + expect(result[0].dust.usd).toBe('0'); + }); + + it('uses zero provider fee when expected output is worth more than input', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + expectedOutputAmount: '2000000', + fees: { + destinationGas: { amountUsd: '0.67' }, + originGas: { amountUsd: '0.45' }, + }, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.provider.usd).toBe('0'); + expect(result[0].fees.provider.fiat).toBe('0'); + }); + + it('falls back to zero minimum output when quote and request minimums are missing', async () => { + const request = { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: undefined, + } as unknown as QuoteRequest; + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + minOutputAmount: undefined, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [request], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].dust.usd).toBe('0.0004'); + }); + + 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); + expect(calculateGasCostMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + gas: 900000, + }), + ); + expect(calculateGasCostMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + gas: 1500000, + isMax: true, + }), + ); + }); + + it('includes approval gas costs and gas limits when approval transactions exist', async () => { + estimateGasMock + .mockRejectedValueOnce(new Error('Approval gas estimation failed')) + .mockResolvedValueOnce({ + gas: '0x7530', + simulationFails: undefined, + }) + .mockResolvedValueOnce({ + gas: '0x5208', + simulationFails: undefined, + }); + + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: [ + { + chainId: 1, + data: '0xaaaa' as Hex, + to: '0xapprove1' as Hex, + value: '0x1' as Hex, + }, + { + chainId: 1, + data: '0xbbbb' as Hex, + to: '0xapprove2' as Hex, + }, + ], + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(calculateGasCostMock).toHaveBeenCalledTimes(6); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: '0x1', + gas: 900000, + }), + ); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: '0x1', + gas: 30000, + }), + ); + expect(calculateGasCostMock).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: '0x1', + gas: 21000, + }), + ); + expect(result[0].original.gasLimits.approval).toStrictEqual([ + { + estimate: 900000, + max: 1500000, + }, + { + estimate: 30000, + max: 30000, + }, + ]); + expect(result[0].original.gasLimits.swap).toStrictEqual({ + estimate: 21000, + max: 21000, + }); + }); + + it('handles missing approval transactions in Across quote response', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + ...QUOTE_MOCK, + approvalTxns: undefined, + }), + } as Response); + + const result = await getAcrossQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].original.gasLimits.approval).toStrictEqual([]); + expect(calculateGasCostMock).toHaveBeenCalledTimes(2); + }); + + 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'); + expect(result[0].dust.usd).toBe('0'); + }); + + 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'); + }); + + 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('uses nested transaction transfer recipient when available', 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 }, + { 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); + + 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); + + 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..0288d124e69 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -0,0 +1,513 @@ +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 { + AcrossGasLimits, + AcrossQuote, + AcrossSwapApprovalResponse, +} from './types'; +import { 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, + estimateGasLimitWithBufferOrFallback, +} 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 { 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 recipient = getAcrossRecipient(transaction, request); + + 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)); + } + + const response = await requestAcrossApproval(config.across.apiBase, params); + + const quote = (await response.json()) as AcrossSwapApprovalResponse; + + const originalQuote: AcrossQuote = { + quote, + request: { + amount, + tradeType, + }, + }; + + return await normalizeQuote(originalQuote, request, fullRequest); +} + +type AcrossApprovalRequest = { + url: string; + options: RequestInit; +}; + +function buildAcrossApprovalRequest( + apiBase: string, + params: URLSearchParams, +): AcrossApprovalRequest { + return { + url: `${apiBase}/swap/approval?${params.toString()}`, + options: { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + }; +} + +async function requestAcrossApproval( + apiBase: string, + params: URLSearchParams, +): Promise { + const { url, options } = buildAcrossApprovalRequest(apiBase, params); + return successfulFetch(url, options); +} + +function getAcrossRecipient( + transaction: TransactionMeta, + request: QuoteRequest, +): Hex { + const { nestedTransactions, txParams } = transaction; + const { 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: { data?: Hex }) => + nestedTx.data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE), + )?.data; + + const recipient = + tokenTransferData ?? + (isTokenTransfer && singleData ? singleData : undefined); + + if (recipient) { + return getTransferRecipient(recipient); + } + + const hasNoData = singleData === undefined || singleData === '0x'; + + if (hasNoData || isTokenTransfer) { + return from; + } + + throw new Error( + 'Across only supports transfer-style destination flows at the moment', + ); +} + +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, gasLimits } = await calculateSourceNetworkCost( + quote, + messenger, + request, + ); + + const targetNetwork = getFiatValueFromUsd(new BigNumber(0), usdToFiatRate); + + const inputAmountRaw = quote.inputAmount ?? '0'; + const expectedOutputRaw = new BigNumber( + quote.expectedOutputAmount ?? + quote.minOutputAmount ?? + request.targetAmountMinimum ?? + '0', + ); + const outputAmountRaw = expectedOutputRaw.toString(10); + + const sourceAmount = getAmountFromTokenAmount({ + amountRaw: inputAmountRaw, + decimals: quote.inputToken.decimals, + fiatRate: sourceFiatRate, + }); + + const providerUsd = calculateProviderUsd( + quote, + inputAmountRaw, + expectedOutputRaw, + sourceFiatRate, + targetFiatRate, + ); + const provider = getFiatValueFromUsd(providerUsd, usdToFiatRate); + + const targetAmount = getAmountFromTokenAmount({ + amountRaw: outputAmountRaw, + decimals: quote.outputToken.decimals, + fiatRate: targetFiatRate, + }); + + return { + dust, + estimatedDuration: quote.expectedFillTime ?? 0, + fees: { + provider, + sourceNetwork, + targetNetwork, + }, + original: { + ...original, + gasLimits, + }, + 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 expectedOutputRaw = quote.expectedOutputAmount; + + if (expectedOutputRaw === undefined) { + return new BigNumber(0); + } + + const expectedOutput = new BigNumber(expectedOutputRaw); + const minimumOutput = new BigNumber( + quote.minOutputAmount ?? request.targetAmountMinimum ?? '0', + ); + + const dustRaw = expectedOutput.minus(minimumOutput).isNegative() + ? new BigNumber(0) + : expectedOutput.minus(minimumOutput); + const dustHuman = dustRaw.shiftedBy(-quote.outputToken.decimals); + + return dustHuman.multipliedBy(targetFiatRate.usdRate); +} + +function calculateProviderUsd( + quote: AcrossSwapApprovalResponse, + inputAmountRaw: string, + expectedOutputRaw: BigNumber, + sourceFiatRate: FiatRates, + targetFiatRate: FiatRates, +): BigNumber { + const totalFeeUsd = quote.fees?.total?.amountUsd; + + if (totalFeeUsd !== undefined) { + return new BigNumber(totalFeeUsd).abs(); + } + + if (expectedOutputRaw.lte(0)) { + return new BigNumber(0); + } + + const inputAmountUsd = new BigNumber(inputAmountRaw) + .shiftedBy(-quote.inputToken.decimals) + .multipliedBy(sourceFiatRate.usdRate); + const expectedOutputUsd = expectedOutputRaw + .shiftedBy(-quote.outputToken.decimals) + .multipliedBy(targetFiatRate.usdRate); + const providerFeeUsd = inputAmountUsd.minus(expectedOutputUsd); + + return providerFeeUsd.isNegative() ? new BigNumber(0) : providerFeeUsd; +} + +function sumAmounts(amounts: Amount[]): Amount { + return amounts.reduce( + (total, amount) => ({ + fiat: new BigNumber(total.fiat).plus(amount.fiat).toString(10), + human: new BigNumber(total.human).plus(amount.human).toString(10), + raw: new BigNumber(total.raw).plus(amount.raw).toString(10), + usd: new BigNumber(total.usd).plus(amount.usd).toString(10), + }), + { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + ); +} + +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<{ + sourceNetwork: TransactionPayQuote['fees']['sourceNetwork']; + gasLimits: AcrossGasLimits; +}> { + const { from } = request; + const approvalTxns = quote.approvalTxns ?? []; + const { swapTx } = quote; + const swapChainId = toHex(swapTx.chainId); + + const approvalGasResults = await Promise.all( + approvalTxns.map(async (approval) => { + const chainId = toHex(approval.chainId); + const gas = await estimateGasLimitWithBufferOrFallback({ + chainId, + data: approval.data, + from, + messenger, + to: approval.to, + value: approval.value ?? '0x0', + }); + + if (gas.usedFallback) { + log('Gas estimate failed, using fallback', { + error: gas.error, + transactionType: 'approval', + }); + } + + return { chainId, gas }; + }), + ); + + const swapGas = await estimateGasLimitWithBufferOrFallback({ + chainId: swapChainId, + data: swapTx.data, + from, + messenger, + to: swapTx.to, + value: swapTx.value ?? '0x0', + }); + + if (swapGas.usedFallback) { + log('Gas estimate failed, using fallback', { + error: swapGas.error, + transactionType: 'swap', + }); + } + + const estimate = sumAmounts([ + ...approvalGasResults.map(({ chainId, gas }) => + calculateGasCost({ + chainId, + gas: gas.estimate, + messenger, + }), + ), + calculateGasCost({ + chainId: swapChainId, + gas: swapGas.estimate, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }), + ]); + + const max = sumAmounts([ + ...approvalGasResults.map(({ chainId, gas }) => + calculateGasCost({ + chainId, + gas: gas.max, + isMax: true, + messenger, + }), + ), + calculateGasCost({ + chainId: swapChainId, + gas: swapGas.max, + isMax: true, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + messenger, + }), + ]); + + return { + sourceNetwork: { + estimate, + max, + }, + gasLimits: { + approval: approvalGasResults.map(({ gas }) => ({ + estimate: gas.estimate, + max: gas.max, + })), + swap: { + estimate: swapGas.estimate, + max: swapGas.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..1ad24c40678 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.test.ts @@ -0,0 +1,1040 @@ +import { successfulFetch, 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(), +})); +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: 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: { + gasLimits: { + approval: [{ estimate: 21000, max: 21000 }], + swap: { estimate: 22000, max: 22000 }, + }, + 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 successfulFetchMock = jest.mocked(successfulFetch); + + 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, + }); + successfulFetchMock.mockResolvedValue({ + json: async () => ({ status: 'pending' }), + } as Response); + }); + + describe('submitAcrossQuotes', () => { + const setupConfirmedSubmission = (): void => { + 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 buildDepositQuote = (): TransactionPayQuote => + ({ + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + id: 'deposit-id', + }, + }, + }) as TransactionPayQuote; + + 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('preserves transaction type when 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.swap, + }), + ); + }); + + it('defaults to perps across deposit when transaction type is undefined', 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: undefined, + }, + 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('polls Across status endpoint when quote includes a deposit id', 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, + }; + }); + + successfulFetchMock.mockResolvedValueOnce({ + json: async () => ({ + destinationTxHash: '0xtarget', + status: 'success', + }), + } as Response); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + id: 'deposit-id', + }, + }, + } as TransactionPayQuote; + + const result = await submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(successfulFetchMock).toHaveBeenCalledWith( + expect.stringContaining('/deposit/status?'), + expect.objectContaining({ method: 'GET' }), + ); + expect(result.transactionHash).toBe('0xtarget'); + }); + + it('throws when Across status endpoint reports failure', 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, + }; + }); + + successfulFetchMock.mockResolvedValueOnce({ + json: async () => ({ + status: 'failed', + }), + } as Response); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + id: 'deposit-id', + }, + }, + } as TransactionPayQuote; + + await expect( + submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }), + ).rejects.toThrow('Across request failed with status: failed'); + }); + + it('returns fill tx hash when destination hash is missing', async () => { + setupConfirmedSubmission(); + successfulFetchMock.mockResolvedValueOnce({ + json: async () => ({ + fillTxHash: '0xfill', + status: 'filled', + }), + } as Response); + + const result = await submitAcrossQuotes({ + messenger, + quotes: [buildDepositQuote()], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(result.transactionHash).toBe('0xfill'); + }); + + it('returns tx hash when destination and fill hashes are missing', async () => { + setupConfirmedSubmission(); + successfulFetchMock.mockResolvedValueOnce({ + json: async () => ({ + status: 'success', + txHash: '0xbridge', + }), + } as Response); + + const result = await submitAcrossQuotes({ + messenger, + quotes: [buildDepositQuote()], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(result.transactionHash).toBe('0xbridge'); + }); + + it('falls back to source transaction hash when success has no destination hash fields', async () => { + setupConfirmedSubmission(); + successfulFetchMock.mockResolvedValueOnce({ + json: async () => ({ + status: 'completed', + }), + } as Response); + + const result = await submitAcrossQuotes({ + messenger, + quotes: [buildDepositQuote()], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + expect(result.transactionHash).toBe('0xconfirmed'); + }); + + it('returns original transaction hash when Across status polling times out', async () => { + jest.useFakeTimers(); + + try { + setupConfirmedSubmission(); + successfulFetchMock.mockResolvedValue({ + json: async () => ({ + status: 'pending', + }), + } as Response); + + const resultPromise = submitAcrossQuotes({ + messenger, + quotes: [buildDepositQuote()], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + await jest.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.transactionHash).toBe('0xconfirmed'); + expect(successfulFetchMock).toHaveBeenCalledTimes(20); + } finally { + jest.useRealTimers(); + } + }); + + it('reuses gas limits from quote when available', 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 { gas: Hex }; + + expect(params.gas).toBe(toHex(22000)); + expect(estimateGasMock).not.toHaveBeenCalled(); + }); + + it('uses fallback gas value when estimation fails', async () => { + estimateGasMock.mockRejectedValue(new Error('Gas estimation failed')); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + gasLimits: undefined as never, + 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('uses fallback gas value when gas simulation fails', async () => { + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: { + debug: {}, + }, + }); + + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + gasLimits: undefined as never, + 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, + gasLimits: undefined as never, + 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('converts decimal gas price strings to hex for swap transaction', async () => { + const decimalGasQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + swapTx: { + ...QUOTE_MOCK.original.quote.swapTx, + maxFeePerGas: '256', + maxPriorityFeePerGas: '16', + }, + }, + }, + } as TransactionPayQuote; + + await submitAcrossQuotes({ + messenger, + quotes: [decimalGasQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }); + + const params = addTransactionMock.mock.calls[0][0] as { + maxFeePerGas: Hex; + maxPriorityFeePerGas: Hex; + }; + + expect(params.maxFeePerGas).toBe(toHex('256')); + expect(params.maxPriorityFeePerGas).toBe(toHex('16')); + }); + + 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); + }); + + it('unsubscribes transaction collector if submission throws', async () => { + const unsubscribeSpy = jest.spyOn(messenger, 'unsubscribe'); + const noApprovalQuote = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + quote: { + ...QUOTE_MOCK.original.quote, + approvalTxns: [], + }, + }, + } as TransactionPayQuote; + + addTransactionMock.mockRejectedValue(new Error('submission failed')); + + await expect( + submitAcrossQuotes({ + messenger, + quotes: [noApprovalQuote], + transaction: TRANSACTION_META_MOCK, + isSmartTransaction: jest.fn(), + }), + ).rejects.toThrow('submission failed'); + + expect(unsubscribeSpy).toHaveBeenCalledWith( + 'TransactionController:unapprovedTransactionAdded', + expect.any(Function), + ); + }); + }); + + 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..7cfdbf46bad --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/across-submit.ts @@ -0,0 +1,422 @@ +import { + ORIGIN_METAMASK, + successfulFetch, + toHex, +} from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + TransactionMeta, + TransactionParams, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import type { AcrossQuote, AcrossSwapApprovalResponse } from './types'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyExecuteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { estimateGasLimitWithBufferOrFallback } from '../../utils/gas'; +import { + collectTransactionIds, + getTransaction, + updateTransaction, + waitForTransactionConfirmed, +} from '../../utils/transaction'; + +const log = createModuleLogger(projectLogger, 'across-strategy'); +const ACROSS_STATUS_POLL_INTERVAL = 3000; +const ACROSS_STATUS_MAX_ATTEMPTS = 20; + +type PreparedAcrossTransaction = { + params: TransactionParams; + type: TransactionType; +}; + +/** + * 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 { quotes, messenger, transaction } = request; + let transactionHash: Hex | undefined; + + for (const quote of quotes) { + ({ transactionHash } = await executeSingleQuote( + quote, + messenger, + transaction, + )); + } + + return { transactionHash }; +} + +async function executeSingleQuote( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, +): Promise<{ transactionHash?: Hex }> { + log('Executing single quote', quote); + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Remove nonce from skipped transaction', + }, + (tx) => { + tx.txParams.nonce = undefined; + }, + ); + + const acrossDepositType = getAcrossDepositType(transaction.type); + const transactionHash = await submitTransactions( + quote, + transaction.id, + acrossDepositType, + messenger, + ); + + updateTransaction( + { + transactionId: transaction.id, + messenger, + note: 'Intent complete after Across submission', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + + return { transactionHash }; +} + +/** + * Submit transactions for an Across quote. + * + * @param quote - Across quote. + * @param parentTransactionId - ID of the parent transaction. + * @param acrossDepositType - Transaction type used for the swap/deposit step. + * @param messenger - Controller messenger. + * @returns Hash of the last submitted transaction, if available. + */ +async function submitTransactions( + quote: TransactionPayQuote, + parentTransactionId: string, + acrossDepositType: TransactionType, + messenger: TransactionPayControllerMessenger, +): Promise { + const { approvalTxns, swapTx } = quote.original.quote; + const quoteGasLimits = quote.original.gasLimits; + const { from } = quote.request; + const chainId = toHex(swapTx.chainId); + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + const transactions: PreparedAcrossTransaction[] = []; + + if (approvalTxns?.length) { + for (const [index, approval] of approvalTxns.entries()) { + transactions.push({ + params: await buildTransactionParams(messenger, from, { + chainId: approval.chainId, + data: approval.data, + gasLimit: quoteGasLimits?.approval[index]?.estimate, + to: approval.to, + value: approval.value, + }), + type: TransactionType.tokenMethodApprove, + }); + } + } + + transactions.push({ + params: await buildTransactionParams(messenger, from, { + chainId: swapTx.chainId, + data: swapTx.data, + gasLimit: quoteGasLimits?.swap?.estimate, + to: swapTx.to, + value: swapTx.value, + maxFeePerGas: swapTx.maxFeePerGas, + maxPriorityFeePerGas: swapTx.maxPriorityFeePerGas, + }), + type: acrossDepositType, + }); + + const transactionIds: string[] = []; + + const { end } = collectTransactionIds( + chainId, + from, + messenger, + (transactionId) => { + transactionIds.push(transactionId); + + updateTransaction( + { + transactionId: parentTransactionId, + messenger, + note: 'Add required transaction ID from Across submission', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(transactionId); + }, + ); + }, + ); + + let result: { result: Promise } | undefined; + + try { + if (transactions.length === 1) { + result = await messenger.call( + 'TransactionController:addTransaction', + transactions[0].params, + { + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + type: transactions[0].type, + }, + ); + } else { + const batchTransactions = transactions.map(({ params, type }) => ({ + params: toBatchTransactionParams(params), + type, + })); + + await messenger.call('TransactionController:addTransactionBatch', { + from, + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + transactions: batchTransactions, + }); + } + } finally { + end(); + } + + if (result) { + const txHash = await result.result; + log('Submitted transaction', txHash); + } + + await Promise.all( + transactionIds.map((txId) => waitForTransactionConfirmed(txId, messenger)), + ); + + const hash = transactionIds.length + ? getTransaction(transactionIds.slice(-1)[0], messenger)?.hash + : undefined; + + return await waitForAcrossCompletion( + quote.original, + hash as Hex | undefined, + messenger, + ); +} + +type AcrossStatusResponse = { + status?: string; + destinationTxHash?: Hex; + fillTxHash?: Hex; + txHash?: Hex; +}; + +async function waitForAcrossCompletion( + quote: AcrossQuote, + transactionHash: Hex | undefined, + messenger: TransactionPayControllerMessenger, +): Promise { + if (!transactionHash || !quote.quote.id) { + return transactionHash; + } + + const config = getPayStrategiesConfig(messenger); + const params = new URLSearchParams({ + depositId: quote.quote.id, + originChainId: String(quote.quote.swapTx.chainId), + txHash: transactionHash, + }); + const url = `${config.across.apiBase}/deposit/status?${params.toString()}`; + + for (let attempt = 0; attempt < ACROSS_STATUS_MAX_ATTEMPTS; attempt++) { + const response = await successfulFetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + const status = (await response.json()) as AcrossStatusResponse; + const normalizedStatus = status.status?.toLowerCase(); + + log('Polled Across status', { + attempt: attempt + 1, + status: normalizedStatus, + transactionHash, + }); + + if ( + normalizedStatus && + ['completed', 'filled', 'success'].includes(normalizedStatus) + ) { + return ( + status.destinationTxHash ?? + status.fillTxHash ?? + status.txHash ?? + transactionHash + ); + } + + if ( + normalizedStatus && + ['error', 'failed', 'refund', 'refunded'].includes(normalizedStatus) + ) { + throw new Error(`Across request failed with status: ${normalizedStatus}`); + } + + await new Promise((resolve) => + setTimeout(resolve, ACROSS_STATUS_POLL_INTERVAL), + ); + } + + log('Across status polling timed out', { transactionHash }); + return transactionHash; +} + +function getAcrossDepositType( + transactionType?: TransactionType, +): TransactionType { + switch (transactionType) { + case TransactionType.perpsDeposit: + return TransactionType.perpsAcrossDeposit; + case TransactionType.predictDeposit: + return TransactionType.predictAcrossDeposit; + case undefined: + return TransactionType.perpsAcrossDeposit; + default: + return transactionType; + } +} + +async function buildTransactionParams( + messenger: TransactionPayControllerMessenger, + from: Hex, + params: { + chainId: number; + data: Hex; + gasLimit?: number; + to: Hex; + value?: Hex; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + }, +): Promise { + const chainId = toHex(params.chainId); + const value = toHex(params.value ?? '0x0'); + const gas = + params.gasLimit ?? + (await estimateGasLimit( + messenger, + { + chainId, + data: params.data, + to: params.to, + value, + }, + from, + )); + + return { + data: params.data, + from, + gas: toHex(gas), + maxFeePerGas: normalizeOptionalHex(params.maxFeePerGas), + maxPriorityFeePerGas: normalizeOptionalHex(params.maxPriorityFeePerGas), + to: params.to, + value, + }; +} + +function normalizeOptionalHex(value?: string): Hex | undefined { + if (value === undefined) { + return undefined; + } + + return toHex(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 estimateGasLimit( + messenger: TransactionPayControllerMessenger, + params: { + chainId: Hex; + data: Hex; + to: Hex; + value: Hex; + }, + from: Hex, +): Promise { + const { chainId, data, to, value } = params; + const gasResult = await estimateGasLimitWithBufferOrFallback({ + chainId, + data, + fallbackOnSimulationFailure: true, + from, + messenger, + to, + value, + }); + + if (gasResult.usedFallback) { + log('Gas estimate failed, using fallback', { error: gasResult.error }); + } + + return gasResult.estimate; +} + +export function isAcrossQuote( + quote: TransactionPayQuote, +): quote is TransactionPayQuote { + return Boolean( + quote && + typeof quote === 'object' && + quote.original && + typeof quote.original === 'object' && + 'quote' in quote.original, + ); +} + +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..bdb20e5434a --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/types.ts @@ -0,0 +1,95 @@ +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 AcrossGasLimits = { + approval: { + estimate: number; + max: number; + }[]; + swap: { + estimate: number; + max: number; + }; +}; + +export type AcrossQuote = { + gasLimits: AcrossGasLimits; + quote: AcrossSwapApprovalResponse; + request: { + amount: string; + tradeType: 'exactOutput' | 'exactInput'; + }; +}; diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts new file mode 100644 index 00000000000..7ac8935d85b --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts @@ -0,0 +1,105 @@ +import type { Hex } from '@metamask/utils'; + +import { getRelayQuotes } from './relay-quotes'; +import { submitRelayQuotes } from './relay-submit'; +import { RelayStrategy } from './RelayStrategy'; +import type { RelayQuote } from './types'; +import type { + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, + TransactionPayQuote, +} from '../../types'; +import { getPayStrategiesConfig } from '../../utils/feature-flags'; + +jest.mock('./relay-quotes'); +jest.mock('./relay-submit'); +jest.mock('../../utils/feature-flags'); + +describe('RelayStrategy', () => { + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig); + + const messenger = {} as never; + + const request = { + messenger, + 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, + }, + ], + transaction: { + txParams: { from: '0xabc' as Hex }, + }, + } as unknown as PayStrategyGetQuotesRequest; + + beforeEach(() => { + jest.resetAllMocks(); + + getPayStrategiesConfigMock.mockReturnValue({ + across: { + allowSameChain: false, + apiBase: 'https://across.test', + enabled: true, + }, + relay: { + enabled: true, + }, + }); + }); + + it('returns true from supports when relay is enabled', () => { + const strategy = new RelayStrategy(); + expect(strategy.supports(request)).toBe(true); + }); + + it('returns false from supports when relay is disabled', () => { + getPayStrategiesConfigMock.mockReturnValue({ + across: { + allowSameChain: false, + apiBase: 'https://across.test', + enabled: true, + }, + relay: { + enabled: false, + }, + }); + + const strategy = new RelayStrategy(); + expect(strategy.supports(request)).toBe(false); + }); + + it('delegates getQuotes', async () => { + const quote = { strategy: 'relay' } as TransactionPayQuote; + getRelayQuotesMock.mockResolvedValue([quote]); + + const strategy = new RelayStrategy(); + expect(await strategy.getQuotes(request)).toStrictEqual([quote]); + expect(getRelayQuotesMock).toHaveBeenCalledWith(request); + }); + + it('delegates execute', async () => { + const executeRequest = { + messenger, + quotes: [], + transaction: request.transaction, + isSmartTransaction: jest.fn(), + } as PayStrategyExecuteRequest; + + submitRelayQuotesMock.mockResolvedValue({ transactionHash: '0xhash' }); + + const strategy = new RelayStrategy(); + expect(await strategy.execute(executeRequest)).toStrictEqual({ + transactionHash: '0xhash', + }); + expect(submitRelayQuotesMock).toHaveBeenCalledWith(executeRequest); + }); +}); 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/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index f4d96cf1af0..217495eee62 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, @@ -10,6 +11,7 @@ import { getEIP7702SupportedChains, getFeatureFlags, getGasBuffer, + getPayStrategiesConfig, getSlippage, getStrategyOrder, } from './feature-flags'; @@ -389,6 +391,60 @@ describe('Feature Flags Utils', () => { }); }); + 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, + }), + ); + }); + + it('returns feature-flag values when provided', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + across: { + allowSameChain: true, + apiBase: 'https://across.test', + enabled: false, + }, + relay: { + enabled: false, + }, + }, + }, + }, + }); + + const config = getPayStrategiesConfig(messenger); + + expect(config.across).toStrictEqual( + expect.objectContaining({ + allowSameChain: true, + apiBase: 'https://across.test', + enabled: false, + }), + ); + expect(config.relay).toStrictEqual( + expect.objectContaining({ + enabled: false, + }), + ); + }); + }); + describe('getStrategyOrder', () => { it('returns default strategy order when none is set', () => { const strategyOrder = getStrategyOrder(messenger); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index e2cc7f51673..e9b2b87f7ab 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -16,8 +16,10 @@ 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: StrategyOrder = [ TransactionPayStrategy.Relay, + TransactionPayStrategy.Across, ]; type FeatureFlagsRaw = { @@ -40,6 +42,7 @@ type FeatureFlagsRaw = { slippage?: number; slippageTokens?: Record>; strategyOrder?: string[]; + payStrategies?: PayStrategiesConfigRaw; }; export type FeatureFlags = { @@ -52,6 +55,30 @@ export type FeatureFlags = { slippage: number; }; +export type AcrossConfigRaw = { + allowSameChain?: boolean; + apiBase?: string; + enabled?: boolean; +}; + +export type PayStrategiesConfigRaw = { + across?: AcrossConfigRaw; + relay?: { + enabled?: boolean; + }; +}; + +export type PayStrategiesConfig = { + across: AcrossConfigRaw & { + allowSameChain: boolean; + apiBase: string; + enabled: boolean; + }; + relay: { + enabled: boolean; + }; +}; + /** * Get ordered list of strategies to try. * @@ -120,6 +147,49 @@ 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, + enabled: acrossRaw.enabled ?? true, + }; + + const relay = { + enabled: relayRaw.enabled ?? true, + }; + + return { + across, + relay, + }; +} + +/** + * Get fallback gas limits for quote/submit flows. + * + * @param messenger - Controller messenger. + * @returns Fallback gas limits. + */ +export function getRelayFallbackGas( + messenger: TransactionPayControllerMessenger, +): FeatureFlags['relayFallbackGas'] { + return getFeatureFlags(messenger).relayFallbackGas; +} + /** * Get the gas buffer value for a specific chain ID. * diff --git a/packages/transaction-pay-controller/src/utils/gas.test.ts b/packages/transaction-pay-controller/src/utils/gas.test.ts index 9f505008e57..44bf7f739ba 100644 --- a/packages/transaction-pay-controller/src/utils/gas.test.ts +++ b/packages/transaction-pay-controller/src/utils/gas.test.ts @@ -2,10 +2,12 @@ import { toHex } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; import { clone, cloneDeep } from 'lodash'; +import { getGasBuffer, getRelayFallbackGas } from './feature-flags'; import { calculateGasCost, calculateGasFeeTokenCost, calculateTransactionGasCost, + estimateGasLimitWithBufferOrFallback, } from './gas'; import { getTokenBalance, getTokenFiatRate } from './token'; import type { GasFeeEstimates } from '../../../gas-fee-controller/src'; @@ -16,6 +18,11 @@ import type { import { getMessengerMock } from '../tests/messenger-mock'; jest.mock('./token'); +jest.mock('./feature-flags', () => ({ + ...jest.requireActual('./feature-flags'), + getGasBuffer: jest.fn(), + getRelayFallbackGas: jest.fn(), +})); const GAS_USED_MOCK = toHex(21000); const GAS_LIMIT_NO_BUFFER_MOCK = toHex(30000); @@ -55,15 +62,28 @@ const GAS_FEE_CONTROLLER_STATE_MOCK = { }; describe('Gas Utils', () => { + const getGasBufferMock = jest.mocked(getGasBuffer); + const getRelayFallbackGasMock = jest.mocked(getRelayFallbackGas); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); const getTokenBalanceMock = jest.mocked(getTokenBalance); - const { messenger, getGasFeeControllerStateMock } = getMessengerMock(); + const { + estimateGasMock, + findNetworkClientIdByChainIdMock, + messenger, + getGasFeeControllerStateMock, + } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); getGasFeeControllerStateMock.mockReturnValue(GAS_FEE_CONTROLLER_STATE_MOCK); getTokenBalanceMock.mockReturnValue('147000000000000'); + getRelayFallbackGasMock.mockReturnValue({ + estimate: 123, + max: 456, + }); + getGasBufferMock.mockReturnValue(1.5); + findNetworkClientIdByChainIdMock.mockReturnValue('network-client-id'); getTokenFiatRateMock.mockReturnValue({ usdRate: '4000', @@ -395,4 +415,103 @@ describe('Gas Utils', () => { expect(result).toBeUndefined(); }); }); + + describe('estimateGasLimitWithBufferOrFallback', () => { + it('returns buffered gas estimate when simulation succeeds', async () => { + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: undefined, + }); + + expect( + await estimateGasLimitWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: '0xdead' as Hex, + from: '0xabc' as Hex, + messenger, + to: '0xdef' as Hex, + value: '0x1' as Hex, + }), + ).toStrictEqual({ + estimate: Math.ceil(21000 * 1.5), + max: Math.ceil(21000 * 1.5), + usedFallback: false, + }); + }); + + it('throws when estimate reports simulation failure by default', async () => { + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: { + debug: {}, + }, + }); + + await expect( + estimateGasLimitWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: '0xdead' as Hex, + from: '0xabc' as Hex, + messenger, + to: '0xdef' as Hex, + }), + ).rejects.toThrow('Gas simulation failed'); + }); + + it('returns fallback gas when estimate reports simulation failure and fallback is enabled', async () => { + estimateGasMock.mockResolvedValue({ + gas: '0x5208', + simulationFails: { + debug: {}, + }, + }); + + const result = await estimateGasLimitWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: '0xdead' as Hex, + fallbackOnSimulationFailure: true, + from: '0xabc' as Hex, + messenger, + to: '0xdef' as Hex, + }); + + expect(result).toMatchObject({ + estimate: 123, + max: 456, + usedFallback: true, + }); + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toBe('Gas simulation failed'); + + expect(estimateGasMock).toHaveBeenCalledWith( + { + from: '0xabc', + data: '0xdead', + to: '0xdef', + value: '0x0', + }, + 'network-client-id', + ); + }); + + it('returns fallback gas when estimate throws', async () => { + const error = new Error('estimate failed'); + estimateGasMock.mockRejectedValue(error); + + expect( + await estimateGasLimitWithBufferOrFallback({ + chainId: CHAIN_ID_MOCK, + data: '0xdead' as Hex, + from: '0xabc' as Hex, + messenger, + to: '0xdef' as Hex, + }), + ).toStrictEqual({ + estimate: 123, + max: 456, + usedFallback: true, + error, + }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/gas.ts b/packages/transaction-pay-controller/src/utils/gas.ts index 95c2bfb8668..a4fb70e5dc8 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 { getGasBuffer, getRelayFallbackGas } from './feature-flags'; import { getNativeToken, getTokenBalance, getTokenFiatRate } from './token'; import type { TransactionPayControllerMessenger } from '..'; import { createModuleLogger, projectLogger } from '../logger'; @@ -198,6 +199,74 @@ export function calculateGasFeeTokenCost({ }; } +export async function estimateGasLimitWithBufferOrFallback({ + chainId, + data, + fallbackOnSimulationFailure = false, + from, + messenger, + to, + value, +}: { + chainId: Hex; + data: Hex; + fallbackOnSimulationFailure?: boolean; + 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 estimateGasError: unknown; + let simulationError: Error | undefined; + + try { + const { gas: gasHex, simulationFails } = await messenger.call( + 'TransactionController:estimateGas', + { from, data, to, value: value ?? '0x0' }, + networkClientId, + ); + + if (simulationFails) { + simulationError = new Error('Gas simulation failed'); + } else { + const estimatedGas = new BigNumber(gasHex).toNumber(); + const bufferedGas = Math.ceil(estimatedGas * gasBuffer); + + return { + estimate: bufferedGas, + max: bufferedGas, + usedFallback: false, + }; + } + } catch (caughtError) { + estimateGasError = caughtError; + } + + if (simulationError !== undefined && !fallbackOnSimulationFailure) { + throw simulationError; + } + + const fallbackGas = getRelayFallbackGas(messenger); + + return { + estimate: fallbackGas.estimate, + max: fallbackGas.max, + usedFallback: true, + error: estimateGasError ?? simulationError, + }; +} + /** * 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 bcb281ec9ad..f9bf38a1af5 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -6,8 +6,8 @@ import { cloneDeep } from 'lodash'; import type { UpdateQuotesRequest } from './quotes'; import { refreshQuotes, updateQuotes } from './quotes'; -import { getLiveTokenBalance, getTokenFiatRate } from './token'; import { getStrategiesByName, getStrategyByName } from './strategy'; +import { getLiveTokenBalance, getTokenFiatRate } from './token'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; import { TransactionPayStrategy } from '../constants'; diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 1772646d63e..9c946ed0a2c 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -4,12 +4,12 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { getStrategiesByName, getStrategyByName } from './strategy'; import { computeTokenAmounts, getLiveTokenBalance, getTokenFiatRate, } from './token'; -import { getStrategiesByName, getStrategyByName } from './strategy'; import { calculateTotals } from './totals'; import { getTransaction, updateTransaction } from './transaction'; import { TransactionPayStrategy } from '../constants'; diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index 8e781f203dc..9ac352be9d9 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -1,11 +1,17 @@ import { getStrategiesByName, getStrategyByName } 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'; describe('Strategy Utils', () => { 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); diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 22c2cf7c9dc..6dfc724c88c 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -1,4 +1,5 @@ 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'; @@ -19,6 +20,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;