From 689e7929cac956805538f4373df0043bb504c6c3 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 11 Jun 2026 13:55:07 -0700 Subject: [PATCH] wip --- .../bridge-controller.sse.batch.test.ts.snap | 2 +- .../bridge-controller.sse.test.ts.snap | 19 +- .../src/bridge-controller.sse.batch.test.ts | 43 +- .../src/bridge-controller.sse.test.ts | 189 ++++--- .../src/bridge-controller.test.ts | 252 ++++----- .../src/bridge-controller.ts | 37 +- packages/bridge-controller/src/index.ts | 17 +- .../bridge-controller/src/selectors.test.ts | 515 ++++++++++-------- packages/bridge-controller/src/selectors.ts | 165 ++++-- packages/bridge-controller/src/types.ts | 49 +- .../bridge-controller/src/utils/bridge.ts | 2 +- .../bridge-controller/src/utils/fetch.test.ts | 21 +- packages/bridge-controller/src/utils/fetch.ts | 108 ++-- .../src/utils/metrics/properties.test.ts | 89 ++- .../src/utils/metrics/properties.ts | 36 +- .../src/utils/metrics/types.ts | 3 +- .../bridge-controller/src/utils/quote-fees.ts | 79 +-- .../bridge-controller/src/utils/quote.test.ts | 258 +++++---- packages/bridge-controller/src/utils/quote.ts | 139 +++-- .../src/validators/bridge-asset.ts | 100 ++++ .../src/validators/feature-flags.ts | 6 +- .../validators/quote-response-v2-migration.ts | 257 +++++++++ .../quote-response-v2.migration.test.ts | 135 +++++ .../src/validators/quote-response-v2.test.ts | 25 + .../src/validators/quote-response-v2.ts | 279 ++++++++++ .../src/validators/quote-response.ts | 51 +- .../mock-quotes-erc20-erc20-migration-v2.ts | 91 ++++ .../tests/mock-quotes-erc20-erc20.ts | 11 +- .../tests/mock-quotes-erc20-native.ts | 9 +- .../tests/mock-quotes-native-erc20-eth.ts | 9 +- .../tests/mock-quotes-native-erc20.ts | 9 +- .../tests/mock-quotes-sol-erc20.ts | 9 +- packages/bridge-controller/tests/mock-sse.ts | 18 +- 33 files changed, 2116 insertions(+), 916 deletions(-) create mode 100644 packages/bridge-controller/src/validators/quote-response-v2-migration.ts create mode 100644 packages/bridge-controller/src/validators/quote-response-v2.migration.test.ts create mode 100644 packages/bridge-controller/src/validators/quote-response-v2.test.ts create mode 100644 packages/bridge-controller/src/validators/quote-response-v2.ts create mode 100644 packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap index 0ee671c741..048b1709b2 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap @@ -60,7 +60,7 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes s } `; -exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes should trigger quote polling if request is valid 2`] = ` +exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes should trigger quote polling if request is valid 3`] = ` [ [ "Unified SwapBridge Input Changed", diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index 76019e0e68..b9f0c271fa 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -9,16 +9,24 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "failures": [ - "lifi|trade", + "lifi|unknown", + "lifi|quote.src", + "lifi|quote.dest", + "lifi|quote.feeData.metabridge", + "lifi|quote.aggregator", + "lifi|quote.protocols", + "lifi|namespace", + "lifi|chainId", "lifi|trade.chainId", "lifi|trade.to", "lifi|trade.from", "lifi|trade.value", "lifi|trade.data", "lifi|trade.gasLimit", + "lifi|trade", + "lifi|trade.raw_data_hex", "lifi|trade.unsignedPsbtBase64", "lifi|trade.inputsToSign", - "lifi|trade.raw_data_hex", ], "feature_id": "unified_swap_bridge", "location": "Main View", @@ -52,7 +60,14 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "failures": [ + "unknown|unknown", "unknown|quote", + "unknown|namespace", + "unknown|chainId", + "unknown|trade", + "unknown|trade.raw_data_hex", + "unknown|trade.unsignedPsbtBase64", + "unknown|trade.inputsToSign", ], "feature_id": "unified_swap_bridge", "location": "Main View", diff --git a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts index 23baf3f531..f8f501d2e3 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -7,8 +7,14 @@ import type { } from '@metamask/messenger'; import { flushPromises } from '../../../tests/helpers'; -import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../tests/mock-quotes-erc20-erc20'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20'; import { advanceToNthTimerThenFlush, mockSseBatchSellEventSource, @@ -213,6 +219,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () }); it('should trigger quote polling if request is valid', async function () { + const consoleWarnSpy = jest.spyOn(console, 'warn'); await withController( async ({ controller: bridgeController, @@ -227,7 +234,10 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () mockFetchFn.mockImplementationOnce(async () => { return mockSseBatchSellEventSource([ mockBridgeQuotesNativeErc20V1, - mockBridgeQuotesErc20Erc20V1, + mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 1, + })), ]); }); hasSufficientBalanceSpy.mockResolvedValue(true); @@ -399,6 +409,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () // After first fetch jest.advanceTimersByTime(5000); await flushPromises(); + expect(consoleWarnSpy.mock.calls).toMatchInlineSnapshot(`[]`); expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); expect(bridgeController.state).toStrictEqual({ ...expectedState, @@ -420,7 +431,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20V1 + quotes: getMockBridgeQuotesNativeErc20V2() .map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -429,13 +440,15 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () featureId: FeatureId.BATCH_SELL, })) .concat( - mockBridgeQuotesErc20Erc20V1.map((quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x2', - resetApproval: undefined, - quoteRequestIndex: 1, - featureId: FeatureId.BATCH_SELL, - })), + getMockBridgeQuotesErc20Erc20V2({ quoteRequestIndex: 1 }).map( + (quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x2', + resetApproval: undefined, + quoteRequestIndex: 1, + featureId: FeatureId.BATCH_SELL, + }), + ), ), quotesRefreshCount: 1, quotesLoadingStatus: 1, @@ -644,7 +657,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); await rootMessenger.call( 'BridgeController:updateBatchSellTrades', - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, ); @@ -684,7 +697,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () '13.8.0', ]); expect(fetchBatchSellTradesSpy.mock.calls[1]).toStrictEqual([ - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, expect.any(AbortSignal), 'extension', @@ -881,7 +894,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () // 2nd fetch await rootMessenger.call( 'BridgeController:updateBatchSellTrades', - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, ); @@ -891,7 +904,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); expect(abortControllerSpy).toHaveBeenCalledTimes(1); expect(fetchBatchSellTradesSpy.mock.calls[1][0]).toStrictEqual( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), ); expect(startPollingSpy).not.toHaveBeenCalled(); expect(bridgeController.state.batchSellTrades).toBeNull(); diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 8b0a5edbc2..08c464139b 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -10,9 +10,18 @@ import type { import { abiERC20 } from '@metamask/metamask-eth-abis'; import { flushPromises } from '../../../tests/helpers'; -import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20'; -import { mockBridgeQuotesNativeErc20EthV1 } from '../tests/mock-quotes-native-erc20-eth'; +import { + mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2, +} from '../tests/mock-quotes-erc20-erc20'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20'; +import { + getMockBridgeQuotesNativeErc20EthV2, + mockBridgeQuotesNativeErc20EthV1, +} from '../tests/mock-quotes-native-erc20-eth'; import { advanceToNthTimer, advanceToNthTimerThenFlush, @@ -35,9 +44,10 @@ import * as balanceUtils from './utils/balance'; import { formatChainIdToDec } from './utils/caip-formatters'; import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; +import { FeatureId } from './validators/feature-flags'; +import { validateQuoteResponseV1 } from './validators/quote-response'; import { QuoteStreamCompleteReason } from './validators/quote-stream-complete'; import { TokenFeatureType } from './validators/token-feature'; -import { FeatureId } from './validators/feature-flags'; import type { TxData } from './validators/trade'; type RootMessenger = Messenger< @@ -317,7 +327,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', resetApproval: undefined, @@ -377,7 +387,14 @@ describe('BridgeController SSE', function () { ...quote, quote: { ...quote.quote, - srcTokenAddress, + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}` as const, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + }, srcChainId: 1, destChainId: formatChainIdToDec(destChainId), }, @@ -488,7 +505,19 @@ describe('BridgeController SSE', function () { resetApproval, }, ], - quotes: mockUSDTQuoteResponse.map((quote) => ({ + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + }, + srcChainId: 1, + }, + }).map((quote) => ({ ...quote, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, resetApproval: tradeData @@ -541,11 +570,22 @@ describe('BridgeController SSE', function () { ...quote, quote: { ...quote.quote, - srcTokenAddress: ETH_USDT_ADDRESS, + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}` as const, + chainId: 1, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + }, srcChainId: 1, }, }), ); + mockUSDTQuoteResponse.forEach((quote) => + validateQuoteResponseV1(quote), + ); + mockFetchFn.mockImplementationOnce(async () => { return mockSseEventSource(mockUSDTQuoteResponse); }); @@ -647,7 +687,19 @@ describe('BridgeController SSE', function () { resetApproval: true, }, ], - quotes: mockUSDTQuoteResponse.map((quote) => ({ + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + name: 'Tether USD', + decimals: 6, + symbol: 'USDT', + chainId: 1, + }, + srcChainId: 1, + }, + }).map((quote) => ({ ...quote, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, resetApproval: { @@ -702,7 +754,7 @@ describe('BridgeController SSE', function () { jest.advanceTimersByTime(FIRST_FETCH_DELAY); await flushPromises(); expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20V1.map((quote) => ({ + getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', resetApproval: undefined, @@ -727,7 +779,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: [mockBridgeQuotesNativeErc20EthV1[0]].map((quote) => ({ + quotes: [getMockBridgeQuotesNativeErc20EthV2()[0]].map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -754,7 +806,7 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(); expect(bridgeController.state).toStrictEqual({ ...expectedState, - quotes: mockBridgeQuotesNativeErc20EthV1.map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20EthV2().map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -824,7 +876,7 @@ describe('BridgeController SSE', function () { FIRST_FETCH_DELAY, ); expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20EthV1.map((quote) => ({ + getMockBridgeQuotesNativeErc20EthV2().map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -857,13 +909,13 @@ describe('BridgeController SSE', function () { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ).toBeGreaterThan(t2!); expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Failed to stream bridge quotes", - "Network error", - ], - ] - `); + [ + [ + "Failed to stream bridge quotes", + "Network error", + ], + ] + `); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); @@ -902,7 +954,7 @@ describe('BridgeController SSE', function () { [ ...mockBridgeQuotesNativeErc20V1, ...mockBridgeQuotesNativeErc20V1, - ], + ] as never, THIRD_FETCH_DELAY, ); }); @@ -996,7 +1048,7 @@ describe('BridgeController SSE', function () { quotesInitialLoadTime: THIRD_FETCH_DELAY, quotes: [ { - ...mockBridgeQuotesNativeErc20V1[0], + ...getMockBridgeQuotesNativeErc20V2()[0], l1GasFeesInHexWei: '0x1', resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1031,8 +1083,8 @@ describe('BridgeController SSE', function () { quotesRefreshCount: 1, quotesLoadingStatus: RequestStatus.FETCHED, quotes: [ - ...mockBridgeQuotesNativeErc20V1, - ...mockBridgeQuotesNativeErc20V1, + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), ].map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -1179,13 +1231,13 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(3); expect(bridgeController.state.quotes).toStrictEqual( [ - ...mockBridgeQuotesNativeErc20V1, - ...mockBridgeQuotesNativeErc20V1, + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), ].map((quote) => ({ ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, l1GasFeesInHexWei: '0x1', resetApproval: undefined, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, })), ); @@ -1210,7 +1262,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: [mockBridgeQuotesNativeErc20EthV1[0]].map((quote) => ({ + quotes: [getMockBridgeQuotesNativeErc20EthV2()[0]].map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1236,22 +1288,30 @@ describe('BridgeController SSE', function () { t6!, ); expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` - [ - "Quote validation failed", [ - "lifi|trade", - "lifi|trade.chainId", - "lifi|trade.to", - "lifi|trade.from", - "lifi|trade.value", - "lifi|trade.data", - "lifi|trade.gasLimit", - "lifi|trade.unsignedPsbtBase64", - "lifi|trade.inputsToSign", - "lifi|trade.raw_data_hex", - ], - ] - `); + "Quote validation failed", + [ + "lifi|unknown", + "lifi|quote.src", + "lifi|quote.dest", + "lifi|quote.feeData.metabridge", + "lifi|quote.aggregator", + "lifi|quote.protocols", + "lifi|namespace", + "lifi|chainId", + "lifi|trade.chainId", + "lifi|trade.to", + "lifi|trade.from", + "lifi|trade.value", + "lifi|trade.data", + "lifi|trade.gasLimit", + "lifi|trade", + "lifi|trade.raw_data_hex", + "lifi|trade.unsignedPsbtBase64", + "lifi|trade.inputsToSign", + ], + ] + `); // Invalid quote jest.advanceTimersByTime(FOURTH_FETCH_DELAY * 3 - 1000); await flushPromises(); @@ -1266,21 +1326,28 @@ describe('BridgeController SSE', function () { ); expect(consoleWarnSpy.mock.calls).toHaveLength(3); expect(consoleWarnSpy.mock.calls[1]).toMatchInlineSnapshot(` - [ - "Quote validation failed", - [ - "unknown|unknown", - ], - ] - `); + [ + "Quote validation failed", + [ + "unknown|unknown", + ], + ] + `); expect(consoleWarnSpy.mock.calls[2]).toMatchInlineSnapshot(` - [ - "Quote validation failed", [ - "unknown|quote", - ], - ] - `); + "Quote validation failed", + [ + "unknown|unknown", + "unknown|quote", + "unknown|namespace", + "unknown|chainId", + "unknown|trade", + "unknown|trade.raw_data_hex", + "unknown|trade.unsignedPsbtBase64", + "unknown|trade.inputsToSign", + ], + ] + `); expect(consoleLogSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(5); @@ -1401,11 +1468,11 @@ describe('BridgeController SSE', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy.mock.calls[0]).toMatchInlineSnapshot(` - [ - "Failed to stream bridge quotes", - [Error: Bridge-api error: timeout from server], - ] - `); + [ + "Failed to stream bridge quotes", + [Error: Bridge-api error: timeout from server], + ] + `); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(0); // eslint-disable-next-line jest/no-restricted-matchers diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 291ee62cce..93e209a5e2 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -19,24 +19,25 @@ import { flushPromises } from '../../../tests/helpers'; import { handleFetch } from '../../controller-utils/src'; import { mockBridgeQuotesErc20NativeV1 } from '../tests/mock-quotes-erc20-native'; import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20'; -import { mockBridgeQuotesNativeErc20EthV1 } from '../tests/mock-quotes-native-erc20-eth'; -import { mockBridgeQuotesSolErc20V1 } from '../tests/mock-quotes-sol-erc20'; +import { + mockBridgeQuotesNativeErc20EthV1, + getMockBridgeQuotesNativeErc20EthV2, +} from '../tests/mock-quotes-native-erc20-eth'; +import { + getMockBridgeQuotesSolErc20V2, + mockBridgeQuotesSolErc20V1, +} from '../tests/mock-quotes-sol-erc20'; import { advanceToNthTimerThenFlush } from '../tests/mock-sse'; import { BridgeController } from './bridge-controller'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL, DEFAULT_BRIDGE_CONTROLLER_STATE, - ETH_USDT_ADDRESS, } from './constants/bridge'; import { SWAPS_API_V2_BASE_URL } from './constants/swaps'; import * as selectors from './selectors'; import { ChainId, RequestStatus, SortOrder, StatusTypes } from './types'; -import type { - BridgeControllerMessenger, - QuoteResponseV1, - GenericQuoteRequest, -} from './types'; +import type { BridgeControllerMessenger, GenericQuoteRequest } from './types'; import * as balanceUtils from './utils/balance'; import { getNativeAssetForChainId, isSolanaChainId } from './utils/bridge'; import { @@ -53,6 +54,7 @@ import { UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; import { FeatureId } from './validators/feature-flags'; +import type { QuoteResponseV1 } from './validators/quote-response'; const EMPTY_INIT_STATE = DEFAULT_BRIDGE_CONTROLLER_STATE; @@ -247,7 +249,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -266,7 +268,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }, metricsContext, @@ -328,7 +330,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -347,7 +349,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }, metricsContext, @@ -403,7 +405,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -420,7 +422,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -831,7 +833,7 @@ describe('BridgeController', function () { quotes: [ ...mockBridgeQuotesNativeErc20EthV1, ...mockBridgeQuotesNativeErc20EthV1, - ], + ] as never, validationFailures: [], }); }, 10000); @@ -853,7 +855,7 @@ describe('BridgeController', function () { quotes: [ ...mockBridgeQuotesNativeErc20EthV1, ...mockBridgeQuotesNativeErc20EthV1, - ], + ] as never, validationFailures: [], }); }, 10000); @@ -962,7 +964,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, }), ); @@ -985,8 +987,8 @@ describe('BridgeController', function () { }, ], quotes: [ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ], quotesLoadingStatus: 1, quoteFetchError: null, @@ -1062,8 +1064,8 @@ describe('BridgeController', function () { expect(stateWithoutTimestamp).toMatchSnapshot(); expect(quotes).toStrictEqual([ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ]); expect( quotesLastFetched, @@ -1121,6 +1123,8 @@ describe('BridgeController', function () { }, snap: { id: 'npm:@metamask/solana-snap', + // name: 'Solana Snap', + // enabled: true, }, }, options: { @@ -1244,7 +1248,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1321,7 +1325,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1370,7 +1374,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '0', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1566,7 +1570,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1608,7 +1612,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1750,7 +1754,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1867,6 +1871,11 @@ describe('BridgeController', function () { }); it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()) + .mockImplementationOnce(jest.fn()); + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); jest.useFakeTimers(); await withController( async ({ controller: bridgeController, rootMessenger }) => { @@ -1880,7 +1889,7 @@ describe('BridgeController', function () { ); default: return { - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), currentCurrency: 'usd', currencyRates: {}, @@ -1900,7 +1909,7 @@ describe('BridgeController', function () { return await new Promise((resolve) => { return setTimeout(() => { resolve({ - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: mockBridgeQuotesNativeErc20EthV1 as never, validationFailures: [], }); }, 5000); @@ -1911,9 +1920,9 @@ describe('BridgeController', function () { srcChainId: '0x1', destChainId: '0xa', srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', + destTokenAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -1926,12 +1935,27 @@ describe('BridgeController', function () { await advanceToNthTimerThenFlush(); expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBeUndefined(); + expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + ] + `); }, ); }); it('updateBridgeQuoteRequestParams should include auth token as Authentication header', async function () { + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); + jest.useFakeTimers(); await withController( async ({ controller: bridgeController, rootMessenger }) => { const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); @@ -1942,7 +1966,7 @@ describe('BridgeController', function () { return 'AUTH_TOKEN'; default: return { - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), currentCurrency: 'usd', currencyRates: {}, @@ -1961,18 +1985,24 @@ describe('BridgeController', function () { .mockResolvedValue(true); const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: mockBridgeQuotesNativeErc20EthV1, - validationFailures: [], + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); }); const quoteParams = { srcChainId: '0x1', destChainId: '0xa', srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: ETH_USDT_ADDRESS, + destTokenAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', srcTokenAmount: '1000000000000000000', - walletAddress: ETH_USDT_ADDRESS, + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -2313,7 +2343,7 @@ describe('BridgeController', function () { bridgeController.state; expect(stateWithoutQuotes).toMatchSnapshot(); - expect(quotes).toStrictEqual(mockBridgeQuotesNativeErc20EthV1); + expect(quotes).toStrictEqual(getMockBridgeQuotesNativeErc20EthV2()); expect(quotesLastFetched).toBeCloseTo(Date.now() - 10000); jest.advanceTimersByTime(10000); @@ -2325,7 +2355,7 @@ describe('BridgeController', function () { } = bridgeController.state; expect(stateWithoutQuotes2).toMatchSnapshot(); - expect(quotes2).toStrictEqual(mockBridgeQuotesNativeErc20EthV1); + expect(quotes2).toStrictEqual(getMockBridgeQuotesNativeErc20EthV2()); expect(quotesLastFetched2).toBe(quotesLastFetched); expect(consoleLogSpy).toHaveBeenCalledTimes(1); @@ -2620,6 +2650,7 @@ describe('BridgeController', function () { jest.advanceTimersByTime(100); await flushPromises(); const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(expectedQuotesLength); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ quotesLoadingStatus: RequestStatus.FETCHED, @@ -2630,9 +2661,7 @@ describe('BridgeController', function () { // Verify non-EVM fees quotes.forEach((quote) => { expect(quote.nonEvmFeesInNative).toBe( - isSolanaChainId(quote.quote.srcChainId) - ? expectedFees - : undefined, + isSolanaChainId(quote.chainId) ? expectedFees : undefined, ); }); @@ -2643,8 +2672,6 @@ describe('BridgeController', function () { expect(snapCalls).toMatchSnapshot(); - expect(quotes).toHaveLength(expectedQuotesLength); - // Verify validation failure tracking expect(trackMetaMetricsFn).toHaveBeenCalledTimes( 6 + (validationFailures.length ? 1 : 0), @@ -2880,11 +2907,11 @@ describe('BridgeController', function () { expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalledTimes(2); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3:', + 'Failed to compute non-EVM fees for quote in bip122:000000000019d6689c085ae165831e93:', new Error('Failed to compute fees'), ); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d:', + 'Failed to compute non-EVM fees for quote in bip122:000000000019d6689c085ae165831e93:', new Error('Failed to compute fees'), ); }, @@ -3567,7 +3594,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3616,7 +3643,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3771,11 +3798,9 @@ describe('BridgeController', function () { ...overrides, }); - let getBridgeFeatureFlagsSpy: jest.SpyInstance; - beforeEach(() => { jest.clearAllMocks(); - getBridgeFeatureFlagsSpy = jest + jest .spyOn(featureFlagUtils, 'getBridgeFeatureFlags') .mockReturnValueOnce({ ...defaultFlags, @@ -3785,7 +3810,10 @@ describe('BridgeController', function () { bridgeIds: ['bridge1', 'bridge2'], fee: 0, }, + [FeatureId.QUICK_BUY_FOLLOW_TRADING]: undefined, [FeatureId.QUICK_BUY_TOKEN_DETAILS]: undefined, + [FeatureId.BATCH_SELL]: undefined, + [FeatureId.UNIFIED_SWAP_BRIDGE]: undefined, [FeatureId.DAPP_SWAP]: undefined, }, }); @@ -3888,12 +3916,12 @@ describe('BridgeController', function () { srcTokenAddress: 'NATIVE', destTokenAddress: '0x1234', srcTokenAmount: '1000000', - walletAddress: undefined as never, slippage: 0.5, aggIds: ['other'], bridgeIds: ['other', 'debridge'], gasIncluded: false, gasIncluded7702: false, + walletAddress: undefined as never, }, FeatureId.PERPS, null, @@ -3912,7 +3940,7 @@ describe('BridgeController', function () { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') .mockResolvedValueOnce({ - quotes: quotesByDecreasingProcessingTime as never, + quotes: quotesByDecreasingProcessingTime, validationFailures: [], }); const expectedControllerState = bridgeController.state; @@ -4005,96 +4033,30 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - { - "destChainId": "1", - "destTokenAddress": "0x1234", - "gasIncluded": false, - "gasIncluded7702": false, - "resetApproval": false, - "slippage": 0.5, - "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "srcTokenAddress": "NATIVE", - "srcTokenAmount": "1000000", - "walletAddress": "0x123", - }, - null, - "extension", - "AUTH_TOKEN", - [Function], - "https://bridge.api.cx.metamask.io", - "unified_swap_bridge", - "13.7.0", - ], - ] - `); - expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); - expect(bridgeController.state).toStrictEqual(expectedControllerState); - }, - ); - }); - - it('should not add aggIds and fee if quoteRequestOverrides is not set', async () => { - await withController( - async ({ controller: bridgeController, rootMessenger }) => { - getBridgeFeatureFlagsSpy.mockRestore(); - getBridgeFeatureFlagsSpy.mockReturnValueOnce({ - ...defaultFlags, - quoteRequestOverrides: undefined, - }); - - const fetchBridgeQuotesSpy = jest - .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: mockBridgeQuotesSolErc20V1, - validationFailures: [], - }); - const expectedControllerState = bridgeController.state; - - const quotes = await rootMessenger.call( - 'BridgeController:fetchQuotes', - { - srcChainId: SolScope.Mainnet, - destChainId: '1', - srcTokenAddress: 'NATIVE', - destTokenAddress: '0x1234', - srcTokenAmount: '1000000', - walletAddress: '0x123', - slippage: 0.5, - gasIncluded: false, - gasIncluded7702: false, - }, - FeatureId.PERPS, - null, - ); - - expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); - expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - { - "destChainId": "1", - "destTokenAddress": "0x1234", - "gasIncluded": false, - "gasIncluded7702": false, - "resetApproval": false, - "slippage": 0.5, - "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "srcTokenAddress": "NATIVE", - "srcTokenAmount": "1000000", - "walletAddress": "0x123", - }, - null, - "extension", - "AUTH_TOKEN", - [Function], - "https://bridge.api.cx.metamask.io", - "perps", - "13.7.0", - ], - ] - `); + [ + [ + { + "destChainId": "1", + "destTokenAddress": "0x1234", + "gasIncluded": false, + "gasIncluded7702": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "srcTokenAddress": "NATIVE", + "srcTokenAmount": "1000000", + "walletAddress": "0x123", + }, + null, + "extension", + "AUTH_TOKEN", + [Function], + "https://bridge.api.cx.metamask.io", + "unified_swap_bridge", + "13.7.0", + ], + ] + `); expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); expect(bridgeController.state).toStrictEqual(expectedControllerState); }, @@ -4109,7 +4071,7 @@ describe('BridgeController', function () { ...mockBridgeQuotesNativeErc20EthV1[0].quote, gasSponsored: true, }, - }; + } as QuoteResponseV1; const secondQuote: QuoteResponseV1 = mockBridgeQuotesNativeErc20EthV1[1]; const quotesWithFlag: QuoteResponseV1[] = [ @@ -4155,7 +4117,7 @@ describe('BridgeController', function () { ], }; - const mockQuote = mockBridgeQuotesNativeErc20EthV1[0]; + const mockQuote = getMockBridgeQuotesNativeErc20EthV2()[0]; beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 6f0ed11df2..0d3cae694e 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -31,7 +31,6 @@ import type { GenericQuoteRequest, NonEvmFees, QuoteRequest, - QuoteResponseV1, BridgeControllerState, BridgeControllerMessenger, FetchFunction, @@ -89,6 +88,9 @@ import { import { appendFeesToQuotes } from './utils/quote-fees'; import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; import type { FeatureId } from './validators/feature-flags'; +import type { QuoteResponseV1 } from './validators/quote-response'; +import type { QuoteResponse } from './validators/quote-response-v2'; +import { toQuoteResponseV2 } from './validators/quote-response-v2-migration'; const metadata: StateMetadata = { quoteRequest: { @@ -420,13 +422,21 @@ export class BridgeController extends StaticIntervalPollingController quote.quote.srcChainId)), + ).filter(Boolean); + + const quotesWithFees = + srcChainIds.length > 1 || srcChainIds.length === 0 + ? // Don't append fees if there are multiple srcChainIds + baseQuotes + : await appendFeesToQuotes( + formatChainIdToCaip(srcChainIds[0]), + baseQuotes, + this.messenger, + this.#getLayer1GasFee, + this.#getMultichainSelectedAccount(quoteRequest.walletAddress), + ); return sortQuotes(quotesWithFees, featureId); }; @@ -440,7 +450,7 @@ export class BridgeController extends StaticIntervalPollingController => { this.#batchSellTradesAbortController?.abort( @@ -868,12 +878,16 @@ export class BridgeController extends StaticIntervalPollingController { + // validateQuoteResponseV1(quote); + return toQuoteResponseV2(quote); + }); state.quotesLoadingStatus = RequestStatus.FETCHED; }); }, ); } catch (error) { + console.error('Failed to fetch quotes', error); // Reset the quotes list if the fetch fails to avoid showing stale quotes this.update((state) => { state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; @@ -967,9 +981,10 @@ export class BridgeController extends StaticIntervalPollingController this.#trackQuoteValidationFailures(validationFailures, featureId), - onValidQuoteReceived: async (quote: QuoteResponseV1) => { + onValidQuoteReceived: async (quote: QuoteResponse) => { const feeAppendPromise = (async () => { const quotesWithFees = await appendFeesToQuotes( + quote.chainId, [quote], this.messenger, this.#getLayer1GasFee, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 0ecfecd244..46be1ace1e 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -47,7 +47,6 @@ export type { Step, RefuelData, Quote, - QuoteResponseV1 as QuoteResponse, FeeData, Intent, IntentOrderLike, @@ -93,6 +92,12 @@ export type { Trade, } from './validators/trade'; export { isBitcoinTrade, isTronTrade, isEvmTxData } from './validators/trade'; +export { + type QuoteResponseV1, + validateQuoteResponseV1, +} from './validators/quote-response'; +export type { QuoteResponse } from './validators/quote-response-v2'; +export { toQuoteResponseV2 } from './validators/quote-response-v2-migration'; export { FeeType, ActionTypes } from './validators/quote-response'; export { validateQuoteStreamComplete, @@ -100,7 +105,15 @@ export { } from './validators/quote-stream-complete'; export { BatchSellTransactionType } from './validators/batch-sell'; export { TokenFeatureType } from './validators/token-feature'; -export { BridgeAssetSchema } from './validators/bridge-asset'; +export type { BridgeAssetV2 } from './validators/bridge-asset'; +export { + BridgeAssetSchema, + validateBridgeAssetV2, + validateMinimalAssetObject, + MinimalAssetSchema, + BridgeAssetV2Schema, + BridgeAssetSecurityDataType, +} from './validators/bridge-asset'; export { FeatureId } from './validators/feature-flags'; export { diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 069365cf0f..2ee866c66b 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -2,7 +2,12 @@ import { getAddress } from '@ethersproject/address'; import type { MarketDataDetails } from '@metamask/assets-controllers'; import { toHex } from '@metamask/controller-utils'; import { SolScope } from '@metamask/keyring-api'; -import { parseCaipAssetType } from '@metamask/utils'; +import { create } from '@metamask/superstruct'; +import { + KnownCaipNamespace, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { merge } from 'lodash'; @@ -22,14 +27,7 @@ import { selectBatchSellQuotes, selectBatchSellTrades, } from './selectors'; -import { - SortOrder, - RequestStatus, - ChainId, - BridgeAsset, - NonEvmFees, - QuoteResponseV1, -} from './types'; +import { SortOrder, RequestStatus, ChainId, NonEvmFees } from './types'; import { getNativeAssetForChainId, isNativeAddress } from './utils/bridge'; import { formatAddressToAssetId, @@ -38,8 +36,18 @@ import { formatChainIdToDec, formatChainIdToHex, } from './utils/caip-formatters'; -import { validateQuoteResponseV1 } from './validators/quote-response'; import { BatchSellTransactionType } from './validators/batch-sell'; +import { BridgeAssetV2, BridgeAssetV2FromV1 } from './validators/bridge-asset'; +import { validateQuoteResponseV1 } from './validators/quote-response'; +import { + QuoteResponse, + validateQuoteResponse, +} from './validators/quote-response-v2'; +import { + DeepPartial, + mergeQuoteMetadata, + toQuoteResponseV2, +} from './validators/quote-response-v2-migration'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -408,13 +416,13 @@ describe('Bridge Selectors', () => { describe('selectBridgeQuotes', () => { const getMockState = ( chainId: ChainId, - quoteOverrides?: Partial, + quoteOverrides: DeepPartial = {}, stateOverrides?: Partial, ): BridgeAppState => { const decChainId = formatChainIdToDec(chainId); const caipChainId = formatChainIdToCaip(chainId); - const mockQuote = { + const mockQuoteV1 = { quote: { requestId: '123', srcChainId: decChainId, @@ -457,58 +465,71 @@ describe('Bridge Selectors', () => { }, }, estimatedProcessingTimeInSeconds: 300, - trade: { - value: '0x0', - gasLimit: 24000, - effectiveGas: 21000, - chainId: decChainId, - from: '0x0000000000000000000000000000000000000000', - to: '0x0000000000000000000000000000000000000000', - data: '0x0', - }, - approval: { - gasLimit: 49000, - effectiveGas: 46000, - chainId: decChainId, - from: '0x0000000000000000000000000000000000000000', - to: '0x0000000000000000000000000000000000000000', - data: '0x0', - value: '0x0', - }, + ...(parseCaipChainId(caipChainId).namespace === + KnownCaipNamespace.Eip155 + ? { + trade: { + value: '0x0', + gasLimit: 24000, + effectiveGas: 21000, + chainId: decChainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + }, + } + : { trade: 'SOLANATRADE' }), + ...(parseCaipChainId(caipChainId).namespace === + KnownCaipNamespace.Eip155 + ? { + approval: { + gasLimit: 49000, + effectiveGas: 46000, + chainId: decChainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + value: '0x0', + }, + } + : {}), }; - validateQuoteResponseV1(mockQuote); + + const mockQuoteV2 = [ + mockQuoteV1, + { + ...mockQuoteV1, + quote: { + ...mockQuoteV1.quote, + requestId: '456', + destTokenAmount: '2100000000000000000', + }, + }, + ] + .map((quote) => toQuoteResponseV2(quote)) + .map((quote) => ({ + ...merge({}, quote, quoteOverrides), + quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), + })); + + const srcChainId = parseCaipAssetType( + mockQuoteV2[0].quote.src.asset.assetId, + ).chainId; + const destChainId = parseCaipAssetType( + mockQuoteV2[0].quote.dest.asset.assetId, + ).chainId; return { - quotes: [ - merge( - {}, - { - ...mockQuote, - }, - quoteOverrides, - ), - merge( - {}, - { - ...mockQuote, - quote: { - ...mockQuote.quote, - requestId: '456', - destTokenAmount: '2100000000000000000', - }, - }, - quoteOverrides, - ), - ], + quotes: mockQuoteV2, quoteRequest: [ { - srcChainId: quoteOverrides?.quote?.srcAsset?.chainId ?? decChainId, - destChainId: quoteOverrides?.quote?.destAsset?.chainId ?? 137, + srcChainId: srcChainId ?? decChainId, + destChainId: destChainId ?? 137, srcTokenAddress: - quoteOverrides?.quote?.srcAsset?.address ?? + mockQuoteV2[0].quote.src.asset.assetId ?? '0x0000000000000000000000000000000000000000', destTokenAddress: - quoteOverrides?.quote?.destAsset?.address ?? + mockQuoteV2[0].quote.dest.asset.assetId ?? '0x0000000000000000000000000000000000000000', insufficientBal: false, }, @@ -575,17 +596,11 @@ describe('Bridge Selectors', () => { { ...mockState, assetExchangeRates: { - [formatAddressToAssetId( - mockQuote.quote.srcAsset.address, - mockQuote.quote.srcChainId, - ) ?? '']: { + [mockQuote.quote.src.asset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [formatAddressToAssetId( - mockQuote.quote.destAsset.address, - mockQuote.quote.destChainId, - ) ?? '']: { + [mockQuote.quote.dest.asset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -596,12 +611,12 @@ describe('Bridge Selectors', () => { const expectedQuoteMetadata = { adjustedReturn: { - usd: '13.099927', - valueInCurrency: '2597.985546', + usd: '2.08686', + valueInCurrency: '419.98686', }, cost: { - usd: '-2.099927', - valueInCurrency: '-419.985546', + usd: '8.91314', + valueInCurrency: '1758.01314', }, gasFee: { effective: { @@ -643,20 +658,20 @@ describe('Bridge Selectors', () => { valueInCurrency: '-2177.971092', }, totalNetworkFee: { - amount: '-1.0999927', - usd: '-10.999927', - valueInCurrency: '-2177.985546', + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', }, }; + const expectedQuoteV2 = mergeQuoteMetadata( + mockState.quotes[1], + expectedQuoteMetadata, + ); - const quoteResponseV1 = { - ...mockState.quotes[1], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - - expect(result.sortedQuotes[0]).toStrictEqual(quoteResponseV1); - expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('-419.985546'); + expect(result.sortedQuotes[0]).toStrictEqual(expectedQuoteV2); + expect( + result.recommendedQuote?.quote.priceData?.cost?.valueInCurrency, + ).toBe('1758.01314'); }); it('should return metadata when quotes are empty', () => { @@ -668,17 +683,11 @@ describe('Bridge Selectors', () => { ...mockState, quotes: [], assetExchangeRates: { - [formatAddressToAssetId( - mockQuote.quote.srcAsset.address, - mockQuote.quote.srcChainId, - ) ?? '']: { + [mockQuote.quote.src.asset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [formatAddressToAssetId( - mockQuote.quote.destAsset.address, - mockQuote.quote.destChainId, - ) ?? '']: { + [mockQuote.quote.dest.asset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -709,7 +718,7 @@ describe('Bridge Selectors', () => { mockClientParams, ); - const expectedActiveQuote = { + const expectedQuoteMetadata = { adjustedReturn: { usd: null, valueInCurrency: null, @@ -758,21 +767,23 @@ describe('Bridge Selectors', () => { valueInCurrency: '-1979.97372', }, totalNetworkFee: { - amount: '-1.0999927', - usd: '-1979.98686', - valueInCurrency: '-1979.98686', + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', }, }; - const quoteResponseV1 = { - ...mockState.quotes[1], - ...expectedActiveQuote, - }; - validateQuoteResponseV1(quoteResponseV1); - - expect(result.sortedQuotes[0]).toStrictEqual(quoteResponseV1); - expect(result.sortedQuotes[0]?.cost?.valueInCurrency).toBeNull(); - expect(result.recommendedQuote?.toTokenAmount?.amount).toBe('2.1'); + const expectedQuoteV2 = mockState.quotes[1]; + expect(result.sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), + ); + expect( + result.sortedQuotes[0].quote.priceData?.cost?.valueInCurrency, + ).toBeNull(); + expect(result.recommendedQuote?.quote.dest.amount).toBe( + '2100000000000000000', + ); + expect(result.recommendedQuote?.quote.dest.normalizedAmount).toBe('2.1'); }); it('should use priceImpact to sort quotes if exchange rate is not available', () => { @@ -782,14 +793,14 @@ describe('Bridge Selectors', () => { ...mockState.quotes[0], quote: { ...mockState.quotes[0].quote, - priceData: { priceImpact: '0.01' }, + priceData: { priceImpact: { amount: '0.01' } }, }, }, { ...mockState.quotes[1], quote: { ...mockState.quotes[1].quote, - priceData: { priceImpact: '-0.02' }, + priceData: { priceImpact: { amount: '-0.02' } }, }, }, ]; @@ -853,32 +864,32 @@ describe('Bridge Selectors', () => { valueInCurrency: '-1979.97372', }, totalNetworkFee: { - amount: '-1.0999927', - usd: '-1979.98686', - valueInCurrency: '-1979.98686', + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', }, }; - const quoteResponseV1 = { - ...quotesWithPriceImpact[1], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); + const expectedQuoteV2 = quotesWithPriceImpact[1]; - expect(result.recommendedQuote?.quote.priceData?.priceImpact).toBe( - '-0.02', + expect( + result.sortedQuotes[0].quote.priceData?.cost?.valueInCurrency, + ).toBeNull(); + expect(result.recommendedQuote).toStrictEqual( + mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), ); - expect(result.sortedQuotes[0]?.cost?.valueInCurrency).toBeNull(); - expect(result.recommendedQuote).toStrictEqual(quoteResponseV1); + expect( + result.recommendedQuote?.quote.priceData?.priceImpact?.amount, + ).toBe('-0.02'); }); describe('returns swap metadata', () => { const getMockSwapState = ( - srcAsset: BridgeAsset, - destAsset: BridgeAsset, + srcAsset: BridgeAssetV2, + destAsset: BridgeAssetV2, txFee?: { amount: string; - asset: BridgeAsset; + asset: BridgeAssetV2; }, gasIncluded7702?: boolean, gasEstimatesChainId?: number, @@ -891,7 +902,10 @@ describe('Bridge Selectors', () => { const { chainId: caipChainId } = parseCaipAssetType(srcAsset.assetId); const chainId = formatChainIdToDec(caipChainId); const hexChainId = formatChainIdToHex(chainId); - const nativeAsset = getNativeAssetForChainId(chainId); + const nativeAsset = create( + getNativeAssetForChainId(chainId), + BridgeAssetV2FromV1, + ); const currencyRates = { [nativeAsset.symbol]: { conversionRate: 551.98, @@ -1021,10 +1035,14 @@ describe('Bridge Selectors', () => { }, }; validateQuoteResponseV1(quoteResponse); - const mockState = getMockState(1); + + const mockState = getMockState(gasEstimatesChainId ?? chainId); + return { - ...getMockState(gasEstimatesChainId ?? chainId), - quotes: [quoteResponse as never], + ...mockState, + quotes: [quoteResponse as never].map((quote) => + toQuoteResponseV2(quote), + ), currencyRates, marketData, quoteRequest: [ @@ -1042,12 +1060,9 @@ describe('Bridge Selectors', () => { it('for native -> erc20', () => { const srcAsset = { decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, + assetId: getNativeAssetForChainId(1).assetId, symbol: 'ETH', name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - chainId: 1, }; const destAsset = { decimals: 18, @@ -1055,8 +1070,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d' as const, symbol: 'USDC', name: 'USD Coin', - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, }; const newState = getMockSwapState(srcAsset, destAsset); @@ -1117,12 +1130,9 @@ describe('Bridge Selectors', () => { valueInCurrency: '0.00446386226', }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(newState.quotes[0], expectedQuoteMetadata), + ); }); it('erc20 -> native', () => { @@ -1133,8 +1143,6 @@ describe('Bridge Selectors', () => { assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, }, { decimals: 18, @@ -1142,8 +1150,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - chainId: 1, }, ); @@ -1203,33 +1209,28 @@ describe('Bridge Selectors', () => { valueInCurrency: '0.00446386226', }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('erc20 -> native but gas estimates are not available', () => { const newState = getMockSwapState( { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', name: 'USD Coin', - chainId: 1, }, { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, undefined, undefined, @@ -1292,44 +1293,37 @@ describe('Bridge Selectors', () => { valueInCurrency: '0', }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('when gas is included and is taken from dest token', () => { const newState = getMockSwapState( { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', name: 'USD Coin', - chainId: 1, }, { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, { amount: '1000000000000000', asset: { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, }, ); @@ -1394,12 +1388,11 @@ describe('Bridge Selectors', () => { valueInCurrency: '0.00446386226', }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('when gas is included and is taken from src token', () => { @@ -1410,28 +1403,22 @@ describe('Bridge Selectors', () => { assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, - chainId: 1, - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', }, { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, { amount: '3000000000000000000', asset: { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, }, ); @@ -1497,33 +1484,28 @@ describe('Bridge Selectors', () => { }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + const quoteResponseV2 = newState.quotes[0]; + + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('when gasIncluded7702=true and is taken from dest token', () => { const newState = getMockSwapState( { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', name: 'USD Coin', - chainId: 1, }, { - address: '0x0000000000000000000000000000000000000001', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000001', symbol: 'WETH', name: 'Ethereum', - chainId: 1, }, { amount: '1000000000000000000', @@ -1533,8 +1515,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x0000000000000000000000000000000000000001', symbol: 'WETH', name: 'Ethereum', - chainId: 1, - address: '0x0000000000000000000000000000000000000001', }, }, true, @@ -1601,12 +1581,10 @@ describe('Bridge Selectors', () => { }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); }); @@ -1662,18 +1640,48 @@ describe('Bridge Selectors', () => { const selectedQuote = { ...mockState.quotes[0], quote: { ...mockState.quotes[0].quote, requestId: '123' }, - } as never; + }; const result = selectBridgeQuotes(mockState, { ...mockClientParams, selectedQuote, }); - expect(result.recommendedQuote).toStrictEqual( - expect.objectContaining(mockState.quotes[1]), - ); - expect(result.activeQuote).toStrictEqual( - expect.objectContaining(selectedQuote), + const recommendedQuoteV2 = mergeQuoteMetadata(mockState.quotes[1], { + minToTokenAmount: { + amount: '1.8', + usd: null, + valueInCurrency: null, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + toTokenAmount: { + amount: '2.1', + usd: null, + valueInCurrency: null, + }, + swapRate: '1.90909090909090909091', + cost: { + usd: null, + valueInCurrency: null, + }, + adjustedReturn: { + usd: null, + valueInCurrency: null, + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }); + expect(result.recommendedQuote).toStrictEqual(recommendedQuoteV2); + expect(result.recommendedQuote).not.toStrictEqual(selectedQuote); + expect(result.activeQuote?.quote.requestId).toStrictEqual( + selectedQuote.quote.requestId, ); }); @@ -1689,9 +1697,38 @@ describe('Bridge Selectors', () => { selectedQuote, }); - expect(result.recommendedQuote).toStrictEqual( - expect.objectContaining(mockState.quotes[1]), - ); + const recommendedQuoteV2 = mergeQuoteMetadata(mockState.quotes[1], { + minToTokenAmount: { + amount: '1.8', + usd: null, + valueInCurrency: null, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + toTokenAmount: { + amount: '2.1', + usd: null, + valueInCurrency: null, + }, + swapRate: '1.90909090909090909091', + cost: { + usd: null, + valueInCurrency: null, + }, + adjustedReturn: { + usd: null, + valueInCurrency: null, + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }); + expect(result.recommendedQuote).toStrictEqual(recommendedQuoteV2); expect(result.activeQuote).toStrictEqual(result.recommendedQuote); }); @@ -1733,28 +1770,28 @@ describe('Bridge Selectors', () => { const solanaState = getMockState( ChainId.SOLANA, { + namespace: KnownCaipNamespace.Solana, nonEvmFeesInNative: '5000', trade: 'SOLANATRADE', quote: { - srcChainId: 1151111081099710, - srcAsset: { - address: '0x0000000000000000000000000000000000000000', - decimals: 9, - assetId: getNativeAssetForChainId(ChainId.SOLANA).assetId, - chainId: 1151111081099710, - symbol: 'SOL', - name: 'SOL', + src: { + asset: { + decimals: 9, + assetId: getNativeAssetForChainId(ChainId.SOLANA).assetId, + symbol: 'SOL', + name: 'SOL', + }, }, - destAsset: { - address: 'gjslkdfjsljflds', - decimals: 18, - assetId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:gjslkdfjsljflds', - chainId: 1151111081099710, - symbol: 'USDC', - name: 'USD Coin', + dest: { + asset: { + decimals: 18, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:gjslkdfjsljflds', + symbol: 'USDC', + name: 'USD Coin', + }, }, - } as never, + }, }, { assetExchangeRates: { @@ -1769,6 +1806,7 @@ describe('Bridge Selectors', () => { }, currencyRates: { SOL: { + conversionDate: Date.now(), conversionRate: 100, usdConversionRate: 10000, }, @@ -1827,7 +1865,7 @@ describe('Bridge Selectors', () => { valueInCurrency: '500000000', }, nonEvmFeesInNative: '5000', - swapRate: '2.1e-9', + swapRate: '0.0000000021', toTokenAmount: { amount: '2.1', usd: '210000', @@ -1844,10 +1882,13 @@ describe('Bridge Selectors', () => { valueInCurrency: '2500', }, }; + const solanaQuote = solanaState.quotes[1]; - const quoteResponseV1 = { ...solanaQuote, ...expectedQuoteMetadata }; - validateQuoteResponseV1(quoteResponseV1); - expect(result.recommendedQuote).toStrictEqual(quoteResponseV1); + validateQuoteResponse(solanaQuote); + + expect(result.recommendedQuote).toStrictEqual( + mergeQuoteMetadata(solanaQuote, expectedQuoteMetadata), + ); }); }); @@ -1863,7 +1904,7 @@ describe('Bridge Selectors', () => { ...quote, quoteRequestIndex: 0, })), - ].map((quote) => quote), + ].map((quote) => toQuoteResponseV2(quote)), quoteRequest: [ { srcChainId: '10', @@ -1953,14 +1994,28 @@ describe('Bridge Selectors', () => { expect(totalReceived).toMatchInlineSnapshot(` { - "amount": "38.423182", + "amount": "38423182", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "normalizedAmount": "38.423182", "usd": "38.423182", "valueInCurrency": "7684.6364", } `); expect(minimumReceived).toMatchInlineSnapshot(` { - "amount": "37.6", + "amount": "37600000", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "normalizedAmount": "37.6", "usd": "37.6", "valueInCurrency": "7520", } @@ -2008,6 +2063,8 @@ describe('Bridge Selectors', () => { expect(totalReceived).toMatchInlineSnapshot(` { "amount": "0", + "asset": undefined, + "normalizedAmount": "0", "usd": "0", "valueInCurrency": "0", } @@ -2015,6 +2072,8 @@ describe('Bridge Selectors', () => { expect(minimumReceived).toMatchInlineSnapshot(` { "amount": "0", + "asset": undefined, + "normalizedAmount": "0", "usd": "0", "valueInCurrency": "0", } @@ -2153,7 +2212,7 @@ describe('Bridge Selectors', () => { expect(result.totalNetworkFee).toMatchInlineSnapshot(` { - "amount": "0.01", + "amount": "10000", "asset": { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", @@ -2162,6 +2221,7 @@ describe('Bridge Selectors', () => { "name": "USD Coin", "symbol": "USDC", }, + "normalizedAmount": "0.01", "usd": "0.05", "valueInCurrency": "2", } @@ -2176,6 +2236,7 @@ describe('Bridge Selectors', () => { ETH: { conversionRate: 1, usdConversionRate: 1, + conversionDate: Date.now(), }, }, marketData: { @@ -2192,7 +2253,7 @@ describe('Bridge Selectors', () => { expect(result.totalNetworkFee).toMatchInlineSnapshot(` { - "amount": "0.01", + "amount": "10000", "asset": { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", @@ -2201,6 +2262,7 @@ describe('Bridge Selectors', () => { "name": "USD Coin", "symbol": "USDC", }, + "normalizedAmount": "0.01", "usd": "0.01", "valueInCurrency": "0.01", } @@ -2227,7 +2289,7 @@ describe('Bridge Selectors', () => { expect(result.totalNetworkFee).toMatchInlineSnapshot(` { - "amount": "0.01", + "amount": "10000", "asset": { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", @@ -2236,6 +2298,7 @@ describe('Bridge Selectors', () => { "name": "USD Coin", "symbol": "USDC", }, + "normalizedAmount": "0.01", "usd": null, "valueInCurrency": null, } @@ -2767,7 +2830,7 @@ describe('Bridge Selectors', () => { }); it('should return an empty array when there are no warnings', () => { - const state = { tokenWarnings: [] } as BridgeAppState; + const state = { tokenWarnings: [] } as unknown as BridgeAppState; expect(selectTokenWarnings(state)).toStrictEqual([]); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 49f8b231cf..d1c0690290 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -10,7 +10,11 @@ import type { GasFeeEstimatesByChainId, } from '@metamask/gas-fee-controller'; import type { CaipAssetType } from '@metamask/utils'; -import { isStrictHexString, parseCaipAssetType } from '@metamask/utils'; +import { + isStrictHexString, + KnownCaipNamespace, + parseCaipAssetType, +} from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { orderBy } from 'lodash'; import { @@ -23,13 +27,10 @@ import type { BridgeControllerState, ExchangeRate, QuoteMetadata, - QuoteResponseV1, - TokenAmountValues, } from './types'; import { RequestStatus, SortOrder } from './types'; import { getNativeAssetForChainId, - isEvmQuoteResponse, isNativeAddress, isNonEvmChainId, } from './utils/bridge'; @@ -51,10 +52,11 @@ import { calcSwapRate, calcToAmount, calcTotalEstimatedNetworkFee, - calcTotalMaxNetworkFee, calcBatchFees, } from './utils/quote'; import { getDefaultSlippagePercentage } from './utils/slippage'; +import type { QuoteResponse } from './validators/quote-response-v2'; +import { mergeQuoteMetadata } from './validators/quote-response-v2-migration'; /** * The controller states that provide exchange rates @@ -109,7 +111,7 @@ const createBridgeSelector = createSelector_.withTypes(); */ type BridgeQuotesClientParams = { sortOrder: SortOrder; - selectedQuote: (QuoteResponseV1 & QuoteMetadata) | null; + selectedQuote: (QuoteResponse & QuoteMetadata) | null; }; type EvmTokenExchangeRate = { price?: number; currency?: string }; @@ -298,7 +300,7 @@ export const selectIsAssetExchangeRateInState = ( const selectBridgeFeesPerGas = createBridgeSelector( [ (state) => state.gasFeeEstimatesByChainId, - (state) => state.quotes?.[0]?.quote.srcChainId, + (state) => state.quotes?.[0]?.chainId, ], (gasFeeEstimatesByChainId, srcChainId) => { if (!srcChainId) { @@ -307,6 +309,7 @@ const selectBridgeFeesPerGas = createBridgeSelector( if (isNonEvmChainId(srcChainId)) { return null; } + // @ts-expect-error - all supported networks use this type of estimates const gasFeeEstimates: GasFeeEstimates | undefined = gasFeeEstimatesByChainId?.[ @@ -366,17 +369,17 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( srcTokenExchangeRate, destTokenExchangeRate, nativeExchangeRate, - ) => { + ): QuoteResponse[] => { const newQuotes = quotes.map((quote) => { const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); const toTokenAmount = calcToAmount( - quote.quote.destTokenAmount, - quote.quote.destAsset, + quote.quote.dest.amount, + quote.quote.dest.asset, destTokenExchangeRate, ); const minToTokenAmount = calcToAmount( - quote.quote.minDestTokenAmount, - quote.quote.destAsset, + quote.quote.dest.min?.amount ?? '0', + quote.quote.dest.asset, destTokenExchangeRate, ); @@ -386,12 +389,9 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( destTokenExchangeRate, ); - let totalEstimatedNetworkFee, - totalMaxNetworkFee, - relayerFee, - gasFee: QuoteMetadata['gasFee']; + let totalEstimatedNetworkFee, relayerFee, gasFee; - if (isEvmQuoteResponse(quote)) { + if (quote.namespace === KnownCaipNamespace.Eip155) { relayerFee = calcRelayerFee(quote, nativeExchangeRate); gasFee = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: quote, @@ -403,19 +403,18 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( gasFee, relayerFee, ); - totalMaxNetworkFee = calcTotalMaxNetworkFee(gasFee, relayerFee); } else { // Use the new generic function for all non-EVM chains totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( quote, nativeExchangeRate, ); + gasFee = { effective: totalEstimatedNetworkFee, total: totalEstimatedNetworkFee, max: totalEstimatedNetworkFee, }; - totalMaxNetworkFee = totalEstimatedNetworkFee; } const adjustedReturn = calcAdjustedReturn( @@ -425,9 +424,7 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( ); const cost = calcCost(adjustedReturn, sentAmount); - return { - ...quote, - // QuoteMetadata fields + return mergeQuoteMetadata(quote, { sentAmount, toTokenAmount, minToTokenAmount, @@ -438,7 +435,6 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( Should be used for balance checks and tx submission. */ totalNetworkFee: totalEstimatedNetworkFee, - totalMaxNetworkFee, /** This contains gas fee estimates for the bridge transaction Does not include the relayer fee (if needed), just the gasLimit and effectiveGas returned by the bridge API. @@ -448,7 +444,7 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( adjustedReturn, cost, includedTxFees, - }; + }); }); return newQuotes; @@ -460,7 +456,7 @@ const selectSortedBridgeQuotes = createBridgeSelector( selectBridgeQuotesWithMetadata, (_, { sortOrder }: BridgeQuotesClientParams) => sortOrder, ], - (quotesWithMetadata, sortOrder): (QuoteResponseV1 & QuoteMetadata)[] => { + (quotesWithMetadata, sortOrder): QuoteResponse[] => { switch (sortOrder) { case SortOrder.ETA_ASC: return orderBy( @@ -469,27 +465,32 @@ const selectSortedBridgeQuotes = createBridgeSelector( 'asc', ); default: - if (quotesWithMetadata.every((quote) => quote.cost.valueInCurrency)) { + if ( + quotesWithMetadata.every( + (quote) => quote.quote.priceData?.cost?.valueInCurrency, + ) + ) { return orderBy( quotesWithMetadata, - ({ cost }) => Number(cost.valueInCurrency), + ({ quote: { priceData } }) => + Number(priceData?.cost?.valueInCurrency ?? 0), 'asc', ); } if ( quotesWithMetadata.every( - (quote) => quote.quote.priceData?.priceImpact, + (quote) => quote.quote.priceData?.priceImpact?.amount, ) ) { return orderBy( quotesWithMetadata, - ({ quote }) => Number(quote.priceData?.priceImpact), + ({ quote }) => Number(quote.priceData?.priceImpact?.amount ?? 0), 'asc', ); } return orderBy( quotesWithMetadata, - ({ quote }) => Number(quote.destTokenAmount), + ({ quote }) => Number(quote.dest.amount), 'desc', ); } @@ -545,8 +546,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); @@ -591,35 +592,95 @@ const selectRecommendedQuotes = createBridgeSelector( const requestIndex = quote.quoteRequestIndex ?? 0; acc[requestIndex] ??= quote; return acc; - }, Array<(QuoteResponseV1 & QuoteMetadata) | null>(requestCount).fill(null)), + }, Array(requestCount).fill(null)), ); -const selectMetadataSum = createBridgeSelector( - [ - selectRecommendedQuotes, - ( - _, +const selectDestAmountSum = createBridgeSelector( + [selectRecommendedQuotes], + (recommendedQuotes) => { + const destAsset = recommendedQuotes.find(Boolean)?.quote.dest.asset; + if (!destAsset) { + return { + amount: '0', + usd: '0', + valueInCurrency: '0', + normalizedAmount: '0', + asset: destAsset, + }; + } + return recommendedQuotes.reduce( + (acc, quote) => { + if (!quote) { + return acc; + } + + acc.usd = new BigNumber(acc.usd ?? 0) + .plus(quote?.quote.dest?.usd ?? 0) + .toString(); + acc.valueInCurrency = new BigNumber(acc.valueInCurrency ?? 0) + .plus(quote?.quote.dest.valueInCurrency ?? 0) + .toString(); + acc.amount = new BigNumber(acc.amount ?? 0) + .plus(quote?.quote.dest.amount ?? 0) + .toString(); + acc.normalizedAmount = new BigNumber(acc.normalizedAmount ?? 0) + .plus(quote?.quote.dest.normalizedAmount ?? 0) + .toString(); + return acc; + }, { - key, - }: { key: 'totalNetworkFee' | 'minToTokenAmount' | 'toTokenAmount' }, - ) => key, - ], - (recommendedQuotes, key) => - recommendedQuotes.reduce( + usd: '0', + valueInCurrency: '0', + amount: '0', + normalizedAmount: '0', + asset: destAsset, + }, + ); + }, +); + +const selectMinDestAmountSum = createBridgeSelector( + [selectRecommendedQuotes], + (recommendedQuotes) => { + const destAsset = recommendedQuotes.find(Boolean)?.quote.dest.asset; + if (!destAsset) { + return { + amount: '0', + usd: '0', + valueInCurrency: '0', + normalizedAmount: '0', + asset: destAsset, + }; + } + return recommendedQuotes.reduce( (acc, quote) => { + if (!quote) { + return acc; + } + acc.usd = new BigNumber(acc.usd ?? 0) - .plus(quote?.[key]?.usd ?? 0) + .plus(quote?.quote.dest.min?.usd ?? 0) .toString(); acc.valueInCurrency = new BigNumber(acc.valueInCurrency ?? 0) - .plus(quote?.[key]?.valueInCurrency ?? 0) + .plus(quote?.quote.dest.min?.valueInCurrency ?? 0) .toString(); acc.amount = new BigNumber(acc.amount ?? 0) - .plus(quote?.[key]?.amount ?? 0) + .plus(quote?.quote.dest.min?.amount ?? 0) + .toString(); + acc.normalizedAmount = new BigNumber(acc.normalizedAmount ?? 0) + .plus(quote?.quote.dest.min?.normalizedAmount ?? 0) .toString(); return acc; }, - { usd: null, valueInCurrency: null, amount: '0' }, - ), + { + usd: '0', + valueInCurrency: '0', + amount: '0', + normalizedAmount: '0', + asset: destAsset, + }, + ); + }, ); /** @@ -644,10 +705,8 @@ const selectMetadataSum = createBridgeSelector( */ export const selectBatchSellQuotes = createStructuredBridgeSelector({ recommendedQuotes: selectRecommendedQuotes, - totalReceived: (state, opts) => - selectMetadataSum(state, { ...opts, key: 'toTokenAmount' }), - minimumReceived: (state, opts) => - selectMetadataSum(state, { ...opts, key: 'minToTokenAmount' }), + totalReceived: selectDestAmountSum, + minimumReceived: selectMinDestAmountSum, quotesLastFetchedMs: (state) => state.quotesLastFetched, isLoading: (state) => state.quotesLoadingStatus === RequestStatus.LOADING, quoteFetchError: (state) => state.quoteFetchError, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 134c629d08..5ffe23fddf 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -33,7 +33,6 @@ import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; import type { SimulatedGasFeeLimitsSchema } from './validators/batch-sell'; import type { BatchSellTradesResponseSchema } from './validators/batch-sell'; import type { BridgeAssetSchema } from './validators/bridge-asset'; -import type { FeatureId } from './validators/feature-flags'; import type { ChainConfigurationSchema, ChainRankingSchema, @@ -41,21 +40,16 @@ import type { } from './validators/feature-flags'; import type { FeeDataSchema, + GaslessPropertiesSchema, IntentSchema, ProtocolSchema, - QuoteResponseSchema, QuoteSchema, StepSchema, - GaslessPropertiesSchema, TxFeeGasLimitsSchema, } from './validators/quote-response'; +import type { QuoteResponse } from './validators/quote-response-v2'; import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete'; import type { TokenFeatureSchema } from './validators/token-feature'; -import type { - BitcoinTradeData, - TronTradeData, - TxData, -} from './validators/trade'; export type FetchFunction = ( input: RequestInfo | URL | string, @@ -84,10 +78,16 @@ export type ChainConfiguration = Infer; export type ChainRanking = Infer; +/** + * @deprecated Use feeData.network instead + */ export type L1GasFees = { - l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by BridgeController.#appendL1GasFees + l1GasFeesInHexWei?: Hex; // l1 fees for approval and trade in hex wei, appended by BridgeController.#appendL1GasFees }; +/** + * @deprecated Use feeData.network instead + */ export type NonEvmFees = { nonEvmFeesInNative?: string; // Non-EVM chain fees in native units (SOL for Solana, BTC for Bitcoin) }; @@ -286,35 +286,8 @@ export type Quote = Infer; export type Intent = Infer; export type IntentOrderLike = Intent['order']; -/** - * This is the type for the quote response from the bridge-api - * TxDataType can be overriden to be a string when the quote is non-evm - * ApprovalType can be overriden when you know the specific approval type (e.g., TxData for EVM-only contexts) - */ -export type QuoteResponseV1< - TxDataType = TxData | string | BitcoinTradeData | TronTradeData, - ApprovalType = TxData | TronTradeData, -> = Infer & { - trade: TxDataType; - approval?: ApprovalType; - /** - * Appended to the quote response based on the quote request - */ - featureId?: FeatureId; - /** - * Appended to the quote response based on the quote request resetApproval flag - * If defined, the quote's total network fee will include the reset approval's gas limit. - */ - resetApproval?: TxData; - /** - * Appended to the quote if there are multiple quote requests in a batch. This - * indicates which quoteRequest the quote is for - */ - quoteRequestIndex?: number; -}; - export type BatchSellTradesRequest = { - quotes: QuoteResponseV1[]; + quotes: Omit[]; stxEnabled: boolean; }; @@ -376,7 +349,7 @@ export enum RequestStatus { export type BridgeControllerState = { quoteRequest: Partial[]; - quotes: (QuoteResponseV1 & L1GasFees & NonEvmFees)[]; + quotes: (QuoteResponse & L1GasFees & NonEvmFees)[]; /** * The time elapsed between the initial quote fetch and when the first valid quote was received */ diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index 8d7cfa62ef..c1497fb371 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -21,9 +21,9 @@ import type { BridgeAsset, BridgeControllerState, GenericQuoteRequest, - QuoteResponseV1, } from '../types'; import { ChainId } from '../types'; +import type { QuoteResponseV1 } from '../validators/quote-response'; import type { TxData } from '../validators/trade'; import { formatChainIdToCaip, diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 82fd3f3f51..0a520f95fc 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -1,11 +1,15 @@ import { AddressZero } from '@ethersproject/constants'; import type { CaipAssetType } from '@metamask/utils'; -import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../../tests/mock-quotes-erc20-erc20'; import { mockBridgeQuotesNativeErc20V1 } from '../../tests/mock-quotes-native-erc20'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL } from '../constants/bridge'; import { BatchSellTransactionType } from '../validators/batch-sell'; import { FeatureId } from '../validators/feature-flags'; +import { toQuoteResponseV2 } from '../validators/quote-response-v2-migration'; import { fetchBridgeQuotes, fetchBridgeTokens, @@ -745,7 +749,10 @@ describe('fetch', () => { signal, method: 'POST', body: JSON.stringify( - formatBatchSellTradesRequest(mockBridgeQuotesErc20Erc20V1, stxEnabled), + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20V1.map((quote) => toQuoteResponseV2(quote)), + stxEnabled, + ), ), }); @@ -769,7 +776,7 @@ describe('fetch', () => { const { signal } = new AbortController(); const result = await fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -796,7 +803,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -862,7 +869,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - [...mockBridgeQuotesErc20Erc20V1, null], + [...getMockBridgeQuotesErc20Erc20V2(), null], false, signal, BridgeClientId.EXTENSION, @@ -876,7 +883,7 @@ describe('fetch', () => { const result = await Promise.allSettled( Array.from({ length: 3 }, () => fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -947,7 +954,7 @@ describe('fetch', () => { const { signal } = new AbortController(); await fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), true, signal, BridgeClientId.EXTENSION, diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 59a0afee6e..22b29e5971 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StructError } from '@metamask/superstruct'; +import { KnownCaipNamespace } from '@metamask/utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import type { - QuoteResponseV1, FetchFunction, GenericQuoteRequest, QuoteRequest, @@ -16,10 +16,14 @@ import type { import { validateBatchSellTradesResponse } from '../validators/batch-sell'; import { validateBridgeAsset } from '../validators/bridge-asset'; import type { FeatureId } from '../validators/feature-flags'; +import type { QuoteResponseV1 } from '../validators/quote-response'; import { validateQuoteResponseV1 } from '../validators/quote-response'; +import type { QuoteResponse } from '../validators/quote-response-v2'; +import { toQuoteResponseV2 } from '../validators/quote-response-v2-migration'; import { validateQuoteStreamComplete } from '../validators/quote-stream-complete'; import { validateTokenFeature } from '../validators/token-feature'; import { isEvmTxData } from '../validators/trade'; +import type { TxData } from '../validators/trade'; import { getEthUsdtResetData } from './bridge'; import { formatAddressToAssetId, @@ -309,13 +313,17 @@ const getQuoteRequestId = ({ srcTokenAddress, destTokenAddress, }: QuoteRequest): string => - `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`; + `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); const getQuoteResponseId = ({ - srcAsset: { address: srcTokenAddress, chainId: srcChainId }, - destAsset: { address: destTokenAddress, chainId: destChainId }, -}: QuoteResponseV1['quote']): string => - `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`; + src: { + asset: { assetId: srcAssetId }, + }, + dest: { + asset: { assetId: destAssetId }, + }, +}: QuoteResponse['quote']): string => + `${srcAssetId}-${destAssetId}`.toLowerCase(); /** * Fetches quotes from the bridge-api @@ -347,7 +355,9 @@ export async function fetchBridgeQuoteStream( serverEventHandlers: { onClose: () => void | Promise; onQuoteValidationFailure: (validationFailures: string[]) => void; - onValidQuoteReceived: (quotes: QuoteResponseV1) => Promise; + onValidQuoteReceived: ( + quotes: QuoteResponse & { resetApproval?: TxData }, + ) => Promise; onTokenWarning: (warning: TokenFeature) => void; onComplete: (data: QuoteStreamCompleteData) => void; }, @@ -363,48 +373,51 @@ export async function fetchBridgeQuoteStream( ? normalizedQuoteRequests.map(getQuoteRequestId) : undefined; - const onQuoteReceived = async (quoteResponse: unknown): Promise => { + const onQuoteReceived = async ( + quoteResponseV1OrV2: unknown, + ): Promise => { const uniqueValidationFailures: Set = new Set([]); try { - if (validateQuoteResponseV1(quoteResponse)) { - // Fallback to 0 if the quote doesn't match any requests - const matchedQuoteRequestIdx = Math.max( - quoteRequestIds?.findIndex((id) => { - return id === getQuoteResponseId(quoteResponse.quote); - }) ?? 0, - 0, - ); - const matchingQuoteRequest = - normalizedQuoteRequests[matchedQuoteRequestIdx]; - - return await serverEventHandlers.onValidQuoteReceived({ - ...quoteResponse, - featureId, - // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval - resetApproval: - matchingQuoteRequest.resetApproval && - quoteResponse.approval && - isEvmTxData(quoteResponse.approval) - ? { - ...quoteResponse.approval, - data: getEthUsdtResetData(matchingQuoteRequest.destChainId), - } - : undefined, - ...(isBatchSellRequest && { - quoteRequestIndex: matchedQuoteRequestIdx, - }), - }); - } + const quoteResponseV2 = toQuoteResponseV2(quoteResponseV1OrV2, featureId); + // Fallback to 0 if the quote doesn't match any requests + const matchedQuoteRequestIdx = Math.max( + quoteRequestIds?.findIndex((id) => { + return id === getQuoteResponseId(quoteResponseV2.quote); + }) ?? 0, + 0, + ); + const matchingQuoteRequest = + normalizedQuoteRequests[matchedQuoteRequestIdx]; + + return await serverEventHandlers.onValidQuoteReceived({ + ...quoteResponseV2, + featureId, + // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval + resetApproval: + quoteResponseV2.namespace === KnownCaipNamespace.Eip155 && + matchingQuoteRequest.resetApproval && + quoteResponseV2.approval + ? { + ...quoteResponseV2.approval, + data: getEthUsdtResetData(matchingQuoteRequest.destChainId), + } + : undefined, + ...(isBatchSellRequest && { + quoteRequestIndex: matchedQuoteRequestIdx, + }), + }); } catch (error) { if (error instanceof StructError) { error.failures().forEach(({ branch, path }) => { const aggregatorId = - branch?.[0]?.quote?.bridgeId ?? - branch?.[0]?.quote?.bridges?.[0] ?? - (quoteResponse as QuoteResponseV1)?.quote?.bridgeId ?? - ((quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] || - ('unknown' as string)); + branch?.[0]?.quote?.aggregator ?? + branch?.[0]?.quote?.protocols?.[0] ?? + (quoteResponseV1OrV2 as QuoteResponseV1)?.quote?.protocols?.[0] ?? + (quoteResponseV1OrV2 as QuoteResponseV1)?.quote?.bridgeId ?? + (quoteResponseV1OrV2 as QuoteResponseV1)?.quote?.bridges?.[0] ?? + (quoteResponseV1OrV2 as QuoteResponse)?.quote?.aggregator ?? + 'unknown'; const pathString = path?.join('.') || 'unknown'; uniqueValidationFailures.add([aggregatorId, pathString].join('|')); }); @@ -495,11 +508,16 @@ export async function fetchBridgeQuoteStream( } export const formatBatchSellTradesRequest = ( - quotes: (QuoteResponseV1 | null)[], + quotes: (QuoteResponse | null)[], stxEnabled: boolean, ): BatchSellTradesRequest => ({ quotes: quotes - .filter((quote): quote is QuoteResponseV1 => quote !== null) + .filter( + ( + quote, + ): quote is QuoteResponse & { namespace: KnownCaipNamespace.Eip155 } => + quote !== null && quote.namespace === KnownCaipNamespace.Eip155, + ) .map( ({ trade, @@ -532,7 +550,7 @@ export const formatBatchSellTradesRequest = ( * @returns The batch sell trades and the total network fee */ export async function fetchBatchSellTrades( - quotes: (QuoteResponseV1 | null)[], + quotes: (QuoteResponse | null)[], stxEnabled: boolean, signal: AbortSignal | null, clientId: string, diff --git a/packages/bridge-controller/src/utils/metrics/properties.test.ts b/packages/bridge-controller/src/utils/metrics/properties.test.ts index a7773ab138..6b524f8d07 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.test.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.test.ts @@ -1,7 +1,9 @@ import { SolScope } from '@metamask/keyring-api'; import type { CaipChainId } from '@metamask/utils'; -import type { QuoteResponseV1 } from '../../types'; +import { validateQuoteResponseV1 } from '../../validators/quote-response'; +import type { QuoteResponseV1 } from '../../validators/quote-response'; +import { toQuoteResponseV2 } from '../../validators/quote-response-v2-migration'; import { getNativeAssetForChainId } from '../bridge'; import { formatChainIdToCaip } from '../caip-formatters'; import { MetricsSwapType } from './constants'; @@ -171,54 +173,23 @@ describe('properties', () => { it('should format provider label correctly', () => { const mockQuoteResponse: QuoteResponseV1 = { quote: { - requestId: 'request1', - srcChainId: 1, - srcAsset: { - chainId: 1, - address: '0x123', - symbol: 'ETH', - name: 'Ethereum', - decimals: 18, - assetId: 'eip155:1/slip44:60', - }, - srcTokenAmount: '1000000000000000000', - destChainId: 1, - destAsset: { - chainId: 1, - address: '0x456', - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - assetId: 'eip155:1/erc20:0x456', - }, - destTokenAmount: '1000000', - minDestTokenAmount: '950000', - feeData: { - metabridge: { - amount: '10000000000000000', - asset: { - chainId: 1, - address: '0x123', - symbol: 'ETH', - name: 'Ethereum', - decimals: 18, - assetId: 'eip155:1/slip44:60', - }, - }, - }, bridgeId: 'bridge1', bridges: ['bridge1'], steps: [], }, - trade: { - chainId: 1, - to: '0x789', - from: '0xabc', - value: '0', - data: '0x', - gasLimit: 100000, + }; + + const result = formatProviderLabel(mockQuoteResponse.quote); + + expect(result).toBe('bridge1_bridge1'); + }); + + it('should format provider label correctly (V2)', () => { + const mockQuoteResponse = { + quote: { + aggregator: 'bridge1', + protocols: ['bridge1'], }, - estimatedProcessingTimeInSeconds: 60, }; const result = formatProviderLabel(mockQuoteResponse.quote); @@ -405,23 +376,31 @@ describe('properties', () => { }, trade: { chainId: 1, - to: '0x789', - from: '0xabc', - value: '0', - data: '0x', + to: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + value: '0x0', + data: '0x0', gasLimit: 100000, + effectiveGas: 100000, }, estimatedProcessingTimeInSeconds: 60, }; + validateQuoteResponseV1(mockQuoteResponse); + const mockQuoteResponseV2 = toQuoteResponseV2(mockQuoteResponse); - const result = getQuotesReceivedProperties(mockQuoteResponse, [], false, { - ...mockQuoteResponse, - quote: { - ...mockQuoteResponse.quote, - bridges: ['bridge2'], - bridgeId: 'bridge2', + const result = getQuotesReceivedProperties( + mockQuoteResponseV2, + [], + false, + { + ...mockQuoteResponseV2, + quote: { + ...mockQuoteResponseV2.quote, + aggregator: 'bridge2', + protocols: ['bridge2'], + }, }, - }); + ); expect(result).toMatchInlineSnapshot(` { diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 97e7e5f5dc..adb11d2291 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -2,13 +2,9 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge'; import { ChainId } from '../../types'; -import type { - GenericQuoteRequest, - QuoteMetadata, - QuoteRequest, - QuoteResponseV1, -} from '../../types'; -import type { TxData } from '../../validators/trade'; +import type { GenericQuoteRequest, QuoteRequest } from '../../types'; +import { FeatureId } from '../../validators/feature-flags'; +import type { QuoteResponse } from '../../validators/quote-response-v2'; import { getNativeAssetForChainId, isCrossChain } from '../bridge'; import { formatAddressToAssetId, @@ -22,7 +18,6 @@ import type { QuoteWarning, RequestParams, } from './types'; -import { FeatureId } from '../../validators/feature-flags'; export const toInputChangedPropertyKey: Partial< Record @@ -74,10 +69,17 @@ export const getSwapTypeFromQuote = ( }; export const formatProviderLabel = ({ - bridgeId, + aggregator, + protocols, bridges, -}: QuoteResponseV1['quote']): `${string}_${string}` => - `${bridgeId}_${bridges[0]}`; + bridgeId, +}: { + aggregator?: string; + protocols?: string[]; + bridges?: string[]; + bridgeId?: string; +}): `${string}_${string}` => + `${aggregator ?? bridgeId ?? ''}_${protocols?.[0] ?? bridges?.[0] ?? ''}`; /** * @param quoteRequest - The current quote request used to derive chain and token identity fields. @@ -156,10 +158,10 @@ export const isCustomSlippage = (slippage: GenericQuoteRequest['slippage']) => { }; export const getQuotesReceivedProperties = ( - activeQuote: null | (QuoteResponseV1 & QuoteMetadata), + activeQuote: null | QuoteResponse, warnings: QuoteWarning[] = [], isSubmittable: boolean = true, - recommendedQuote?: null | (QuoteResponseV1 & QuoteMetadata), + recommendedQuote?: null | QuoteResponse, usdBalanceSource?: number, hasSufficientGasForQuote?: boolean | null, ) => { @@ -171,15 +173,17 @@ export const getQuotesReceivedProperties = ( quoted_time_minutes: activeQuote?.estimatedProcessingTimeInSeconds ? activeQuote.estimatedProcessingTimeInSeconds / 60 : 0, - usd_quoted_gas: Number(activeQuote?.gasFee?.effective?.usd ?? 0), - usd_quoted_return: Number(activeQuote?.toTokenAmount?.usd ?? 0), + usd_quoted_gas: Number(activeQuote?.quote?.feeData?.network?.[0]?.usd ?? 0), + usd_quoted_return: Number(activeQuote?.quote?.dest?.usd ?? 0), usd_balance_source: usdBalanceSource ?? 0, best_quote_provider: recommendedQuote ? formatProviderLabel(recommendedQuote.quote) : provider, provider, warnings, - price_impact: Number(activeQuote?.quote.priceData?.priceImpact ?? 0), + price_impact: Number( + activeQuote?.quote.priceData?.priceImpact?.amount ?? 0, + ), ...(hasSufficientGasForQuote !== undefined && { has_sufficient_gas_for_quote: hasSufficientGasForQuote, }), diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index b766a6ba01..69c2cdc364 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { CaipAssetType, CaipChainId } from '@metamask/utils'; -import type { FeatureId, SortOrder, StatusTypes } from '../../types'; +import type { SortOrder, StatusTypes } from '../../types'; +import type { FeatureId } from '../../validators/feature-flags'; import type { UnifiedSwapBridgeEventName, MetaMetricsSwapsEventSource, diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index 0ff8bd4a77..ed5fa1d697 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -1,49 +1,50 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { TransactionController } from '@metamask/transaction-controller'; -import { numberToHex } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import { CHAIN_IDS } from '../constants/chains'; import type { - QuoteResponseV1, L1GasFees, NonEvmFees, BridgeControllerMessenger, } from '../types'; +import type { QuoteResponseV1 } from '../validators/quote-response'; import { isTronTrade } from '../validators/trade'; import type { TxData } from '../validators/trade'; import { isNonEvmChainId, sumHexes } from './bridge'; -import { formatChainIdToCaip } from './caip-formatters'; +import { formatChainIdToCaip, formatChainIdToHex } from './caip-formatters'; import { computeFeeRequest } from './snaps'; import { extractTradeData } from './trade-utils'; /** * Appends transaction fees for EVM chains to quotes * + * @param chainId - The CAIP srcChainId of the quotes * @param quotes - Array of quote responses to append fees to * @param getLayer1GasFee - The function to use to get the layer 1 gas fee * @returns Array of quotes with fees appended, or undefined if quotes are for non-EVM chains */ -const appendL1GasFees = async ( - quotes: QuoteResponseV1[], +const appendL1GasFees = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, -): Promise<(QuoteResponseV1 & L1GasFees)[] | undefined> => { +): Promise<(QuoteType & L1GasFees)[] | undefined> => { // Indicates whether some of the quotes are not for optimism or base - const hasInvalidQuotes = quotes.some(({ quote }) => { - const chainId = formatChainIdToCaip(quote.srcChainId); - return ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] - .map(formatChainIdToCaip) - .includes(chainId); - }); + const hasInvalidQuotes = ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] + .map(formatChainIdToCaip) + .includes(chainId); // Only append L1 gas fees if all quotes are for either optimism or base if (hasInvalidQuotes) { return undefined; } + const hexChainId = formatChainIdToHex(chainId); const l1GasFeePromises = Promise.allSettled( quotes.map(async (quoteResponse) => { - const { quote, trade, approval } = quoteResponse; - const chainId = numberToHex(quote.srcChainId); + const { trade, approval } = quoteResponse; const getTxParams = (txData: TxData) => ({ from: txData.from, @@ -54,13 +55,13 @@ const appendL1GasFees = async ( }); const approvalL1GasFees = approval ? await getLayer1GasFee({ - transactionParams: getTxParams(approval), - chainId, + transactionParams: getTxParams(approval as TxData), + chainId: hexChainId, }) : '0x0'; const tradeL1GasFees = await getLayer1GasFee({ transactionParams: getTxParams(trade as TxData), - chainId, + chainId: hexChainId, }); if (approvalL1GasFees === undefined || tradeL1GasFees === undefined) { @@ -75,7 +76,7 @@ const appendL1GasFees = async ( ); const quotesWithL1GasFees = (await l1GasFeePromises).reduce< - (QuoteResponseV1 & L1GasFees)[] + (QuoteType & L1GasFees)[] >((acc, result) => { if (result.status === 'fulfilled' && result.value) { acc.push(result.value); @@ -85,31 +86,36 @@ const appendL1GasFees = async ( return acc; }, []); - return quotesWithL1GasFees; + if (quotesWithL1GasFees.length) { + return quotesWithL1GasFees; + } + return undefined; }; /** * Appends transaction fees for non-EVM chains to quotes * + * @param chainId - The CAIP chain ID of the quotes * @param quotes - Array of quote responses to append fees to * @param messenger - The messaging system to use to call the snap controller * @param selectedAccount - The selected account for which the quotes were requested * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains */ -const appendNonEvmFees = async ( - quotes: QuoteResponseV1[], +const appendNonEvmFees = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], messenger: BridgeControllerMessenger, selectedAccount?: InternalAccount, -): Promise<(QuoteResponseV1 & NonEvmFees)[] | undefined> => { - if ( - quotes.some(({ quote: { srcChainId } }) => !isNonEvmChainId(srcChainId)) - ) { +): Promise<(QuoteType & NonEvmFees)[] | undefined> => { + if (!isNonEvmChainId(chainId)) { return undefined; } const nonEvmFeePromises = Promise.allSettled( quotes.map(async (quoteResponse) => { - const { trade, quote } = quoteResponse; + const { trade } = quoteResponse; // Skip fee computation if no snap account or trade data if (!selectedAccount?.metadata?.snap?.id || !trade) { @@ -117,7 +123,7 @@ const appendNonEvmFees = async ( } try { - const scope = formatChainIdToCaip(quote.srcChainId); + const scope = chainId; const transaction = extractTradeData(trade); @@ -164,7 +170,7 @@ const appendNonEvmFees = async ( // Return quote with undefined fee if snap fails (e.g., insufficient UTXO funds) // Client can render special UI or skip the quote card row for quotes with missing fee data console.error( - `Failed to compute non-EVM fees for quote ${quote.requestId}:`, + `Failed to compute non-EVM fees for quote in ${chainId}:`, error, ); return { @@ -176,7 +182,7 @@ const appendNonEvmFees = async ( ); const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce< - (QuoteResponseV1 & NonEvmFees)[] + (QuoteType & NonEvmFees)[] >((acc, result) => { if (result.status === 'fulfilled' && result.value) { acc.push(result.value); @@ -190,24 +196,31 @@ const appendNonEvmFees = async ( /** * Appends transaction fees to quotes * + * @param chainId - The CAIP chain ID of the quotes * @param quotes - Array of quote responses to append fees to * @param messenger - The bridge controller to use to call the snap controller * @param getLayer1GasFee - The function to use to get the layer 1 gas fee * @param selectedAccount - The selected account for which the quotes were requested * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains */ -export const appendFeesToQuotes = async ( - quotes: QuoteResponseV1[], +export const appendFeesToQuotes = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], messenger: BridgeControllerMessenger, getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, selectedAccount?: InternalAccount, -): Promise<(QuoteResponseV1 & L1GasFees & NonEvmFees)[]> => { +): Promise<(QuoteType & L1GasFees & NonEvmFees)[]> => { // Safe to cast: appendL1GasFees checks if all quotes are EVM and returns undefined otherwise const quotesWithL1GasFees = await appendL1GasFees( - quotes as QuoteResponseV1[], + chainId, + quotes, getLayer1GasFee, ); + const quotesWithNonEvmFees = await appendNonEvmFees( + chainId, quotes, messenger, selectedAccount, diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index f75f838503..e1805ccdea 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -2,14 +2,12 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import type { - GenericQuoteRequest, - QuoteResponseV1, - Quote, - NonEvmFees, - L1GasFees, -} from '../types'; -import type { TxData } from '../validators/trade'; +import { getMockBridgeQuotesErc20Erc20V2 } from '../../tests/mock-quotes-erc20-erc20'; +import { getMockBridgeQuotesNativeErc20V2 } from '../../tests/mock-quotes-native-erc20'; +import { getMockBridgeQuotesSolErc20V2 } from '../../tests/mock-quotes-sol-erc20'; +import type { GenericQuoteRequest, Quote, L1GasFees } from '../types'; +import { QuoteResponse } from '../validators/quote-response-v2'; +import { getNativeAssetForChainId } from './bridge'; import { isValidQuoteRequest, getQuoteIdentifier, @@ -166,13 +164,15 @@ describe('Quote Metadata Utils', () => { describe('calcSentAmount', () => { it('should calculate sent amount correctly with exchange rates', () => { - const mockQuote: Quote = { - srcTokenAmount: '12555423', - srcAsset: { decimals: 6 }, - feeData: { - metabridge: { amount: '100000000' }, + const mockQuote = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcTokenAmount: '12555423', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '100000000' }, + }, }, - } as Quote; + })[0].quote; const result = calcSentAmount(mockQuote, { exchangeRate: '2.14', usdExchangeRate: '1.5', @@ -184,13 +184,15 @@ describe('Quote Metadata Utils', () => { }); it('should handle missing exchange rates', () => { - const mockQuote: Quote = { - srcTokenAmount: '1000000000', - srcAsset: { decimals: 6 }, - feeData: { - metabridge: { amount: '100000000' }, + const mockQuote = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcTokenAmount: '1000000000', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '100000000' }, + }, }, - } as Quote; + })[0].quote; const result = calcSentAmount(mockQuote, {}); expect(result.amount).toBe('1100'); @@ -199,20 +201,16 @@ describe('Quote Metadata Utils', () => { }); it('should handle zero values', () => { - const mockQuote: Quote = { - srcTokenAmount: '0', - srcAsset: { decimals: 6 }, - feeData: { - metabridge: { amount: '0' }, - }, - } as Quote; - const zeroQuote = { - ...mockQuote, - srcTokenAmount: '0', - feeData: { - metabridge: { amount: '0' }, + const zeroQuote = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcTokenAmount: '0', + srcAsset: { decimals: 6 }, + + feeData: { + metabridge: { amount: '0' }, + }, }, - } as unknown as Quote; + })[0].quote; const result = calcSentAmount(zeroQuote, { exchangeRate: '2', @@ -225,24 +223,27 @@ describe('Quote Metadata Utils', () => { }); it('should handle large numbers', () => { - const largeQuote = { - srcTokenAmount: '1000000000000000000', - srcAsset: { - decimals: 18, - assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', - }, - feeData: { - metabridge: { - amount: '100000000000000000', - asset: { - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', - address: '0x0000000000000000000000000000000000000000', - decimals: 18, + const largeQuote = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcTokenAmount: '1000000000000000000', + srcAsset: { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + }, + feeData: { + metabridge: { + amount: '100000000000000000', + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, }, }, }, - } as unknown as Quote; + })[0].quote; const result = calcSentAmount(largeQuote, { exchangeRate: '2', @@ -259,25 +260,48 @@ describe('Quote Metadata Utils', () => { // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is already // the total fixed commitment including protocol fees. Adding feeData fees // on top would double-count them. - const intentQuote = { - srcTokenAmount: '10000000', // 10 USDT (6 decimals), fee already included - srcAsset: { - decimals: 6, - assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', - }, - feeData: { - metabridge: { - amount: '500000', // 0.5 USDT protocol fee — already inside srcTokenAmount - asset: { - assetId: - 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - decimals: 6, + + const intentQuote = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcTokenAmount: '10000000', // 10 USDT (6 decimals), fee already included + srcAsset: { + decimals: 6, + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + feeData: { + metabridge: { + amount: '500000', // 0.5 USDT protocol fee — already inside srcTokenAmount + asset: { + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + }, + }, + intent: { + protocol: 'cow', + order: { + sellToken: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + buyToken: '0x0000000000000000000000000000000000000000', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }, + typedData: { + types: {}, + domain: {}, + primaryType: 'Order', + message: {}, }, }, }, - intent: { protocol: 'cow', order: {} }, - } as unknown as Quote; + })[0].quote; const result = calcSentAmount(intentQuote, { exchangeRate: '1', @@ -292,11 +316,9 @@ describe('Quote Metadata Utils', () => { }); describe('calcNonEvmTotalNetworkFee', () => { - const mockBridgeQuote: QuoteResponseV1 & NonEvmFees = { + const mockBridgeQuote = getMockBridgeQuotesSolErc20V2({ nonEvmFeesInNative: '1', - quote: {} as Quote, - trade: {}, - } as QuoteResponseV1 & NonEvmFees; + })[0]; it('should calculate Solana fees correctly with exchange rates', () => { const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, { @@ -310,11 +332,9 @@ describe('Quote Metadata Utils', () => { }); it('should calculate Bitcoin fees correctly with exchange rates', () => { - const btcQuote: QuoteResponseV1 & NonEvmFees = { + const btcQuote = getMockBridgeQuotesSolErc20V2({ nonEvmFeesInNative: '0.00005', // BTC fee in native units - quote: {} as Quote, - trade: {}, - } as QuoteResponseV1 & NonEvmFees; + })[0]; const result = calcNonEvmTotalNetworkFee(btcQuote, { exchangeRate: '60000', @@ -382,14 +402,17 @@ describe('Quote Metadata Utils', () => { }); describe('calcRelayerFee', () => { - const mockBridgeQuote: QuoteResponseV1 = { + const mockBridgeQuote = getMockBridgeQuotesNativeErc20V2({ quote: { - srcAsset: { address: '0x123', decimals: 18 }, srcTokenAmount: '1000000000000000000', - feeData: { metabridge: { amount: '100000000000000000' } }, + feeData: { + metabridge: { + amount: '100000000000000000', + }, + }, }, trade: { value: '0x10A741A462780000' }, - } as QuoteResponseV1; + })[0]; it('should calculate relayer fee correctly with exchange rates', () => { const result = calcRelayerFee(mockBridgeQuote, { @@ -397,30 +420,32 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '1.5', }); - expect(result.amount).toStrictEqual(new BigNumber(1.2)); - expect(result.valueInCurrency).toStrictEqual(new BigNumber(2.4)); - expect(result.usd).toStrictEqual(new BigNumber(1.8)); + expect(result.amount).toStrictEqual(new BigNumber(1.2).toFixed()); + expect(result.valueInCurrency).toStrictEqual( + new BigNumber(2.4).toFixed(), + ); + expect(result.usd).toStrictEqual(new BigNumber(1.8).toFixed()); }); it('should calculate relayer fee correctly with no trade.value', () => { const result = calcRelayerFee( - { ...mockBridgeQuote, trade: {} as TxData }, + getMockBridgeQuotesNativeErc20V2({ + trade: { ...mockBridgeQuote.trade, value: '0x0' }, + })[0], { exchangeRate: '2', usdExchangeRate: '1.5', }, ); - expect(result.amount).toStrictEqual(new BigNumber(0)); - expect(result.valueInCurrency).toStrictEqual(new BigNumber(0)); - expect(result.usd).toStrictEqual(new BigNumber(0)); + expect(result.amount).toStrictEqual(new BigNumber(0).toFixed()); + expect(result.valueInCurrency).toStrictEqual(new BigNumber(0).toFixed()); + expect(result.usd).toStrictEqual(new BigNumber(0).toFixed()); }); it('should handle native token address', () => { - const nativeBridgeQuote = { - ...mockBridgeQuote, + const nativeBridgeQuote = getMockBridgeQuotesNativeErc20V2({ quote: { - ...mockBridgeQuote.quote, srcTokenAmount: '1000000000000000000', feeData: { metabridge: { @@ -428,19 +453,20 @@ describe('Quote Metadata Utils', () => { asset: { address: AddressZero, decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + assetId: getNativeAssetForChainId(1).assetId, }, }, }, srcAsset: { address: AddressZero, decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + assetId: getNativeAssetForChainId(1).assetId, }, }, - } as unknown as QuoteResponseV1; + trade: { + value: '0x10A741A462780000', + }, + })[0]; const result = calcRelayerFee(nativeBridgeQuote, { exchangeRate: '2', @@ -451,20 +477,20 @@ describe('Quote Metadata Utils', () => { convertHexToDecimal(nativeBridgeQuote.trade.value).toString(), ).toBe('1200000000000000000'); expect(result).toStrictEqual({ - amount: new BigNumber(0.1), - valueInCurrency: new BigNumber(0.2), - usd: new BigNumber(0.15), + amount: new BigNumber(0.1).toFixed(), + valueInCurrency: new BigNumber(0.2).toFixed(), + usd: new BigNumber(0.15).toFixed(), }); }); }); describe('calcEstimatedAndMaxTotalGasFee', () => { - const mockBridgeQuote: QuoteResponseV1 & L1GasFees = { + const mockBridgeQuote: QuoteResponse & L1GasFees = { quote: {} as Quote, trade: { gasLimit: 21000 }, approval: { gasLimit: 46000 }, l1GasFeesInHexWei: '0x5AF3107A4000', - } as QuoteResponseV1 & L1GasFees; + }; it('should calculate estimated and max gas fees correctly', () => { const result = calcEstimatedAndMaxTotalGasFee({ @@ -504,7 +530,7 @@ describe('Quote Metadata Utils', () => { ...mockBridgeQuote, trade: { gasLimit: 21000, effectiveGas: 10000 }, approval: { gasLimit: 46000, effectiveGas: 20000 }, - } as QuoteResponseV1 & L1GasFees, + }, feePerGasInDecGwei: '52', maxFeePerGasInDecGwei: '102', exchangeRate: '2000', @@ -588,7 +614,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 0 }, l1GasFeesInHexWei: '0x0', estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: zeroGasQuote, @@ -611,7 +637,7 @@ describe('Quote Metadata Utils', () => { approval: undefined, l1GasFeesInHexWei: '0x5AF3107A4000', estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: noApprovalQuote, @@ -635,7 +661,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 46000 }, l1GasFeesInHexWei: '0x5AF3107A4000', estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: noGasLimitQuote, @@ -656,7 +682,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 500000 }, l1GasFeesInHexWei: '0x1BC16D674EC80000', // 2 ETH in wei estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: largeGasQuote, @@ -718,9 +744,9 @@ describe('Quote Metadata Utils', () => { }; const mockRelayerFee = { - amount: new BigNumber(0.05), - valueInCurrency: new BigNumber(100), - usd: new BigNumber(75), + amount: '0.05', + valueInCurrency: '100', + usd: '75', }; it('should calculate total estimated network fee correctly', () => { @@ -741,7 +767,7 @@ describe('Quote Metadata Utils', () => { it('should calculate total estimated network fee correctly with no relayer fee', () => { const result = calcTotalEstimatedNetworkFee(mockGasFee, { - amount: new BigNumber(0), + amount: '0', valueInCurrency: null, usd: null, }); @@ -753,7 +779,7 @@ describe('Quote Metadata Utils', () => { it('should calculate total max network fee correctly with no relayer fee', () => { const result = calcTotalMaxNetworkFee(mockGasFee, { - amount: new BigNumber(0), + amount: '0', valueInCurrency: null, usd: null, }); @@ -779,17 +805,21 @@ describe('Quote Metadata Utils', () => { const mockQuote = { feeData: { - txFee: { - asset: { - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + txFee: [ + { + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + }, }, - }, + ], }, - destAsset: { - assetId: 'eip155:10/erc20:0x0000000000000000000000000000000000000000', + dest: { + asset: { + assetId: 'eip155:10/erc20:0x0000000000000000000000000000000000000000', + }, }, - } as unknown as Quote; + }; it('should calculate adjusted return correctly', () => { const result = calcAdjustedReturn( mockToAmount, diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 2328fe0696..5f1fa15dce 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -1,23 +1,22 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { convertHexToDecimal, toHex, weiHexToGweiDec, } from '@metamask/controller-utils'; +import { KnownCaipNamespace } from '@metamask/utils'; +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { BigNumber } from 'bignumber.js'; import type { - BridgeAsset, ExchangeRate, GenericQuoteRequest, L1GasFees, - Quote, - QuoteMetadata, - QuoteResponseV1, NonEvmFees, } from '../types'; +import type { BridgeAssetV2 } from '../validators/bridge-asset'; import { FeatureId } from '../validators/feature-flags'; -import type { TxData } from '../validators/trade'; +import type { QuoteResponseV1 } from '../validators/quote-response'; +import type { QuoteResponse } from '../validators/quote-response-v2'; import { isNativeAddress, isNonEvmChainId } from './bridge'; export const isValidQuoteRequest = ( @@ -92,6 +91,8 @@ export const isValidBatchSellQuoteRequest = ( /** * Generates a pseudo-unique string that identifies each quote by aggregator, bridge, and steps * + * @deprecated No longer used + * * @param quote - The quote to generate an identifier for * @returns A pseudo-unique string that identifies the quote */ @@ -103,8 +104,13 @@ const calcTokenAmount = (value: string | BigNumber, decimals: number) => { return new BigNumber(value).div(divisor); }; +export const calcTokenValue = (value: string | BigNumber, decimals: number) => { + const divisor = new BigNumber(10).pow(decimals ?? 0); + return new BigNumber(value).times(divisor).toFixed(); +}; + export const calcNonEvmTotalNetworkFee = ( - bridgeQuote: QuoteResponseV1 & NonEvmFees, + bridgeQuote: QuoteResponse & NonEvmFees, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const { nonEvmFeesInNative } = bridgeQuote; @@ -112,17 +118,17 @@ export const calcNonEvmTotalNetworkFee = ( const feeInNative = new BigNumber(nonEvmFeesInNative ?? '0'); return { - amount: feeInNative.toString(), + amount: feeInNative.toFixed(), valueInCurrency: exchangeRate - ? feeInNative.times(exchangeRate).toString() + ? feeInNative.times(exchangeRate).toFixed() : null, - usd: usdExchangeRate ? feeInNative.times(usdExchangeRate).toString() : null, + usd: usdExchangeRate ? feeInNative.times(usdExchangeRate).toFixed() : null, }; }; export const calcToAmount = ( destTokenAmount: string, - destAsset: BridgeAsset, + destAsset: BridgeAssetV2, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const normalizedDestAmount = calcTokenAmount( @@ -130,18 +136,22 @@ export const calcToAmount = ( destAsset.decimals, ); return { - amount: normalizedDestAmount.toString(), + amount: normalizedDestAmount.toFixed(), valueInCurrency: exchangeRate - ? normalizedDestAmount.times(exchangeRate).toString() + ? normalizedDestAmount.times(exchangeRate).toFixed() : null, usd: usdExchangeRate - ? normalizedDestAmount.times(usdExchangeRate).toString() + ? normalizedDestAmount.times(usdExchangeRate).toFixed() : null, }; }; export const calcSentAmount = ( - { srcTokenAmount, srcAsset, feeData, intent }: Quote, + { + src: { amount: srcTokenAmount, asset: srcAsset }, + feeData, + intent, + }: QuoteResponse['quote'], { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is the total @@ -152,6 +162,7 @@ export const calcSentAmount = ( const sentAmount = intent ? new BigNumber(srcTokenAmount) : Object.values(feeData) + .flat() .filter((fee) => fee?.amount && fee.asset?.assetId === srcAsset.assetId) .reduce( (acc, { amount }) => acc.plus(amount), @@ -159,37 +170,38 @@ export const calcSentAmount = ( ); const normalizedSentAmount = calcTokenAmount(sentAmount, srcAsset.decimals); return { - amount: normalizedSentAmount.toString(), + amount: normalizedSentAmount.toFixed(), valueInCurrency: exchangeRate - ? normalizedSentAmount.times(exchangeRate).toString() + ? normalizedSentAmount.times(exchangeRate).toFixed() : null, usd: usdExchangeRate - ? normalizedSentAmount.times(usdExchangeRate).toString() + ? normalizedSentAmount.times(usdExchangeRate).toFixed() : null, }; }; export const calcBatchFees = ( amount: string, - asset: BridgeAsset, + asset: BridgeAssetV2, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const normalizedAmount = calcTokenAmount(amount, asset.decimals); return { - amount: normalizedAmount.toString(), + amount, + normalizedAmount: normalizedAmount.toFixed(), valueInCurrency: exchangeRate - ? normalizedAmount.times(exchangeRate).toString() + ? normalizedAmount.times(exchangeRate).toFixed() : null, usd: usdExchangeRate - ? normalizedAmount.times(usdExchangeRate).toString() + ? normalizedAmount.times(usdExchangeRate).toFixed() : null, asset, }; }; export const calcRelayerFee = ( - quoteResponse: QuoteResponseV1, + quoteResponse: QuoteResponse & { namespace: KnownCaipNamespace.Eip155 }, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const { quote, trade } = quoteResponse; @@ -197,9 +209,12 @@ export const calcRelayerFee = ( convertHexToDecimal(trade.value || '0x0'), ); let relayerFeeInNative = calcTokenAmount(relayerFeeAmount, 18); + if (relayerFeeInNative.lte(0)) { + return { amount: '0', valueInCurrency: '0', usd: '0' }; + } // Subtract srcAmount and other fees from trade value if srcAsset is native - if (isNativeAddress(quote.srcAsset.address)) { + if (isNativeAddress(quote.src.asset.assetId)) { const sentAmountInNative = calcSentAmount(quote, { exchangeRate, usdExchangeRate, @@ -208,11 +223,13 @@ export const calcRelayerFee = ( } return { - amount: relayerFeeInNative, + amount: relayerFeeInNative.toFixed(), valueInCurrency: exchangeRate - ? relayerFeeInNative.times(exchangeRate) + ? relayerFeeInNative.times(exchangeRate).toFixed() + : null, + usd: usdExchangeRate + ? relayerFeeInNative.times(usdExchangeRate).toFixed() : null, - usd: usdExchangeRate ? relayerFeeInNative.times(usdExchangeRate) : null, }; }; @@ -251,9 +268,9 @@ const calcTotalGasFee = ({ : null; return { - amount: gasFeesInDecEth.toString(), - valueInCurrency: gasFeesInDisplayCurrency?.toString() ?? null, - usd: gasFeesInUSD?.toString() ?? null, + amount: gasFeesInDecEth.toFixed(), + valueInCurrency: gasFeesInDisplayCurrency?.toFixed() ?? null, + usd: gasFeesInUSD?.toFixed() ?? null, }; }; @@ -264,10 +281,12 @@ export const calcEstimatedAndMaxTotalGasFee = ({ exchangeRate: nativeToDisplayCurrencyExchangeRate, usdExchangeRate: nativeToUsdExchangeRate, }: { - bridgeQuote: QuoteResponseV1 & L1GasFees; + bridgeQuote: QuoteResponse & { + namespace: KnownCaipNamespace.Eip155; + } & L1GasFees; maxFeePerGasInDecGwei?: string; feePerGasInDecGwei?: string; -} & ExchangeRate): QuoteMetadata['gasFee'] => { +} & ExchangeRate) => { // Estimated gas fees spent after receiving refunds, this is shown to the user const { amount: amountEffective, @@ -345,16 +364,14 @@ export const calcTotalEstimatedNetworkFee = ( return { amount: new BigNumber(gasFeeToDisplay?.amount ?? '0') .plus(relayerFee.amount) - .toString(), + .toFixed(), valueInCurrency: gasFeeToDisplay?.valueInCurrency ? new BigNumber(gasFeeToDisplay.valueInCurrency) .plus(relayerFee.valueInCurrency ?? '0') - .toString() + .toFixed() : null, usd: gasFeeToDisplay?.usd - ? new BigNumber(gasFeeToDisplay.usd) - .plus(relayerFee.usd ?? '0') - .toString() + ? new BigNumber(gasFeeToDisplay.usd).plus(relayerFee.usd ?? '0').toFixed() : null, }; }; @@ -364,21 +381,26 @@ export const calcTotalMaxNetworkFee = ( relayerFee: ReturnType, ) => { return { - amount: new BigNumber(gasFee.max.amount).plus(relayerFee.amount).toString(), + amount: new BigNumber(gasFee.max.amount).plus(relayerFee.amount).toFixed(), valueInCurrency: gasFee.max.valueInCurrency ? new BigNumber(gasFee.max.valueInCurrency) .plus(relayerFee.valueInCurrency ?? '0') - .toString() + .toFixed() : null, usd: gasFee.max.usd - ? new BigNumber(gasFee.max.usd).plus(relayerFee.usd ?? '0').toString() + ? new BigNumber(gasFee.max.usd).plus(relayerFee.usd ?? '0').toFixed() : null, }; }; // Gas is included for some swap quotes and this is the value displayed in the client export const calcIncludedTxFees = ( - { gasIncluded, gasIncluded7702, srcAsset, feeData: { txFee } }: Quote, + { + gasIncluded, + gasIncluded7702, + src: { asset: srcAsset }, + feeData: { txFee }, + }: QuoteResponse['quote'], srcTokenExchangeRate: ExchangeRate, destTokenExchangeRate: ExchangeRate, ) => { @@ -387,21 +409,21 @@ export const calcIncludedTxFees = ( } // Use exchange rate of the token that is being used to pay for the transaction const { exchangeRate, usdExchangeRate } = - txFee.asset.assetId === srcAsset.assetId + txFee[0].asset.assetId === srcAsset.assetId ? srcTokenExchangeRate : destTokenExchangeRate; const normalizedTxFeeAmount = calcTokenAmount( - txFee.amount, - txFee.asset.decimals, + txFee[0].amount, + txFee[0].asset.decimals, ); return { - amount: normalizedTxFeeAmount.toString(), + amount: normalizedTxFeeAmount.toFixed(), valueInCurrency: exchangeRate - ? normalizedTxFeeAmount.times(exchangeRate).toString() + ? normalizedTxFeeAmount.times(exchangeRate).toFixed() : null, usd: usdExchangeRate - ? normalizedTxFeeAmount.times(usdExchangeRate).toString() + ? normalizedTxFeeAmount.times(usdExchangeRate).toFixed() : null, }; }; @@ -409,10 +431,15 @@ export const calcIncludedTxFees = ( export const calcAdjustedReturn = ( toTokenAmount: ReturnType, totalEstimatedNetworkFee: ReturnType, - { feeData: { txFee }, destAsset: { assetId: destAssetId } }: Quote, + { + feeData: { txFee }, + dest: { + asset: { assetId: destAssetId }, + }, + }: QuoteResponse['quote'], ) => { // If gas is included and is taken from the dest token, don't subtract network fee from return - if (txFee?.asset?.assetId === destAssetId) { + if (txFee?.[0]?.asset?.assetId === destAssetId) { return { valueInCurrency: toTokenAmount.valueInCurrency, usd: toTokenAmount.usd, @@ -423,19 +450,19 @@ export const calcAdjustedReturn = ( toTokenAmount.valueInCurrency && totalEstimatedNetworkFee.valueInCurrency ? new BigNumber(toTokenAmount.valueInCurrency) .minus(totalEstimatedNetworkFee.valueInCurrency) - .toString() + .toFixed() : null, usd: toTokenAmount.usd && totalEstimatedNetworkFee.usd ? new BigNumber(toTokenAmount.usd) .minus(totalEstimatedNetworkFee.usd) - .toString() + .toFixed() : null, }; }; export const calcSwapRate = (sentAmount: string, destTokenAmount: string) => - new BigNumber(destTokenAmount).div(sentAmount).toString(); + new BigNumber(destTokenAmount).div(sentAmount).toFixed(); export const calcCost = ( adjustedReturn: ReturnType, @@ -445,11 +472,11 @@ export const calcCost = ( adjustedReturn.valueInCurrency && sentAmount.valueInCurrency ? new BigNumber(sentAmount.valueInCurrency) .minus(adjustedReturn.valueInCurrency) - .toString() + .toFixed() : null, usd: adjustedReturn.usd && sentAmount.usd - ? new BigNumber(sentAmount.usd).minus(adjustedReturn.usd).toString() + ? new BigNumber(sentAmount.usd).minus(adjustedReturn.usd).toFixed() : null, }); @@ -471,7 +498,7 @@ export const calcSlippagePercentage = ( .div(sentAmount.valueInCurrency) .times(100) .abs() - .toString(); + .toFixed(); } if (cost.usd && sentAmount.usd) { @@ -479,7 +506,7 @@ export const calcSlippagePercentage = ( .div(sentAmount.usd) .times(100) .abs() - .toString(); + .toFixed(); } return null; diff --git a/packages/bridge-controller/src/validators/bridge-asset.ts b/packages/bridge-controller/src/validators/bridge-asset.ts index 0e8092a691..b633ff6e7b 100644 --- a/packages/bridge-controller/src/validators/bridge-asset.ts +++ b/packages/bridge-controller/src/validators/bridge-asset.ts @@ -5,12 +5,80 @@ import { optional, nullable, is, + intersection, + enums, + array, + boolean, + coerce, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { CaipAssetTypeStruct } from '@metamask/utils'; export const ChainIdSchema = number(); +export const MinimalAssetSchema = type({ + /** + * Case-sensitive for non-EVM chains, case-insensitive for EVM chains + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of token object + */ + symbol: string(), + /** + * The name for the network + */ + name: string(), + decimals: number(), +}); + +export enum BridgeAssetSecurityDataType { + INFO = 'Info', + BENIGN = 'Benign', + VERIFIED = 'Verified', + WARNING = 'Warning', + SPAM = 'Spam', + MALICIOUS = 'Malicious', +} + +const BridgeAssetSecurityData = type({ + isVerified: optional(boolean()), + securityData: optional( + type({ + type: enums(Object.values(BridgeAssetSecurityDataType)), + metadata: optional( + type({ + features: array( + type({ + featureId: string(), + type: enums(Object.values(BridgeAssetSecurityDataType)), + description: string(), + }), + ), + }), + ), + }), + ), +}); + +export const BridgeAssetV2Schema = intersection([ + MinimalAssetSchema, + BridgeAssetSecurityData, + type({ + /** + * URL for token icon + */ + iconUrl: nullable(optional(string())), + noFee: optional( + type({ + isDestination: nullable(optional(boolean())), + isSource: nullable(optional(boolean())), + }), + ), + }), +]); +export type BridgeAssetV2 = Infer; + export const BridgeAssetSchema = type({ /** * The chainId of the token @@ -43,8 +111,40 @@ export const BridgeAssetSchema = type({ iconUrl: optional(nullable(string())), }); +export const BridgeAssetV2FromV1 = coerce( + BridgeAssetV2Schema, + BridgeAssetSchema, + (value) => { + const { + chainId, + address, + // @ts-expect-error - chainAgnosticId is not in the schema + chainAgnosticId, + icon, + // @ts-expect-error - logoURI is not in the schema + logoURI, + ...rest + } = value; + return { + ...rest, + }; + }, +); + export const validateBridgeAsset = ( data: unknown, ): data is Infer => { return is(data, BridgeAssetSchema); }; + +export const validateMinimalAssetObject = ( + data: unknown, +): data is Infer => { + return is(data, MinimalAssetSchema); +}; + +export const validateBridgeAssetV2 = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetV2Schema); +}; diff --git a/packages/bridge-controller/src/validators/feature-flags.ts b/packages/bridge-controller/src/validators/feature-flags.ts index 63250ddeb5..145d82c93f 100644 --- a/packages/bridge-controller/src/validators/feature-flags.ts +++ b/packages/bridge-controller/src/validators/feature-flags.ts @@ -7,7 +7,6 @@ import { array, boolean, number, - enums, is, define, } from '@metamask/superstruct'; @@ -93,15 +92,14 @@ const GenericQuoteRequestSchema = type({ bridgeIds: optional(array(string())), fee: optional(number()), }); -const FeatureIdSchema = enums(Object.values(FeatureId)); + /** * This is the schema for the feature flags response from the RemoteFeatureFlagController */ - export const PlatformConfigSchema = type({ priceImpactThreshold: optional(PriceImpactThresholdSchema), quoteRequestOverrides: optional( - record(FeatureIdSchema, optional(GenericQuoteRequestSchema)), + record(string(), optional(GenericQuoteRequestSchema)), ), minimumVersion: string(), refreshRate: number(), diff --git a/packages/bridge-controller/src/validators/quote-response-v2-migration.ts b/packages/bridge-controller/src/validators/quote-response-v2-migration.ts new file mode 100644 index 0000000000..7d3ae1a2a9 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response-v2-migration.ts @@ -0,0 +1,257 @@ +import { create, coerce } from '@metamask/superstruct'; +import { KnownCaipNamespace, parseCaipAssetType } from '@metamask/utils'; + +import { QuoteMetadata } from '../types'; +import { getNativeAssetForChainId } from '../utils/bridge'; +import { formatChainIdToHex } from '../utils/caip-formatters'; +import { calcTokenValue } from '../utils/quote'; +import { BridgeAssetV2FromV1 } from './bridge-asset'; +import { FeatureId } from './feature-flags'; +import { + FeeType, + QuoteResponseSchemaV1, + QuoteResponseV1, + StepSchema, +} from './quote-response'; +import { + QuoteResponse, + QuoteResponseSchemaV2, + StepSchemaV2, +} from './quote-response-v2'; + +const StepSchemaV2FromV1 = coerce(StepSchemaV2, StepSchema, (value) => { + const { srcChainId, destChainId, action } = value; + return { + srcChainId, + destChainId, + action, + }; +}); + +const QuoteResponseV2FromV1 = coerce( + QuoteResponseSchemaV2, + QuoteResponseSchemaV1, + (value: QuoteResponseV1) => { + const { quote, l1GasFeesInHexWei, nonEvmFeesInNative, ...rest } = value; + const { + srcTokenAmount, + destTokenAmount, + minDestTokenAmount, + srcAsset, + destAsset, + srcChainId, + destChainId, + walletAddress, + destWalletAddress, + priceData, + feeData, + bridgeId, + bridges, + steps, + ...restQuote + } = quote; + + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(srcAsset.assetId); + + return { + ...rest, + ...(nonEvmFeesInNative ? { nonEvmFeesInNative } : {}), + ...(l1GasFeesInHexWei ? { l1GasFeesInHexWei } : {}), + namespace, + chainId, + hexChainId: + namespace === KnownCaipNamespace.Eip155 + ? formatChainIdToHex(chainId) + : undefined, + quote: { + protocols: bridges, + aggregator: bridgeId, + src: { + amount: srcTokenAmount, + asset: create(srcAsset, BridgeAssetV2FromV1), + walletAddress, + }, + dest: { + amount: destTokenAmount, + asset: create(destAsset, BridgeAssetV2FromV1), + walletAddress: destWalletAddress, + min: { + amount: minDestTokenAmount, + }, + }, + priceData: { + priceImpact: { + amount: value.quote.priceData?.priceImpact, + }, + }, + feeData: { + [FeeType.METABRIDGE]: [ + { + ...feeData[FeeType.METABRIDGE], + asset: create( + feeData[FeeType.METABRIDGE].asset, + BridgeAssetV2FromV1, + ), + usd: priceData?.totalFeeAmountUsd, + }, + ], + [FeeType.TX_FEE]: [ + ...(feeData[FeeType.TX_FEE] + ? [ + { + ...feeData[FeeType.TX_FEE], + asset: create( + feeData[FeeType.TX_FEE].asset, + BridgeAssetV2FromV1, + ), + }, + ] + : []), + ], + }, + /** + * @deprecated This field is deprecated. + */ + steps: steps + ? steps.map((step) => create(step, StepSchemaV2FromV1)) + : undefined, + ...restQuote, + }, + }; + }, +); + +/** + * Converts a {@link QuoteResponseV1} to a {@link QuoteResponseV2} + * + * @param quoteResponse - The {@link QuoteResponseV1} to convert + * @param featureId - The {@link FeatureId} of the quote response + * @returns The {@link QuoteResponseV2} + */ +export const toQuoteResponseV2 = ( + quoteResponse: unknown, + featureId?: FeatureId, +): QuoteResponse => { + const quoteResponseV2 = create(quoteResponse, QuoteResponseV2FromV1); + if (!quoteResponseV2) { + throw new Error('Invalid quote response'); + } + return { ...quoteResponseV2, featureId }; +}; + +/** + * Inserts legacy {@link QuoteMetadata} values into the {@link QuoteResponse} + * + * @param quoteResponse - The {@link QuoteResponse} to merge the metadata into + * @param quoteResponse.quote - The {@link Quote} to merge the metadata into + * @param expectedQuoteMetadata - The {@link QuoteMetadata} values to merge + * @returns The {@link QuoteResponse} with the metadata merged in + */ +export const mergeQuoteMetadata = ( + { quote, ...restOfQuoteResponse }: QuoteResponse, + expectedQuoteMetadata: Partial, +): QuoteResponse => { + const srcChainId = parseCaipAssetType(quote.src.asset.assetId).chainId; + const nativeAsset = create( + getNativeAssetForChainId(srcChainId), + BridgeAssetV2FromV1, + ); + + return { + ...restOfQuoteResponse, + quote: { + ...quote, + dest: { + ...quote.dest, + normalizedAmount: expectedQuoteMetadata.toTokenAmount?.amount, + usd: expectedQuoteMetadata.toTokenAmount?.usd, + valueInCurrency: expectedQuoteMetadata.toTokenAmount?.valueInCurrency, + min: { + ...quote.dest.min, + normalizedAmount: expectedQuoteMetadata.minToTokenAmount?.amount, + usd: expectedQuoteMetadata.minToTokenAmount?.usd, + valueInCurrency: + expectedQuoteMetadata.minToTokenAmount?.valueInCurrency, + }, + }, + src: { + ...quote.src, + ...(expectedQuoteMetadata.sentAmount + ? { + usd: expectedQuoteMetadata.sentAmount?.usd, + valueInCurrency: + expectedQuoteMetadata.sentAmount?.valueInCurrency, + normalizedAmount: expectedQuoteMetadata.sentAmount?.amount, + amount: calcTokenValue( + expectedQuoteMetadata.sentAmount?.amount ?? '0', + quote.src.asset.decimals, + ), + } + : {}), + }, + feeData: { + metabridge: [quote.feeData.metabridge[0]], + network: [ + { + ...(quote.feeData.network?.[0] ?? {}), + normalizedAmount: expectedQuoteMetadata.totalNetworkFee?.amount, + amount: calcTokenValue( + expectedQuoteMetadata.totalNetworkFee?.amount ?? '0', + nativeAsset.decimals, + ), + usd: expectedQuoteMetadata.totalNetworkFee?.usd, + valueInCurrency: + expectedQuoteMetadata.totalNetworkFee?.valueInCurrency, + asset: nativeAsset, + }, + ], + ...(expectedQuoteMetadata.includedTxFees?.amount && + quote.feeData.txFee?.[0] + ? { + txFee: [ + { + ...(quote.feeData.txFee?.[0] ?? {}), + normalizedAmount: expectedQuoteMetadata.includedTxFees.amount, + usd: expectedQuoteMetadata.includedTxFees?.usd, + valueInCurrency: + expectedQuoteMetadata.includedTxFees?.valueInCurrency, + }, + ], + } + : {}), + }, + priceData: { + ...quote.priceData, + swapRate: expectedQuoteMetadata.swapRate, + ...(expectedQuoteMetadata.adjustedReturn + ? { + adjustedReturn: { + usd: expectedQuoteMetadata.adjustedReturn.usd, + valueInCurrency: + expectedQuoteMetadata.adjustedReturn.valueInCurrency, + }, + } + : {}), + cost: { + usd: expectedQuoteMetadata.cost?.usd, + valueInCurrency: expectedQuoteMetadata.cost?.valueInCurrency, + }, + }, + }, + }; +}; + +export type DeepPartial = Type extends string + ? Type + : { + [K in keyof Type]?: Type[K] extends (infer U)[] + ? DeepPartial[] + : Type[K] extends readonly (infer U)[] + ? readonly DeepPartial[] + : Type[K] extends object + ? DeepPartial + : Type[K]; + }; diff --git a/packages/bridge-controller/src/validators/quote-response-v2.migration.test.ts b/packages/bridge-controller/src/validators/quote-response-v2.migration.test.ts new file mode 100644 index 0000000000..3145bd4f88 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response-v2.migration.test.ts @@ -0,0 +1,135 @@ +import { StructError } from '@metamask/superstruct'; + +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20'; +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2'; +import { FeatureId } from './feature-flags'; +import { toQuoteResponseV2 } from './quote-response-v2-migration'; + +describe('quote-response-v2-migration', () => { + describe('toQuoteResponseV2', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + const expectedError = new StructError( + { + value: '', + key: '', + type: '', + message: + 'Expected the value to satisfy a union of `intersection | intersection | intersection | intersection', + explanation: + 'Expected the value to satisfy a union of `intersection | intersection | intersection | intersection`, but received: [object Object]', + branch: [], + path: [], + refinement: undefined, + }, + function () { + return [ + { + path: ['quote', 'src'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'dest'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'feeData'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'aggregator'], + message: 'Expected a string, but received: undefined', + }, + { + path: ['quote', 'protocols'], + message: 'Expected an array value, but received: undefined', + }, + { + path: ['estimatedProcessingTimeInSeconds'], + message: 'Expected a number, but received: undefined', + }, + { + path: ['namespace'], + message: + 'Expected the literal `"eip155"`, but received: undefined', + }, + { + path: ['chainId'], + message: + 'Expected a value of type `CaipChainId`, but received: \`undefined\`', + }, + { + path: ['trade'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['namespace'], + message: + 'Expected the literal `"solana"`, but received: undefined', + }, + { + path: ['trade'], + message: 'Expected a string, but received: undefined', + }, + { + path: ['namespace'], + message: 'Expected the literal `"tron"`, but received: undefined', + }, + + { + path: ['namespace'], + message: + 'Expected the literal `"bip122"`, but received: undefined', + }, + ]; + }, + ); + expect(() => toQuoteResponseV2(quoteResponse)).toThrow(expectedError); + + expect( + JSON.stringify( + Array.from( + new Set( + expectedError + .failures() + .filter(({ path }) => path.length > 0) + .map( + ({ message, path }) => + `At path: ${path.join('.')} -- ${message}`, + ), + ), + ), + ), + ).toMatchInlineSnapshot( + `"["At path: quote.src -- Expected an object, but received: undefined","At path: quote.dest -- Expected an object, but received: undefined","At path: quote.feeData -- Expected an object, but received: undefined","At path: quote.aggregator -- Expected a string, but received: undefined","At path: quote.protocols -- Expected an array value, but received: undefined","At path: estimatedProcessingTimeInSeconds -- Expected a number, but received: undefined","At path: namespace -- Expected the literal \`\\"eip155\\"\`, but received: undefined","At path: chainId -- Expected a value of type \`CaipChainId\`, but received: \`undefined\`","At path: trade -- Expected an object, but received: undefined","At path: namespace -- Expected the literal \`\\"solana\\"\`, but received: undefined","At path: trade -- Expected a string, but received: undefined","At path: namespace -- Expected the literal \`\\"tron\\"\`, but received: undefined","At path: namespace -- Expected the literal \`\\"bip122\\"\`, but received: undefined"]"`, + ); + }); + + it('should return a valid QuoteResponse with V1 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + const quoteResponseV2 = toQuoteResponseV2( + quoteResponse, + FeatureId.DAPP_SWAP, + ); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + }); + + it('should return a valid QuoteResponse with V2 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + const quoteResponseV2 = toQuoteResponseV2( + quoteResponse, + FeatureId.DAPP_SWAP, + ); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/validators/quote-response-v2.test.ts b/packages/bridge-controller/src/validators/quote-response-v2.test.ts new file mode 100644 index 0000000000..479039dfd1 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response-v2.test.ts @@ -0,0 +1,25 @@ +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2'; +import { validateQuoteResponse } from './quote-response-v2'; + +describe('quote-response-v2', () => { + describe('validateQuoteResponse', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + expect(() => + validateQuoteResponse(quoteResponse), + ).toThrowErrorMatchingInlineSnapshot( + `"At path: quote.src -- Expected an object, but received: undefined"`, + ); + }); + + it('should validate a valid quote response', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + expect(validateQuoteResponse(quoteResponse)).toBe(true); + }); + }); +}); diff --git a/packages/bridge-controller/src/validators/quote-response-v2.ts b/packages/bridge-controller/src/validators/quote-response-v2.ts new file mode 100644 index 0000000000..0ba189a28a --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response-v2.ts @@ -0,0 +1,279 @@ +import { + assert, + Infer, + number, + optional, + string, + type, + union, + intersection, + array, + Describe, + nullable, + omit, + enums, + literal, + partial, +} from '@metamask/superstruct'; +import { + CaipChainId, + CaipChainIdStruct, + KnownCaipNamespace, + parseCaipAssetType, + Hex, + StrictHexStruct, +} from '@metamask/utils'; + +import { formatChainIdToHex } from '../utils/caip-formatters'; +import { BridgeAssetV2Schema, ChainIdSchema } from './bridge-asset'; +import { FeatureId } from './feature-flags'; +import { + ActionTypes, + FeeDataSchema, + FeeType, + FloatStringSchema, + GaslessPropertiesSchema, + IntentSchema, + NumberStringSchema, + StepSchema, + TxFeeGasLimitsSchema, +} from './quote-response'; +import { + BitcoinTradeData, + BitcoinTradeDataSchema, + TronTradeData, + TronTradeDataSchema, + TxData, + TxDataSchema, +} from './trade'; + +const AmountsAndAssetSchema = type({ + amount: NumberStringSchema, + normalizedAmount: optional(FloatStringSchema), + asset: BridgeAssetV2Schema, + usd: optional(nullable(FloatStringSchema)), + valueInCurrency: optional(nullable(FloatStringSchema)), +}); + +const GaslessFeeDataSchema = intersection([ + TxFeeGasLimitsSchema, + AmountsAndAssetSchema, +]); + +export type GaslessFeeData = Infer; + +export const StepSchemaV2 = type({ + action: enums(Object.values(ActionTypes)), + srcChainId: ChainIdSchema, + destChainId: ChainIdSchema, +}); + +export const QuoteSchemaV2 = intersection([ + GaslessPropertiesSchema, + type({ + requestId: string(), + src: intersection([ + AmountsAndAssetSchema, + type({ + walletAddress: optional(string()), + }), + ]), + dest: intersection([ + AmountsAndAssetSchema, + type({ + min: optional(partial(AmountsAndAssetSchema)), + walletAddress: optional(string()), + }), + ]), + priceData: optional( + type({ + swapRate: optional(FloatStringSchema), + priceImpact: optional( + type({ + usd: optional(FloatStringSchema), + amount: optional(nullable(FloatStringSchema)), + valueInCurrency: optional(nullable(FloatStringSchema)), + }), + ), + cost: optional( + type({ + usd: optional(nullable(FloatStringSchema)), + valueInCurrency: optional(nullable(FloatStringSchema)), + }), + ), + adjustedReturn: optional( + type({ + usd: nullable(optional(FloatStringSchema)), + valueInCurrency: nullable(optional(FloatStringSchema)), + }), + ), + }), + ), + feeData: type({ + [FeeType.METABRIDGE]: array( + intersection([omit(FeeDataSchema, ['asset']), AmountsAndAssetSchema]), + ), + [FeeType.REFUEL]: optional(array(AmountsAndAssetSchema)), + [FeeType.TX_FEE]: optional(array(GaslessFeeDataSchema)), + [FeeType.NETWORK]: optional(array(AmountsAndAssetSchema)), + }), + aggregator: string(), + protocols: array(string()), + steps: optional(array(StepSchemaV2)), + refuel: optional(StepSchema), + intent: optional(IntentSchema), + slippage: optional(number()), + }), +]); + +const CommonQuoteResponseSchema = type({ + quoteId: optional(string()), + quote: QuoteSchemaV2, + estimatedProcessingTimeInSeconds: number(), +}); + +const EvmQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Eip155), + chainId: CaipChainIdStruct, + hexChainId: optional(StrictHexStruct), + trade: TxDataSchema, + approval: optional(TxDataSchema), + resetApproval: optional(TxDataSchema), + }), +]); + +const TronQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Tron), + chainId: CaipChainIdStruct, + trade: TronTradeDataSchema, + approval: optional(TronTradeDataSchema), + }), +]); + +const SolanaQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Solana), + chainId: CaipChainIdStruct, + trade: string(), + }), +]); + +const BitcoinQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Bip122), + chainId: CaipChainIdStruct, + trade: BitcoinTradeDataSchema, + }), +]); + +export const QuoteResponseSchemaV2 = nullable( + union([ + EvmQuoteResponseSchema, + SolanaQuoteResponseSchema, + TronQuoteResponseSchema, + BitcoinQuoteResponseSchema, + ]), +); + +/** + * This is the V2 QuoteResponse type, including metadata calculated after quote fetch + */ +export type QuoteResponse = Omit< + NonNullable>, + 'trade' | 'approval' | 'resetApproval' +> & { + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex?: number; + /** + * Appended to the quote response based on the quote requested featureId + */ + featureId?: FeatureId; +} & ( + | { + namespace: KnownCaipNamespace.Eip155; + chainId: CaipChainId; + hexChainId?: Hex; + trade: TxData & { + data: string; + }; + approval?: TxData; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; + } + | { + namespace: KnownCaipNamespace.Solana; + chainId: CaipChainId; + trade: string; + } + | { + namespace: KnownCaipNamespace.Tron; + chainId: CaipChainId; + trade: TronTradeData & { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: string; + }; + approval?: TronTradeData; + } + | { + namespace: KnownCaipNamespace.Bip122; + chainId: CaipChainId; + trade: BitcoinTradeData; + } + ); +// This ensures the QuoteResponse type is in sync with the QuoteResponseSchemaV2 +const QuoteResponse: Describe = QuoteResponseSchemaV2; + +export const validateQuoteResponse = ( + quoteResponse: unknown, +): quoteResponse is QuoteResponse => { + // Validate common fields first + assert(quoteResponse, CommonQuoteResponseSchema); + + const { + chain: { namespace: namespaceString }, + chainId, + } = parseCaipAssetType(quoteResponse.quote.src.asset.assetId); + + const namespace = namespaceString as KnownCaipNamespace.Eip155 & + KnownCaipNamespace.Tron & + KnownCaipNamespace.Solana & + KnownCaipNamespace.Bip122; + + // Conditionally validate the trade and approval fields for the chain + const nameSpaceToTradeSchema = { + [KnownCaipNamespace.Eip155]: EvmQuoteResponseSchema, + [KnownCaipNamespace.Tron]: TronQuoteResponseSchema, + [KnownCaipNamespace.Solana]: SolanaQuoteResponseSchema, + [KnownCaipNamespace.Bip122]: BitcoinQuoteResponseSchema, + } as const; + + if (!nameSpaceToTradeSchema[namespace]) { + return false; + } + + assert( + { + ...quoteResponse, + namespace, + chainId, + hexChainId: + namespace === KnownCaipNamespace.Eip155 + ? formatChainIdToHex(chainId) + : undefined, + }, + nameSpaceToTradeSchema[namespace], + ); + return true; +}; diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts index 31a500f8bf..455cbbb054 100644 --- a/packages/bridge-controller/src/validators/quote-response.ts +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -19,17 +19,20 @@ import { import { StrictHexStruct } from '@metamask/utils'; import { BridgeAssetSchema, ChainIdSchema } from './bridge-asset'; +import type { FeatureId } from './feature-flags'; import { TxDataSchema, TronTradeDataSchema, BitcoinTradeDataSchema, HexAddressOrChecksumAddressSchema, } from './trade'; +import type { BitcoinTradeData, TronTradeData, TxData } from './trade'; export enum FeeType { METABRIDGE = 'metabridge', REFUEL = 'refuel', TX_FEE = 'txFee', + NETWORK = 'network', } export enum ActionTypes { @@ -46,10 +49,19 @@ export const NumberStringSchema = define( export const truthyString = (value: string): boolean => Boolean(value?.length); const TruthyDigitStringSchema = pattern(string(), /^\d+$/u); +export const FloatStringSchema = define( + 'FloatString', + (value: unknown) => typeof value === 'string' && /^-*\d*\.*\d+$/u.test(value), +); + export const FeeDataSchema = type({ amount: TruthyDigitStringSchema, asset: BridgeAssetSchema, + quoteBpsFee: optional(number()), + baseBpsFee: optional(number()), + discountType: optional(string()), }); +export type FeeData = Infer; export const ProtocolSchema = type({ name: string(), @@ -269,10 +281,14 @@ export const QuoteSchema = intersection([ }), ), intent: optional(IntentSchema), + walletAddress: optional(string()), + destWalletAddress: optional(string()), + slippage: optional(number()), + protocols: optional(array(string())), }), ]); -export const QuoteResponseSchema = type({ +export const QuoteResponseSchemaV1 = type({ quoteId: optional(string()), quote: QuoteSchema, estimatedProcessingTimeInSeconds: number(), @@ -283,11 +299,40 @@ export const QuoteResponseSchema = type({ TronTradeDataSchema, string(), ]), + l1GasFeesInHexWei: optional(StrictHexStruct), + nonEvmFeesInNative: optional(FloatStringSchema), }); export const validateQuoteResponseV1 = ( data: unknown, -): data is Infer => { - assert(data, QuoteResponseSchema); +): data is Infer => { + assert(data, QuoteResponseSchemaV1); return true; }; + +/** + * This is the type for the quote response from the bridge-api + * TxDataType can be overriden to be a string when the quote is non-evm + * ApprovalType can be overriden when you know the specific approval type (e.g., TxData for EVM-only contexts) + */ +export type QuoteResponseV1< + TxDataType = TxData | string | BitcoinTradeData | TronTradeData, + ApprovalType = TxData | TronTradeData, +> = Infer & { + trade: TxDataType; + approval?: ApprovalType; + /** + * Appended to the quote response based on the quote request + */ + featureId?: FeatureId; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex?: number; +}; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts new file mode 100644 index 0000000000..8ea1ae7eca --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts @@ -0,0 +1,91 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import { ActionTypes, FeatureId } from '../src'; +import { QuoteResponse } from '../src/validators/quote-response-v2'; + +export const mockBridgeQuotesErc20Erc20V2Migration: QuoteResponse[] = [ + { + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + hexChainId: '0xa', + featureId: FeatureId.DAPP_SWAP, + approval: { + chainId: 10, + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 61865, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + value: '0x00', + }, + estimatedProcessingTimeInSeconds: 60, + quote: { + dest: { + amount: '13984280', + asset: { + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + decimals: 6, + name: 'Native USD Coin (POS)', + symbol: 'USDC', + }, + min: { + amount: '13700000', + }, + walletAddress: undefined, + }, + feeData: { + metabridge: [ + { + amount: '0', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + usd: undefined, + }, + ], + txFee: [], + }, + gasIncluded: false, + gasIncluded7702: false, + gasSponsored: false, + priceData: { + priceImpact: { + amount: undefined, + }, + }, + protocols: ['across'], + aggregator: 'socket', + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + slippage: 2, + src: { + amount: '14000000', + asset: { + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + walletAddress: undefined, + }, + steps: [ + { + action: ActionTypes.BRIDGE, + destChainId: 137, + srcChainId: 10, + }, + ], + }, + trade: { + chainId: 10, + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 287227, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + value: '0x038d7ea4c68000', + }, + }, +]; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts index 1bca02382c..75e62d96ce 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -1,10 +1,13 @@ import { merge } from 'lodash'; -import type { QuoteResponseV1, DeepPartial } from '../src/types'; import { ActionTypes, validateQuoteResponseV1, } from '../src/validators/quote-response'; +import type { QuoteResponseV1 } from '../src/validators/quote-response'; +import type { QuoteResponse } from '../src/validators/quote-response-v2'; +import { toQuoteResponseV2 } from '../src/validators/quote-response-v2-migration'; +import type { DeepPartial } from '../src/validators/quote-response-v2-migration'; export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ { @@ -206,12 +209,12 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ }, ]; -export const getMockBridgeQuotesErc20Erc20V1 = ( +export const getMockBridgeQuotesErc20Erc20V2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesErc20Erc20V1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts index 1ee086ea9a..cc290633de 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -1,10 +1,13 @@ import { merge } from 'lodash'; -import type { QuoteResponseV1, DeepPartial } from '../src/types'; import { ActionTypes, validateQuoteResponseV1, } from '../src/validators/quote-response'; +import type { QuoteResponseV1 } from '../src/validators/quote-response'; +import type { QuoteResponse } from '../src/validators/quote-response-v2'; +import { toQuoteResponseV2 } from '../src/validators/quote-response-v2-migration'; +import type { DeepPartial } from '../src/validators/quote-response-v2-migration'; export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ { @@ -981,10 +984,10 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ export const getMockBridgeQuotesErc20NativeV2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesErc20NativeV1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts index 4f4f864349..4ae076a4b5 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts @@ -1,10 +1,13 @@ import { merge } from 'lodash'; -import type { QuoteResponseV1, DeepPartial } from '../src/types'; import { ActionTypes, validateQuoteResponseV1, } from '../src/validators/quote-response'; +import type { QuoteResponseV1 } from '../src/validators/quote-response'; +import type { QuoteResponse } from '../src/validators/quote-response-v2'; +import { toQuoteResponseV2 } from '../src/validators/quote-response-v2-migration'; +import type { DeepPartial } from '../src/validators/quote-response-v2-migration'; export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ { @@ -241,10 +244,10 @@ export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ export const getMockBridgeQuotesNativeErc20EthV2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesNativeErc20EthV1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts index f1289ec65a..dce045d236 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -1,10 +1,13 @@ import { merge } from 'lodash'; -import type { QuoteResponseV1, DeepPartial } from '../src/types'; import { ActionTypes, validateQuoteResponseV1, } from '../src/validators/quote-response'; +import type { QuoteResponseV1 } from '../src/validators/quote-response'; +import type { QuoteResponse } from '../src/validators/quote-response-v2'; +import { toQuoteResponseV2 } from '../src/validators/quote-response-v2-migration'; +import type { DeepPartial } from '../src/validators/quote-response-v2-migration'; export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ { @@ -305,10 +308,10 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ export const getMockBridgeQuotesNativeErc20V2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesNativeErc20V1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts index 6adbb08cae..509fb2796e 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -1,10 +1,13 @@ import { merge } from 'lodash'; -import type { QuoteResponseV1, DeepPartial } from '../src/types'; import { ActionTypes, validateQuoteResponseV1, } from '../src/validators/quote-response'; +import type { QuoteResponseV1 } from '../src/validators/quote-response'; +import type { QuoteResponse } from '../src/validators/quote-response-v2'; +import { toQuoteResponseV2 } from '../src/validators/quote-response-v2-migration'; +import type { DeepPartial } from '../src/validators/quote-response-v2-migration'; export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ { @@ -197,10 +200,10 @@ export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ export const getMockBridgeQuotesSolErc20V2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesSolErc20V1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts index e0fcab4206..652ed42141 100644 --- a/packages/bridge-controller/tests/mock-sse.ts +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -2,12 +2,8 @@ import { ReadableStream } from 'node:stream/web'; import { flushPromises } from '../../../tests/helpers'; -import type { - QuoteResponse, - QuoteStreamCompleteData, - TokenFeature, - Trade, -} from '../src'; +import type { QuoteStreamCompleteData, TokenFeature } from '../src'; +import { QuoteResponseV1 } from '../src'; type MockSseResponse = { status: number; ok: boolean; body: ReadableStream }; @@ -49,7 +45,7 @@ const emitLine = ( * @returns a delayed stream of quotes */ export const mockSseEventSource = ( - mockQuotes: QuoteResponse[], + mockQuotes: QuoteResponseV1[], delay: number = 3000, ): MockSseResponse => { return { @@ -78,7 +74,7 @@ export const mockSseEventSource = ( * @returns a delayed stream of quotes */ export const mockSseBatchSellEventSource = ( - mockQuotes: QuoteResponse[][], + mockQuotes: QuoteResponseV1[][], delay: number = 3000, ): MockSseResponse => { return { @@ -108,7 +104,7 @@ export const mockSseBatchSellEventSource = ( * @returns a stream of quotes with multiple delays in between each quote */ export const mockSseEventSourceWithMultipleDelays = async ( - mockQuotes: QuoteResponse[], + mockQuotes: QuoteResponseV1[], delay: number = 4000, ): Promise => { return { @@ -143,7 +139,7 @@ export const mockSseEventSourceWithMultipleDelays = async ( * @returns a delayed stream of quotes and token warnings */ export const mockSseEventSourceWithWarnings = ( - mockQuotes: QuoteResponse[], + mockQuotes: QuoteResponseV1[], mockWarnings: TokenFeature[], delay: number = 3000, ): MockSseResponse => { @@ -183,7 +179,7 @@ export const mockSseEventSourceWithWarnings = ( * @returns a delayed stream of quotes, token warnings, and a complete event */ export const mockSseEventSourceWithComplete = ( - mockQuotes: QuoteResponse[], + mockQuotes: QuoteResponseV1[], mockWarnings: TokenFeature[], mockComplete: QuoteStreamCompleteData, delay: number = 3000,