diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 10264f4bb25..f25389837c1 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -25,6 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes +- **BREAKING:** Use QuoteResponse V2 within the BridgeController; this affects the batch-sell, unified swap/bridge and quickBuy experiences ([#9085](https://github.com/MetaMask/core/pull/9085)) + - convert quotes to QuoteResponse v2 in `fetchBridgeQuoteStream` + - store quotes as QuoteResponse v2 in the BridgeController + - `QuoteResponse` export now means v2; v1 is still exported as `QuoteResponseV1` + - `fetchBridgeQuoteStream` and `fetchBatchSellTrades` now return `QuoteResponse` v2 + - `fetchBatchSellTrades` expects V2 quotes, then transforms them to V1 for backend compatibility + - Bump `@metamask/assets-controllers` from `^110.0.0` to `^110.0.1` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706)) - Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) - Bump `@metamask/assets-controller` from `^11.2.1` to `^11.3.1` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706)) 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 db9eb7e632a..7fcfe0b4439 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 b67e8b87a17..0eef36e57a5 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.js'; -import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; import { advanceToNthTimerThenFlush, mockSseBatchSellEventSource, @@ -419,7 +425,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20V1 + quotes: getMockBridgeQuotesNativeErc20V2() .map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -428,13 +434,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, @@ -642,7 +650,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); await rootMessenger.call( 'BridgeController:updateBatchSellTrades', - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, ); @@ -682,7 +690,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', @@ -879,7 +887,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () // 2nd fetch await rootMessenger.call( 'BridgeController:updateBatchSellTrades', - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, ); @@ -889,7 +897,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 56a789fd944..f0ebc848edd 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -8,12 +8,20 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; import { abiERC20 } from '@metamask/metamask-eth-abis'; -import { merge } from 'lodash'; import { flushPromises } from '../../../tests/helpers.js'; -import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; -import { mockBridgeQuotesNativeErc20EthV1 } from '../tests/mock-quotes-native-erc20-eth.js'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; +import { + mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2, +} from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20EthV2, + mockBridgeQuotesNativeErc20EthV1, +} from '../tests/mock-quotes-native-erc20-eth.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; import { advanceToNthTimer, advanceToNthTimerThenFlush, @@ -322,7 +330,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', resetApproval: undefined, @@ -358,7 +366,7 @@ describe('BridgeController SSE', function () { ], ['swapping', '0', '0x1', undefined, false, 1], ])( - 'should append resetApproval when %s USDT on Ethereum', + 'should append resetApproval when %s USDT on Ethereum (%s, %s)', async function ( _: string, allowance: string, @@ -377,31 +385,21 @@ describe('BridgeController SSE', function () { fetchBridgeQuotesSpy, consoleLogSpy, }) => { - const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20V1.map( - (quote) => ({ - ...quote, - quote: { - ...quote.quote, - srcAsset: { - address: ETH_USDT_ADDRESS, - assetId: - `${formatChainIdToCaip(1)}/erc20:${srcTokenAddress}` as const, - symbol: 'USDT', - name: 'Tether USD', - decimals: 6, - chainId: 1, - iconUrl: 'https://media.socket.tech/tokens/all/USDT', - }, - srcChainId: 1, - destChainId: formatChainIdToDec(destChainId), - destAsset: { - ...quote.quote.destAsset, - assetId: - `${formatChainIdToCaip(destChainId)}/erc20:${quote.quote.destAsset.address}` as const, - }, + const mockUSDTQuoteResponse = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: + `${formatChainIdToCaip(1)}/erc20:${srcTokenAddress}` as const, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', }, - }), - ); + srcChainId: 1, + }, + }); mockFetchFn.mockImplementationOnce(async () => { return mockSseEventSource(mockUSDTQuoteResponse); }); @@ -507,39 +505,30 @@ describe('BridgeController SSE', function () { resetApproval, }, ], - quotes: mockBridgeQuotesErc20Erc20V1 - .map((quote) => - merge({}, quote, { - quote: { - srcAsset: { - address: ETH_USDT_ADDRESS, - assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, - symbol: 'USDT', - name: 'Tether USD', - decimals: 6, - chainId: 1, - iconUrl: 'https://media.socket.tech/tokens/all/USDT', - }, - srcChainId: 1, - destAsset: { - ...quote.quote.destAsset, - assetId: - `${formatChainIdToCaip(destChainId)}/erc20:${quote.quote.destAsset.address}` as const, - }, - destChainId: formatChainIdToDec(destChainId), - }, - }), - ) - .map((quote) => ({ - ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, - resetApproval: tradeData - ? { - ...quote.approval, - data: tradeData, - } - : undefined, - })), + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, + srcChainId: 1, + destChainId: formatChainIdToDec(destChainId), + }, + }).map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: tradeData + ? { + ...quote.approval, + data: tradeData, + } + : undefined, + })), quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, @@ -701,31 +690,27 @@ describe('BridgeController SSE', function () { resetApproval: true, }, ], - quotes: mockBridgeQuotesErc20Erc20V1 - .map((quote) => - merge({}, quote, { - quote: { - srcAsset: { - address: ETH_USDT_ADDRESS, - assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, - name: 'Tether USD', - decimals: 6, - symbol: 'USDT', - chainId: 1, - iconUrl: 'https://media.socket.tech/tokens/all/USDT', - }, - srcChainId: 1, - }, - }), - ) - .map((quote) => ({ - ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, - resetApproval: { - ...quote.approval, - data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + name: 'Tether USD', + decimals: 6, + symbol: 'USDT', + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', }, - })), + srcChainId: 1, + }, + }).map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: { + ...quote.approval, + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + }, + })), quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, @@ -773,7 +758,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, @@ -798,7 +783,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, @@ -825,7 +810,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, @@ -895,7 +880,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, @@ -928,13 +913,13 @@ describe('BridgeController SSE', function () { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ).toBeGreaterThan(t2!); expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Failed to stream bridge quotes", - "Network error", - ], - ] - `); + [ + [ + "Failed to stream bridge quotes", + "Network error", + ], + ] + `); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); @@ -1067,7 +1052,7 @@ describe('BridgeController SSE', function () { quotesInitialLoadTime: THIRD_FETCH_DELAY, quotes: [ { - ...mockBridgeQuotesNativeErc20V1[0], + ...getMockBridgeQuotesNativeErc20V2()[0], l1GasFeesInHexWei: '0x1', resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1102,8 +1087,8 @@ describe('BridgeController SSE', function () { quotesRefreshCount: 1, quotesLoadingStatus: RequestStatus.FETCHED, quotes: [ - ...mockBridgeQuotesNativeErc20V1, - ...mockBridgeQuotesNativeErc20V1, + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), ].map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -1250,8 +1235,8 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(3); expect(bridgeController.state.quotes).toStrictEqual( [ - ...mockBridgeQuotesNativeErc20V1, - ...mockBridgeQuotesNativeErc20V1, + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), ].map((quote) => ({ ...quote, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1281,7 +1266,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, @@ -1310,20 +1295,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 03bc146e07c..f573c7dc2f5 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -1,5 +1,6 @@ /* eslint-disable jest/no-restricted-matchers */ import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { handleFetch } from '@metamask/controller-utils'; import { BtcScope, EthAccountType, @@ -17,11 +18,16 @@ import type { CaipAssetType } from '@metamask/utils'; import nock from 'nock'; import { flushPromises } from '../../../tests/helpers.js'; -import { handleFetch } from '../../controller-utils/src/index.js'; import { mockBridgeQuotesErc20NativeV1 } from '../tests/mock-quotes-erc20-native.js'; -import { mockBridgeQuotesNativeErc20EthV1 } from '../tests/mock-quotes-native-erc20-eth.js'; +import { + mockBridgeQuotesNativeErc20EthV1, + getMockBridgeQuotesNativeErc20EthV2, +} from '../tests/mock-quotes-native-erc20-eth.js'; import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; -import { mockBridgeQuotesSolErc20V1 } from '../tests/mock-quotes-sol-erc20.js'; +import { + getMockBridgeQuotesSolErc20V2, + mockBridgeQuotesSolErc20V1, +} from '../tests/mock-quotes-sol-erc20.js'; import { advanceToNthTimerThenFlush } from '../tests/mock-sse.js'; import { BridgeController } from './bridge-controller.js'; import { @@ -724,7 +730,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, }), ); @@ -747,8 +753,8 @@ describe('BridgeController', function () { }, ], quotes: [ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ], quotesLoadingStatus: 1, quoteFetchError: null, @@ -824,8 +830,8 @@ describe('BridgeController', function () { expect(stateWithoutTimestamp).toMatchSnapshot(); expect(quotes).toStrictEqual([ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ]); expect( quotesLastFetched, @@ -1006,7 +1012,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1083,7 +1089,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1132,7 +1138,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '0', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1328,7 +1334,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1370,7 +1376,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1512,7 +1518,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -2101,7 +2107,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); @@ -2113,7 +2119,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); @@ -2419,9 +2425,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, ); }); @@ -2667,11 +2671,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'), ); }, @@ -3579,7 +3583,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3628,7 +3632,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3901,12 +3905,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, @@ -4018,30 +4022,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", - ], - ] - `); + [ + [ + { + "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); }, @@ -4172,7 +4176,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 18937f3a950..ce3dcbb4be4 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.js'; import type { BridgeClientId } from './constants/bridge.js'; import { BRIDGE_CONTROLLER_NAME, @@ -91,6 +92,7 @@ import { isValidBatchSellQuoteRequest, } from './validators/quote-request.js'; import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; const metadata: StateMetadata = { quoteRequest: { @@ -427,13 +429,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); }; @@ -447,7 +457,7 @@ export class BridgeController extends StaticIntervalPollingController => { this.#batchSellTradesAbortController?.abort( @@ -869,7 +879,7 @@ export class BridgeController extends StaticIntervalPollingController this.#trackQuoteValidationFailures(validationFailures, featureId), - onValidQuoteReceived: async (quote: QuoteResponseV1) => { + onValidQuoteReceived: async (quote: QuoteResponse) => { const feeAppendPromise = (async () => { const quotesWithFees = await appendFeesToQuotes( + quote.chainId, [quote], this.messenger, this.#getLayer1GasFee, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 3d819ea12de..41dc433bc28 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -74,6 +74,7 @@ export { export { validateQuoteResponseV1, QuoteResponseSchemaV1, + type QuoteResponseV1, } from './validators/quote-response-v1.js'; export { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; @@ -113,11 +114,10 @@ export { isEvmTxData, isStellarTrade, } from './validators/trade.js'; -export type { - QuoteResponseV1 as QuoteResponse, - QuoteResponseV1, -} from './validators/quote-response-v1.js'; -export { validateQuoteResponse } from './validators/quote-response.js'; +export { + validateQuoteResponse, + type QuoteResponse, +} from './validators/quote-response.js'; export type { Quote } from './validators/quote.js'; export { FeeType, DiscountType } from './validators/quote.js'; export { ActionTypes } from './validators/step.js'; @@ -127,11 +127,14 @@ export { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; export { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; export { toQuoteMetadataV2 } from './utils/quote-metadata/to-quote-metadata-v2.js'; +export { sumAmounts } from './utils/number-formatters.js'; + export { validateQuoteStreamComplete, QuoteStreamCompleteReason, } from './validators/quote-stream-complete.js'; export { BatchSellTransactionType } from './validators/batch-sell.js'; +export { type AmountsAndAssetSchema } from './validators/amount-and-asset.js'; export { TokenFeatureType } from './validators/token-feature.js'; export type { BridgeAsset, @@ -147,6 +150,7 @@ export { BridgeAssetSecurityDataType, } from './validators/bridge-asset.js'; export { FeatureId } from './validators/feature-flags.js'; +export { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2.js'; export { ALLOWED_BRIDGE_CHAIN_IDS, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 772f3f79778..b75e34b3b7d 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -12,9 +12,11 @@ import { merge } from 'lodash'; import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; import { - getMockBridgeQuotesNativeErc20V1, mockBridgeQuotesNativeErc20V1, + getMockBridgeQuotesNativeErc20V2, } from '../tests/mock-quotes-native-erc20.js'; +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; +import { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2.js'; import { DEFAULT_CHAIN_RANKING, ETH_USDT_ADDRESS } from './constants/bridge.js'; import type { BridgeAppState } from './selectors.js'; import { @@ -43,9 +45,8 @@ import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; import { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; import { BatchSellTransactionType } from './validators/batch-sell.js'; -import type { BridgeAsset } from './validators/bridge-asset.js'; +import type { BridgeAssetV2 } from './validators/bridge-asset.js'; import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; -import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; import type { QuoteResponse } from './validators/quote-response.js'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; @@ -612,16 +613,18 @@ describe('Bridge Selectors', () => { destTokenAmount: '2100000000000000000', }, }, - ].map((quote) => ({ - ...merge({}, quote, quoteOverrides), - quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), - })); + ] + .map(toQuoteResponseV2) + .map((quote) => ({ + ...merge({}, quote, quoteOverrides), + quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), + })); const srcChainId = parseCaipAssetType( - mockQuoteV2[0].quote.srcAsset.assetId, + mockQuoteV2[0].quote.src.asset.assetId, ).chainId; const destChainId = parseCaipAssetType( - mockQuoteV2[0].quote.destAsset.assetId, + mockQuoteV2[0].quote.dest.asset.assetId, ).chainId; return { @@ -630,12 +633,8 @@ describe('Bridge Selectors', () => { { srcChainId: srcChainId ?? decChainId, destChainId: destChainId ?? 137, - srcTokenAddress: - mockQuoteV2[0].quote.srcAsset.assetId ?? - '0x0000000000000000000000000000000000000000', - destTokenAddress: - mockQuoteV2[0].quote.destAsset.assetId ?? - '0x0000000000000000000000000000000000000000', + srcTokenAddress: mockQuoteV2[0].quote.src.asset.assetId, + destTokenAddress: mockQuoteV2[0].quote.dest.asset.assetId, insufficientBal: false, }, ], @@ -700,12 +699,36 @@ describe('Bridge Selectors', () => { selectBridgeQuotes( { ...mockState, + quotes: mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + usd: '0.01514', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })), assetExchangeRates: { - [mockQuote.quote.srcAsset.assetId]: { + [mockQuote.quote.src.asset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [mockQuote.quote.destAsset.assetId]: { + [mockQuote.quote.dest.asset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -724,12 +747,29 @@ describe('Bridge Selectors', () => { nativeExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, }); const expectedQuoteV2 = mergeQuoteMetadata(quote, expectedQuoteMetadata); + expect( + expectedQuoteV2?.quote?.priceData?.priceImpact?.usd, + ).toMatchInlineSnapshot(`"8.9"`); expect(result.sortedQuotes[0]).toStrictEqual(expectedQuoteV2); expect(result.recommendedQuote?.priceImpact?.valueInCurrency).toBe( expectedQuoteV2.priceImpact?.valueInCurrency, ); + expect(result.recommendedQuote?.quote.priceData?.priceImpact) + .toMatchInlineSnapshot(` + { + "usd": "8.9", + "valueInCurrency": "1758", + } + `); + expect(result.recommendedQuote?.priceImpact).toMatchInlineSnapshot(` + { + "usd": "8.9", + "valueInCurrency": "1758", + } + `); + expect(toQuoteMetadataV1(result.recommendedQuote)).toStrictEqual( expectedQuoteMetadata, ); @@ -747,11 +787,11 @@ describe('Bridge Selectors', () => { ...mockState, quotes: [], assetExchangeRates: { - [mockQuote.quote.srcAsset.assetId]: { + [mockQuote.quote.src.asset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [mockQuote.quote.destAsset.assetId]: { + [mockQuote.quote.dest.asset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -820,10 +860,10 @@ describe('Bridge Selectors', () => { expect( result.sortedQuotes[0].quote.priceData?.priceImpact, ).toBeUndefined(); - expect(result.recommendedQuote?.quote.destTokenAmount).toBe( + expect(result.recommendedQuote?.quote.dest.amount).toBe( '2100000000000000000', ); - expect(result.recommendedQuote?.toTokenAmount?.amount).toBe('2.1'); + expect(result.recommendedQuote?.quote.dest.normalizedAmount).toBe('2.1'); }); it('should use priceImpact to sort quotes if exchange rate is not available', () => { @@ -833,14 +873,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' } }, }, }, ]; @@ -888,22 +928,24 @@ describe('Bridge Selectors', () => { const expectedQuoteV2 = quotesWithPriceImpact[1]; - expect(result.sortedQuotes[0].cost?.valueInCurrency).toBeUndefined(); + expect( + result.sortedQuotes[0].quote.priceData?.priceImpact?.valueInCurrency, + ).toBeUndefined(); expect(result.recommendedQuote).toStrictEqual( mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), ); - expect(result.recommendedQuote?.quote.priceData?.priceImpact).toBe( - '-0.02', - ); + expect( + result.recommendedQuote?.quote.priceData?.priceImpact?.amount, + ).toBe('-0.02'); }); describe('returns swap metadata', () => { const getMockSwapState = ( - srcAsset: Omit, - destAsset: Omit, + srcAsset: Omit, + destAsset: Omit, txFee?: { amount: string; - asset: Omit; + asset: Omit; }, gasIncluded7702?: boolean, gasEstimatesChainId?: number, @@ -916,7 +958,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, @@ -1050,7 +1092,7 @@ describe('Bridge Selectors', () => { return { ...mockState, - quotes: [quoteResponse as QuoteResponseV1], + quotes: [toQuoteResponseV2(quoteResponse)], currencyRates, marketData, quoteRequest: [ @@ -1390,14 +1432,23 @@ describe('Bridge Selectors', () => { ...quote, quote: { ...quote.quote, + priceData: { + priceImpact: { + usd: '1935.36', + }, + swapRate: '1', + }, feeData: { ...quote.quote.feeData, - txFee: { - amount: `${(3 + index) * 1000000}`, - asset: quote.quote.srcAsset, - maxFeePerGas: '1000000000000000000', - maxPriorityFeePerGas: '1000000000000000000', - }, + txFee: [ + { + amount: `${(3 + index) * 1000000}`, + asset: quote.quote.src.asset, + usd: '1935.36', + maxFeePerGas: '1000000000000000000', + maxPriorityFeePerGas: '1000000000000000000', + }, + ], }, }, })), @@ -1423,20 +1474,25 @@ describe('Bridge Selectors', () => { }, }); - expect(sortedQuotes[0].quote.feeData.txFee).toStrictEqual({ + expect(sortedQuotes[0].quote.feeData.txFee?.[0]).toStrictEqual({ amount: '3000000', asset: { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, decimals: 6, name: 'USD Coin', symbol: 'USDC', }, maxFeePerGas: '1000000000000000000', maxPriorityFeePerGas: '1000000000000000000', + normalizedAmount: '3', + usd: '1935.36', + valueInCurrency: '1655.94', }); + expect(sortedQuotes[0].quote.src?.normalizedAmount).toBe('3.018117'); + expect(sortedQuotes[0].quote.feeData.txFee?.[0].normalizedAmount).toBe( + '3', + ); expect(sortedQuotes[0].sentAmount?.amount).toBe('3.018117'); const expectedQuoteV2 = mergeQuoteMetadata( newState.quotes[0], @@ -1711,28 +1767,56 @@ describe('Bridge Selectors', () => { const solanaState = getMockState( ChainId.SOLANA, { + namespace: KnownCaipNamespace.Solana, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 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', + }, + }, + feeData: { + metabridge: [ + { + amount: '3000', + asset: toBridgeAssetV2( + getNativeAssetForChainId(ChainId.SOLANA), + ), + usd: '999', + }, + ], + network: [ + { + amount: '3000', + asset: toBridgeAssetV2( + getNativeAssetForChainId(ChainId.SOLANA), + ), + usd: '999', + }, + ], + }, + priceData: { + priceImpact: { + usd: '999', + valueInCurrency: '999', + }, + swapRate: '0.9', }, - } as never, + }, }, { assetExchangeRates: { @@ -1756,7 +1840,7 @@ describe('Bridge Selectors', () => { ); const solanaQuote = solanaState.quotes[1]; - expect(solanaQuote.quote.destTokenAmount).toBe('2100000000000000000'); + expect(solanaQuote.quote.dest.amount).toBe('2100000000000000000'); const expectedQuoteMetadata = calcQuoteMetadata(solanaQuote, { srcTokenExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, @@ -1774,7 +1858,7 @@ describe('Bridge Selectors', () => { solanaQuote, expectedQuoteMetadata, ); - expect(expectedQuoteV2?.toTokenAmount?.amount).toBe('2.1'); + expect(expectedQuoteV2?.quote.dest.amount).toBe('2100000000000000000'); const result = selectBridgeQuotes(solanaState, mockClientParams); expect(result.sortedQuotes).toHaveLength(2); @@ -1794,7 +1878,7 @@ describe('Bridge Selectors', () => { ...quote, quoteRequestIndex: 0, })), - ], + ].map(toQuoteResponseV2), quoteRequest: [ { srcChainId: '10', @@ -1882,16 +1966,36 @@ describe('Bridge Selectors', () => { expect(totalReceived).toMatchInlineSnapshot(` { - "amount": "38.423182", - "usd": "38.423182", - "valueInCurrency": "7684.6364", + "amount": "38240503", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "minAmount": "37460000", + "minAmountNormalized": "37.46", + "minAmountUsd": "37.46", + "minAmountValueInCurrency": "7492", + "normalizedAmount": "38.240503", + "usd": "38.240503", + "valueInCurrency": "7648.1006", } `); expect(minimumReceived).toMatchInlineSnapshot(` { - "amount": "37.6", - "usd": "37.6", - "valueInCurrency": "7520", + "amount": "37460000", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "normalizedAmount": "37.46", + "usd": "37.46", + "valueInCurrency": "7492", } `); expect(rest).toMatchInlineSnapshot(` @@ -1905,20 +2009,36 @@ describe('Bridge Selectors', () => { expect(recommendedQuotes.map((quote) => quote?.quote.requestId)) .toMatchInlineSnapshot(` [ - "381c23bc-e3e4-48fe-bc53-257471e388ad", + "4277a368-40d7-4e82-aa67-74f29dc5f98a", "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", ] `); - expect(recommendedQuotes.map((quote) => quote?.sentAmount)) + expect(recommendedQuotes.map((quote) => quote?.quote.src)) .toMatchInlineSnapshot(` [ { - "amount": "0.01", + "amount": "10000000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "iconUrl": "https://media.socket.tech/tokens/all/ETH", + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "0.01", "usd": "0.1", "valueInCurrency": "18", }, { - "amount": "14", + "amount": "14000000", + "asset": { + "assetId": "eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "USD Coin", + "symbol": "USDC", + }, + "normalizedAmount": "14", "usd": "140", "valueInCurrency": "27720", }, @@ -1949,20 +2069,8 @@ describe('Bridge Selectors', () => { const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = result; - expect(totalReceived).toMatchInlineSnapshot(` - { - "amount": "0", - "usd": "0", - "valueInCurrency": "0", - } - `); - expect(minimumReceived).toMatchInlineSnapshot(` - { - "amount": "0", - "usd": "0", - "valueInCurrency": "0", - } - `); + expect(totalReceived).toBeUndefined(); + expect(minimumReceived).toBeUndefined(); expect(rest).toMatchInlineSnapshot(` { "isLoading": false, @@ -1979,7 +2087,7 @@ describe('Bridge Selectors', () => { const { recommendedQuotes } = selectBatchSellQuotes( { ...mockState, - quotes: getMockBridgeQuotesNativeErc20V1().map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, quoteRequestIndex: undefined, })), diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 5ba66309cf8..55f4a07f5f4 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -19,7 +19,11 @@ import { } from 'reselect'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from './constants/bridge.js'; -import type { BridgeControllerState, ExchangeRate } from './types.js'; +import type { + BridgeControllerState, + DeepPartial, + ExchangeRate, +} from './types.js'; import { RequestStatus, SortOrder } from './types.js'; import { getNativeAssetForChainId, @@ -33,15 +37,13 @@ import { formatChainIdToHex, } from './utils/caip-formatters.js'; import { processFeatureFlags } from './utils/feature-flags.js'; +import { sumAmounts } from './utils/number-formatters.js'; import { calcBatchFees } from './utils/quote-metadata/calculators.js'; import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; -import type { - QuoteMetadata, - TokenAmountValues, -} from './utils/quote-metadata/types.js'; +import type { QuoteMetadata } from './utils/quote-metadata/types.js'; import { getDefaultSlippagePercentage } from './utils/slippage.js'; -import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; /** * The controller states that provide exchange rates @@ -95,7 +97,7 @@ const createBridgeSelector = createSelector_.withTypes(); */ type BridgeQuotesClientParams = { sortOrder: SortOrder; - selectedQuote: (QuoteResponseV1 & QuoteMetadata) | null; + selectedQuote: (QuoteResponse & QuoteMetadata) | null; }; type EvmTokenExchangeRate = { price?: number; currency?: string }; @@ -292,7 +294,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) { @@ -340,20 +342,20 @@ const selectMetadata = createBridgeSelector( calcQuoteMetadata(quote, { srcTokenExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - quote.quote.srcAsset.assetId, + quote.quote.src.asset.assetId, ), bridgeFeesPerGas, destTokenExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - formatAddressToAssetId( - destTokenAddress ?? quote.quote.destAsset.assetId, - destChainId, - ), + quote.quote.dest.asset.assetId ?? + formatAddressToAssetId( + destTokenAddress ?? quote.quote.dest.asset.assetId, + destChainId, + ), ), nativeExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - getNativeAssetForChainId(srcChainId ?? quote.quote.srcChainId) - ?.assetId, + getNativeAssetForChainId(srcChainId ?? quote.chainId)?.assetId, ), }), ); @@ -374,7 +376,7 @@ const selectSortedBridgeQuotes = createBridgeSelector( selectBridgeQuotesWithMetadata, (_, { sortOrder }: BridgeQuotesClientParams) => sortOrder, ], - (quotesWithMetadata, sortOrder): (QuoteResponseV1 & QuoteMetadata)[] => { + (quotesWithMetadata, sortOrder): (QuoteResponse & QuoteMetadata)[] => { switch (sortOrder) { case SortOrder.ETA_ASC: return orderBy( @@ -383,27 +385,32 @@ const selectSortedBridgeQuotes = createBridgeSelector( 'asc', ); default: - if (quotesWithMetadata.every((quote) => quote?.cost?.valueInCurrency)) { + if ( + quotesWithMetadata.every( + (quote) => quote.quote.priceData?.priceImpact?.amount, + ) + ) { return orderBy( quotesWithMetadata, - ({ cost }) => Number(cost?.valueInCurrency), + ({ quote: { priceData } }) => + Number(priceData?.priceImpact?.amount), 'asc', ); - } - if ( + } else if ( quotesWithMetadata.every( - (quote) => quote.quote.priceData?.priceImpact, + (quote) => quote.quote.priceData?.priceImpact?.valueInCurrency, ) ) { return orderBy( quotesWithMetadata, - ({ quote }) => Number(quote.priceData?.priceImpact), + ({ quote: { priceData } }) => + Number(priceData?.priceImpact?.valueInCurrency), 'asc', ); } return orderBy( quotesWithMetadata, - ({ quote }) => Number(quote.destTokenAmount), + ({ quote }) => Number(quote.dest.amount), 'desc', ); } @@ -419,12 +426,11 @@ const selectActiveQuote = createBridgeSelector( [ selectRecommendedQuote, selectSortedBridgeQuotes, - (_, { selectedQuote }) => selectedQuote, + (_, { selectedQuote }) => selectedQuote?.quote.requestId, ], - (recommendedQuote, sortedQuotes, selectedQuote) => - sortedQuotes.find( - (quote) => quote.quote.requestId === selectedQuote?.quote.requestId, - ) ?? recommendedQuote, + (recommendedQuote, sortedQuotes, requestId) => + sortedQuotes.find((quote) => quote.quote.requestId === requestId) ?? + recommendedQuote, ); const selectIsQuoteGoingToRefresh = createBridgeSelector( @@ -505,35 +511,39 @@ 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, - ( - _, - { - key, - }: { key: 'totalNetworkFee' | 'minToTokenAmount' | 'toTokenAmount' }, - ) => key, - ], - (recommendedQuotes, key) => - recommendedQuotes.reduce( - (acc, quote) => { - acc.usd = new BigNumber(acc.usd) - .plus(quote?.[key]?.usd ?? 0) - .toString(); - acc.valueInCurrency = new BigNumber(acc.valueInCurrency) - .plus(quote?.[key]?.valueInCurrency ?? 0) - .toString(); - acc.amount = new BigNumber(acc.amount) - .plus(quote?.[key]?.amount ?? 0) - .toString(); - return acc; - }, - { usd: '0', valueInCurrency: '0', amount: '0' }, - ), +const selectDestAmountSum = createBridgeSelector( + [selectRecommendedQuotes], + (recommendedQuotes) => { + return sumAmounts(recommendedQuotes.map((quote) => quote?.quote.dest)); + }, +); + +const selectMinDestAmountSum = createBridgeSelector( + [selectDestAmountSum], + (destAmountSum): DeepPartial | undefined => { + if (!destAmountSum) { + return undefined; + } + + const { + minAmount, + minAmountNormalized, + minAmountValueInCurrency, + minAmountUsd, + asset, + } = destAmountSum; + + return { + amount: minAmount, + normalizedAmount: minAmountNormalized, + valueInCurrency: minAmountValueInCurrency, + usd: minAmountUsd, + asset, + }; + }, ); /** @@ -558,10 +568,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 23e1421c4b8..cb44f9615b0 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -35,6 +35,7 @@ import type { } from './validators/feature-flags.js'; import type { IntentSchema } from './validators/intent.js'; import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete.js'; import type { TxFeeGasLimitsSchema } from './validators/quote.js'; import type { FeeDataSchema } from './validators/quote.js'; @@ -260,7 +261,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/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 0de0ac25c38..c0f087dc8be 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.js'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../../tests/mock-quotes-erc20-erc20.js'; import { mockBridgeQuotesNativeErc20V1 } from '../../tests/mock-quotes-native-erc20.js'; +import { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2.js'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL, @@ -758,7 +762,10 @@ describe('fetch', () => { signal, method: 'POST', body: JSON.stringify( - formatBatchSellTradesRequest(mockBridgeQuotesErc20Erc20V1, stxEnabled), + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20V1.map(toQuoteResponseV2), + stxEnabled, + ), ), }); @@ -782,7 +789,7 @@ describe('fetch', () => { const { signal } = new AbortController(); const result = await fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -809,7 +816,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -875,7 +882,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - [...mockBridgeQuotesErc20Erc20V1, null], + [...getMockBridgeQuotesErc20Erc20V2(), null], false, signal, BridgeClientId.EXTENSION, @@ -889,7 +896,7 @@ describe('fetch', () => { const result = await Promise.allSettled( Array.from({ length: 3 }, () => fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -960,7 +967,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 9fca0814ef5..bf26dacc33c 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.js'; +import { toQuoteResponseV1 } from '../coercers/quote-response-v2-to-v1.js'; import type { FetchFunction, GenericQuoteRequest, @@ -17,6 +20,7 @@ import { validateBridgeAsset } from '../validators/bridge-asset.js'; import type { FeatureId } from '../validators/feature-flags.js'; import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; import { validateQuoteStreamComplete } from '../validators/quote-stream-complete.js'; import { validateTokenFeature } from '../validators/token-feature.js'; import { isEvmTxData } from '../validators/trade.js'; @@ -27,6 +31,7 @@ import { formatChainIdToDec, } from './caip-formatters.js'; import { fetchServerEvents } from './fetch-server-events.js'; +import type { QuoteMetadata } from './quote-metadata/types.js'; import { formatStructErrors } from './struct-error.js'; export const getClientHeaders = ({ @@ -310,13 +315,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 +353,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; }, @@ -368,45 +375,47 @@ export async function fetchBridgeQuoteStream( 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, - }), - }); - } + // Always coerce to QuoteResponseV2 + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + // Fallback to 0 if the quote doesn't match any requests + const matchedQuoteRequestIdx = Math.max( + quoteRequestIds?.findIndex((id) => { + return id === getQuoteResponseId(quoteResponseV2.quote); + }) ?? 0, + 0, + ); + const matchingQuoteRequest = + normalizedQuoteRequests[matchedQuoteRequestIdx]; + + return await serverEventHandlers.onValidQuoteReceived({ + ...quoteResponseV2, + featureId, + // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval + resetApproval: + quoteResponseV2.namespace === KnownCaipNamespace.Eip155 && + matchingQuoteRequest.resetApproval && + quoteResponseV2.approval + ? { + ...quoteResponseV2.approval, + data: getEthUsdtResetData(matchingQuoteRequest.destChainId), + } + : undefined, + ...(isBatchSellRequest && { + quoteRequestIndex: matchedQuoteRequestIdx, + }), + }); } catch (error) { if (error instanceof StructError) { console.warn('Quote validation failed', formatStructErrors(error)); error.failures().forEach(({ branch, path }) => { const aggregatorId = - branch?.[0]?.quote?.bridgeId ?? - branch?.[0]?.quote?.bridges?.[0] ?? + branch?.[0]?.quote?.aggregator ?? + branch?.[0]?.quote?.protocols?.[0] ?? + (quoteResponse as QuoteResponseV1)?.quote?.protocols?.[0] ?? (quoteResponse as QuoteResponseV1)?.quote?.bridgeId ?? - ((quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] || - ('unknown' as string)); + (quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] ?? + (quoteResponse as QuoteResponse)?.quote?.aggregator ?? + 'unknown'; const pathString = path?.join('.') || 'unknown'; uniqueValidationFailures.add([aggregatorId, pathString].join('|')); }); @@ -418,7 +427,6 @@ export async function fetchBridgeQuoteStream( // Rethrow any unexpected errors throw error; } - return undefined; }; const onTokenWarningReceived = (data: unknown): void => { @@ -496,26 +504,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(toQuoteResponseV1), stxEnabled, }); @@ -533,7 +530,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 917f51c7ec2..077b3ba96b7 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 { toQuoteResponseV2 } from '../../coercers/quote-response-v1-to-v2.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../../validators/quote-response-v1.js'; import { getNativeAssetForChainId } from '../bridge.js'; import { formatChainIdToCaip } from '../caip-formatters.js'; import type { QuoteMetadata } from '../quote-metadata/types.js'; @@ -170,43 +172,8 @@ describe('properties', () => { describe('formatProviderLabel', () => { it('should format provider label correctly', () => { - const mockQuoteResponse: QuoteResponseV1 = { + const mockQuoteResponse = { 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); @@ -421,23 +400,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(` { @@ -520,7 +507,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 0fd51f24baa..1854fccb51d 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -5,14 +5,12 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge.js'; import { ChainId } from '../../types.js'; import type { GenericQuoteRequest, QuoteRequest } from '../../types.js'; import { FeatureId } from '../../validators/feature-flags.js'; -import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; -import type { TxData } from '../../validators/trade.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; import { getNativeAssetForChainId, isCrossChain } from '../bridge.js'; import { formatAddressToAssetId, formatChainIdToCaip, } from '../caip-formatters.js'; -import type { QuoteMetadata } from '../quote-metadata/types.js'; import { MetricsSwapType } from './constants.js'; import type { AccountHardwareType, @@ -72,17 +70,24 @@ 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. * @param quoteRequest.srcChainId - Source chain id of the quote request. * @param quoteRequest.destChainId - Destination chain id of the quote request. * @param quoteRequest.srcTokenAddress - Source token address of the quote request. - * @param quoteRequest.destTokenAddress - Destination token address of the quote request. + * @param quoteRequest.destTokenAddress - Destination token address of the quote request. * @param tokenSecurityTypeDestination - The security classification of the destination token, * supplied by the client (e.g. from token security/scanning data). Pass `null` when no * security data is available for the selected destination token. @@ -155,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, ) => { @@ -170,17 +175,19 @@ export const getQuotesReceivedProperties = ( quoted_time_minutes: activeQuote?.estimatedProcessingTimeInSeconds ? activeQuote.estimatedProcessingTimeInSeconds / 60 : 0, - usd_quoted_gas: Number(activeQuote?.gasFee?.total?.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/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index cb0db21ce21..a9bda927090 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -310,7 +310,7 @@ type RequiredEventContextFromClientBase = { // Emitted by clients [UnifiedSwapBridgeEventName.AllQuotesOpened]: Pick< TradeData, - 'gas_included' + 'gas_included' | 'gas_included_7702' > & Pick & Pick & { diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index 6c15b6363a9..186fd6dd382 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.js'; import type { @@ -12,38 +12,39 @@ import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; import { isTronTrade } from '../validators/trade.js'; import type { TxData } from '../validators/trade.js'; import { isNonEvmChainId, sumHexes } from './bridge.js'; -import { formatChainIdToCaip } from './caip-formatters.js'; +import { formatChainIdToCaip, formatChainIdToHex } from './caip-formatters.js'; import { computeFeeRequest } from './snaps.js'; import { extractTradeData } from './trade-utils.js'; /** * 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,8 +123,6 @@ const appendNonEvmFees = async ( } try { - const scope = formatChainIdToCaip(quote.srcChainId); - const transaction = extractTradeData(trade); // Tron trades need the visible flag and contract type to be included in the request options @@ -136,7 +140,7 @@ const appendNonEvmFees = async ( selectedAccount?.metadata?.snap?.id, transaction, selectedAccount?.id, - scope, + chainId, options, ), )) as { @@ -164,7 +168,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 +180,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 +194,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-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 40a4c91cbd0..20f85a39857 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -6,6 +6,7 @@ import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; import { toQuoteMetadataV2 } from './to-quote-metadata-v2.js'; import type { QuoteMetadata } from './types.js'; +import { toNormalizedAmounts } from './to-normalized-amounts.js'; /** * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} @@ -28,11 +29,13 @@ export function mergeQuoteMetadata< legacyQuoteMetadata, quoteResponse, ); + const normalizedAmountsV2 = toNormalizedAmounts(quoteResponse); // Phase 1 of migration uses calcQuoteMetadata's results return merge( {}, quoteResponse, + normalizedAmountsV2, legacyQuoteMetadataV2, legacyQuoteMetadata, // return for client testing ); diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts index df366dc73cd..05f6c011530 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -1,8 +1,10 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ @@ -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 24c6e0db380..6ef89d6770e 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts @@ -1,8 +1,10 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ @@ -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 11cbd1575f2..52214e3c433 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -1,8 +1,11 @@ +import { KnownCaipNamespace } from '@metamask/utils'; import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; import type { TxData } from '../src/validators/trade.js'; @@ -270,3 +273,11 @@ export const getMockBridgeQuotesNativeErc20V1 = ( return mergedQuote as QuoteResponseV1; }); }; + +export const getMockBridgeQuotesNativeErc20V2 = ( + quoteOverrides?: DeepPartial>, +): (QuoteResponse & { namespace: KnownCaipNamespace.Eip155 })[] => { + return getMockBridgeQuotesNativeErc20V1(quoteOverrides).map( + toQuoteResponseV2, + ) as (QuoteResponse & { namespace: KnownCaipNamespace.Eip155 })[]; +}; diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts index 8aff2cea101..92e90049ea9 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -1,8 +1,10 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ @@ -189,3 +191,9 @@ export const getMockBridgeQuotesSolErc20V1 = ( return mergedQuote; }); }; + +export const getMockBridgeQuotesSolErc20V2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return getMockBridgeQuotesSolErc20V1(quoteOverrides).map(toQuoteResponseV2); +}; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index aa05e55ca58..96d0cdec76a 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9085](https://github.com/MetaMask/core/pull/9085)) +- Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx`. Controller logic and utils still require V1, but clients can submit quotes in both formats ([#9085](https://github.com/MetaMask/core/pull/9085)) - Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) ## [74.6.0] 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 ad7731d81f5..a337b40e997 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, @@ -19,6 +19,8 @@ import { UnifiedSwapBridgeEventName, MetaMetricsSwapsEventSource, mergeQuoteMetadata, + validateQuoteResponseV1, + toQuoteResponseV2, } from '@metamask/bridge-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -2191,7 +2193,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana bridge', () => { - const mockQuote: QuoteResponse = { + const mockQuote: QuoteResponseV1 = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2424,7 +2426,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana swap', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2677,7 +2679,7 @@ describe('BridgeStatusController', () => { '0a02aabb22084dde86d0f68ae3e5403a680801b2630a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412330a15418f7ea8cce9f8bba67d7ae59cd49a1965d617e71b121541a614f803b6fd780986a42c78ec9c7f77e6ded13c', }; - const mockQuoteResponse: QuoteResponse & + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', @@ -2944,7 +2946,8 @@ describe('BridgeStatusController', () => { chainId: 42161, gasLimit: 21000, }, - } as QuoteResponse & QuoteMetadata; + }; + validateQuoteResponseV1(mockEvmQuoteResponse); const mockEvmTxMeta = { id: 'test-tx-id', @@ -3157,7 +3160,7 @@ describe('BridgeStatusController', () => { }) => { const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; const quoteResponseV2 = mergeQuoteMetadata( - quoteWithoutApproval, + toQuoteResponseV2(quoteWithoutApproval), quoteWithoutApproval, ); const quotesReceivedContext = getQuotesReceivedProperties( @@ -4388,6 +4391,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: undefined, + valueInCurrency: undefined, + usd: undefined, + }, + }; + validateQuoteResponseV1(mockQuote); + await withController( { mockMessengerCall }, async ({ @@ -4395,36 +4427,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: '100', - asset: quoteWithoutApproval.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, - }, - }, + mockQuote, false, // isStxEnabledOnClient = FALSE (key for this test) ); controller.stopAllPolling(); @@ -6617,7 +6623,7 @@ describe('BridgeStatusController', () => { const EVM_TX_META_ID = 'evmEarlyTxMetaId'; const EVM_QUOTE_ID = 'evm-early-quote-1'; - const mockEvmSwapQuoteResponse = { + const mockEvmSwapQuoteResponse: QuoteResponseV1 & QuoteMetadata = { ...getMockQuote({ srcChainId: 42161, destChainId: 42161 }), quoteId: EVM_QUOTE_ID, quote: { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 709d2ccbde0..c2540b43e76 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -2,11 +2,15 @@ import type { StateMetadata } from '@metamask/base-controller'; import { QuoteMetadata, RequiredEventContextFromClient, - QuoteResponse, + QuoteResponseV1, Trade, FeatureId, BatchSellTradesResponse, InputPrimaryDenomination, + QuoteResponse, + toQuoteMetadataV1, + mergeQuoteMetadata, + toQuoteResponseV1, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -19,8 +23,10 @@ import { PollingStatus, formatChainIdToHex, } from '@metamask/bridge-controller'; +import { QuoteResponseSchemaV1 } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { is } from '@metamask/superstruct'; import { TransactionStatus, TransactionType, @@ -1372,8 +1378,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, @@ -1388,9 +1396,17 @@ export class BridgeStatusController extends StaticIntervalPollingController { + if (is(quote, QuoteResponseSchemaV1)) { + return quote; + } + const quoteMetadata = toQuoteMetadataV1(quote); + return mergeQuoteMetadata(toQuoteResponseV1(quote), quoteMetadata); + }); const quoteResponse = quoteResponses[0]; const { quote } = quoteResponse; @@ -1519,7 +1535,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; + quoteResponse: QuoteResponse; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1557,7 +1573,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata) | null)[]; + quoteResponses: (QuoteResponse | null)[]; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1574,9 +1590,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata => + (quoteResponse): quoteResponse is QuoteResponse & 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 2d56a7f8d9a..00e7f39ea28 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 64867050805..6f381beb066 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 */ @@ -157,7 +156,7 @@ export type BridgeHistoryItem = { * persisted before this field was introduced. */ quoteIds?: string[]; - quote: Quote; + quote: QuoteResponseV1['quote']; /** * This is the the quote id used on single swaps/bridges. On batch sell, it is set * as the first item of `quoteIds`. @@ -290,7 +289,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']; @@ -319,7 +318,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 15040f27032..c15bf33cd39 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/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index 38421882f08..8831ff0423b 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 dee8e2e7bc3..183f6f47989 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,7 +154,7 @@ export const getRequestParamFromHistory = ( }; export const getTradeDataFromQuote = ( - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, batchSellTrades?: BatchSellTradesResponse | null, ): TradeData => { return { @@ -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 9540658b257..1117a074cd5 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 3faf53d55ab..570cd46cf3a 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.js'; 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 f5d8be3aab3..7b9923e2a9f 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( @@ -1516,7 +1516,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('getClientRequest', () => { it('should generate a valid client request', () => { - const mockQuoteResponse: Omit, 'approval'> & + const mockQuoteResponse: Omit, 'approval'> & QuoteMetadata = { quote: { bridgeId: 'bridge1', @@ -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 519a2edf533..922e5ff1f6c 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[] = [];