diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..512ff90b13f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.tsdk.path": "node_modules/typescript/lib" +} diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index f25389837c1..db78d210265 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -26,7 +26,9 @@ 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 +- Pass migrationPhase to selector, determines how metadata is resolved - **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` diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index ce3dcbb4be4..afb162b3d90 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -39,6 +39,7 @@ import { getDefaultBridgeControllerState, isCrossChain, isEthUsdt, + isNativeAddress, isNonEvmChainId, isSolanaChainId, } from './utils/bridge.js'; diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts index f077653ad4f..b38b530f5b9 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -135,6 +135,7 @@ describe('quote-response-v2 migration', () => { "valueInCurrency": undefined, }, ], + "txFee": undefined, } `); diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 41dc433bc28..9571567a8f9 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -215,6 +215,7 @@ export { export { calcSlippagePercentage, calcQuoteMetadata, + calcQuoteMetadataV2, } from './utils/quote-metadata/calculators.js'; export { calcLatestSrcBalance } from './utils/balance.js'; @@ -247,6 +248,8 @@ export { selectBridgeFeatureFlags, selectMinimumBalanceForRentExemptionInSOL, selectTokenWarnings, + selectMetadataV2, + selectUsdToFiatExchangeRate, } from './selectors.js'; export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge.js'; diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 55f4a07f5f4..5b457d9b1d9 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,54 @@ const selectMetadata = createBridgeSelector( }, ); +export 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; + }, +); + +export 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, + ], + (quoteMetadata, quoteMetadataV2, quotes, migrationPhase) => + quotes.map((quote, index) => { + // if phase 1, skip toQuoteMetadataV2(quoteMetadataV2) so new metadata keys have old data + // if phase 1.5, add quoteMetadataV2, so new metadata keys can have both old and new data + // if phase 2, skip quoteMetadata, so new metadata keys have new data and old data can be removed + return mergeQuoteMetadata( + quote, + quoteMetadata[index], + migrationPhase, + quoteMetadataV2?.[index], + ); + }), ); const selectSortedBridgeQuotes = createBridgeSelector( @@ -465,8 +510,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index bf26dacc33c..bde74d3d691 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -24,6 +24,7 @@ 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'; +import type { TxData } from '../validators/trade.js'; import { getEthUsdtResetData } from './bridge.js'; import { formatAddressToAssetId, diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index 7e02e6a56f2..7e39c4fed99 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -23,6 +23,7 @@ import type { TxData } from '../../validators/trade.js'; import { isEvmQuoteResponse, isNativeAddress } from '../bridge.js'; import { calcTokenAmount } from '../number-formatters.js'; import type { QuoteMetadata, TokenAmountValues } from './types.js'; +import { FeeType } from '../../index.js'; export const calcNonEvmTotalNetworkFee = ( bridgeQuote: QuoteResponseV1 & NonEvmFees, @@ -447,6 +448,7 @@ export const calcQuoteMetadata = ( const isQuoteV2 = is(quote, QuoteResponseSchemaV2); const quoteV1 = isQuoteV2 ? toQuoteResponseV1(quote) : quote; + // const quote = toQuoteResponseV2(toQuoteResponseV1(baseQuote)); const sentAmount = calcSentAmount( quoteV1.quote, @@ -533,3 +535,59 @@ export const calcQuoteMetadata = ( }), }; }; + +export const calcQuoteMetadataV2 = ( + quote: QuoteResponse, + usdToFiatExchangeRateString?: string, +): DeepPartial => { + if (!usdToFiatExchangeRateString) { + return {}; + } + const usdToFiatExchangeRate = new BigNumber(usdToFiatExchangeRateString); + // Calculate fiat based on usd value + 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) + .map((fee) => ({ + valueInCurrency: usdToFiatExchangeRate + .times(fee.usd as string) + .toFixed(), + })), + ]), + ), + priceData: { + priceImpact: { + valueInCurrency: quote.quote.priceData?.priceImpact?.usd + ? usdToFiatExchangeRate + .times(quote.quote.priceData?.priceImpact?.usd) + .toFixed() + : undefined, + }, + adjustedReturn: { + valueInCurrency: quote.quote.priceData?.adjustedReturn?.usd + ? usdToFiatExchangeRate + .times(quote.quote.priceData.adjustedReturn.usd) + .toFixed() + : undefined, + }, + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 20f85a39857..7c58550f08d 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -7,12 +7,15 @@ 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'; +import type { DeepPartial } from '../../types.js'; /** * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} * * @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); @@ -31,6 +36,30 @@ export function mergeQuoteMetadata< ); const normalizedAmountsV2 = toNormalizedAmounts(quoteResponse); + if (migrationPhase === '2') { + // TODO Phase 2 of migration only uses metadata from the API response + // @ts-expect-error - TODO: fix this + return merge({}, quoteResponse, normalizedAmountsV2, fiatQuoteMetadata); + } + + if (migrationPhase === '1.5') { + // console.log('====', { + // legacyQuoteMetadataV2: legacyQuoteMetadataV2.quote?.priceData, + // quoteResponse: quoteResponse.quote?.priceData, + // normalizedAmountsV2: normalizedAmountsV2.quote?.priceData, + // fiatQuoteMetadata: fiatQuoteMetadata?.quote?.priceData, + // legacyQuoteMetadata: legacyQuoteMetadata?.priceImpact, + // }); + return merge( + {}, + legacyQuoteMetadataV2, + quoteResponse, + normalizedAmountsV2, + fiatQuoteMetadata, + legacyQuoteMetadata, // return for client testing + ); + } + // Phase 1 of migration uses calcQuoteMetadata's results return merge( {}, 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 dc27284fb51..a5d072f3e82 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 @@ -2,20 +2,25 @@ 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 type { QuoteMetadata } from './types.js'; +import { is } from '@metamask/superstruct'; +import { sumAmounts } from '../number-formatters.js'; /** * Extracts legacy {@link QuoteMetadata} values from a {@link QuoteResponse} or {@link QuoteResponseV1}. * 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', ): QuoteMetadata => { if (!quoteResponse) { return {}; @@ -57,6 +62,97 @@ export const toQuoteMetadataV1 = ( : {}), }; - // Phase 1 only uses legacyMetadata + // Build V1 from V2 quote + if (is(quoteResponse, QuoteResponseSchemaV2) && migrationPhase !== '1') { + 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 ?? undefined, + valueInCurrency: + quoteResponse?.quote?.src?.valueInCurrency ?? undefined, + }, + toTokenAmount: { + amount: quoteResponse?.quote?.dest?.normalizedAmount, + usd: quoteResponse?.quote?.dest?.usd ?? undefined, + valueInCurrency: + quoteResponse?.quote?.dest?.valueInCurrency ?? undefined, + }, + minToTokenAmount: { + amount: quoteResponse?.quote?.dest?.minAmountNormalized, + valueInCurrency: + quoteResponse?.quote?.dest?.minAmountValueInCurrency ?? undefined, + usd: quoteResponse?.quote?.dest?.minAmountUsd ?? undefined, + }, + swapRate: quoteResponse?.quote?.priceData?.swapRate, + adjustedReturn: { + usd: quoteResponse?.quote?.priceData?.adjustedReturn?.usd ?? undefined, + valueInCurrency: + quoteResponse?.quote?.priceData?.adjustedReturn?.valueInCurrency ?? + undefined, + }, + cost: { + valueInCurrency: + quoteResponse?.quote?.priceData?.priceImpact?.valueInCurrency ?? + undefined, + usd: quoteResponse?.quote?.priceData?.priceImpact?.usd ?? undefined, + }, + gasFee: { + total: { + amount: + quoteResponse?.quote?.feeData?.network?.[0]?.normalizedAmount ?? + undefined, + usd: quoteResponse?.quote?.feeData?.network?.[0]?.usd ?? undefined, + valueInCurrency: + quoteResponse?.quote?.feeData?.network?.[0]?.valueInCurrency ?? + undefined, + }, + }, + totalNetworkFee: { + amount: totalNetworkFeeV2?.normalizedAmount ?? undefined, + usd: totalNetworkFeeV2?.usd ?? undefined, + valueInCurrency: totalNetworkFeeV2?.valueInCurrency ?? undefined, + }, + priceImpact: { + usd: quoteResponse?.quote?.priceData?.priceImpact?.usd ?? undefined, + valueInCurrency: + quoteResponse?.quote?.priceData?.priceImpact?.valueInCurrency ?? + undefined, + }, + relayerFee: { + amount: + quoteResponse?.quote?.feeData?.relayer?.[0]?.normalizedAmount ?? + undefined, + usd: quoteResponse?.quote?.feeData?.relayer?.[0]?.usd ?? undefined, + valueInCurrency: + quoteResponse?.quote?.feeData?.relayer?.[0]?.valueInCurrency ?? + undefined, + }, + includedTxFees: { + amount: + quoteResponse?.quote?.feeData?.txFee?.[0]?.normalizedAmount ?? + undefined, + usd: quoteResponse?.quote?.feeData?.txFee?.[0]?.usd ?? undefined, + 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 + if (migrationPhase === '2' && v2Metadata) { + return v2Metadata; + } + } + + // Return legacy metadata as-is, extract from quote return merge({}, legacyMetadata); }; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 96d0cdec76a..3d2939edab2 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Pass migrationPhase to submit, determines how metadata is resolved - **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)) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index c2540b43e76..63e2004eff8 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,15 @@ export class BridgeStatusController extends StaticIntervalPollingController; activeAbTests?: { key: string; value: string }[]; @@ -1550,6 +1561,7 @@ export class BridgeStatusController extends StaticIntervalPollingController; activeAbTests?: { key: string; value: string }[]; @@ -1596,6 +1610,7 @@ export class BridgeStatusController extends StaticIntervalPollingController