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 523ec40f63..4d3b86d654 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 @@ -28,7 +28,7 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes s }, { "destChainId": "137", - "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destTokenAddress": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", "destWalletAddress": "SolanaWalletAddres1234", "insufficientBal": false, "resetApproval": false, @@ -61,7 +61,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 253eec98a3..14f18db8c5 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,18 +9,15 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "failures": [ - "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", - "lifi|trade.xdrBase64", - "lifi|trade.xdr", + "lifi|quote.src", + "lifi|quote.dest", + "lifi|quote.feeData.metabridge", + "lifi|quote.aggregator", + "lifi|quote.protocols", + "lifi|quote.steps.0.src", + "lifi|quote.steps.0.dest", + "lifi|quote.steps.1.src", + "lifi|quote.steps.1.dest", ], "feature_id": "unified_swap_bridge", "location": "Unknown", 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 e4a2708720..1b37fabda5 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, @@ -39,6 +48,7 @@ import { FeatureId } from './validators/feature-flags'; import { QuoteStreamCompleteReason } from './validators/quote-stream-complete'; import { TokenFeatureType } from './validators/token-feature'; import type { TxData } from './validators/trade'; +import { validateQuoteResponseV1 } from './validators/quote-response-v1'; type RootMessenger = Messenger< MockAnyNamespace, @@ -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, @@ -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, @@ -1239,20 +1291,15 @@ describe('BridgeController SSE', function () { [ "Quote validation failed", [ - "At path: trade (union) -- Expected the value to satisfy a union of \`type | type | type | union | string\`, but received: [object Object]", - "At path: trade.chainId (union) -- Expected a number, but received: undefined", - "At path: trade.to (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.from (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.value (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.data (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.gasLimit (union) -- Expected a number, but received: undefined", - "At path: trade.unsignedPsbtBase64 (union) -- Expected a string, but received: undefined", - "At path: trade.inputsToSign (union) -- Expected an array value, but received: undefined", - "At path: trade.raw_data_hex (union) -- Expected a string, but received: undefined", - "At path: trade (union) -- Expected the value to satisfy a union of \`type | type\`, but received: [object Object]", - "At path: trade.xdrBase64 (union) -- Expected a string, but received: undefined", - "At path: trade.xdr (union) -- Expected a string, but received: undefined", - "At path: trade (union) -- Expected a string, but received: [object Object]", + "At path: quote.src (type) -- Expected an object, but received: undefined", + "At path: quote.dest (type) -- Expected an object, but received: undefined", + "At path: quote.feeData.metabridge (type) -- Expected an array value, but received: [object Object]", + "At path: quote.aggregator (type) -- Expected a string, but received: undefined", + "At path: quote.protocols (type) -- Expected an array value, but received: undefined", + "At path: quote.steps.0.src (type) -- Expected an object, but received: undefined", + "At path: quote.steps.0.dest (type) -- Expected an object, but received: undefined", + "At path: quote.steps.1.src (type) -- Expected an object, but received: undefined", + "At path: quote.steps.1.dest (type) -- Expected an object, but received: undefined", ], ] `); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 04eda0c7b8..53edfc613e 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -20,8 +20,14 @@ 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 { @@ -247,7 +253,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -266,7 +272,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }, metricsContext, @@ -328,7 +334,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -347,7 +353,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }, metricsContext, @@ -403,7 +409,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -420,7 +426,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -831,7 +837,7 @@ describe('BridgeController', function () { quotes: [ ...mockBridgeQuotesNativeErc20EthV1, ...mockBridgeQuotesNativeErc20EthV1, - ], + ] as never, validationFailures: [], }); }, 10000); @@ -853,7 +859,7 @@ describe('BridgeController', function () { quotes: [ ...mockBridgeQuotesNativeErc20EthV1, ...mockBridgeQuotesNativeErc20EthV1, - ], + ] as never, validationFailures: [], }); }, 10000); @@ -962,7 +968,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, }), ); @@ -985,8 +991,8 @@ describe('BridgeController', function () { }, ], quotes: [ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ], quotesLoadingStatus: 1, quoteFetchError: null, @@ -1062,8 +1068,8 @@ describe('BridgeController', function () { expect(stateWithoutTimestamp).toMatchSnapshot(); expect(quotes).toStrictEqual([ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ]); expect( quotesLastFetched, @@ -1121,6 +1127,8 @@ describe('BridgeController', function () { }, snap: { id: 'npm:@metamask/solana-snap', + // name: 'Solana Snap', + // enabled: true, }, }, options: { @@ -1244,7 +1252,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 +1329,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 +1378,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 +1574,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1608,7 +1616,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1750,7 +1758,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1867,6 +1875,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 +1893,7 @@ describe('BridgeController', function () { ); default: return { - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), currentCurrency: 'usd', currencyRates: {}, @@ -1900,7 +1913,7 @@ describe('BridgeController', function () { return await new Promise((resolve) => { return setTimeout(() => { resolve({ - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: mockBridgeQuotesNativeErc20EthV1 as never, validationFailures: [], }); }, 5000); @@ -1911,9 +1924,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 +1939,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 +1970,7 @@ describe('BridgeController', function () { return 'AUTH_TOKEN'; default: return { - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), currentCurrency: 'usd', currencyRates: {}, @@ -1961,18 +1989,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 +2347,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 +2359,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 +2654,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 +2665,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 +2676,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 +2911,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'), ); }, @@ -3791,7 +3822,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3840,7 +3871,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3995,11 +4026,9 @@ describe('BridgeController', function () { ...overrides, }); - let getBridgeFeatureFlagsSpy: jest.SpyInstance; - beforeEach(() => { jest.clearAllMocks(); - getBridgeFeatureFlagsSpy = jest + jest .spyOn(featureFlagUtils, 'getBridgeFeatureFlags') .mockReturnValueOnce({ ...defaultFlags, @@ -4009,7 +4038,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, }, }); @@ -4112,12 +4144,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, @@ -4136,7 +4168,7 @@ describe('BridgeController', function () { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') .mockResolvedValueOnce({ - quotes: quotesByDecreasingProcessingTime as never, + quotes: quotesByDecreasingProcessingTime, validationFailures: [], }); const expectedControllerState = bridgeController.state; @@ -4229,96 +4261,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); }, @@ -4333,7 +4299,7 @@ describe('BridgeController', function () { ...mockBridgeQuotesNativeErc20EthV1[0].quote, gasSponsored: true, }, - }; + } as QuoteResponseV1; const secondQuote: QuoteResponseV1 = mockBridgeQuotesNativeErc20EthV1[1]; const quotesWithFlag: QuoteResponseV1[] = [ @@ -4379,7 +4345,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 8e81ad7681..656d627a9a 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -10,6 +10,7 @@ import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionController } from '@metamask/transaction-controller'; import type { CaipAssetType, Hex } from '@metamask/utils'; +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2'; import type { BridgeClientId } from './constants/bridge'; import { BRIDGE_CONTROLLER_NAME, @@ -94,6 +95,7 @@ import { import { appendFeesToQuotes } from './utils/quote-fees'; import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; import type { FeatureId } from './validators/feature-flags'; +import type { QuoteResponse } from './validators/quote-response'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; const metadata: StateMetadata = { @@ -431,13 +433,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); }; @@ -451,7 +461,7 @@ export class BridgeController extends StaticIntervalPollingController => { this.#batchSellTradesAbortController?.abort( @@ -896,7 +906,9 @@ export class BridgeController extends StaticIntervalPollingController { + return toQuoteResponseV2(quote); + }); state.quotesLoadingStatus = RequestStatus.FETCHED; }); }, @@ -995,9 +1007,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/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts new file mode 100644 index 0000000000..d73cb44c3c --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -0,0 +1,144 @@ +import { Failure, StructError } from '@metamask/superstruct'; + +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20'; +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2'; +import { formatStructErrors } from '../utils/struct-error'; +import { toQuoteResponseV2 } from './quote-response-v1-to-v2'; + +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: + 'At path: quote.src -- Expected an object, but received: undefined', + branch: [], + path: [], + refinement: undefined, + }, + function (): Generator { + 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', + }, + ] as unknown as Generator; + }, + ); + expect(() => toQuoteResponseV2(quoteResponse)).toThrow(expectedError); + + expect(formatStructErrors(expectedError)).toMatchInlineSnapshot(` + [ + "At path: -- Expected the value to satisfy a union of \`intersection | intersection | intersection | intersection", + "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); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + }); + + it('should return a valid QuoteResponse with converted V2 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + }); + + it('should return a valid QuoteResponse with V2 input', () => { + const { chainId, namespace, ...quoteResponse } = + mockBridgeQuotesErc20Erc20V2Migration[0]; + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + }); + + it('should throw an error for a null input', () => { + expect(() => toQuoteResponseV2(null)).toThrow( + 'Expected an object, but received: null', + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts new file mode 100644 index 0000000000..1eca61c8fc --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -0,0 +1,334 @@ +import { + create, + coerce, + Infer, + is, + StructError, + intersection, +} from '@metamask/superstruct'; +import { KnownCaipNamespace, parseCaipAssetType } from '@metamask/utils'; + +import { DeepPartial, QuoteMetadata } from '../types'; +import { getNativeAssetForChainId } from '../utils/bridge'; +import { formatChainIdToHex } from '../utils/caip-formatters'; +import { calcTokenValue, calcTokenAmount } from '../utils/quote'; +import { formatStructErrors } from '../utils/struct-error'; +import { + BridgeAssetSchema, + BridgeAssetV2Schema, + MinimalAssetSchema, +} from '../validators/bridge-asset'; +import { QuoteSchemaV2 } from '../validators/quote'; +import { FeeType, QuoteSchema } from '../validators/quote'; +import { + QuoteResponse, + QuoteResponseSchemaV2, + validateQuoteResponse, +} from '../validators/quote-response'; +import { + QuoteResponseSchemaV1, + QuoteResponseV1, +} from '../validators/quote-response-v1'; +import { StepSchemaV2 } from '../validators/step'; +import { StepSchema } from '../validators/step'; +import { extractQuoteMetadata } from './quote-response-v2-to-v1'; + +const BridgeAssetV2FromV1 = coerce( + BridgeAssetV2Schema, + intersection([BridgeAssetSchema, MinimalAssetSchema]), + (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 toBridgeAssetV2 = ( + data: unknown, +): Infer => { + return create(data, BridgeAssetV2FromV1); +}; + +const StepSchemaV2FromV1 = coerce(StepSchemaV2, StepSchema, (value) => { + const { srcAsset, destAsset, action } = value; + return { + action, + src: { + asset: toBridgeAssetV2(srcAsset), + }, + dest: { + asset: toBridgeAssetV2(destAsset), + }, + }; +}); +const toStepV2 = (step: unknown): Infer => + create(step, StepSchemaV2FromV1); + +const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { + const { + srcTokenAmount, + destTokenAmount, + minDestTokenAmount, + srcAsset, + destAsset, + srcChainId, + destChainId, + walletAddress, + destWalletAddress, + priceData, + feeData, + bridgeId, + bridges, + steps, + ...restQuote + } = value; + + return { + protocols: bridges, + aggregator: bridgeId, + src: { + amount: srcTokenAmount, + asset: toBridgeAssetV2(srcAsset), + walletAddress, + }, + dest: { + amount: destTokenAmount, + asset: toBridgeAssetV2(destAsset), + walletAddress: destWalletAddress, + minAmount: minDestTokenAmount, + }, + priceData: { + priceImpact: { + amount: value.priceData?.priceImpact, + }, + }, + feeData: { + [FeeType.METABRIDGE]: [ + { + ...feeData[FeeType.METABRIDGE], + asset: toBridgeAssetV2(feeData[FeeType.METABRIDGE].asset), + usd: priceData?.totalFeeAmountUsd, + }, + ], + ...(feeData[FeeType.TX_FEE] && { + [FeeType.TX_FEE]: [ + { + ...feeData[FeeType.TX_FEE], + asset: toBridgeAssetV2(feeData[FeeType.TX_FEE].asset), + }, + ], + }), + }, + steps: steps ? steps.map(toStepV2) : undefined, + ...restQuote, + }; +}); + +export const toQuoteV2 = (quote: unknown): Infer => { + const quoteV2 = create(quote, QuoteV2FromV1); + return quoteV2; +}; + +const QuoteResponseV2FromV1 = coerce( + QuoteResponseSchemaV2, + QuoteResponseSchemaV1, + (value: QuoteResponseV1) => { + const { quote, l1GasFeesInHexWei, nonEvmFeesInNative, ...rest } = value; + const { srcAsset } = quote; + + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(srcAsset.assetId); + + return { + ...rest, + ...(nonEvmFeesInNative && { nonEvmFeesInNative }), + ...(l1GasFeesInHexWei && { l1GasFeesInHexWei }), + namespace, + chainId, + hexChainId: + namespace === KnownCaipNamespace.Eip155 + ? formatChainIdToHex(chainId) + : undefined, + quote: toQuoteV2(quote), + }; + }, +); + +/** + * 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 quoteMetadata - The {@link QuoteMetadata} values to merge + * @returns The {@link QuoteResponse} with the metadata merged in + */ +export const mergeQuoteMetadata = ( + { quote, ...restOfQuoteResponse }: QuoteResponse, + quoteMetadata: DeepPartial, +): QuoteResponse => { + const srcChainId = parseCaipAssetType(quote.src.asset.assetId).chainId; + const nativeAsset = toBridgeAssetV2(getNativeAssetForChainId(srcChainId)); + + const { + sentAmount, + toTokenAmount, + minToTokenAmount, + totalNetworkFee, + includedTxFees, + swapRate, + adjustedReturn, + cost, + } = quoteMetadata; + + // const priceImpact = calcPriceImpact(quote); + return { + ...restOfQuoteResponse, + quote: { + ...quote, + dest: { + ...quote.dest, + ...(toTokenAmount && { + normalizedAmount: toTokenAmount.amount, + valueInCurrency: toTokenAmount.valueInCurrency, + usd: toTokenAmount?.usd ?? quote.dest.usd, + }), + minAmount: minToTokenAmount?.amount + ? calcTokenValue(minToTokenAmount.amount, quote.dest.asset.decimals) + : quote.dest.minAmount, + minAmountUsd: minToTokenAmount?.usd ?? undefined, + minAmountValueInCurrency: + minToTokenAmount?.valueInCurrency ?? undefined, + }, + src: { + ...quote.src, + ...(sentAmount?.valueInCurrency && { + valueInCurrency: sentAmount?.valueInCurrency, + }), + ...(sentAmount?.amount && { + amount: calcTokenValue( + sentAmount?.amount ?? '0', + quote.src.asset.decimals, + ), + normalizedAmount: sentAmount?.amount, + }), + ...(sentAmount?.usd && { + usd: sentAmount?.usd, + }), + }, + feeData: { + metabridge: [quote.feeData.metabridge[0]], + network: [ + { + amount: totalNetworkFee?.amount + ? calcTokenValue(totalNetworkFee.amount, nativeAsset.decimals) + : (quote.feeData.network?.[0]?.amount ?? '0'), + asset: nativeAsset, + normalizedAmount: + totalNetworkFee?.amount ?? + calcTokenAmount( + quote.feeData.network?.[0]?.amount ?? '0', + nativeAsset.decimals, + ).toFixed(), + usd: totalNetworkFee?.usd ?? quote.feeData.network?.[0]?.usd, + valueInCurrency: totalNetworkFee?.valueInCurrency, + }, + ], + ...(includedTxFees?.amount && + quote.feeData.txFee?.[0] && { + txFee: [ + { + ...quote.feeData.txFee[0], + normalizedAmount: includedTxFees.amount, + valueInCurrency: includedTxFees?.valueInCurrency, + ...(includedTxFees?.usd && { + usd: includedTxFees?.usd, + }), + }, + ], + }), + }, + ...(quote.priceData && { + priceData: { + ...quote.priceData, + ...(swapRate && { swapRate }), + ...(cost && { cost }), + // TODO add priceImpact metadata here + ...(adjustedReturn && { + adjustedReturn: { + valueInCurrency: adjustedReturn.valueInCurrency, + ...(adjustedReturn?.usd && { + usd: adjustedReturn.usd, + }), + }, + }), + }, + }), + }, + }; +}; + +export function toQuoteResponseV2(quoteResponse: unknown): QuoteResponse; +export function toQuoteResponseV2( + quoteResponse: QuoteResponse, + metadata: DeepPartial, +): QuoteResponse; + +/** + * Converts a {@link QuoteResponseV1 & QuoteMetadata} to a {@link QuoteResponseV2} + * + * @param quoteResponse - The {@link QuoteResponseV1} to convert + * @returns The {@link QuoteResponseV2} + */ +export function toQuoteResponseV2(quoteResponse: unknown): QuoteResponse { + // V2 with namespace, chainId, hexChainId (metadata has been added after validation/selector) + if (quoteResponse && is(quoteResponse, QuoteResponseSchemaV2)) { + console.debug('Returning unmodified QuoteResponseV2'); + return quoteResponse; + } + + // V1 quote + if (quoteResponse && is(quoteResponse, QuoteResponseSchemaV1)) { + console.debug('Converting bridge-api V1 quote to QuoteResponseV2'); + // Build QuoteResponseV2 without metadata + const quoteResponseV2 = create(quoteResponse, QuoteResponseV2FromV1); + if (!quoteResponseV2) { + throw new Error('QuoteResponseV1 to V2 conversion failed'); + } + // Add metadata + const quoteMetadata = extractQuoteMetadata(quoteResponseV2); + return mergeQuoteMetadata(quoteResponseV2, quoteMetadata); + } + + // V2 quote directly from bridge-api with no namespace, chainId, hexChainId + if (validateQuoteResponse(quoteResponse)) { + console.debug('Returning QuoteResponseV2 after appending metadata'); + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(quoteResponse.quote.src.asset.assetId); + // Add namespace, chainId, hexChainId + return { + ...quoteResponse, + namespace: namespace as never, // KnownCaipNamespace, + chainId, + hexChainId: + namespace === KnownCaipNamespace.Eip155 + ? formatChainIdToHex(chainId) + : undefined, + }; + } + throw new Error( + 'QuoteResponseV1 to V2 conversion failed. Invalid quoteResponse provided', + ); +} diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts new file mode 100644 index 0000000000..07c03159bd --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts @@ -0,0 +1,263 @@ +import { Failure, StructError } from '@metamask/superstruct'; + +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20'; +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2'; +import { calcTokenValue } from '../utils/quote'; +import { formatStructErrors } from '../utils/struct-error'; +import { mergeQuoteMetadata } from './quote-response-v1-to-v2'; +import { toQuoteResponseV2 } from './quote-response-v1-to-v2'; +import { toQuoteResponseV1 } from './quote-response-v2-to-v1'; + +describe('quote-response-v1 compatibility', () => { + describe('toQuoteResponseV1', () => { + 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 (): Generator { + 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', + }, + ] as unknown as Generator; + }, + ); + // @ts-expect-error - invalid quote response + expect(() => toQuoteResponseV1(quoteResponse)) + .toThrowErrorMatchingInlineSnapshot(` + "Failed to convert QuoteResponse V2 to V1. [ + "At path: quote.srcChainId (number) -- Expected a number, but received: undefined", + "At path: quote.srcAsset (number) -- Expected an object, but received: undefined", + "At path: quote.srcTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.destChainId (number) -- Expected a number, but received: undefined", + "At path: quote.destAsset (number) -- Expected an object, but received: undefined", + "At path: quote.destTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.minDestTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.feeData (number) -- Expected an object, but received: undefined", + "At path: quote.bridgeId (number) -- Expected a string, but received: undefined", + "At path: quote.bridges (number) -- Expected an array value, but received: undefined", + "At path: quote.steps (number) -- Expected an array value, but received: undefined", + "At path: estimatedProcessingTimeInSeconds (number) -- Expected a number, but received: undefined", + "At path: trade (number) -- Expected the value to satisfy a union of \`type | type | type | union | string\`, but received: undefined", + "At path: trade (number) -- Expected an object, but received: undefined", + "At path: trade (number) -- Expected the value to satisfy a union of \`type | type\`, but received: undefined", + "At path: trade (number) -- Expected a string, but received: undefined" + ]" + `); + + expect(formatStructErrors(expectedError)).toMatchInlineSnapshot(` + [ + "At path: -- Expected the value to satisfy a union of \`intersection | intersection | intersection | intersection", + "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 QuoteResponseV1 with V2 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + expect(quoteResponse.quote.minDestTokenAmount).toBe('13700000'); + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2.quote.dest.minAmount).toBe('13700000'); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + expect(quoteResponseV2.quote.dest.minAmount).toMatchInlineSnapshot( + `"13700000"`, + ); + + const quoteResponseV1 = toQuoteResponseV1(quoteResponseV2); + expect(quoteResponseV1.quote.minDestTokenAmount).toBe('13700000'); + expect(quoteResponseV1.minToTokenAmount).toBeUndefined(); + expect(quoteResponseV1).toStrictEqual(mockBridgeQuotesErc20Erc20V1[0]); + }); + + it('should return a valid QuoteResponseV1 with V2 input and metadata', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + expect(quoteResponse.quote.minDestTokenAmount).toBe('13700000'); + // @ts-expect-error - minToTokenAmount is not in the schema + expect(quoteResponse.minToTokenAmount).toBeUndefined(); + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2).toStrictEqual( + mockBridgeQuotesErc20Erc20V2Migration[0], + ); + expect(quoteResponseV2.quote.dest.minAmount).toBe('13700000'); + expect(quoteResponseV2.quote.feeData.network).toBeUndefined(); + const quoteMetadata = { + adjustedReturn: { + usd: '2.08686', + valueInCurrency: '419.98686', + }, + cost: { + usd: '8.91314', + valueInCurrency: '1758.01314', + }, + // minToTokenAmount: { + // amount: '13.7', + // usd: null, + // valueInCurrency: null, + // }, + sentAmount: { + amount: '1.1', + usd: '11', + valueInCurrency: '2178', + }, + swapRate: '1.90909090909090909091', + toTokenAmount: { + amount: '2.1', + usd: '2.1', + valueInCurrency: '420', + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }; + const { quote, ...expectedQuoteResponse } = + mockBridgeQuotesErc20Erc20V1[0]; + + expect(quoteMetadata.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + } + `); + const quoteResponseV2WithMetadata = mergeQuoteMetadata( + quoteResponseV2, + quoteMetadata, + ); + expect(quoteResponseV2.quote.dest.minAmount).toBe('13700000'); + + expect(quoteResponseV2WithMetadata.quote.feeData.network) + .toMatchInlineSnapshot(` + [ + { + "amount": "7300000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "iconUrl": "", + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + ] + `); + + const quoteResponseV1 = toQuoteResponseV1(quoteResponseV2WithMetadata); + // expect(quoteResponseV1.minToTokenAmount).toStrictEqual({ + // amount: '13.7', + // }); + expect(quoteResponseV1).toStrictEqual({ + ...expectedQuoteResponse, + quote: { + ...quote, + srcTokenAmount: calcTokenValue( + quoteMetadata.sentAmount?.amount, + quote.srcAsset.decimals, + ), + }, + ...quoteMetadata, + }); + }); + + it('should return a valid QuoteResponse with V1 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + const quoteResponseV2 = toQuoteResponseV1(quoteResponse); + expect(quoteResponseV2).toStrictEqual(mockBridgeQuotesErc20Erc20V1[0]); + }); + + it('should throw an error for a null input', () => { + // @ts-expect-error - null input + expect(() => toQuoteResponseV1(null)).toThrow( + 'Failed to convert QuoteResponse V2 (with metadata) to V1. [\n "At path: (type) -- Expected an object, but received: null"\n]', + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts new file mode 100644 index 0000000000..cff6aee5a8 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -0,0 +1,323 @@ +import { + create, + coerce, + is, + StructError, + intersection, + Infer, +} from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { QuoteMetadata, Step } from '../types'; +import { + formatAddressToCaipReference, + formatChainIdToDec, +} from '../utils/caip-formatters'; +import { calcTokenAmount } from '../utils/quote'; +import { formatStructErrors } from '../utils/struct-error'; +import { + BridgeAssetSchema, + BridgeAssetV2Schema, + MinimalAssetSchema, +} from '../validators/bridge-asset'; +import { QuoteSchemaV2 } from '../validators/quote'; +import { FeeType, Quote, QuoteSchema } from '../validators/quote'; +import { + QuoteResponse, + QuoteResponseSchemaV2, +} from '../validators/quote-response'; +import { + QuoteResponseSchemaV1, + QuoteResponseV1, +} from '../validators/quote-response-v1'; +import { StepSchemaV2 } from '../validators/step'; +import { StepSchema } from '../validators/step'; + +const BridgeAssetV1FromV2 = coerce( + intersection([BridgeAssetSchema, MinimalAssetSchema]), + BridgeAssetV2Schema, + (value) => { + const { assetId, ...rest } = value; + + const { chainId, assetReference } = parseCaipAssetType(assetId); + return { + address: formatAddressToCaipReference(assetReference), + chainId: formatChainIdToDec(chainId), + assetId, + ...rest, + }; + }, +); +export const toBridgeAssetV1 = ( + data: unknown, +): Infer => { + return create(data, BridgeAssetV1FromV2); +}; + +const StepSchemaV1FromV2 = coerce(StepSchema, StepSchemaV2, (value) => { + const { src, dest, action } = value; + return { + action, + srcAsset: toBridgeAssetV1(src.asset), + destAsset: toBridgeAssetV1(dest.asset), + }; +}); +const toStepV1 = (step: unknown): Step => { + const stepV2 = create(step, StepSchemaV1FromV2); + return stepV2; +}; + +const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { + const { + priceData, + feeData, + steps, + protocols, + aggregator, + src, + dest, + ...restQuote + } = value; + + const { chainId: srcChainIdInCaip, assetReference: srcReference } = + parseCaipAssetType(src.asset.assetId); + const { chainId: destChainIdInCaip, assetReference: destReference } = + parseCaipAssetType(dest.asset.assetId); + + const srcChainId = formatChainIdToDec(srcChainIdInCaip); + const destChainId = formatChainIdToDec(destChainIdInCaip); + const srcTokenAddress = formatAddressToCaipReference(srcReference); + const destTokenAddress = formatAddressToCaipReference(destReference); + + const { usd, ...metabridgeFeeData } = feeData[FeeType.METABRIDGE][0]; + + console.log({ srcChainId, destChainId, srcTokenAddress, destTokenAddress }); + + return { + bridges: protocols, + bridgeId: aggregator, + aggregator, + protocols, + srcChainId, + destChainId, + srcAsset: { ...src.asset, chainId: srcChainId, address: srcTokenAddress }, + destAsset: { + ...dest.asset, + chainId: destChainId, + address: destTokenAddress, + }, + srcTokenAmount: src.amount, + destTokenAmount: dest.amount, + minDestTokenAmount: dest.minAmount, + destWalletAddress: dest.walletAddress, + feeData: { + [FeeType.METABRIDGE]: { + ...metabridgeFeeData, + asset: { + ...metabridgeFeeData.asset, + chainId: formatChainIdToDec( + parseCaipAssetType(metabridgeFeeData.asset.assetId).chainId, + ), + address: formatAddressToCaipReference( + parseCaipAssetType(metabridgeFeeData.asset.assetId).assetReference, + ), + }, + }, + ...(feeData[FeeType.TX_FEE]?.length && { + ...feeData[FeeType.TX_FEE][0], + asset: feeData[FeeType.TX_FEE][0].asset, + }), + }, + ...(src.walletAddress && { walletAddress: src.walletAddress }), + ...(value.priceData?.priceImpact?.amount && { + priceData: { + priceImpact: value.priceData?.priceImpact?.amount, + }, + }), + /** + * @deprecated This field is deprecated. + */ + steps: steps?.map(toStepV1), + ...restQuote, + }; +}); +const toQuoteV1 = (quote: unknown): Quote => { + const quoteV2 = create(quote, QuoteV1FromV2); + return quoteV2; +}; + +const QuoteResponseV1FromV2 = coerce( + QuoteResponseSchemaV1, + QuoteResponseSchemaV2, + (value: QuoteResponse | null) => { + if (!value) { + return null; + } + const { + quote, + estimatedProcessingTimeInSeconds, + // @ts-expect-error - Some networks don't have an approval field + approval, + // @ts-expect-error - Some networks don't have an approval field + resetApproval, + featureId, + trade, + quoteRequestIndex, + nonEvmFeesInNative, + l1GasFeesInHexWei, + quoteId, + } = value; + + return { + estimatedProcessingTimeInSeconds, + approval, + trade, + featureId, + quote: toQuoteV1(quote), + ...(quoteId && { quoteId }), + ...(resetApproval && { resetApproval }), + ...(quoteRequestIndex !== undefined && { quoteRequestIndex }), + ...(nonEvmFeesInNative && { nonEvmFeesInNative }), + ...(l1GasFeesInHexWei && { l1GasFeesInHexWei }), + }; + }, +); + +/** + * Extracts legacy {@link QuoteMetadata} values from the {@link QuoteResponse} + * + * @param quoteResponse - The {@link QuoteResponse} to extract the metadata from + * @param quoteResponse.quote - The {@link Quote} object within the response + * @returns The {@link QuoteResponse} with the metadata merged in + */ +export const extractQuoteMetadata = ({ + quote, +}: QuoteResponse): QuoteMetadata => { + // @ts-expect-error - metadata properties are populated below + const metadata: QuoteMetadata = {}; + + if (quote.dest.normalizedAmount) { + metadata.toTokenAmount = { + amount: quote.dest.normalizedAmount, + usd: quote.dest.usd ?? null, + valueInCurrency: quote.dest.valueInCurrency ?? null, + }; + } + + if (quote.dest.minAmount && quote.dest.minAmountUsd) { + metadata.minToTokenAmount = { + amount: calcTokenAmount( + quote.dest.minAmount, + quote.dest.asset.decimals, + ).toFixed(), + usd: quote.dest.minAmountUsd ?? null, + valueInCurrency: quote.dest.minAmountValueInCurrency ?? null, + }; + } + + if (quote.src.normalizedAmount) { + metadata.sentAmount = { + amount: quote.src.normalizedAmount, + usd: quote.src.usd ?? null, + valueInCurrency: quote.src.valueInCurrency ?? null, + }; + } + + if (quote.priceData?.swapRate) { + metadata.swapRate = quote.priceData.swapRate; + } + + if (quote.priceData?.adjustedReturn) { + metadata.adjustedReturn = { + usd: quote.priceData.adjustedReturn.usd ?? null, + valueInCurrency: quote.priceData.adjustedReturn.valueInCurrency ?? null, + }; + } + if (quote.priceData?.cost) { + metadata.cost = { + usd: quote.priceData.cost.usd ?? null, + valueInCurrency: quote.priceData.cost.valueInCurrency ?? null, + }; + } + if (quote.feeData?.network?.[0]) { + const networkFee = { + amount: quote.feeData.network[0].normalizedAmount ?? '0', + usd: quote.feeData.network[0].usd ?? null, + valueInCurrency: quote.feeData.network[0].valueInCurrency ?? null, + }; + metadata.totalNetworkFee = networkFee; + } + // Add relayer fee + if (quote.feeData.relayer?.[0]) { + metadata.totalNetworkFee = { + amount: new BigNumber(metadata.totalNetworkFee?.amount ?? 0) + .plus(quote.feeData.relayer[0].normalizedAmount ?? 0) + .toFixed(), + usd: new BigNumber(quote.feeData.relayer[0].usd ?? 0) + .plus(metadata.totalNetworkFee?.usd ?? 0) + .toFixed(), + valueInCurrency: new BigNumber( + metadata.totalNetworkFee?.valueInCurrency ?? 0, + ) + .plus(quote.feeData.relayer[0].valueInCurrency ?? 0) + .toFixed(), + }; + } + + if (quote.feeData.txFee?.[0]) { + metadata.includedTxFees = { + amount: quote.feeData.txFee[0].normalizedAmount ?? '0', + usd: quote.feeData.txFee[0].usd ?? null, + valueInCurrency: quote.feeData.txFee[0].valueInCurrency ?? null, + }; + } + return metadata; +}; + +/** + * Converts a {@link QuoteResponseV2} to a {@link QuoteResponseV1} for backwards compatibility + * + * @deprecated Avoid introducing new code that uses this function. It is only for backwards compatibility with the old quote response format. + * @param quoteResponse - The {@link QuoteResponse} to convert + * @returns The {@link QuoteResponseV1} + */ +export const toQuoteResponseV1 = ( + quoteResponse: + | QuoteResponse + | (QuoteResponseV1 & QuoteMetadata) + | QuoteResponseV1, +): QuoteResponseV1 & Partial => { + let errorMessage = 'Failed to convert QuoteResponse V2 to V1'; + + // V1 quote + if (is(quoteResponse, QuoteResponseSchemaV1)) { + console.debug('Returning unmodified QuoteResponseV1'); + return quoteResponse; + } + + try { + // V2 with namespace, chainId, hexChainId + if (is(quoteResponse, QuoteResponseSchemaV2)) { + console.debug('Converting QuoteResponseV2 + metadata to QuoteResponseV1'); + errorMessage = 'Failed to convert QuoteResponse V2 (with metadata) to V1'; + const quoteResponseV2 = create(quoteResponse, QuoteResponseV1FromV2); + const quoteMetadata = extractQuoteMetadata(quoteResponse); + return { ...quoteResponseV2, ...quoteMetadata }; + } + + // V2 with no namespace, chainId, hexChainId + console.debug('Converting QuoteResponseV2 to QuoteResponseV1'); + return create(quoteResponse, QuoteResponseV1FromV2); + } catch (error) { + let errorDetails = error instanceof Error ? error.message : 'Unknown error'; + + if (error instanceof StructError) { + const formattedErrors = formatStructErrors(error); + errorDetails = JSON.stringify(formattedErrors, null, 2); + console.warn(errorMessage, formatStructErrors(error)); + } + + throw new Error(`${errorMessage}. ${errorDetails}`); + } +}; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 5bd78eb6e5..2d3292517a 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -67,6 +67,7 @@ export type { QuoteStreamCompleteData, BridgeControllerGetStateAction, BridgeControllerStateChangeEvent, + DeepPartial, } from './types'; export { @@ -105,18 +106,41 @@ export { isEvmTxData, isStellarTrade, } from './validators/trade'; -export type { QuoteResponseV1 as QuoteResponse } from './validators/quote-response-v1'; export type { Quote } from './validators/quote'; export { FeeType, DiscountType } from './validators/quote'; export { ActionTypes } from './validators/step'; +export { + type QuoteResponseV1, + validateQuoteResponseV1, +} from './validators/quote-response-v1'; +export { + toQuoteResponseV1, + extractQuoteMetadata, +} from './coercers/quote-response-v2-to-v1'; +export type { QuoteResponse } from './validators/quote-response'; +export { + toQuoteResponseV2, + toQuoteV2, + mergeQuoteMetadata, +} from './coercers/quote-response-v1-to-v2'; + export { validateQuoteStreamComplete, QuoteStreamCompleteReason, } 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, MinimalAsset } from './validators/bridge-asset'; +export { + BridgeAssetSchema, + validateBridgeAsset, + validateBridgeAssetV2, + MinimalAssetSchema, + BridgeAssetV2Schema, + BridgeAssetSecurityDataType, +} from './validators/bridge-asset'; export { FeatureId } from './validators/feature-flags'; +export { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2'; export { ALLOWED_BRIDGE_CHAIN_IDS, @@ -176,6 +200,14 @@ export { calcSlippagePercentage, } from './utils/quote'; +export { + // TODO unexport + calcPriceImpact, + sumFees, + getDestChainId, + getTotalNetworkFees, +} from './utils/quote-v2'; + export { calcLatestSrcBalance } from './utils/balance'; export { diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 03f12996eb..9fd26d3de1 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -2,12 +2,19 @@ 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 { + KnownCaipNamespace, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { merge } from 'lodash'; import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20'; import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20'; +import { mergeQuoteMetadata } from './coercers/quote-response-v1-to-v2'; +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2'; +import { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2'; import { DEFAULT_CHAIN_RANKING, ETH_USDT_ADDRESS } from './constants/bridge'; import type { BridgeAppState } from './selectors'; import { @@ -22,13 +29,8 @@ import { selectBatchSellQuotes, selectBatchSellTrades, } from './selectors'; -import { - SortOrder, - RequestStatus, - ChainId, - BridgeAsset, - NonEvmFees, -} from './types'; +import { SortOrder, RequestStatus, ChainId, NonEvmFees } from './types'; +import type { DeepPartial } from './types'; import { getNativeAssetForChainId, isNativeAddress } from './utils/bridge'; import { formatAddressToAssetId, @@ -38,7 +40,11 @@ import { formatChainIdToHex, } from './utils/caip-formatters'; import { BatchSellTransactionType } from './validators/batch-sell'; -import type { QuoteResponseV1 } from './validators/quote-response-v1'; +import { BridgeAssetV2 } from './validators/bridge-asset'; +import { + QuoteResponse, + validateQuoteResponse, +} from './validators/quote-response'; import { validateQuoteResponseV1 } from './validators/quote-response-v1'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; @@ -408,13 +414,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 +463,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 +594,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 +609,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 +656,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 +681,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 +716,7 @@ describe('Bridge Selectors', () => { mockClientParams, ); - const expectedActiveQuote = { + const expectedQuoteMetadata = { adjustedReturn: { usd: null, valueInCurrency: null, @@ -758,21 +765,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 +791,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 +862,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 +900,7 @@ describe('Bridge Selectors', () => { const { chainId: caipChainId } = parseCaipAssetType(srcAsset.assetId); const chainId = formatChainIdToDec(caipChainId); const hexChainId = formatChainIdToHex(chainId); - const nativeAsset = getNativeAssetForChainId(chainId); + const nativeAsset = toBridgeAssetV2(getNativeAssetForChainId(chainId)); const currencyRates = { [nativeAsset.symbol]: { conversionRate: 551.98, @@ -1021,10 +1030,12 @@ describe('Bridge Selectors', () => { }, }; validateQuoteResponseV1(quoteResponse); - const mockState = getMockState(1); + + const mockState = getMockState(gasEstimatesChainId ?? chainId); + return { - ...getMockState(gasEstimatesChainId ?? chainId), - quotes: [quoteResponse as never], + ...mockState, + quotes: [toQuoteResponseV2(quoteResponse)], currencyRates, marketData, quoteRequest: [ @@ -1042,12 +1053,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 +1063,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 +1123,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 +1136,6 @@ describe('Bridge Selectors', () => { assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, }, { decimals: 18, @@ -1142,8 +1143,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - chainId: 1, }, ); @@ -1203,33 +1202,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 +1286,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 +1381,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 +1396,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 +1477,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 +1508,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x0000000000000000000000000000000000000001', symbol: 'WETH', name: 'Ethereum', - chainId: 1, - address: '0x0000000000000000000000000000000000000001', }, }, true, @@ -1601,12 +1574,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 +1633,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 +1690,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 +1763,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 +1799,7 @@ describe('Bridge Selectors', () => { }, currencyRates: { SOL: { + conversionDate: Date.now(), conversionRate: 100, usdConversionRate: 10000, }, @@ -1827,7 +1858,7 @@ describe('Bridge Selectors', () => { valueInCurrency: '500000000', }, nonEvmFeesInNative: '5000', - swapRate: '2.1e-9', + swapRate: '0.0000000021', toTokenAmount: { amount: '2.1', usd: '210000', @@ -1844,10 +1875,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 +1897,7 @@ describe('Bridge Selectors', () => { ...quote, quoteRequestIndex: 0, })), - ].map((quote) => quote), + ].map((quote) => toQuoteResponseV2(quote)), quoteRequest: [ { srcChainId: '10', @@ -1953,14 +1987,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", } @@ -1981,7 +2029,7 @@ describe('Bridge Selectors', () => { ] `); expect( - recommendedQuotes.map((quote) => quote?.sentAmount.usd), + recommendedQuotes.map((quote) => quote?.quote.src.usd), ).toStrictEqual(['18', '140']); }); @@ -2011,6 +2059,8 @@ describe('Bridge Selectors', () => { expect(totalReceived).toMatchInlineSnapshot(` { "amount": "0", + "asset": undefined, + "normalizedAmount": "0", "usd": "0", "valueInCurrency": "0", } @@ -2018,6 +2068,8 @@ describe('Bridge Selectors', () => { expect(minimumReceived).toMatchInlineSnapshot(` { "amount": "0", + "asset": undefined, + "normalizedAmount": "0", "usd": "0", "valueInCurrency": "0", } @@ -2156,7 +2208,7 @@ describe('Bridge Selectors', () => { expect(result.totalNetworkFee).toMatchInlineSnapshot(` { - "amount": "0.01", + "amount": "10000", "asset": { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", @@ -2165,6 +2217,7 @@ describe('Bridge Selectors', () => { "name": "USD Coin", "symbol": "USDC", }, + "normalizedAmount": "0.01", "usd": "0.05", "valueInCurrency": "2", } @@ -2179,6 +2232,7 @@ describe('Bridge Selectors', () => { ETH: { conversionRate: 1, usdConversionRate: 1, + conversionDate: Date.now(), }, }, marketData: { @@ -2186,7 +2240,7 @@ describe('Bridge Selectors', () => { [getAddress(mockBatchSellTrades.fee.asset.address)]: { price: 1, currency: 'ETH', - }, + } as never, }, }, batchSellTradesLoadingStatus: RequestStatus.FETCHED, @@ -2195,7 +2249,7 @@ describe('Bridge Selectors', () => { expect(result.totalNetworkFee).toMatchInlineSnapshot(` { - "amount": "0.01", + "amount": "10000", "asset": { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", @@ -2204,6 +2258,7 @@ describe('Bridge Selectors', () => { "name": "USD Coin", "symbol": "USDC", }, + "normalizedAmount": "0.01", "usd": "0.01", "valueInCurrency": "0.01", } @@ -2230,7 +2285,7 @@ describe('Bridge Selectors', () => { expect(result.totalNetworkFee).toMatchInlineSnapshot(` { - "amount": "0.01", + "amount": "10000", "asset": { "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", @@ -2239,6 +2294,7 @@ describe('Bridge Selectors', () => { "name": "USD Coin", "symbol": "USDC", }, + "normalizedAmount": "0.01", "usd": null, "valueInCurrency": null, } @@ -2770,7 +2826,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 a9246b7bee..7e5c0e298c 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 { @@ -18,17 +22,12 @@ import { createStructuredSelector as createStructuredSelector_, } from 'reselect'; +import { mergeQuoteMetadata } from './coercers/quote-response-v1-to-v2'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from './constants/bridge'; -import type { - BridgeControllerState, - ExchangeRate, - QuoteMetadata, - TokenAmountValues, -} from './types'; +import type { BridgeControllerState, ExchangeRate } from './types'; import { RequestStatus, SortOrder } from './types'; import { getNativeAssetForChainId, - isEvmQuoteResponse, isNativeAddress, isNonEvmChainId, } from './utils/bridge'; @@ -50,11 +49,11 @@ import { calcSwapRate, calcToAmount, calcTotalEstimatedNetworkFee, - calcTotalMaxNetworkFee, calcBatchFees, + calcTokenAmount, } from './utils/quote'; import { getDefaultSlippagePercentage } from './utils/slippage'; -import type { QuoteResponseV1 } from './validators/quote-response-v1'; +import type { QuoteResponse } from './validators/quote-response'; /** * The controller states that provide exchange rates @@ -109,7 +108,7 @@ const createBridgeSelector = createSelector_.withTypes(); */ type BridgeQuotesClientParams = { sortOrder: SortOrder; - selectedQuote: (QuoteResponseV1 & QuoteMetadata) | null; + selectedQuote: QuoteResponse | null; }; type EvmTokenExchangeRate = { price?: number; currency?: string }; @@ -298,7 +297,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 +306,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?.[ @@ -357,26 +357,22 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( exchangeRateSources, destTokenExchangeRate, nativeExchangeRate, - ) => { - const newQuotes = quotes.map((quote) => { - const sourceAssetId = - formatAddressToAssetId( - quote.quote.srcAsset.address, - quote.quote.srcChainId, - ) ?? quote.quote.srcAsset.assetId; + ): QuoteResponse[] => { + return quotes.map((quote) => { + const sourceAssetId = quote.quote.src.asset.assetId; const srcTokenExchangeRate = selectExchangeRateByAssetId( exchangeRateSources, sourceAssetId, ); 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.minAmount ?? '0', + quote.quote.dest.asset, destTokenExchangeRate, ); @@ -386,36 +382,41 @@ 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, ...bridgeFeesPerGas, ...nativeExchangeRate, }); + // Uses effectiveGasFee to calculate the total estimated network fee totalEstimatedNetworkFee = calcTotalEstimatedNetworkFee( gasFee, relayerFee, ); - totalMaxNetworkFee = calcTotalMaxNetworkFee(gasFee, relayerFee); + + console.log('====fees', quote.quote.aggregator, { + relayer: relayerFee.amount, + gas: gasFee.effective.amount, + metabridge: quote.quote.feeData.metabridge?.[0].amount, + totalEstimatedNetworkFee: totalEstimatedNetworkFee.amount, + }); } 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 +426,7 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( ); const cost = calcCost(adjustedReturn, sentAmount); - return { - ...quote, - // QuoteMetadata fields + return mergeQuoteMetadata(quote, { sentAmount, toTokenAmount, minToTokenAmount, @@ -438,20 +437,11 @@ 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. - Should only be used for display purposes. - */ - gasFee, adjustedReturn, cost, includedTxFees, - }; + }); }); - - return newQuotes; }, ); @@ -460,7 +450,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 +459,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', ); } @@ -591,35 +586,103 @@ 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.minAmountUsd ?? 0) .toString(); acc.valueInCurrency = new BigNumber(acc.valueInCurrency ?? 0) - .plus(quote?.[key]?.valueInCurrency ?? 0) + .plus(quote?.quote.dest.minAmountValueInCurrency ?? 0) .toString(); acc.amount = new BigNumber(acc.amount ?? 0) - .plus(quote?.[key]?.amount ?? 0) + .plus(quote?.quote.dest.minAmount ?? 0) + .toString(); + acc.normalizedAmount = new BigNumber(acc.normalizedAmount ?? 0) + .plus( + (quote?.quote.dest.minAmount + ? calcTokenAmount( + quote?.quote.dest.minAmount, + quote?.quote.dest.asset.decimals, + ).toFixed() + : '0') ?? 0, + ) .toString(); + return acc; }, - { usd: null, valueInCurrency: null, amount: '0' }, - ), + { + usd: '0', + valueInCurrency: '0', + amount: '0', + normalizedAmount: '0', + asset: destAsset, + }, + ); + }, ); /** @@ -644,10 +707,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 2951078a45..ace51078c0 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -42,6 +42,7 @@ import type { IntentSchema } from './validators/intent'; import type { TxFeeGasLimitsSchema } from './validators/quote'; import type { FeeDataSchema } from './validators/quote'; import type { GaslessPropertiesSchema } from './validators/quote'; +import type { QuoteResponse } from './validators/quote-response'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete'; import type { StepSchema } from './validators/step'; @@ -130,14 +131,6 @@ export type QuoteMetadata = { * Show this value to indicate transaction fees for gasless quotes. */ includedTxFees?: TokenAmountValues | null; - /** - * The gas fee for the bridge transaction. - * effective is the gas fee that is shown to the user. If this value is not - * included in the trade, the calculation falls back to the gasLimit (total) - * total is the gas fee that is spent by the user, including refunds. - * max is the max gas fee that will be used by the transaction. - */ - gasFee: Record<'effective' | 'total' | 'max', TokenAmountValues>; /** * The total network fee required to submit the trade and any approvals. This includes * the relayer fee or other native fees. Should be used for balance checks and tx submission. @@ -145,7 +138,6 @@ export type QuoteMetadata = { * get the total network fee for gasless transactions. */ totalNetworkFee: TokenAmountValues; // estimatedGasFees + relayerFees - totalMaxNetworkFee: TokenAmountValues; // maxGasFees + relayerFees /** * The amount that the user will receive (destTokenAmount) */ @@ -347,7 +339,7 @@ export enum RequestStatus { export type BridgeControllerState = { quoteRequest: Partial[]; - quotes: (QuoteResponseV1 & L1GasFees & NonEvmFees)[]; + quotes: QuoteResponse[]; /** * 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 a51bf2d4ef..459168e167 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -142,7 +142,10 @@ export const sumHexes = (...hexStrings: string[]): Hex => { return '0x0'; } - const sum = hexStrings.reduce((acc, hex) => acc + BigInt(hex), BigInt(0)); + const sum = hexStrings.reduce( + (acc, hexString) => acc + BigInt(hexString), + BigInt(0), + ); return `0x${sum.toString(16)}`; }; diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 62f058af5c..e9708f9f4d 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -1,8 +1,12 @@ 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 { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL } from '../constants/bridge'; import { BatchSellTransactionType } from '../validators/batch-sell'; import { FeatureId } from '../validators/feature-flags'; @@ -755,7 +759,10 @@ describe('fetch', () => { signal, method: 'POST', body: JSON.stringify( - formatBatchSellTradesRequest(mockBridgeQuotesErc20Erc20V1, stxEnabled), + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20V1.map((quote) => toQuoteResponseV2(quote)), + stxEnabled, + ), ), }); @@ -779,7 +786,7 @@ describe('fetch', () => { const { signal } = new AbortController(); const result = await fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -806,7 +813,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -872,7 +879,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - [...mockBridgeQuotesErc20Erc20V1, null], + [...getMockBridgeQuotesErc20Erc20V2(), null], false, signal, BridgeClientId.EXTENSION, @@ -886,7 +893,7 @@ describe('fetch', () => { const result = await Promise.allSettled( Array.from({ length: 3 }, () => fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -957,7 +964,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 b63b4d15dd..7522b754d2 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -1,7 +1,10 @@ /* 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 { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2'; +import { toQuoteResponseV1 } from '../coercers/quote-response-v2-to-v1'; import type { FetchFunction, GenericQuoteRequest, @@ -11,15 +14,18 @@ import type { QuoteStreamCompleteData, BatchSellTradesRequest, BatchSellTradesResponse, + QuoteMetadata, } from '../types'; import { validateBatchSellTradesResponse } from '../validators/batch-sell'; import { validateBridgeAsset } from '../validators/bridge-asset'; import type { FeatureId } from '../validators/feature-flags'; +import type { QuoteResponse } from '../validators/quote-response'; import type { QuoteResponseV1 } from '../validators/quote-response-v1'; import { validateQuoteResponseV1 } from '../validators/quote-response-v1'; 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, @@ -310,13 +316,13 @@ 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: srcAsset }, + dest: { asset: destAsset }, +}: QuoteResponse['quote']): string => + `${srcAsset.assetId}-${destAsset.assetId}`.toLowerCase(); /** * Fetches quotes from the bridge-api @@ -348,7 +354,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; }, @@ -364,49 +372,52 @@ 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 quoteResponse = toQuoteResponseV2(quoteResponseV1OrV2); + // 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: + quoteResponse.namespace === KnownCaipNamespace.Eip155 && + matchingQuoteRequest.resetApproval && + quoteResponse.approval + ? { + ...quoteResponse.approval, + data: getEthUsdtResetData(matchingQuoteRequest.destChainId), + } + : undefined, + ...(isBatchSellRequest && { + quoteRequestIndex: matchedQuoteRequestIdx, + }), + }); } catch (error) { if (error instanceof StructError) { console.warn('Quote validation failed', formatStructErrors(error)); 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('|')); }); @@ -418,7 +429,6 @@ export async function fetchBridgeQuoteStream( // Rethrow any unexpected errors throw error; } - return undefined; }; const onTokenWarningReceived = (data: unknown): void => { @@ -496,26 +506,15 @@ export async function fetchBridgeQuoteStream( } export const formatBatchSellTradesRequest = ( - quotes: (QuoteResponseV1 | null)[], + quotes: (QuoteResponse | (QuoteResponseV1 & QuoteMetadata) | null)[], stxEnabled: boolean, ): BatchSellTradesRequest => ({ quotes: quotes - .filter((quote): quote is QuoteResponseV1 => quote !== null) - .map( - ({ - trade, - approval, - quote, - estimatedProcessingTimeInSeconds, - quoteId, - }) => ({ - trade, - approval, - quote, - estimatedProcessingTimeInSeconds, - quoteId, - }), - ), + .filter( + (quote): quote is QuoteResponse | (QuoteResponseV1 & QuoteMetadata) => + quote !== null && Boolean(quote), + ) + .map((quoteV1OrV2) => toQuoteResponseV1(quoteV1OrV2)), stxEnabled, }); @@ -533,7 +532,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 7c46242b87..acfcb3d7c0 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 { QuoteMetadata } from '../../types'; +import { toQuoteResponseV2 } from '../../coercers/quote-response-v1-to-v2'; +import { QuoteMetadata } from '../../types'; +import { validateQuoteResponseV1 } from '../../validators/quote-response-v1'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1'; import { getNativeAssetForChainId } from '../bridge'; import { formatChainIdToCaip } from '../caip-formatters'; @@ -172,41 +174,6 @@ 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: [], @@ -219,7 +186,19 @@ describe('properties', () => { data: '0x', gasLimit: 100000, }, - estimatedProcessingTimeInSeconds: 60, + }; + + const result = formatProviderLabel(mockQuoteResponse.quote); + + expect(result).toBe('bridge1_bridge1'); + }); + + it('should format provider label correctly (V2)', () => { + const mockQuoteResponse = { + quote: { + aggregator: 'bridge1', + protocols: ['bridge1'], + }, }; const result = formatProviderLabel(mockQuoteResponse.quote); @@ -424,23 +403,31 @@ describe('properties', () => { }, trade: { chainId: 1, - to: '0x789', - from: '0xabc', + to: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', value: '0x0', - data: '0x', + 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(` { @@ -523,7 +510,9 @@ describe('properties', () => { estimatedProcessingTimeInSeconds: 60, }; - const result = getQuotesReceivedProperties(mockQuoteResponse); + const result = getQuotesReceivedProperties( + toQuoteResponseV2(mockQuoteResponse), + ); expect(result.token_symbol_source).toBe('WETH'); expect(result.token_symbol_destination).toBe('DAI'); diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 9c0b45989a..f2b13d2688 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -3,14 +3,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, -} from '../../types'; +import type { GenericQuoteRequest, QuoteRequest } from '../../types'; import { FeatureId } from '../../validators/feature-flags'; -import type { QuoteResponseV1 } from '../../validators/quote-response-v1'; -import type { TxData } from '../../validators/trade'; +import type { QuoteResponse } from '../../validators/quote-response'; import { getNativeAssetForChainId, isCrossChain } from '../bridge'; import { formatAddressToAssetId, @@ -75,10 +70,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. @@ -158,10 +160,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, ) => { @@ -173,17 +175,19 @@ 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, - token_symbol_source: activeQuote?.quote.srcAsset.symbol ?? '', - token_symbol_destination: activeQuote?.quote.destAsset.symbol ?? null, + token_symbol_source: activeQuote?.quote.src.asset.symbol ?? '', + token_symbol_destination: activeQuote?.quote.dest.asset.symbol ?? null, 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/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index a6f639655c..ed0c789e62 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -1,6 +1,6 @@ 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 { @@ -12,38 +12,39 @@ import type { QuoteResponseV1 } from '../validators/quote-response-v1'; 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-v2.ts b/packages/bridge-controller/src/utils/quote-v2.ts new file mode 100644 index 0000000000..656339ed5c --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-v2.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import type { AmountsAndAsset } from 'src/validators/amount-and-asset'; + +import type { DeepPartial } from '../types'; +import type { FeeType } from '../validators/quote'; +import type { QuoteResponse } from '../validators/quote-response'; + +/** + * Returns the fiat price impact for a bridge quote — the difference between + * the source input fiat amount and the destination output fiat amount + * + * @param activeQuote - The active quote + * @returns Formatted fiat impact string, or `undefined` when either fiat value is unavailable. + */ +export const calcPriceImpact = (activeQuote?: DeepPartial) => { + if (!activeQuote?.quote?.src || !activeQuote?.quote?.dest) { + return undefined; + } + + const sourceFiat = Number(activeQuote.quote.src.valueInCurrency); + const destFiat = Number(activeQuote.quote.dest.valueInCurrency); + const valueInCurrency = + isNaN(sourceFiat) || isNaN(destFiat) || !sourceFiat || !destFiat + ? null + : Math.abs(sourceFiat - destFiat); + + const sourceUsd = Number(activeQuote.quote.src.usd); + const destUsd = Number(activeQuote.quote.dest.usd); + const usd = + isNaN(sourceUsd) || isNaN(destUsd) || !sourceUsd || !destUsd + ? null + : Math.abs(sourceUsd - destUsd); + + if ( + (isNaN(Number(valueInCurrency)) && isNaN(Number(usd))) || + [usd, valueInCurrency].every((value) => value === null) + ) { + return null; + } + + return { valueInCurrency, usd }; +}; + +/** + * Aggregates a list of fees into a single fee object. If fees have different assets, + * the returned object will only aggregate the usd and valueInCurrency values. + * + * @param maybeFees - The list of fees to aggregate + * @returns The aggregated fee object, or null if no fees are provided + */ +export const sumFees = ( + ...maybeFees: (QuoteResponse['quote']['feeData'][FeeType] | undefined)[] +) => { + const fees = maybeFees + .filter(Boolean) + .flat() + .filter(Boolean) as AmountsAndAsset[]; + if (!fees || fees.length === 0) { + return null; + } + + const isSameAssetForAllFees = + new Set(fees.map(({ asset }) => asset.assetId.toLowerCase())).size === 1; + + const aggregatedFee = fees.reduce( + (acc, { amount, valueInCurrency, usd, normalizedAmount }) => { + // If any of the values are null, don't add them to the total + // if (!amount) { + // return acc; + // } + return { + amount: acc.amount.plus(amount), + normalizedAmount: acc.normalizedAmount.plus(normalizedAmount ?? 0), + valueInCurrency: acc.valueInCurrency.plus(valueInCurrency ?? 0), + usd: usd ? acc.usd.plus(usd) : acc.usd, + }; + }, + { + amount: new BigNumber(0), + normalizedAmount: new BigNumber(0), + valueInCurrency: new BigNumber(0), + usd: new BigNumber(0), + }, + ); + + return { + amount: isSameAssetForAllFees ? aggregatedFee.amount.toFixed() : null, + normalizedAmount: isSameAssetForAllFees + ? aggregatedFee.normalizedAmount.toFixed() + : null, + valueInCurrency: aggregatedFee.valueInCurrency.toFixed(), + usd: aggregatedFee.usd.toFixed(), + asset: isSameAssetForAllFees ? fees[0].asset : null, + }; +}; + +export const getDestChainId = (quote?: QuoteResponse | null) => { + if (!quote) { + return null; + } + return parseCaipAssetType(quote.quote.dest.asset.assetId).chainId; +}; + +export const getTotalNetworkFees = () => {}; diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 4113818c89..a442c06dd6 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -2,10 +2,13 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import type { GenericQuoteRequest, NonEvmFees, L1GasFees } from '../types'; +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, L1GasFees } from '../types'; import type { Quote } from '../validators/quote'; -import type { QuoteResponseV1 } from '../validators/quote-response-v1'; -import type { TxData } from '../validators/trade'; +import { QuoteResponse } from '../validators/quote-response'; +import { getNativeAssetForChainId } from './bridge'; import { isValidQuoteRequest, getQuoteIdentifier, @@ -162,13 +165,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', @@ -180,13 +185,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'); @@ -195,20 +202,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', @@ -221,24 +224,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', @@ -255,25 +261,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', @@ -288,11 +317,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, { @@ -306,11 +333,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', @@ -378,14 +403,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 unknown as QuoteResponseV1; + })[0]; it('should calculate relayer fee correctly with exchange rates', () => { const result = calcRelayerFee(mockBridgeQuote, { @@ -393,30 +421,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: { @@ -424,19 +454,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', @@ -447,20 +478,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 unknown as QuoteResponseV1 & L1GasFees; + }; it('should calculate estimated and max gas fees correctly', () => { const result = calcEstimatedAndMaxTotalGasFee({ @@ -500,7 +531,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', @@ -584,7 +615,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 0 }, l1GasFeesInHexWei: '0x0', estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: zeroGasQuote, @@ -607,7 +638,7 @@ describe('Quote Metadata Utils', () => { approval: undefined, l1GasFeesInHexWei: '0x5AF3107A4000', estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: noApprovalQuote, @@ -631,7 +662,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 46000 }, l1GasFeesInHexWei: '0x5AF3107A4000', estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: noGasLimitQuote, @@ -652,7 +683,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 500000 } as TxData, l1GasFeesInHexWei: '0x1BC16D674EC80000', // 2 ETH in wei estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: largeGasQuote, @@ -714,9 +745,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', () => { @@ -737,7 +768,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, }); @@ -749,7 +780,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, }); @@ -775,17 +806,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 fec1a84538..0bfab241ce 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -4,20 +4,19 @@ import { toHex, weiHexToGweiDec, } from '@metamask/controller-utils'; +import { KnownCaipNamespace } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import type { - BridgeAsset, ExchangeRate, GenericQuoteRequest, L1GasFees, - QuoteMetadata, NonEvmFees, } from '../types'; +import type { BridgeAssetV2 } from '../validators/bridge-asset'; import { FeatureId } from '../validators/feature-flags'; -import type { Quote } from '../validators/quote'; +import type { QuoteResponse } from '../validators/quote-response'; import type { QuoteResponseV1 } from '../validators/quote-response-v1'; -import { TxData } from '../validators/trade'; import { isNativeAddress, isNonEvmChainId } from './bridge'; export const isValidQuoteRequest = ( @@ -92,19 +91,29 @@ 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 */ export const getQuoteIdentifier = (quote: QuoteResponseV1['quote']) => `${quote.bridgeId}-${quote.bridges[0]}-${quote.steps.length}`; -const calcTokenAmount = (value: string | BigNumber, decimals: number) => { +export const calcTokenAmount = ( + value: string | BigNumber, + decimals: number, +) => { const divisor = new BigNumber(10).pow(decimals ?? 0); 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 +121,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 +139,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 +165,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 +173,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; @@ -199,7 +214,7 @@ export const calcRelayerFee = ( let relayerFeeInNative = calcTokenAmount(relayerFeeAmount, 18); // 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, @@ -207,12 +222,18 @@ export const calcRelayerFee = ( relayerFeeInNative = relayerFeeInNative.minus(sentAmountInNative); } + if (relayerFeeInNative.lte(0)) { + return { amount: '0', valueInCurrency: '0', usd: '0' }; + } + 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 +272,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 +285,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 +368,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 +385,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 +413,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 +435,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?.toLowerCase() === destAssetId.toLowerCase()) { return { valueInCurrency: toTokenAmount.valueInCurrency, usd: toTokenAmount.usd, @@ -423,19 +454,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 +476,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 +502,7 @@ export const calcSlippagePercentage = ( .div(sentAmount.valueInCurrency) .times(100) .abs() - .toString(); + .toFixed(); } if (cost.usd && sentAmount.usd) { @@ -479,7 +510,7 @@ export const calcSlippagePercentage = ( .div(sentAmount.usd) .times(100) .abs() - .toString(); + .toFixed(); } return null; diff --git a/packages/bridge-controller/src/v2.json b/packages/bridge-controller/src/v2.json new file mode 100644 index 0000000000..1e9d8e8f63 --- /dev/null +++ b/packages/bridge-controller/src/v2.json @@ -0,0 +1,196 @@ +{ + "quote": { + "bridgeId": "rango", + "requestId": "72939e4e-f827-4542-9834-58c9c6d37a80", + "aggregator": "rango", + "feeData": { + "metabridge": [ + { + "amount": "8750000000000000", + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "assetId": "eip155:1/slip44:60", + "symbol": "ETH", + "decimals": 18, + "name": "Ether", + "coingeckoId": "ethereum", + "aggregators": [], + "occurrences": 100, + "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png", + "metadata": {} + }, + "quoteBpsFee": 87.5, + "baseBpsFee": 87.5, + "discountType": null, + "usd": "15.773275000000002" + } + ], + "network": [ + { + "amount": "29785442769904", + "asset": { + "chainId": 1, + "assetId": "eip155:1/slip44:60", + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "usd": "0.05369302626359515" + } + ], + "relayer": [ + { + "amount": "8750000000000000", + "asset": { + "chainId": 1, + "assetId": "eip155:1/slip44:60", + "address": "0x0000000000000000000000000000000000000000", + "symbol": "ETH", + "name": "Ether", + "decimals": 18 + }, + "usd": "15.773275000000002" + } + ] + }, + "bridges": ["nearintent (via Rango)"], + "protocols": ["nearintent (via Rango)"], + "gasSponsored": false, + "gasIncluded": false, + "gasIncluded7702": false, + "slippage": 2, + "src": { + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "assetId": "eip155:1/slip44:60", + "symbol": "ETH", + "decimals": 18, + "name": "Ether", + "coingeckoId": "ethereum", + "aggregators": [], + "occurrences": 100, + "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png", + "metadata": {} + }, + "amount": "1000000000000000000", + "usd": "1802.66" + }, + "dest": { + "asset": { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "chainId": 8453, + "assetId": "eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coingeckoId": "usd-coin", + "aggregators": [ + "coinGecko", + "oneInch", + "liFi", + "socket", + "rubic", + "squid", + "rango", + "sonarwatch", + "sushiSwap" + ], + "occurrences": 9, + "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.png", + "metadata": { + "storage": { + "balance": 9, + "approval": 10 + } + } + }, + "amount": "1787195835", + "minAmount": "1751451918", + "usd": "1787.0484856561857" + }, + "steps": [ + { + "action": "bridge", + "protocol": { + "name": "Near Intents", + "displayName": "nearintent", + "icon": "https://raw.githubusercontent.com/rango-exchange/assets/main/swappers/NEAR Intents/icon.svg" + }, + "src": { + "asset": { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 1, + "assetId": "eip155:1/slip44:60", + "symbol": "ETH", + "decimals": 18, + "name": "Ether", + "coingeckoId": "ethereum", + "aggregators": [], + "occurrences": 100, + "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png", + "metadata": {} + }, + "amount": "991250000000000000" + }, + "dest": { + "asset": { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "chainId": 8453, + "assetId": "eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": 6, + "name": "USD Coin", + "coingeckoId": "usd-coin", + "aggregators": [ + "coinGecko", + "oneInch", + "liFi", + "socket", + "rubic", + "squid", + "rango", + "sonarwatch", + "sushiSwap" + ], + "occurrences": 9, + "iconUrl": "https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/8453/erc20/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.png", + "metadata": { + "storage": { + "balance": 9, + "approval": 10 + } + } + }, + "amount": "1787195835", + "minAmount": "1751451918" + } + } + ], + "priceData": { + "totalFeeAmountUsd": "15.773275000000002", + "swapRate": "1787.195835", + "priceImpact": { + "amount": "-0.0000905265308217041", + "usd": "31.438482370078003" + } + }, + "walletAddress": "0x98931e2b2272891c2e8e47d675007d94ae90ce64", + "destWalletAddress": "0x98931e2b2272891c2e8e47d675007d94ae90ce64" + }, + "trade": { + "chainId": 1, + "to": "0x0439e60F02a8900a951603950d8D4527f400C3f1", + "from": "0x98931E2B2272891c2E8e47D675007D94aE90cE64", + "value": "0xde0b6b3a7640000", + "data": "0x3ce33bff000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f72616e676f46656544796e616d69630000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000054000000000000000000000000069460570c93f9de5e2edbc3052bf10125f0ca22d00000000000000000000000069460570c93f9de5e2edbc3052bf10125f0ca22d00000000000000000000000000000000000000000000000000000000000021050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000e6b738da243e8fa2a0ed5915645789add5de515200000000000000000000000000000000000000000000000000000000000003e414d08fca0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000022000000000000000000000000098931e2b2272891c2e8e47d675007d94ae90ce640000000000000000000000000000000072939e4ef8274542983458c9c6d37a8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004fabb145d64652a948d72533023f6e7a623c7c530000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002034356235e03b0476885fd39d88c56441572c3c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e9000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000084d6574614d61736b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b8cf3cdbc390180257295bf08cfdeca80dca9e8a000000000000000000000000b8cf3cdbc390180257295bf08cfdeca80dca9e8a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000006458b4e54800000000000000000000000000000000000000000000000000000000000000000000000000000000000000005a92b3669794a9512b69f49d7877b8bdcf7b76d20000000000000000000000000000000000000000000000000dc1a09f859b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000029", + "gasLimit": 283334, + "feeEstimate": 459764642974656, + "effectiveGas": 204752, + "gasCost": "29785442769904" + }, + "estimatedProcessingTimeInSeconds": 90, + "quoteId": "2528f4ac-3169-4391-ab02-9d8fd571274c" +} diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts new file mode 100644 index 0000000000..5f89102fbf --- /dev/null +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -0,0 +1,21 @@ +import { + type, + optional, + nullable, + union, + string, + Infer, +} from '@metamask/superstruct'; + +import { BridgeAssetV2Schema } from './bridge-asset'; +import { NumberStringSchema, FloatStringSchema } from './number'; + +export const AmountsAndAssetSchema = type({ + amount: NumberStringSchema, + normalizedAmount: optional(FloatStringSchema), + asset: BridgeAssetV2Schema, + // TODO remove string and fix usd in backend + usd: optional(nullable(union([FloatStringSchema, string()]))), + valueInCurrency: optional(nullable(FloatStringSchema)), +}); +export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/bridge-asset.ts b/packages/bridge-controller/src/validators/bridge-asset.ts index 0e8092a691..b01112be6a 100644 --- a/packages/bridge-controller/src/validators/bridge-asset.ts +++ b/packages/bridge-controller/src/validators/bridge-asset.ts @@ -5,12 +5,81 @@ import { optional, nullable, is, + intersection, + enums, + array, + boolean, } 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 type MinimalAsset = Infer; + +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 @@ -48,3 +117,9 @@ export const validateBridgeAsset = ( ): data is Infer => { return is(data, BridgeAssetSchema); }; + +export const validateBridgeAssetV2 = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetV2Schema); +}; diff --git a/packages/bridge-controller/src/validators/quote-response.test.ts b/packages/bridge-controller/src/validators/quote-response.test.ts new file mode 100644 index 0000000000..8056cf881f --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.test.ts @@ -0,0 +1,33 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2'; +import { validateQuoteResponse } from './quote-response'; + +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); + }); + + it('should return false when quote namespace is not supported', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + quoteResponse.quote.src.asset.assetId = `${KnownCaipNamespace.Wallet}:123/token:test`; + expect(validateQuoteResponse(quoteResponse)).toBe(false); + }); + }); +}); diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts new file mode 100644 index 0000000000..f100a60c6f --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -0,0 +1,218 @@ +import { + assert, + Infer, + number, + optional, + string, + type, + union, + intersection, + Describe, + nullable, + enums, + literal, +} from '@metamask/superstruct'; +import { + CaipChainId, + CaipChainIdStruct, + KnownCaipNamespace, + parseCaipAssetType, + Hex, + StrictHexStruct, +} from '@metamask/utils'; + +import { formatChainIdToHex } from '../utils/caip-formatters'; +import { FeatureId } from './feature-flags'; +import { FloatStringSchema } from './number'; +import { QuoteSchemaV2 } from './quote'; +import { + BitcoinTradeData, + BitcoinTradeDataSchema, + StellarTradeData, + StellarTradeDataSchema, + TronTradeData, + TronTradeDataSchema, + TxData, + TxDataSchema, +} from './trade'; + +const CommonQuoteResponseSchema = type({ + quoteId: optional(string()), + quote: QuoteSchemaV2, + estimatedProcessingTimeInSeconds: number(), + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex: optional(number()), + /** + * Appended to the quote response based on the quote requested featureId + */ + featureId: optional(enums(Object.values(FeatureId))), + /** + * Appended to the quote response based on the quote request nonEvmFeesInNative flag + * + * @deprecated Use network feeData + */ + nonEvmFeesInNative: optional(FloatStringSchema), + /** + * Appended to the quote response based on the quote request l1GasFeesInHexWei flag + * + * @deprecated Use network feeData + */ + l1GasFeesInHexWei: optional(StrictHexStruct), +}); + +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, + }), +]); + +const StellarQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Stellar), + chainId: CaipChainIdStruct, + trade: StellarTradeDataSchema, + }), +]); + +export const QuoteResponseSchemaV2 = nullable( + union([ + EvmQuoteResponseSchema, + SolanaQuoteResponseSchema, + TronQuoteResponseSchema, + BitcoinQuoteResponseSchema, + StellarQuoteResponseSchema, + ]), +); + +/** + * This is the V2 QuoteResponse type, including metadata calculated after quote fetch + */ +export type QuoteResponse = Omit< + NonNullable>, + 'trade' | 'approval' | 'resetApproval' +> & + ( + | { + 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; + } + | { + namespace: KnownCaipNamespace.Stellar; + chainId: CaipChainId; + trade: StellarTradeData; + } + ); +// 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 & + KnownCaipNamespace.Stellar; + + // Conditionally validate the trade and approval fields for the chain + const nameSpaceToTradeSchema = { + [KnownCaipNamespace.Eip155]: EvmQuoteResponseSchema, + [KnownCaipNamespace.Tron]: TronQuoteResponseSchema, + [KnownCaipNamespace.Solana]: SolanaQuoteResponseSchema, + [KnownCaipNamespace.Bip122]: BitcoinQuoteResponseSchema, + [KnownCaipNamespace.Stellar]: StellarQuoteResponseSchema, + } 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.ts b/packages/bridge-controller/src/validators/quote.ts index f27dc1926d..915750da21 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -10,10 +10,15 @@ import { } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { AmountsAndAssetSchema } from './amount-and-asset'; import { ChainIdSchema, BridgeAssetSchema } from './bridge-asset'; import { IntentSchema } from './intent'; -import { TruthyDigitStringSchema, NumberStringSchema } from './number'; -import { RefuelDataSchema, StepSchema } from './step'; +import { + TruthyDigitStringSchema, + NumberStringSchema, + FloatStringSchema, +} from './number'; +import { RefuelDataSchema, StepSchema, StepSchemaV2 } from './step'; export enum FeeType { METABRIDGE = 'metabridge', @@ -111,3 +116,78 @@ export const QuoteSchema = intersection([ ]); export type Quote = Infer; + +const GaslessFeeDataSchema = intersection([ + TxFeeGasLimitsSchema, + AmountsAndAssetSchema, +]); +export type GaslessFeeData = Infer; + +export const QuoteSchemaV2 = intersection([ + GaslessPropertiesSchema, + type({ + requestId: string(), + src: intersection([ + AmountsAndAssetSchema, + type({ + walletAddress: optional(string()), + }), + ]), + dest: intersection([ + AmountsAndAssetSchema, + type({ + minAmount: optional(string()), + minAmountUsd: optional(string()), + minAmountValueInCurrency: optional(string()), + minAmountNormalized: optional(string()), + 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([ + AmountsAndAssetSchema, + type({ + quoteBpsFee: optional(number()), + baseBpsFee: optional(number()), + discountType: optional(nullable(string())), + }), + ]), + ), + [FeeType.REFUEL]: optional(array(AmountsAndAssetSchema)), + [FeeType.TX_FEE]: optional(array(GaslessFeeDataSchema)), + [FeeType.NETWORK]: optional(array(AmountsAndAssetSchema)), + [FeeType.RELAYER]: optional(array(AmountsAndAssetSchema)), + }), + aggregator: string(), + protocols: array(string()), + steps: optional(array(StepSchemaV2)), + refuel: optional(StepSchema), + intent: optional(IntentSchema), + slippage: optional(number()), + }), +]); diff --git a/packages/bridge-controller/src/validators/step.ts b/packages/bridge-controller/src/validators/step.ts index 59460be4c9..8edbcf3edc 100644 --- a/packages/bridge-controller/src/validators/step.ts +++ b/packages/bridge-controller/src/validators/step.ts @@ -1,6 +1,7 @@ -import { type, enums, optional } from '@metamask/superstruct'; +import { type, enums, optional, pick } from '@metamask/superstruct'; import { BridgeAssetSchema, ChainIdSchema } from './bridge-asset'; +import { AmountsAndAssetSchema } from './amount-and-asset'; export enum ActionTypes { BRIDGE = 'bridge', @@ -17,3 +18,9 @@ export const StepSchema = type({ }); export const RefuelDataSchema = StepSchema; + +export const StepSchemaV2 = type({ + action: enums(Object.values(ActionTypes)), + src: pick(AmountsAndAssetSchema, ['asset']), + dest: pick(AmountsAndAssetSchema, ['asset']), +}); 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..ff3ff910e3 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts @@ -0,0 +1,123 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import { FeatureId } from '../src'; +import { QuoteResponse } from '../src/validators/quote-response'; +import { ActionTypes } from '../src/validators/step'; + +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', + }, + minAmount: '13700000', + minAmountNormalized: '13.7', + minAmountUsd: undefined, + minAmountValueInCurrency: undefined, + walletAddress: undefined, + }, + feeData: { + metabridge: [ + { + amount: '0', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + usd: undefined, + }, + ], + network: [ + { + amount: '0', + asset: { + assetId: 'eip155:10/slip44:60', + decimals: 18, + iconUrl: '', + name: 'Ether', + symbol: 'ETH', + }, + normalizedAmount: '0', + usd: undefined, + valueInCurrency: undefined, + }, + ], + }, + 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, + src: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + name: 'USD Coin', + decimals: 6, + }, + }, + dest: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + name: 'Native USD Coin (POS)', + decimals: 6, + }, + }, + }, + ], + }, + 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 e43025d372..d3961bebb4 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -1,11 +1,12 @@ import { merge } from 'lodash'; import type { DeepPartial } from '../src/types'; -import { - validateQuoteResponseV1, - QuoteResponseV1, -} from '../src/validators/quote-response-v1'; +import { FeatureId } from '../src/validators/feature-flags'; +import type { QuoteResponse } from '../src/validators/quote-response'; +import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1'; import { ActionTypes } from '../src/validators/step'; +import type { QuoteResponseV1 } from '../src/validators/quote-response-v1'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2'; export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ { @@ -23,18 +24,17 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', }, srcTokenAmount: '14000000', + destWalletAddress: undefined, destChainId: 137, destAsset: { chainId: 137, - address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', symbol: 'USDC', name: 'Native USD Coin (POS)', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', }, destTokenAmount: '13984280', minDestTokenAmount: '13700000', @@ -49,13 +49,11 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', }, }, }, bridgeId: 'socket', bridges: ['across'], - protocols: ['across'], steps: [ { action: ActionTypes.BRIDGE, @@ -101,6 +99,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ gasLimit: 287227, }, estimatedProcessingTimeInSeconds: 60, + featureId: FeatureId.DAPP_SWAP, }, { quote: { @@ -113,7 +112,6 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', }, srcTokenAmount: '14000000', destChainId: 137, @@ -124,7 +122,6 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'Native USD Coin (POS)', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', }, destTokenAmount: '13800000', minDestTokenAmount: '13530000', @@ -190,15 +187,16 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ gasLimit: 343079, }, estimatedProcessingTimeInSeconds: 1560, + featureId: FeatureId.DAPP_SWAP, }, ]; -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 07c33bfb41..c7b9ecc64a 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -1,6 +1,8 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2'; import type { DeepPartial } from '../src/types'; +import type { QuoteResponse } from '../src/validators/quote-response'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1'; import { ActionTypes } from '../src/validators/step'; @@ -980,10 +982,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 ad41fbed3e..7e807f6587 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts @@ -1,6 +1,8 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2'; import type { DeepPartial } from '../src/types'; +import type { QuoteResponse } from '../src/validators/quote-response'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1'; import { ActionTypes } from '../src/validators/step'; @@ -212,10 +214,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 ed44c75a92..e55df97683 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -1,6 +1,8 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2'; import type { DeepPartial } from '../src/types'; +import type { QuoteResponse } from '../src/validators/quote-response'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1'; import { ActionTypes } from '../src/validators/step'; @@ -262,10 +264,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 7bc3ab0fcf..d4a54e47b9 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -1,6 +1,8 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2'; import type { DeepPartial } from '../src/types'; +import type { QuoteResponse } from '../src/validators/quote-response'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1'; import { ActionTypes } from '../src/validators/step'; @@ -182,10 +184,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-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 8dfea60eca..075cdeef7e 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponse` to `QuoteResponseV2` +- Support both `QuoteResponse` and `QuoteResponseV2` quoteResponses in `submitTx` +- Use `totalNetworkFee` instead of `gasFee.effective` to calculate gas metrics properties - Bump `@metamask/bridge-controller` from `^77.3.1` to `^77.3.2` ([#9372](https://github.com/MetaMask/core/pull/9372)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 243853ff9e..596bafa2ff 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -1084,7 +1084,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti ] `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 1`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 4`] = ` { "batchId": "batchId1", "chainId": "0xa4b1", @@ -1108,7 +1108,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac } `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 2`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 5`] = ` { "account": "0xaccount1", "actionId": undefined, @@ -1234,7 +1234,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac } `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 3`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 6`] = ` [ [ { @@ -1273,7 +1273,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac ] `; -exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 4`] = ` +exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 7`] = ` [ [ "BridgeController:stopPollingForQuotes", @@ -3681,7 +3681,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap }, }, "txFee": { - "amount": "0", + "amount": "100", "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", @@ -4639,7 +4639,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g }, }, "txFee": { - "amount": "8750000000000", + "amount": "100", "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", @@ -4728,7 +4728,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g } `; -exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 1`] = ` +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (null gasLimit) 1`] = ` { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -4747,7 +4747,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g } `; -exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 2`] = ` +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (null gasLimit) 2`] = ` { "account": "0xaccount1", "actionId": "1234567891.456", @@ -4803,7 +4803,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g }, }, "txFee": { - "amount": "8750000000000", + "amount": "100", "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 05c4ca30e9..8ffa67f6e4 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -1,7 +1,12 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable jest/no-restricted-matchers */ -import { BridgeClientId, StatusTypes } from '@metamask/bridge-controller'; +import { + BridgeClientId, + getNativeAssetForChainId, + StatusTypes, + validateQuoteResponseV1, +} from '@metamask/bridge-controller'; import type { GasFeeEstimates, TransactionMeta, @@ -51,10 +56,28 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { name: 'ETH', decimals: 18, }, - feeData: { txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, + feeData: { + metabridge: { amount: '1', asset: getNativeAssetForChainId(1) }, + txFee: { + amount: '1', + asset: getNativeAssetForChainId(1), + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + }, intent: { protocol: 'cowswap', - order: { some: 'order' }, + order: { + sellToken: '0x0000000000000000000000000000000000000001', + buyToken: '0x0000000000000000000000000000000000000002', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }, settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', typedData: { types: {}, @@ -63,6 +86,7 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { message: {}, }, }, + steps: [], }, sentAmount: { amount: '1', usd: '1' }, gasFee: { effective: { amount: '0', usd: '0' } }, @@ -82,6 +106,7 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { ...overrides, }; }; +validateQuoteResponseV1(minimalIntentQuoteResponse()); const minimalBridgeQuoteResponse = ( accountAddress: string, @@ -111,9 +136,18 @@ const minimalBridgeQuoteResponse = ( name: 'ETH', decimals: 18, }, - feeData: { txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, + feeData: { + metabridge: { amount: '1', asset: getNativeAssetForChainId(1) }, + txFee: { + amount: '1', + asset: getNativeAssetForChainId(1), + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + }, bridges: ['across'], bridgeId: 'socket', + steps: [], }, sentAmount: { amount: '1', usd: '1' }, gasFee: { effective: { amount: '0', usd: '0' } }, @@ -133,6 +167,7 @@ const minimalBridgeQuoteResponse = ( ...overrides, }; }; +validateQuoteResponseV1(minimalBridgeQuoteResponse('0xAccount1')); const createMessengerHarness = ( accountAddress: string, @@ -530,7 +565,15 @@ describe('BridgeStatusController (intent swaps)', () => { "params": { "aggregatorId": "cowswap", "order": { - "some": "order", + "appData": "some-app-data", + "appDataHash": "0xabcd", + "buyToken": "0x0000000000000000000000000000000000000002", + "feeAmount": "100", + "kind": "sell", + "partiallyFillable": false, + "sellAmount": "1000", + "sellToken": "0x0000000000000000000000000000000000000001", + "validTo": 1717027200, }, "quoteId": "req-1", "signature": "0xautosigned", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 2bb5e59242..72279e8c24 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -3,7 +3,7 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import type { BridgeControllerMessenger, - QuoteResponse, + QuoteResponseV1, QuoteMetadata, TxData, TronTradeData, @@ -18,6 +18,8 @@ import { getQuotesReceivedProperties, UnifiedSwapBridgeEventName, MetaMetricsSwapsEventSource, + validateQuoteResponseV1, + toQuoteResponseV2, } from '@metamask/bridge-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -322,13 +324,7 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ sentAmount: { amount: '1.234', valueInCurrency: null, usd: null }, toTokenAmount: { amount: '1.234', valueInCurrency: null, usd: null }, minToTokenAmount: { amount: '1.17', valueInCurrency: null, usd: null }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, - }, + totalNetworkFee: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, adjustedReturn: { valueInCurrency: null, usd: null }, swapRate: '1.234', cost: { valueInCurrency: null, usd: null }, @@ -2162,7 +2158,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana bridge', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2250,21 +2246,12 @@ describe('BridgeStatusController', () => { valueInCurrency: '950', usd: '950', }, - totalNetworkFee: { - amount: '0.1', - valueInCurrency: '10', - usd: '10', - }, + totalNetworkFee: { amount: '0.05', valueInCurrency: '5', usd: '5' }, totalMaxNetworkFee: { amount: '0.15', valueInCurrency: '15', usd: '15', }, - gasFee: { - effective: { amount: '0.05', valueInCurrency: '5', usd: '5' }, - total: { amount: '0.05', valueInCurrency: '5', usd: '5' }, - max: { amount: '0', valueInCurrency: null, usd: null }, - }, adjustedReturn: { valueInCurrency: '985', usd: '985', @@ -2407,7 +2394,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana swap', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2495,21 +2482,7 @@ describe('BridgeStatusController', () => { valueInCurrency: '950', usd: '950', }, - totalNetworkFee: { - amount: '0.1', - valueInCurrency: '10', - usd: '10', - }, - totalMaxNetworkFee: { - amount: '0.15', - valueInCurrency: '15', - usd: '15', - }, - gasFee: { - effective: { amount: '0.05', valueInCurrency: '5', usd: '5' }, - total: { amount: '0.05', valueInCurrency: '5', usd: '5' }, - max: { amount: '0', valueInCurrency: null, usd: null }, - }, + totalNetworkFee: { amount: '0.05', valueInCurrency: '5', usd: '5' }, adjustedReturn: { valueInCurrency: '985', usd: '985', @@ -2662,7 +2635,7 @@ describe('BridgeStatusController', () => { '0a02aabb22084dde86d0f68ae3e5403a680801b2630a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412330a15418f7ea8cce9f8bba67d7ae59cd49a1965d617e71b121541a614f803b6fd780986a42c78ec9c7f77e6ded13c', }; - const mockQuoteResponse: QuoteResponse & + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', @@ -2752,19 +2725,9 @@ describe('BridgeStatusController', () => { usd: '475', }, totalNetworkFee: { - amount: '0.01', - valueInCurrency: '0.01', - usd: '0.01', - }, - totalMaxNetworkFee: { - amount: '0.015', - valueInCurrency: '0.015', - usd: '0.015', - }, - gasFee: { - effective: { amount: '0.005', valueInCurrency: '0.005', usd: '0.005' }, - total: { amount: '0.005', valueInCurrency: '0.005', usd: '0.005' }, - max: { amount: '0', valueInCurrency: null, usd: null }, + amount: '0.005', + valueInCurrency: '0.005', + usd: '0.005', }, adjustedReturn: { valueInCurrency: '499.99', @@ -2899,7 +2862,6 @@ describe('BridgeStatusController', () => { describe('submitTx: EVM bridge', () => { const mockEvmQuoteResponse = { - ...getMockQuote(), quote: { ...getMockQuote(), srcChainId: 42161, // Arbitrum @@ -2917,16 +2879,10 @@ describe('BridgeStatusController', () => { valueInCurrency: '2.85', usd: '0.127', }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { - amount: '1.234', + totalNetworkFee: { + amount: '.00055', valueInCurrency: null, - usd: null, - }, - gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, + usd: '2.5778', }, adjustedReturn: { valueInCurrency: null, usd: null }, swapRate: '1.234', @@ -2947,7 +2903,8 @@ describe('BridgeStatusController', () => { chainId: 42161, gasLimit: 21000, }, - } as QuoteResponse & QuoteMetadata; + }; + validateQuoteResponseV1(mockEvmQuoteResponse); const mockEvmTxMeta = { id: 'test-tx-id', @@ -3159,18 +3116,84 @@ describe('BridgeStatusController', () => { startPollingForBridgeTxStatusSpy, }) => { const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const quoteResponseV2 = toQuoteResponseV2(quoteWithoutApproval); + const quotesReceivedContext = getQuotesReceivedProperties( + quoteResponseV2, + ['low_return'], + true, + ); const result = await rootMessenger.call( 'BridgeStatusController:submitTx', (quoteWithoutApproval.trade as TxData).from, quoteWithoutApproval, true, + quotesReceivedContext, + ); + controller.stopAllPolling(); + + expect(quoteWithoutApproval.totalNetworkFee).toMatchInlineSnapshot(` + { + "amount": ".00055", + "usd": "2.5778", + "valueInCurrency": null, + } + `); + expect(quoteResponseV2.quote.feeData).toMatchInlineSnapshot(` + { + "metabridge": [ + { + "amount": "8750000000000", + "asset": { + "assetId": "eip155:42161/slip44:60", + "coinKey": "ETH", + "decimals": 18, + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "usd": undefined, + }, + ], + "network": [ + { + "amount": "550000000000000", + "asset": { + "assetId": "eip155:42161/slip44:60", + "decimals": 18, + "iconUrl": "", + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": ".00055", + "usd": "2.5778", + }, + ], + } + `); + expect( getQuotesReceivedProperties( - quoteWithoutApproval, + toQuoteResponseV2(quoteWithoutApproval), ['low_return'], true, ), - ); - controller.stopAllPolling(); + ).toMatchInlineSnapshot(` + { + "best_quote_provider": "lifi_across", + "can_submit": true, + "feature_id": "unified_swap_bridge", + "gas_included": false, + "gas_included_7702": false, + "price_impact": 0, + "provider": "lifi_across", + "quoted_time_minutes": 0.25, + "usd_balance_source": 0, + "usd_quoted_gas": 2.5778, + "usd_quoted_return": 0.134214, + "warnings": [ + "low_return", + ], + } + `); expect(result).toMatchSnapshot(); expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); @@ -3491,7 +3514,7 @@ describe('BridgeStatusController', () => { quote: { ...mockEvmQuoteResponse.quote, srcChainId: 59144 }, trade: { ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined as never, + gasLimit: null, }, }; @@ -3538,7 +3561,7 @@ describe('BridgeStatusController', () => { quote: { ...mockEvmQuoteResponse.quote, srcChainId: 8453 }, trade: { ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined as never, + gasLimit: null, }, }; @@ -3900,17 +3923,16 @@ describe('BridgeStatusController', () => { valueInCurrency: '2.85', usd: '0.127', }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + totalNetworkFee: { + amount: '.00055', + valueInCurrency: null, + usd: '2.5778', + }, totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null, }, - gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, - }, adjustedReturn: { valueInCurrency: null, usd: null }, swapRate: '1.234', cost: { valueInCurrency: null, usd: null }, @@ -3930,7 +3952,7 @@ describe('BridgeStatusController', () => { chainId: 42161, gasLimit: 21000, }, - } as QuoteResponse & QuoteMetadata; + }; const mockEvmTxMeta = { id: 'test-tx-id', @@ -4126,7 +4148,7 @@ describe('BridgeStatusController', () => { feeData: { ...mockEvmQuoteResponse.quote.feeData, txFee: { - amount: '0', + amount: '100', asset: getNativeAssetForChainId(42161), maxFeePerGas: '123', maxPriorityFeePerGas: '123', @@ -4213,7 +4235,7 @@ describe('BridgeStatusController', () => { feeData: { ...mockEvmQuoteResponse.quote.feeData, txFee: { - amount: '0', + amount: '100', asset: { address: '0x0000000000000000000000000000000000000032', symbol: 'WETH', @@ -4293,7 +4315,7 @@ describe('BridgeStatusController', () => { { ...quoteWithoutApproval, quote: { ...quoteWithoutApproval.quote, destAsset: erc20Token }, - gasFee: undefined as never, + totalNetworkFee: undefined as never, }, false, ); @@ -4341,9 +4363,8 @@ describe('BridgeStatusController', () => { feeData: { ...quoteWithoutApproval.quote.feeData, txFee: { - amount: - quoteWithoutApproval.quote.feeData.metabridge.amount, - asset: quoteWithoutApproval.quote.feeData.metabridge.asset, + amount: '100', + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, maxFeePerGas: '1395348', // Decimal string from quote maxPriorityFeePerGas: '1000001', }, @@ -4380,7 +4401,7 @@ describe('BridgeStatusController', () => { ); }); - it('should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit)', async () => { + it('should use quote txFee when gasIncluded is true and STX is off (null gasLimit)', async () => { setupEventTrackingMocks(mockMessengerCall); // Setup for single tx path - no gas estimation needed since gasIncluded=true mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); @@ -4395,6 +4416,35 @@ describe('BridgeStatusController', () => { }); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const mockQuote = { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + amount: '100', + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + trade: { + ...(quoteWithoutApproval.trade as TxData), + gasLimit: null, + }, + sentAmount: { + amount: null as never, + valueInCurrency: null, + usd: null, + }, + }; + validateQuoteResponseV1(mockQuote); + await withController( { mockMessengerCall }, async ({ @@ -4402,37 +4452,10 @@ describe('BridgeStatusController', () => { rootMessenger, startPollingForBridgeTxStatusSpy, }) => { - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; const result = await rootMessenger.call( 'BridgeStatusController:submitTx', (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - gasIncluded: true, - gasIncluded7702: false, - feeData: { - ...quoteWithoutApproval.quote.feeData, - txFee: { - amount: - quoteWithoutApproval.quote.feeData.metabridge.amount, - asset: quoteWithoutApproval.quote.feeData.metabridge.asset, - maxFeePerGas: '1395348', // Decimal string from quote - maxPriorityFeePerGas: '1000001', - }, - }, - }, - trade: { - ...(quoteWithoutApproval.trade as TxData), - gasLimit: undefined as never, - }, - sentAmount: { - amount: null as never, - valueInCurrency: null, - usd: null, - }, - }, + mockQuote, false, // isStxEnabledOnClient = FALSE (key for this test) ); controller.stopAllPolling(); @@ -4626,9 +4649,8 @@ describe('BridgeStatusController', () => { feeData: { ...quoteWithoutApproval.quote.feeData, txFee: { - amount: - quoteWithoutApproval.quote.feeData.metabridge.amount, - asset: quoteWithoutApproval.quote.feeData.metabridge.asset, + amount: '100', + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, maxFeePerGas: '1395348', maxPriorityFeePerGas: '1000001', }, @@ -4660,6 +4682,13 @@ describe('BridgeStatusController', () => { addTransactionBatchFn.mockResolvedValueOnce({ batchId: 'batchId1', }); + mockMessengerCall.mockReturnValueOnce({}); + mockMessengerCall.mockReturnValueOnce({ + transactions: [ + { ...mockApprovalTxMeta, batchId: 'batchId1' }, + { ...mockEvmTxMeta, batchId: 'batchId1' }, + ], + }); mockMessengerCall.mockReturnValueOnce({ transactions: [ { ...mockApprovalTxMeta, batchId: 'batchId1' }, @@ -4672,30 +4701,31 @@ describe('BridgeStatusController', () => { 'getAddTransactionBatchParams', ); + const mockQuote = { + ...mockEvmQuoteResponse, + quote: { + ...mockEvmQuoteResponse.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...mockEvmQuoteResponse.quote.feeData, + txFee: { + amount: '100', + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, + }, + }; + await withController( { mockMessengerCall }, async ({ controller, rootMessenger }) => { const result = await rootMessenger.call( 'BridgeStatusController:submitTx', (mockEvmQuoteResponse.trade as TxData).from, - { - ...mockEvmQuoteResponse, - quote: { - ...mockEvmQuoteResponse.quote, - gasIncluded: true, - gasIncluded7702: true, // 7702 takes precedence → batch path - feeData: { - ...mockEvmQuoteResponse.quote.feeData, - txFee: { - amount: - mockEvmQuoteResponse.quote.feeData.metabridge.amount, - asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, - maxFeePerGas: '1395348', - maxPriorityFeePerGas: '1000001', - }, - }, - }, - }, + toQuoteResponseV2(mockQuote), false, // STX off, but gasIncluded7702 = true forces batch path ); controller.stopAllPolling(); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 0295f6929f..b90f46b986 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -2,11 +2,13 @@ import type { StateMetadata } from '@metamask/base-controller'; import { QuoteMetadata, RequiredEventContextFromClient, - QuoteResponse, + QuoteResponseV1, Trade, FeatureId, BatchSellTradesResponse, InputPrimaryDenomination, + toQuoteResponseV1, + QuoteResponse, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -1314,8 +1316,10 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata) - | (QuoteResponse & QuoteMetadata)[], + | QuoteResponse + | QuoteResponse[] + | (QuoteResponseV1 & QuoteMetadata) + | (QuoteResponseV1 & QuoteMetadata)[], isStxEnabled: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.Unknown, @@ -1330,9 +1334,10 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; + quoteResponse: QuoteResponse; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1499,7 +1504,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata) | null)[]; + quoteResponses: (QuoteResponse | null)[]; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1508,6 +1513,9 @@ export class BridgeStatusController extends StaticIntervalPollingController => { + const quoteResponsesV1 = params.quoteResponses.map((quote) => + quote ? toQuoteResponseV1(quote) : null, + ); /** * Retrieve the batch sell trades from the BridgeController's state to ensure we submit * the original response data from the bridge-api @@ -1515,10 +1523,10 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata => + ): quoteResponse is QuoteResponseV1 & QuoteMetadata => quoteResponse !== null, ), params.isStxEnabled ?? false, diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index c45024a9cb..373f78dafc 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -3,7 +3,7 @@ import type { BatchSellTradesResponse, BridgeClientId, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, Trade, TxData, } from '@metamask/bridge-controller'; @@ -61,7 +61,7 @@ export type SubmitStepResult = 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' > & { historyKey: string; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; batchSellData?: BatchSellTradesResponse; quoteIds?: string[]; }; @@ -131,7 +131,7 @@ export type SubmitStrategyParams< */ isStxEnabled: boolean; messenger: BridgeStatusControllerMessenger; - quoteResponses: (QuoteResponse & QuoteMetadata)[]; + quoteResponses: (QuoteResponseV1 & QuoteMetadata)[]; /** * Set to true so hardware wallets get prompted for approval on mobile */ diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index c9bdd98f7f..5e4aaca619 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -6,9 +6,8 @@ import type { import type { ChainId, FeatureId, - Quote, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, MetaMetricsSwapsEventSource, SimulatedGasFeeLimits, TxData, @@ -77,7 +76,7 @@ export type StatusRequest = { bridge: string; // lifi, socket, squid srcChainId: ChainId; // lifi, socket, squid destChainId: ChainId; // lifi, socket, squid - quote?: Quote; // squid + quote?: QuoteResponseV1['quote']; // squid refuel?: boolean; // lifi }; @@ -120,7 +119,7 @@ export type RefuelStatusResponse = object & StatusResponse; */ export type QuoteAndTxMetadata = { type: TransactionType; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; /** * The approval or trade object from the quote response */ @@ -154,7 +153,7 @@ export type BridgeHistoryItem = { * Each quote can be retrieved from txHistory as `txHistory[quoteId]`. */ quoteIds?: string[]; - quote: Quote; + quote: QuoteResponseV1['quote']; quoteId?: string; // Optional: absent on history items persisted before this field was introduced reportedSubmittedTxHash?: string; status: StatusResponse; @@ -168,8 +167,8 @@ export type BridgeHistoryItem = { */ amountSent: QuoteMetadata['sentAmount']['amount']; amountSentInUsd?: QuoteMetadata['sentAmount']['usd']; - quotedGasInUsd?: QuoteMetadata['gasFee']['effective']['usd']; - quotedGasAmount?: QuoteMetadata['gasFee']['effective']['amount']; + quotedGasInUsd?: QuoteMetadata['totalNetworkFee']['usd']; + quotedGasAmount?: QuoteMetadata['totalNetworkFee']['amount']; quotedReturnInUsd?: QuoteMetadata['toTokenAmount']['usd']; quotedRefuelSrcAmountInUsd?: string; quotedRefuelDestAmountInUsd?: string; @@ -242,7 +241,6 @@ export type TokenAmountValuesSerialized = { }; export type QuoteMetadataSerialized = { - gasFee: TokenAmountValuesSerialized; /** * The total network fee for the bridge transaction * estimatedGasFees + relayerFees @@ -281,7 +279,7 @@ export type StartPollingForBridgeTxStatusArgs = { * @deprecated the txMeta or orderUid should be used instead */ originalTransactionId?: string; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; startTime: BridgeHistoryItem['startTime']; slippagePercentage: BridgeHistoryItem['slippagePercentage']; initialDestAssetBalance?: BridgeHistoryItem['initialDestAssetBalance']; @@ -310,7 +308,7 @@ export type StartPollingForBridgeTxStatusArgsSerialized = Omit< StartPollingForBridgeTxStatusArgs, 'quoteResponse' > & { - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; }; export type SourceChainTxMetaId = string; diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts index f97a1e6bf6..ba295d24ef 100644 --- a/packages/bridge-status-controller/src/utils/bridge-status.ts +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -3,7 +3,7 @@ import { isNonEvmChainId, StatusTypes, } from '@metamask/bridge-controller'; -import type { Quote, QuoteResponse } from '@metamask/bridge-controller'; +import type { QuoteResponseV1 } from '@metamask/bridge-controller'; import type { Provider } from '@metamask/network-controller'; import { StructError } from '@metamask/superstruct'; @@ -105,7 +105,7 @@ export const fetchBridgeTxStatus = async ( }; export const getStatusRequestWithSrcTxHash = ( - quote: Quote, + quote: QuoteResponseV1['quote'], srcTxHash: string, ): StatusRequestWithSrcTxHash => { const { bridgeId, bridges, srcChainId, destChainId, refuel } = quote; @@ -205,7 +205,7 @@ export const shouldWaitForFinalBridgeStatus = async ( * @returns The status request parameters */ export const getStatusRequestParams = ( - quoteResponse: QuoteResponse, + quoteResponse: QuoteResponseV1, ): StatusRequest => { return { bridgeId: quoteResponse.quote.bridgeId, diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 3eb541926a..6ee325e3ef 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -227,9 +227,9 @@ export const getInitialHistoryItem = ( pricingData: { amountSent: quoteResponse.sentAmount?.amount ?? '0', amountSentInUsd: quoteResponse.sentAmount?.usd ?? undefined, - quotedGasInUsd: quoteResponse.gasFee?.effective?.usd ?? undefined, + quotedGasInUsd: quoteResponse.totalNetworkFee?.usd ?? undefined, quotedReturnInUsd: quoteResponse.toTokenAmount?.usd ?? undefined, - quotedGasAmount: quoteResponse.gasFee?.effective?.amount ?? undefined, + quotedGasAmount: quoteResponse.totalNetworkFee?.amount ?? undefined, }, initialDestAssetBalance, targetContractAddress, diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index c9edb1a067..7f1ab8d87c 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -3,7 +3,7 @@ import { ChainId, getClientHeaders, Intent, - QuoteResponse, + QuoteResponseV1, StatusTypes, } from '@metamask/bridge-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; @@ -150,7 +150,7 @@ export function mapIntentOrderStatusToTransactionStatus( * @returns The intent data from the quote * @throws Error if the quote does not contain intent data */ -export function getIntentFromQuote(quoteResponse: QuoteResponse): Intent { +export function getIntentFromQuote(quoteResponse: QuoteResponseV1): Intent { const { intent } = quoteResponse.quote; if (!intent) { throw new Error('submitIntent: missing intent data'); diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 99510c4aa5..979e42400d 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -21,7 +21,7 @@ import type { AccountHardwareType, QuoteFetchData, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, TxStatusData, RequestParams, TradeData, @@ -154,11 +154,11 @@ export const getRequestParamFromHistory = ( }; export const getTradeDataFromQuote = ( - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, batchSellTrades?: BatchSellTradesResponse | null, ): TradeData => { return { - usd_quoted_gas: Number(quoteResponse.gasFee?.effective?.usd ?? 0), + usd_quoted_gas: Number(quoteResponse.totalNetworkFee?.usd ?? 0), gas_included: quoteResponse.quote.gasIncluded ?? batchSellTrades?.gasIncluded ?? false, gas_included_7702: @@ -174,7 +174,7 @@ export const getTradeDataFromQuote = ( }; export const getPriceImpactFromQuote = ( - quote: QuoteResponse['quote'], + quote: QuoteResponseV1['quote'], ): Pick => { return { price_impact: Number(quote.priceData?.priceImpact ?? '0') }; }; @@ -195,7 +195,7 @@ export const getPriceImpactFromQuote = ( * @returns The properties for the pre-confirmation event */ export const getPreConfirmationPropertiesFromQuote = ( - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, isStxEnabled: boolean, accountHardwareType: AccountHardwareType, location?: MetaMetricsSwapsEventSource, diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts index 94ce348334..a824c48602 100644 --- a/packages/bridge-status-controller/src/utils/snaps.ts +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -2,7 +2,7 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { QuoteMetadata, - QuoteResponse, + QuoteResponseV1, Trade, } from '@metamask/bridge-controller'; import { @@ -125,7 +125,7 @@ export const getClientRequest = ( }; export const getTxMetaFields = ( - quoteResponse: Omit, 'approval' | 'trade'> & + quoteResponse: Omit, 'approval' | 'trade'> & QuoteMetadata, approvalTxId?: string, ): Omit< @@ -178,7 +178,7 @@ export const handleNonEvmTxResponse = ( | { result: Record } | { signature: string }, trade: Trade, - quoteResponse: Omit, 'trade' | 'approval'> & + quoteResponse: Omit, 'trade' | 'approval'> & QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ): TransactionMeta & SolanaTransactionMeta => { @@ -259,7 +259,7 @@ export const handleNonEvmTxResponse = ( export const handleNonEvmTx = async ( messenger: BridgeStatusControllerMessenger, trade: Trade, - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ): Promise => { if (!selectedAccount.metadata?.snap?.id) { diff --git a/packages/bridge-status-controller/src/utils/trace.ts b/packages/bridge-status-controller/src/utils/trace.ts index 72098788d1..c7e8f49185 100644 --- a/packages/bridge-status-controller/src/utils/trace.ts +++ b/packages/bridge-status-controller/src/utils/trace.ts @@ -2,13 +2,13 @@ import { formatChainIdToCaip, isCrossChain, - QuoteResponse, + QuoteResponseV1, } from '@metamask/bridge-controller'; import { TraceName } from '../constants'; export const getTraceParams = ( - quoteResponse: QuoteResponse, + quoteResponse: QuoteResponseV1, isStxEnabled: boolean, ) => { return { @@ -26,7 +26,7 @@ export const getTraceParams = ( }; export const getApprovalTraceParams = ( - quoteResponse: QuoteResponse, + quoteResponse: QuoteResponseV1, isStxEnabled: boolean, ) => { return { diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index 1cd257935e..d35fa6c444 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -8,7 +8,7 @@ import { } from '@metamask/bridge-controller'; import type { QuoteMetadata, - QuoteResponse, + QuoteResponseV1, TxData, } from '@metamask/bridge-controller'; import { @@ -121,7 +121,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('getStatusRequestParams', () => { it('should extract status request parameters from a quote response', () => { - const mockQuoteResponse: QuoteResponse = { + const mockQuoteResponse: QuoteResponseV1 = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -167,7 +167,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle quote with refuel flag set to true', () => { - const mockQuoteResponse: QuoteResponse = { + const mockQuoteResponse: QuoteResponseV1 = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -209,7 +209,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle quote with multiple bridges', () => { - const mockQuoteResponse: QuoteResponse = { + const mockQuoteResponse: QuoteResponseV1 = { quote: { bridgeId: 'bridge1', bridges: ['bridge1', 'bridge2'], @@ -253,7 +253,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('getTxMetaFields', () => { it('should extract transaction meta fields from a quote response', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -346,7 +346,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should include approvalTxId when provided', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -522,7 +522,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('handleNonEvmTxResponse', () => { it('should handle string response format', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -633,7 +633,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with signature', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -721,7 +721,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle onClientRequest response format with signature', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -808,7 +808,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with txid', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -896,7 +896,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with hash', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -984,7 +984,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with txHash', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -1072,7 +1072,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle new unified interface response with transactionId', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -1168,7 +1168,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle empty or invalid response', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -1371,10 +1371,10 @@ describe('Bridge Status Controller Transaction Utils', () => { steps: [], feeData: {}, }, - // Required properties for QuoteResponse + // Required properties for QuoteResponseV1 trade: {} as TxData, estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponse; + } as unknown as QuoteResponseV1; // Create a promise that will resolve after the delay const delayPromise = handleApprovalDelay( @@ -1411,10 +1411,10 @@ describe('Bridge Status Controller Transaction Utils', () => { steps: [], feeData: {}, }, - // Required properties for QuoteResponse + // Required properties for QuoteResponseV1 trade: {} as TxData, estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponse; + } as unknown as QuoteResponseV1; // Create a promise that will resolve after the delay const delayPromise = handleApprovalDelay( @@ -1451,10 +1451,10 @@ describe('Bridge Status Controller Transaction Utils', () => { steps: [], feeData: {}, }, - // Required properties for QuoteResponse + // Required properties for QuoteResponseV1 trade: {} as TxData, estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponse; + } as unknown as QuoteResponseV1; // Create a promise that will resolve after the delay const delayPromise = handleApprovalDelay( @@ -1822,7 +1822,7 @@ describe('Bridge Status Controller Transaction Utils', () => { includeApproval?: boolean; includeResetApproval?: boolean; } = {}, - ): QuoteResponse & QuoteMetadata => + ): QuoteResponseV1 & QuoteMetadata => ({ quote: { bridgeId: 'bridge1', diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 6942ee261d..b2a36bcfe0 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -10,7 +10,7 @@ import { import type { BatchSellTradesResponse, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, SimulatedGasFeeLimits, Trade, TxData, @@ -215,7 +215,7 @@ export const addSyntheticTransaction = async ( }; export const handleApprovalDelay = async ( - srcChainId: QuoteResponse['quote']['srcChainId'], + srcChainId: QuoteResponseV1['quote']['srcChainId'], ) => { if ([ChainId.LINEA, ChainId.BASE].includes(srcChainId)) { const debugLog = createProjectLogger('bridge'); @@ -295,7 +295,7 @@ export const toQuoteAndTxMetadata = ({ quoteResponse, isBridgeTx, }: { - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; isBridgeTx: boolean; }): Omit[] => { const tradeData: QuoteAndTxMetadata[] = []; @@ -347,7 +347,7 @@ export const toQuoteAndTxMetadataBatch = ({ quoteResponses, batchSellTrades, }: { - quoteResponses: (QuoteResponse & QuoteMetadata)[]; + quoteResponses: (QuoteResponseV1 & QuoteMetadata)[]; batchSellTrades: BatchSellTradesResponse; }): Omit[] => { const tradeData: QuoteAndTxMetadata[] = [];