diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index cecdc6fd069..3d7595b1a05 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Add `migrationPhase` to selector params, which determines how metadata is resolved ([#9744](https://github.com/MetaMask/core/pull/9744)) + - Phase 1 omits V2 quoteMetadata derived from the quotes response. Legacy metadata is served to the clients + - Phase 1.5 includes V2 quoteMetadata but falls back to legacy metadata + - Phase 2 omits legacy metadata from the quotes and enables removal of deprecated metadata utils + ## [79.0.0] ### Added diff --git a/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap b/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap new file mode 100644 index 00000000000..f47136d5d0b --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap @@ -0,0 +1,409 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata (Phase 1.5) 1`] = ` +{ + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "approval": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 46000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 49000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, + "chainId": "eip155:1", + "cost": { + "usd": "8.900073", + "valueInCurrency": "1758.014454", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "namespace": "eip155", + "priceImpact": { + "usd": "8.9", + "valueInCurrency": "1758", + }, + "quote": { + "aggregator": "bridge1", + "dest": { + "amount": "2100000000000000000", + "asset": { + "assetId": "eip155:137/slip44:966", + "decimals": 18, + "name": "Polygon", + "symbol": "POL", + }, + "minAmount": "1800000000000000000", + "minAmountNormalized": "1.8", + "minAmountUsd": "1.8", + "minAmountValueInCurrency": "360", + "normalizedAmount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "feeData": { + "metabridge": [ + { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + }, + ], + "network": [ + { + "amount": "7500000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000075", + "usd": "0.01514", + "valueInCurrency": "2.99772", + }, + ], + "refuel": undefined, + "relayer": [ + { + "amount": "100000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000001", + }, + ], + "txFee": undefined, + }, + "priceData": { + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "priceImpact": { + "usd": "7.9", + "valueInCurrency": "1564.2", + }, + "swapRate": "1.90909090909090909091", + }, + "protocols": [ + "bridge1", + ], + "requestId": "456", + "src": { + "amount": "1100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "1.1", + "usd": "1", + "valueInCurrency": "198", + }, + "steps": [], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalNetworkFee": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + "trade": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 21000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 24000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, +} +`; + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata (Phase 2) 1`] = ` +{ + "approval": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 46000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 49000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, + "chainId": "eip155:1", + "estimatedProcessingTimeInSeconds": 300, + "namespace": "eip155", + "quote": { + "aggregator": "bridge1", + "dest": { + "amount": "2100000000000000000", + "asset": { + "assetId": "eip155:137/slip44:966", + "decimals": 18, + "name": "Polygon", + "symbol": "POL", + }, + "minAmount": "1800000000000000000", + "minAmountNormalized": "1.8", + "minAmountValueInCurrency": undefined, + "normalizedAmount": "2.1", + "usd": "2", + "valueInCurrency": "396", + }, + "feeData": { + "metabridge": [ + { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + }, + ], + "network": [ + { + "amount": "7500000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000075", + "usd": undefined, + }, + ], + "refuel": undefined, + "relayer": [ + { + "amount": "100000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000001", + "usd": "0.0001", + "valueInCurrency": "0.0198", + }, + ], + "txFee": undefined, + }, + "priceData": { + "adjustedReturn": { + "valueInCurrency": undefined, + }, + "priceImpact": { + "usd": "7.9", + "valueInCurrency": "1564.2", + }, + }, + "protocols": [ + "bridge1", + ], + "requestId": "456", + "src": { + "amount": "1100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "1.1", + "usd": "1", + "valueInCurrency": "198", + }, + "steps": [], + }, + "trade": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 21000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 24000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, +} +`; + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata 1`] = ` +{ + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "approval": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 46000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 49000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, + "chainId": "eip155:1", + "cost": { + "usd": "8.900073", + "valueInCurrency": "1758.014454", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "namespace": "eip155", + "priceImpact": { + "usd": "8.9", + "valueInCurrency": "1758", + }, + "quote": { + "aggregator": "bridge1", + "dest": { + "amount": "2100000000000000000", + "asset": { + "assetId": "eip155:137/slip44:966", + "decimals": 18, + "name": "Polygon", + "symbol": "POL", + }, + "minAmount": "1800000000000000000", + "minAmountNormalized": "1.8", + "minAmountUsd": "1.8", + "minAmountValueInCurrency": "360", + "normalizedAmount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "feeData": { + "metabridge": [ + { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + }, + ], + "network": [ + { + "amount": "7300000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + ], + "relayer": undefined, + "txFee": undefined, + }, + "priceData": { + "adjustedReturn": { + "usd": "2.099927", + "valueInCurrency": "419.985546", + }, + "priceImpact": { + "usd": "8.9", + "valueInCurrency": "1758", + }, + "swapRate": "1.90909090909090909091", + }, + "protocols": [ + "bridge1", + ], + "requestId": "456", + "src": { + "amount": "1100000000000000000", + "asset": { + "assetId": "eip155:1/slip44:60", + "decimals": 18, + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "steps": [], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalNetworkFee": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + "trade": { + "chainId": 1, + "data": "0x0", + "effectiveGas": 21000, + "from": "0x0000000000000000000000000000000000000000", + "gasLimit": 24000, + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + }, +} +`; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 3bc70a617d0..df6e0ede6cf 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -703,6 +703,8 @@ describe('Bridge Selectors', () => { ...quote, quote: { ...quote.quote, + src: { ...quote.quote.src, usd: '1' }, + dest: { ...quote.quote.dest, usd: '2' }, feeData: { ...quote.quote.feeData, network: [ @@ -736,8 +738,8 @@ describe('Bridge Selectors', () => { }, mockClientParams, ); - const quote = mockState.quotes[1]; - const expectedQuoteMetadata = calcQuoteMetadata(quote, { + + const expectedQuoteMetadata = calcQuoteMetadata(mockState.quotes[1], { srcTokenExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, bridgeFeesPerGas: { estimatedBaseFeeInDecGwei: '0', @@ -746,36 +748,148 @@ describe('Bridge Selectors', () => { destTokenExchangeRate: { exchangeRate: '200', usdExchangeRate: '1' }, 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, ); - expect(result.sortedQuotes[0]).toStrictEqual(expectedQuoteV2); expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('1758.014454'); + // eslint-disable-next-line jest/no-restricted-matchers + expect(result.recommendedQuote).toMatchSnapshot(); + }); + + it('should return sorted quotes with metadata (Phase 1.5)', () => { + const migrationPhase = '1.5'; + const mockState = getMockState(1); + const mockQuote = mockState.quotes[0]; + const quotes = mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + src: { ...quote.quote.src, usd: '1' }, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + usd: '0.01514', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + relayer: [ + { + amount: '100000000000', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })); + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + quotes, + assetExchangeRates: { + [mockQuote.quote.src.asset.assetId]: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.dest.asset.assetId]: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + { ...mockClientParams, migrationPhase }, + ); + + const expectedQuoteMetadata = calcQuoteMetadata(quotes[1], { + srcTokenExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', + }, + destTokenExchangeRate: { exchangeRate: '200', usdExchangeRate: '1' }, + nativeExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + }); + + // eslint-disable-next-line jest/no-restricted-matchers + expect(result.sortedQuotes[0]).toMatchSnapshot(); + expect(result.recommendedQuote).toMatchObject(expectedQuoteMetadata); + expect(result.recommendedQuote).not.toMatchObject( + toQuoteMetadataV1(result.recommendedQuote, migrationPhase), + ); + }); + + it('should return sorted quotes with metadata (Phase 2)', () => { + const migrationPhase = '2'; + const mockState = getMockState(1); + const mockQuote = mockState.quotes[0]; + const quotes = mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + src: { ...quote.quote.src, usd: '1' }, + dest: { ...quote.quote.dest, usd: '2' }, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + usd: undefined, + }, + ], + relayer: [ + { + amount: '100000000000', + usd: '0.0001', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })); + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + quotes, + assetExchangeRates: { + [mockQuote.quote.src.asset.assetId]: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.dest.asset.assetId]: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + { ...mockClientParams, migrationPhase }, + ); + + // eslint-disable-next-line jest/no-restricted-matchers + expect(result.recommendedQuote).toMatchSnapshot(); + // expect(result.recommendedQuote).not.toMatchObject( + // toQuoteMetadataV1(result.recommendedQuote, migrationPhase), + // ); }); it('should return metadata when quotes are empty', () => { @@ -1958,7 +2072,7 @@ describe('Bridge Selectors', () => { }, }, }, - { ...mockClientParams, requestCount: 2 }, + { ...mockClientParams, requestCount: 2, migrationPhase: '1' }, ); const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = @@ -2063,7 +2177,7 @@ describe('Bridge Selectors', () => { }, }, }, - { ...mockClientParams, requestCount: 2 }, + { ...mockClientParams, requestCount: 2, migrationPhase: '1' }, ); const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = @@ -2102,7 +2216,7 @@ describe('Bridge Selectors', () => { }, }, }, - { ...mockClientParams, requestCount: 1 }, + { ...mockClientParams, requestCount: 1, migrationPhase: '1' }, ); expect(recommendedQuotes).toHaveLength(1); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 55f4a07f5f4..54fbe19ad5e 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -38,8 +38,11 @@ import { } 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 { + calcBatchFees, + calcQuoteMetadataV2, + calcQuoteMetadata, +} from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; import type { QuoteMetadata } from './utils/quote-metadata/types.js'; import { getDefaultSlippagePercentage } from './utils/slippage.js'; @@ -98,6 +101,7 @@ const createBridgeSelector = createSelector_.withTypes(); type BridgeQuotesClientParams = { sortOrder: SortOrder; selectedQuote: (QuoteResponse & QuoteMetadata) | null; + migrationPhase: '1' | '1.5' | '2'; }; type EvmTokenExchangeRate = { price?: number; currency?: string }; @@ -362,13 +366,51 @@ const selectMetadata = createBridgeSelector( }, ); +const selectUsdToFiatExchangeRate = createBridgeSelector( + [ + selectExchangeRateSources, + ({ quoteRequest }) => + getNativeAssetForChainId(quoteRequest[0]?.srcChainId ?? 1)?.assetId, + ], + (exchangeRateSources, nativeAssetId) => { + const exchangeRate = selectExchangeRateByAssetId( + exchangeRateSources, + nativeAssetId, + ); + return exchangeRate?.exchangeRate && exchangeRate?.usdExchangeRate + ? new BigNumber(exchangeRate.exchangeRate) + .div(exchangeRate.usdExchangeRate) + .toFixed() + : undefined; + }, +); + +const selectMetadataV2 = createBridgeSelector( + [({ quotes }) => quotes, selectUsdToFiatExchangeRate], + (quotes, usdToFiatExchangeRate) => { + return quotes.map((quote) => + calcQuoteMetadataV2(quote, usdToFiatExchangeRate), + ); + }, +); + // Selects cross-chain swap quotes including their metadata const selectBridgeQuotesWithMetadata = createBridgeSelector( - [selectMetadata, ({ quotes }) => quotes], - (quoteMetadata, quotes) => - quotes.map((quote, index) => - mergeQuoteMetadata(quote, quoteMetadata[index]), - ), + [ + selectMetadata, + selectMetadataV2, + ({ quotes }) => quotes, + (_, { migrationPhase }: BridgeQuotesClientParams) => migrationPhase, + ], + (legacyQuoteMetadata, quoteMetadataV2, quotes, migrationPhase) => + quotes.map((quote, index) => { + return mergeQuoteMetadata( + quote, + legacyQuoteMetadata[index], + migrationPhase, + quoteMetadataV2?.[index], + ); + }), ); const selectSortedBridgeQuotes = createBridgeSelector( diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index d21cdafec30..020fe687b55 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -19,6 +19,7 @@ import { FloatStringSchema } from '../../validators/number.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; import { QuoteResponseSchemaV2 } from '../../validators/quote-response.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; +import { FeeType } from '../../validators/quote.js'; import type { TxData } from '../../validators/trade.js'; import { isEvmQuoteResponse, isNativeAddress } from '../bridge.js'; import { calcNormalizedTokenAmount } from '../number-formatters.js'; @@ -421,6 +422,8 @@ export const calcPriceImpact = ( * Calculates quote metadata, such as converted fiat amounts and fees, * based on the controller state and the quote response * + * @deprecated Use {@link calcQuoteMetadataV2} instead + * * @param quote - The quote response to calculate the metadata for * @param options - The options for the calculation * @param options.bridgeFeesPerGas - The bridge fees per gas @@ -536,3 +539,66 @@ export const calcQuoteMetadata = ( }), }; }; + +/** + * Builds a partial {@link QuoteResponse} object with fiat values derived from the usd values provided by the bridge-api + * + * @param quote - The quote response to calculate the metadata for + * @param usdToFiatExchangeRateString - The usd to fiat exchange rate + * @returns The partial {@link QuoteResponse} object with fiat values + */ +export const calcQuoteMetadataV2 = ( + quote: QuoteResponse, + usdToFiatExchangeRateString?: string, +): DeepPartial => { + if (!usdToFiatExchangeRateString) { + return {}; + } + + const usdToFiatExchangeRate = new BigNumber(usdToFiatExchangeRateString); + + return { + quote: { + src: { + valueInCurrency: + quote.quote.src.usd && + usdToFiatExchangeRate.times(quote.quote.src.usd).toFixed(), + }, + dest: { + valueInCurrency: + quote.quote.dest.usd && + usdToFiatExchangeRate.times(quote.quote.dest.usd).toFixed(), + minAmountValueInCurrency: + quote.quote.dest.minAmountUsd && + usdToFiatExchangeRate.times(quote.quote.dest.minAmountUsd).toFixed(), + }, + feeData: Object.fromEntries( + Object.values(FeeType).map((feeType) => [ + feeType, + quote.quote.feeData[feeType] + ?.filter((fee) => fee.usd !== undefined) + ?.map((fee) => ({ + valueInCurrency: + fee.usd && usdToFiatExchangeRate.times(fee.usd).toFixed(), + })), + ]), + ), + priceData: { + priceImpact: { + valueInCurrency: + quote.quote.priceData?.priceImpact?.usd && + usdToFiatExchangeRate + .times(quote.quote.priceData.priceImpact.usd) + .toFixed(), + }, + adjustedReturn: { + valueInCurrency: + quote.quote.priceData?.adjustedReturn?.usd && + usdToFiatExchangeRate + .times(quote.quote.priceData.adjustedReturn.usd) + .toFixed(), + }, + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 438f55b495b..2f89aae8943 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -1,6 +1,7 @@ import { is } from '@metamask/superstruct'; import { merge } from 'lodash'; +import type { DeepPartial } from '../../types.js'; import { QuoteResponseSchemaV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; @@ -13,6 +14,8 @@ import type { QuoteMetadata } from './types.js'; * * @param quoteResponse - The {@link QuoteResponse} or {@link QuoteResponseV1} to merge the metadata into * @param legacyQuoteMetadata - The {@link QuoteMetadata} values to merge + * @param migrationPhase - The migration phase + * @param fiatQuoteMetadata - The {@link QuoteMetadataV2} values to merge * @returns The {@link QuoteResponse} with the metadata merged in */ export function mergeQuoteMetadata< @@ -20,6 +23,8 @@ export function mergeQuoteMetadata< >( quoteResponse: QuoteType, legacyQuoteMetadata: QuoteMetadata, + migrationPhase: '1' | '1.5' | '2' = '1', + fiatQuoteMetadata?: DeepPartial, ): QuoteType & QuoteMetadata { if (is(quoteResponse, QuoteResponseSchemaV1)) { return merge({}, quoteResponse, legacyQuoteMetadata); @@ -32,7 +37,25 @@ export function mergeQuoteMetadata< const normalizedAmounts = toNormalizedAmounts(quoteResponse); - // Phase 1 of migration uses calcQuoteMetadata's results + if (migrationPhase === '2') { + // Phase 2 of migration only uses metadata from the API response + // @ts-expect-error - this will have a type error until Phase 2 is fully deployed + return merge({}, quoteResponse, normalizedAmounts, fiatQuoteMetadata); + } + + // Phase 1.5 of migration uses metadata from the API response but falls back to legacy metadata + if (migrationPhase === '1.5') { + return merge( + {}, + legacyQuoteMetadataV2, // legacy metadata in v2 format + quoteResponse, + normalizedAmounts, + fiatQuoteMetadata, // fiat metadata derived from backend's usd values + legacyQuoteMetadata, // return legacy metadata for client testing + ); + } + + // Phase 1 of migration uses calcQuoteMetadata's results (legacy metadata) return merge( {}, quoteResponse, diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts index bf5bd21798d..f63f904660d 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts @@ -1,8 +1,11 @@ +import { is } from '@metamask/superstruct'; import { merge } from 'lodash'; import type { DeepPartial } from '../../types.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { QuoteResponseSchemaV2 } from '../../validators/quote-response.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; +import { sumAmounts } from '../number-formatters.js'; import type { QuoteMetadata } from './types.js'; /** @@ -10,12 +13,14 @@ import type { QuoteMetadata } from './types.js'; * If a QuoteResponse is provided, this assumes that its `valueInCurrency` properties are set. * * @param quoteResponse - The quote to extract the metadata from + * @param migrationPhase - The migration phase to use * @returns A partial {@link QuoteMetadata} object */ export const toQuoteMetadataV1 = ( quoteResponse: | (DeepPartial & QuoteMetadata) | null, + migrationPhase: '1' | '1.5' | '2' = '1', ): QuoteMetadata => { /* istanbul ignore if */ if (!quoteResponse) { @@ -58,6 +63,89 @@ export const toQuoteMetadataV1 = ( : {}), }; - // Phase 1 only uses legacyMetadata - return merge({}, legacyMetadata); + if (!is(quoteResponse, QuoteResponseSchemaV2) || migrationPhase === '1') { + // Return legacy metadata as-is, extract from quote + return legacyMetadata; + } + + // Build V1 from V2 quote + const totalNetworkFeeV2 = sumAmounts( + quoteResponse?.quote?.feeData?.network, + quoteResponse?.quote?.feeData?.relayer, + ); + const v2Metadata: QuoteMetadata | undefined = { + sentAmount: { + amount: quoteResponse?.quote?.src?.normalizedAmount, + usd: quoteResponse?.quote?.src?.usd, + valueInCurrency: quoteResponse?.quote?.src?.valueInCurrency, + }, + toTokenAmount: { + amount: quoteResponse?.quote?.dest?.normalizedAmount, + usd: quoteResponse?.quote?.dest?.usd, + valueInCurrency: quoteResponse?.quote?.dest?.valueInCurrency, + }, + minToTokenAmount: { + amount: quoteResponse?.quote?.dest?.minAmountNormalized, + valueInCurrency: quoteResponse?.quote?.dest?.minAmountValueInCurrency, + usd: quoteResponse?.quote?.dest?.minAmountUsd, + }, + swapRate: quoteResponse?.quote?.priceData?.swapRate, + adjustedReturn: { + usd: quoteResponse?.quote?.priceData?.adjustedReturn?.usd, + valueInCurrency: + quoteResponse?.quote?.priceData?.adjustedReturn?.valueInCurrency ?? + undefined, + }, + cost: { + valueInCurrency: + quoteResponse?.quote?.priceData?.priceImpact?.valueInCurrency ?? + undefined, + usd: quoteResponse?.quote?.priceData?.priceImpact?.usd, + }, + gasFee: { + total: { + amount: + quoteResponse?.quote?.feeData?.network?.[0]?.normalizedAmount ?? + undefined, + usd: quoteResponse?.quote?.feeData?.network?.[0]?.usd, + valueInCurrency: + quoteResponse?.quote?.feeData?.network?.[0]?.valueInCurrency ?? + undefined, + }, + }, + totalNetworkFee: { + amount: totalNetworkFeeV2?.normalizedAmount, + usd: totalNetworkFeeV2?.usd, + valueInCurrency: totalNetworkFeeV2?.valueInCurrency, + }, + priceImpact: { + usd: quoteResponse?.quote?.priceData?.priceImpact?.usd, + valueInCurrency: + quoteResponse?.quote?.priceData?.priceImpact?.valueInCurrency ?? + undefined, + }, + relayerFee: { + amount: + quoteResponse?.quote?.feeData?.relayer?.[0]?.normalizedAmount ?? + undefined, + usd: quoteResponse?.quote?.feeData?.relayer?.[0]?.usd, + valueInCurrency: + quoteResponse?.quote?.feeData?.relayer?.[0]?.valueInCurrency ?? + undefined, + }, + includedTxFees: { + amount: quoteResponse?.quote?.feeData?.txFee?.[0]?.normalizedAmount, + usd: quoteResponse?.quote?.feeData?.txFee?.[0]?.usd, + valueInCurrency: + quoteResponse?.quote?.feeData?.txFee?.[0]?.valueInCurrency ?? undefined, + }, + }; + + if (migrationPhase === '1.5') { + // Phase 1.5 uses legacyMetadata as fallback + return merge({}, legacyMetadata, v2Metadata); + } + + // Phase 2 only uses metadata from the API response + return v2Metadata; }; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index fb6baf30724..bb9ae4188d4 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING**: Add migrationPhase to submit handlers, which determines how quote metadata is resolved ([#9744](https://github.com/MetaMask/core/pull/9744)) +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9726](https://github.com/MetaMask/core/pull/9726)) + - Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx` + - Controller logic and utils still require V1, but clients can submit quotes in both formats + ## [75.0.0] ### Changed diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index c2540b43e76..1c4e31416d5 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1367,6 +1367,7 @@ export class BridgeStatusController extends StaticIntervalPollingController, activeAbTests?: { key: string; value: string }[], tokenSecurityTypeDestination?: string | null, @@ -1404,8 +1406,13 @@ export class BridgeStatusController extends StaticIntervalPollingController; activeAbTests?: { key: string; value: string }[]; @@ -1550,6 +1559,7 @@ export class BridgeStatusController extends StaticIntervalPollingController; activeAbTests?: { key: string; value: string }[]; @@ -1596,6 +1608,7 @@ export class BridgeStatusController extends StaticIntervalPollingController