From 1f53dd20cb142650c9f5d2646c4c61a5c2b336ed Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 13 Jul 2026 15:27:00 -0700 Subject: [PATCH 1/8] refactor: extract quote-metadata utils --- packages/bridge-controller/CHANGELOG.md | 1 + packages/bridge-controller/src/index.ts | 4 +- .../bridge-controller/src/selectors.test.ts | 83 +++++-- packages/bridge-controller/src/selectors.ts | 128 ++--------- packages/bridge-controller/src/types.ts | 87 +------- .../bridge-controller/src/utils/assets.ts | 3 +- .../bridge-controller/src/utils/fetch.test.ts | 4 +- .../src/utils/metrics/properties.test.ts | 2 +- .../src/utils/metrics/properties.ts | 7 +- .../src/utils/quote-metadata.ts | 211 ++++++++++++++++++ .../bridge-controller/src/utils/quote.test.ts | 75 ++++++- packages/bridge-controller/src/utils/quote.ts | 3 +- .../tests/mock-quotes-erc20-native.ts | 26 +-- .../tests/mock-quotes-native-erc20.ts | 20 +- 14 files changed, 399 insertions(+), 255 deletions(-) create mode 100644 packages/bridge-controller/src/utils/quote-metadata.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 9bb1e26a44a..92c11b577bd 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extract `QuoteMetadata` type and calculation to a new file - Bump `@metamask/assets-controller` from `^10.2.1` to `^11.0.0` ([#9485](https://github.com/MetaMask/core/pull/9485)) ## [77.4.1] diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 5bd78eb6e51..e6e2fb1592c 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -37,11 +37,12 @@ export { getQuotesReceivedProperties, } from './utils/metrics/properties'; +export type { QuoteMetadata, TokenAmountValues } from './utils/quote-metadata'; + export type { ChainConfiguration, L1GasFees, NonEvmFees, - QuoteMetadata, GasMultiplierByChainId, FeatureFlagResponse, BridgeAsset, @@ -49,7 +50,6 @@ export type { BatchSellTradesResponse, GaslessProperties, SimulatedGasFeeLimits, - TokenAmountValues, Step, RefuelData, FeeData, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 03f12996ebe..1ede161451d 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -33,7 +33,6 @@ import { getNativeAssetForChainId, isNativeAddress } from './utils/bridge'; import { formatAddressToAssetId, formatAddressToCaipReference, - formatChainIdToCaip, formatChainIdToDec, formatChainIdToHex, } from './utils/caip-formatters'; @@ -412,7 +411,6 @@ describe('Bridge Selectors', () => { stateOverrides?: Partial, ): BridgeAppState => { const decChainId = formatChainIdToDec(chainId); - const caipChainId = formatChainIdToCaip(chainId); const mockQuote = { quote: { @@ -426,7 +424,7 @@ describe('Bridge Selectors', () => { chainId: decChainId, address: '0x0000000000000000000000000000000000000000', decimals: 18, - assetId: `${caipChainId}/erc20:0x0000000000000000000000000000000000000000`, + assetId: getNativeAssetForChainId(chainId).assetId.toLowerCase(), symbol: 'ETH', name: 'Ethereum', }, @@ -434,8 +432,7 @@ describe('Bridge Selectors', () => { chainId: 137, address: '0x0000000000000000000000000000000000000000', decimals: 18, - assetId: - 'eip155:137/erc20:0x0000000000000000000000000000000000000000', + assetId: getNativeAssetForChainId(137).assetId.toLowerCase(), symbol: 'POL', name: 'Polygon', }, @@ -451,7 +448,8 @@ describe('Bridge Selectors', () => { decimals: 18, symbol: 'ETH', name: 'Ethereum', - assetId: `${caipChainId}/erc20:0x0000000000000000000000000000000000000000`, + assetId: + getNativeAssetForChainId(chainId).assetId.toLowerCase(), }, }, }, @@ -476,17 +474,10 @@ describe('Bridge Selectors', () => { value: '0x0', }, }; - validateQuoteResponseV1(mockQuote); return { quotes: [ - merge( - {}, - { - ...mockQuote, - }, - quoteOverrides, - ), + merge({}, mockQuote, quoteOverrides), merge( {}, { @@ -575,17 +566,21 @@ describe('Bridge Selectors', () => { { ...mockState, assetExchangeRates: { - [formatAddressToAssetId( + [mockQuote.quote.srcAsset.assetId.toLowerCase() ?? + formatAddressToAssetId( mockQuote.quote.srcAsset.address, mockQuote.quote.srcChainId, - ) ?? '']: { + ) ?? + '']: { exchangeRate: '1980', usdExchangeRate: '10', }, - [formatAddressToAssetId( + [mockQuote.quote.destAsset.assetId.toLowerCase() ?? + formatAddressToAssetId( mockQuote.quote.destAsset.address, mockQuote.quote.destChainId, - ) ?? '']: { + ) ?? + '']: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -659,6 +654,58 @@ describe('Bridge Selectors', () => { expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('-419.985546'); }); + it('should return sorted quotes with metadata (no assetId)', () => { + const mockState = getMockState(1, { + quote: { + srcAsset: { + chainId: 1, + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + assetId: null, + symbol: 'ETH', + name: 'Ethereum', + }, + } as never, + }); + const mockQuote = mockState.quotes[0]; + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + assetExchangeRates: { + [mockQuote.quote.srcAsset.assetId?.toLowerCase() ?? + formatAddressToAssetId( + mockQuote.quote.srcAsset.address, + mockQuote.quote.srcChainId, + ) ?? + '']: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [mockQuote.quote.destAsset.assetId?.toLowerCase() ?? + formatAddressToAssetId( + mockQuote.quote.destAsset.address, + mockQuote.quote.destChainId, + ) ?? + '']: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + mockClientParams, + ); + + const quoteResponseV1 = { + ...mockState.quotes[1], + }; + + expect(result.sortedQuotes[0]).toStrictEqual( + expect.objectContaining(quoteResponseV1), + ); + expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('-419.985546'); + }); + it('should return metadata when quotes are empty', () => { const mockState = getMockState(1); const mockQuote = mockState.quotes[0]; diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index a9246b7bee5..9386aba2e26 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -19,16 +19,10 @@ import { } from 'reselect'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from './constants/bridge'; -import type { - BridgeControllerState, - ExchangeRate, - QuoteMetadata, - TokenAmountValues, -} from './types'; +import type { BridgeControllerState } from './types'; import { RequestStatus, SortOrder } from './types'; import { getNativeAssetForChainId, - isEvmQuoteResponse, isNativeAddress, isNonEvmChainId, } from './utils/bridge'; @@ -39,20 +33,11 @@ import { formatChainIdToHex, } from './utils/caip-formatters'; import { processFeatureFlags } from './utils/feature-flags'; -import { - calcAdjustedReturn, - calcCost, - calcEstimatedAndMaxTotalGasFee, - calcIncludedTxFees, - calcRelayerFee, - calcSentAmount, - calcNonEvmTotalNetworkFee, - calcSwapRate, - calcToAmount, - calcTotalEstimatedNetworkFee, - calcTotalMaxNetworkFee, - calcBatchFees, -} from './utils/quote'; +import { calcBatchFees } from './utils/quote'; +import type { ExchangeRate } from './utils/quote-metadata'; +import type { TokenAmountValues } from './utils/quote-metadata'; +import { calcQuoteMetadata } from './utils/quote-metadata'; +import type { QuoteMetadata } from './utils/quote-metadata'; import { getDefaultSlippagePercentage } from './utils/slippage'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; @@ -358,100 +343,31 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( destTokenExchangeRate, nativeExchangeRate, ) => { - const newQuotes = quotes.map((quote) => { + return quotes.map((quote) => { + // This is a fallback for client unit tests + const fallbackSourceAssetId = formatAddressToAssetId( + quote.quote.srcAsset.address, + quote.quote.srcChainId, + ); const sourceAssetId = - formatAddressToAssetId( - quote.quote.srcAsset.address, - quote.quote.srcChainId, - ) ?? quote.quote.srcAsset.assetId; + quote.quote.srcAsset.assetId ?? + /* c8 ignore next */ + fallbackSourceAssetId; const srcTokenExchangeRate = selectExchangeRateByAssetId( exchangeRateSources, sourceAssetId, ); - const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); - const toTokenAmount = calcToAmount( - quote.quote.destTokenAmount, - quote.quote.destAsset, - destTokenExchangeRate, - ); - const minToTokenAmount = calcToAmount( - quote.quote.minDestTokenAmount, - quote.quote.destAsset, - destTokenExchangeRate, - ); - - const includedTxFees = calcIncludedTxFees( - quote.quote, + const quoteMetadata = calcQuoteMetadata(quote, { srcTokenExchangeRate, + bridgeFeesPerGas, destTokenExchangeRate, - ); - - let totalEstimatedNetworkFee, - totalMaxNetworkFee, - relayerFee, - gasFee: QuoteMetadata['gasFee']; - - if (isEvmQuoteResponse(quote)) { - relayerFee = calcRelayerFee(quote, nativeExchangeRate); - gasFee = calcEstimatedAndMaxTotalGasFee({ - bridgeQuote: quote, - ...bridgeFeesPerGas, - ...nativeExchangeRate, - }); - // Uses effectiveGasFee to calculate the total estimated network fee - totalEstimatedNetworkFee = calcTotalEstimatedNetworkFee( - gasFee, - relayerFee, - ); - totalMaxNetworkFee = calcTotalMaxNetworkFee(gasFee, relayerFee); - } else { - // Use the new generic function for all non-EVM chains - totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( - quote, - nativeExchangeRate, - ); - gasFee = { - effective: totalEstimatedNetworkFee, - total: totalEstimatedNetworkFee, - max: totalEstimatedNetworkFee, - }; - totalMaxNetworkFee = totalEstimatedNetworkFee; - } - - const adjustedReturn = calcAdjustedReturn( - toTokenAmount, - totalEstimatedNetworkFee, - quote.quote, - ); - const cost = calcCost(adjustedReturn, sentAmount); - + nativeExchangeRate, + }); return { ...quote, - // QuoteMetadata fields - sentAmount, - toTokenAmount, - minToTokenAmount, - swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), - /** - This is the amount required to submit all the transactions. - Includes the relayer fee or other native fees. - Should be used for balance checks and tx submission. - */ - totalNetworkFee: totalEstimatedNetworkFee, - totalMaxNetworkFee, - /** - This contains gas fee estimates for the bridge transaction - Does not include the relayer fee (if needed), just the gasLimit and effectiveGas returned by the bridge API. - Should only be used for display purposes. - */ - gasFee, - adjustedReturn, - cost, - includedTxFees, + ...quoteMetadata, }; }); - - return newQuotes; }, ); @@ -545,8 +461,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/types.ts b/packages/bridge-controller/src/types.ts index d0228f874ac..932afc9ef54 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -30,6 +30,7 @@ import type { import type { BridgeController } from './bridge-controller'; import type { BridgeControllerMethodActions } from './bridge-controller-method-action-types'; import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; +import { ExchangeRate } from './utils/quote-metadata'; import type { SimulatedGasFeeLimitsSchema } from './validators/batch-sell'; import type { BatchSellTradesResponseSchema } from './validators/batch-sell'; import type { BridgeAssetSchema } from './validators/bridge-asset'; @@ -90,92 +91,6 @@ export type NonEvmFees = { export type InputPrimaryDenomination = 'token_amount' | 'fiat_value'; -/** - * The types of values for the token amount and its values when converted to the user's selected currency and USD - */ -export type TokenAmountValues = { - /** - * The amount of the token - * - * @example "1.005" - */ - amount: string; - /** - * The amount of the token in the user's selected currency - * - * @example "4.55" - */ - valueInCurrency: string | null; - /** - * The amount of the token in USD - * - * @example "1.234" - */ - usd: string | null; -}; - -/** - * Asset exchange rate values for a given chain and address - */ -export type ExchangeRate = { exchangeRate?: string; usdExchangeRate?: string }; - -/** - * Values derived from the quote response - * - * @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead - */ -export type QuoteMetadata = { - /** - * If gas is included, this is the value of the src or dest token that was used to pay for the gas. - * Show this value to indicate transaction fees for gasless quotes. - */ - includedTxFees?: TokenAmountValues | null; - /** - * The gas fee for the bridge transaction. - * effective is the gas fee that is shown to the user. If this value is not - * included in the trade, the calculation falls back to the gasLimit (total) - * total is the gas fee that is spent by the user, including refunds. - * max is the max gas fee that will be used by the transaction. - */ - gasFee: Record<'effective' | 'total' | 'max', TokenAmountValues>; - /** - * The total network fee required to submit the trade and any approvals. This includes - * the relayer fee or other native fees. Should be used for balance checks and tx submission. - * Note: This is only accurate for non-gasless transactions. Use {@link QuoteMetadata.includedTxFees} to - * get the total network fee for gasless transactions. - */ - totalNetworkFee: TokenAmountValues; // estimatedGasFees + relayerFees - totalMaxNetworkFee: TokenAmountValues; // maxGasFees + relayerFees - /** - * The amount that the user will receive (destTokenAmount) - */ - toTokenAmount: TokenAmountValues; - /** - * The minimum amount that the user will receive (minDestTokenAmount) - */ - minToTokenAmount: TokenAmountValues; - /** - * If gas is included: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.includedTxFees}. - * Otherwise: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.totalNetworkFee}. - */ - adjustedReturn: Omit; - /** - * The amount that the user will send, including fees that are paid in the src token - * {@link Quote.srcTokenAmount} + {@link Quote.feeData[FeeType.METABRIDGE].amount} + {@link Quote.feeData[FeeType.TX_FEE].amount} - */ - sentAmount: TokenAmountValues; - /** - * The swap rate is the amount that the user will receive per amount sent. Accounts for fees paid in the src or dest token. - * This is calculated as {@link QuoteMetadata.toTokenAmount} / {@link QuoteMetadata.sentAmount}. - */ - swapRate: string; - /** - * The cost of the trade, which is the difference between the amount sent and the adjusted return. - * This is calculated as {@link QuoteMetadata.sentAmount} - {@link QuoteMetadata.adjustedReturn}. - */ - cost: Omit; // sentAmount - adjustedReturn -}; - /** * Sort order set by the user */ diff --git a/packages/bridge-controller/src/utils/assets.ts b/packages/bridge-controller/src/utils/assets.ts index 41bc81f3f50..c3055ece981 100644 --- a/packages/bridge-controller/src/utils/assets.ts +++ b/packages/bridge-controller/src/utils/assets.ts @@ -1,8 +1,9 @@ import type { CaipAssetType } from '@metamask/utils'; -import type { ExchangeRate, GenericQuoteRequest } from '../types'; +import type { GenericQuoteRequest } from '../types'; import { getNativeAssetForChainId } from './bridge'; import { formatAddressToAssetId } from './caip-formatters'; +import type { ExchangeRate } from './quote-metadata'; export const getAssetIdsForToken = ( tokenAddress: GenericQuoteRequest['srcTokenAddress'], diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 62f058af5c2..8b2718a03bb 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -22,7 +22,7 @@ describe('fetch', () => { const mockResponse = [ { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', decimals: 18, name: 'Ether', @@ -103,7 +103,7 @@ describe('fetch', () => { '0x0000000000000000000000000000000000000000': { address: '0x0000000000000000000000000000000000000000', aggregators: [], - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', chainId: 10, coingeckoId: 'ethereum', decimals: 18, diff --git a/packages/bridge-controller/src/utils/metrics/properties.test.ts b/packages/bridge-controller/src/utils/metrics/properties.test.ts index 7c46242b872..7ec1ad885d7 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.test.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.test.ts @@ -1,10 +1,10 @@ import { SolScope } from '@metamask/keyring-api'; import type { CaipChainId } from '@metamask/utils'; -import type { QuoteMetadata } from '../../types'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1'; import { getNativeAssetForChainId } from '../bridge'; import { formatChainIdToCaip } from '../caip-formatters'; +import type { QuoteMetadata } from '../quote-metadata'; import { MetricsSwapType } from './constants'; import { getAccountHardwareType, diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 9c0b45989a3..74e639fc806 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -3,11 +3,7 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge'; import { ChainId } from '../../types'; -import type { - GenericQuoteRequest, - QuoteMetadata, - QuoteRequest, -} from '../../types'; +import type { GenericQuoteRequest, QuoteRequest } from '../../types'; import { FeatureId } from '../../validators/feature-flags'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1'; import type { TxData } from '../../validators/trade'; @@ -16,6 +12,7 @@ import { formatAddressToAssetId, formatChainIdToCaip, } from '../caip-formatters'; +import type { QuoteMetadata } from '../quote-metadata'; import { MetricsSwapType } from './constants'; import type { AccountHardwareType, diff --git a/packages/bridge-controller/src/utils/quote-metadata.ts b/packages/bridge-controller/src/utils/quote-metadata.ts new file mode 100644 index 00000000000..12fc42dd155 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata.ts @@ -0,0 +1,211 @@ +import { QuoteResponse } from '..'; +import { isEvmQuoteResponse } from './bridge'; +import { + calcAdjustedReturn, + calcCost, + calcEstimatedAndMaxTotalGasFee, + calcIncludedTxFees, + calcNonEvmTotalNetworkFee, + calcRelayerFee, + calcSentAmount, + calcSwapRate, + calcToAmount, + calcTotalEstimatedNetworkFee, + calcTotalMaxNetworkFee, +} from './quote'; + +/** + * Asset exchange rate values for a given chain and address + */ +export type ExchangeRate = { exchangeRate?: string; usdExchangeRate?: string }; + +/** + * The types of values for the token amount and its values when converted to the user's selected currency and USD + */ +export type TokenAmountValues = { + /** + * The amount of the token + * + * @example "1.005" + */ + amount: string; + /** + * The amount of the token in the user's selected currency + * + * @example "4.55" + */ + valueInCurrency: string | null; + /** + * The amount of the token in USD + * + * @example "1.234" + */ + usd: string | null; +}; + +/** + * Values derived from the quote response + * + * @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead + */ +export type QuoteMetadata = { + /** + * If gas is included, this is the value of the src or dest token that was used to pay for the gas. + * Show this value to indicate transaction fees for gasless quotes. + */ + includedTxFees?: TokenAmountValues | null; + /** + * The gas fee for the bridge transaction. + * effective is the gas fee that is shown to the user. If this value is not + * included in the trade, the calculation falls back to the gasLimit (total) + * total is the gas fee that is spent by the user, including refunds. + * max is the max gas fee that will be used by the transaction. + */ + gasFee: Record<'effective' | 'total' | 'max', TokenAmountValues>; + /** + * The total network fee required to submit the trade and any approvals. This includes + * the relayer fee or other native fees. Should be used for balance checks and tx submission. + * Note: This is only accurate for non-gasless transactions. Use {@link QuoteMetadata.includedTxFees} to + * get the total network fee for gasless transactions. + */ + totalNetworkFee: TokenAmountValues; // estimatedGasFees + relayerFees + totalMaxNetworkFee: TokenAmountValues; // maxGasFees + relayerFees + + /** + * The amount that the user will receive (destTokenAmount) + */ + toTokenAmount: TokenAmountValues; + /** + * The minimum amount that the user will receive (minDestTokenAmount) + */ + minToTokenAmount: TokenAmountValues; + /** + * If gas is included: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.includedTxFees}. + * Otherwise: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.totalNetworkFee}. + */ + adjustedReturn: Omit; + /** + * The amount that the user will send, including fees that are paid in the src token + * {@link Quote.srcTokenAmount} + {@link Quote.feeData[FeeType.METABRIDGE].amount} + {@link Quote.feeData[FeeType.TX_FEE].amount} + */ + sentAmount: TokenAmountValues; + /** + * The swap rate is the amount that the user will receive per amount sent. Accounts for fees paid in the src or dest token. + * This is calculated as {@link QuoteMetadata.toTokenAmount} / {@link QuoteMetadata.sentAmount}. + */ + swapRate: string; + /** + * The cost of the trade, which is the difference between the amount sent and the adjusted return. + * This is calculated as {@link QuoteMetadata.sentAmount} - {@link QuoteMetadata.adjustedReturn}. + */ + cost: Omit; // sentAmount - adjustedReturn +}; + +export const mergeQuoteMetadata = ( + quote: QuoteResponse, + quoteMetadata: QuoteMetadata, +) => { + return { + ...quote, + ...quoteMetadata, + }; +}; + +export const calcQuoteMetadata = ( + quote: QuoteResponse, + options: { + bridgeFeesPerGas: null | { + estimatedBaseFeeInDecGwei: string | null; + feePerGasInDecGwei?: string; + maxFeePerGasInDecGwei?: string; + }; + srcTokenExchangeRate: ExchangeRate; + destTokenExchangeRate: ExchangeRate; + nativeExchangeRate: ExchangeRate; + }, +): QuoteMetadata => { + const { + bridgeFeesPerGas, + srcTokenExchangeRate, + destTokenExchangeRate, + nativeExchangeRate, + } = options; + + const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); + const toTokenAmount = calcToAmount( + quote.quote.destTokenAmount, + quote.quote.destAsset, + destTokenExchangeRate, + ); + const minToTokenAmount = calcToAmount( + quote.quote.minDestTokenAmount, + quote.quote.destAsset, + destTokenExchangeRate, + ); + + const includedTxFees = calcIncludedTxFees( + quote.quote, + srcTokenExchangeRate, + destTokenExchangeRate, + ); + + let totalEstimatedNetworkFee, + totalMaxNetworkFee, + relayerFee, + gasFee: QuoteMetadata['gasFee']; + + if (isEvmQuoteResponse(quote)) { + relayerFee = calcRelayerFee(quote, nativeExchangeRate); + gasFee = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: quote, + ...bridgeFeesPerGas, + ...nativeExchangeRate, + }); + // Uses effectiveGasFee to calculate the total estimated network fee + totalEstimatedNetworkFee = calcTotalEstimatedNetworkFee(gasFee, relayerFee); + totalMaxNetworkFee = calcTotalMaxNetworkFee(gasFee, relayerFee); + } else { + // Use the new generic function for all non-EVM chains + totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( + quote, + nativeExchangeRate, + ); + gasFee = { + effective: totalEstimatedNetworkFee, + total: totalEstimatedNetworkFee, + max: totalEstimatedNetworkFee, + }; + totalMaxNetworkFee = totalEstimatedNetworkFee; + } + + const adjustedReturn = calcAdjustedReturn( + toTokenAmount, + totalEstimatedNetworkFee, + quote.quote, + ); + const cost = calcCost(adjustedReturn, sentAmount); + + return mergeQuoteMetadata(quote, { + // QuoteMetadata fields + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), + /** + This is the amount required to submit all the transactions. + Includes the relayer fee or other native fees. + Should be used for balance checks and tx submission. + */ + totalNetworkFee: totalEstimatedNetworkFee, + totalMaxNetworkFee, + /** + This contains gas fee estimates for the bridge transaction + Does not include the relayer fee (if needed), just the gasLimit and effectiveGas returned by the bridge API. + Should only be used for display purposes. + */ + gasFee, + adjustedReturn, + cost, + includedTxFees, + }); +}; diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 4113818c892..35a09ca722a 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -6,6 +6,7 @@ import type { GenericQuoteRequest, NonEvmFees, L1GasFees } from '../types'; import type { Quote } from '../validators/quote'; import type { QuoteResponseV1 } from '../validators/quote-response-v1'; import type { TxData } from '../validators/trade'; +import { getNativeAssetForChainId } from './bridge'; import { isValidQuoteRequest, getQuoteIdentifier, @@ -161,14 +162,50 @@ describe('Quote Metadata Utils', () => { }); describe('calcSentAmount', () => { + it('should calculate sent amount correctly with exchange rates (no assetId)', () => { + const mockQuote: Quote = { + srcTokenAmount: '12555423', + srcAsset: { + decimals: 6, + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + }, + feeData: { + metabridge: { + amount: '100000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + }, + }, + }, + } as unknown as Quote; + const result = calcSentAmount(mockQuote, { + exchangeRate: '2.14', + usdExchangeRate: '1.5', + }); + + expect(result.amount).toBe('112.555423'); + expect(result.valueInCurrency).toBe('240.86860522'); + expect(result.usd).toBe('168.8331345'); + }); + it('should calculate sent amount correctly with exchange rates', () => { const mockQuote: Quote = { srcTokenAmount: '12555423', - srcAsset: { decimals: 6 }, + srcAsset: { + decimals: 6, + assetId: getNativeAssetForChainId(1).assetId, + }, feeData: { - metabridge: { amount: '100000000' }, + metabridge: { + amount: '100000000', + asset: { + assetId: getNativeAssetForChainId(1).assetId, + }, + }, }, - } as Quote; + } as unknown as Quote; const result = calcSentAmount(mockQuote, { exchangeRate: '2.14', usdExchangeRate: '1.5', @@ -182,11 +219,21 @@ describe('Quote Metadata Utils', () => { it('should handle missing exchange rates', () => { const mockQuote: Quote = { srcTokenAmount: '1000000000', - srcAsset: { decimals: 6 }, + srcAsset: { + decimals: 6, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, + }, feeData: { - metabridge: { amount: '100000000' }, + metabridge: { + amount: '100000000', + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, + }, + }, }, - } as Quote; + } as unknown as Quote; const result = calcSentAmount(mockQuote, {}); expect(result.amount).toBe('1100'); @@ -197,11 +244,21 @@ describe('Quote Metadata Utils', () => { it('should handle zero values', () => { const mockQuote: Quote = { srcTokenAmount: '0', - srcAsset: { decimals: 6 }, + srcAsset: { + decimals: 6, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, + }, feeData: { - metabridge: { amount: '0' }, + metabridge: { + amount: '0', + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, + }, + }, }, - } as Quote; + } as unknown as Quote; const zeroQuote = { ...mockQuote, srcTokenAmount: '0', diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index fec1a84538e..739c6b28025 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -8,10 +8,8 @@ import { BigNumber } from 'bignumber.js'; import type { BridgeAsset, - ExchangeRate, GenericQuoteRequest, L1GasFees, - QuoteMetadata, NonEvmFees, } from '../types'; import { FeatureId } from '../validators/feature-flags'; @@ -19,6 +17,7 @@ import type { Quote } from '../validators/quote'; import type { QuoteResponseV1 } from '../validators/quote-response-v1'; import { TxData } from '../validators/trade'; import { isNativeAddress, isNonEvmChainId } from './bridge'; +import type { ExchangeRate, QuoteMetadata } from './quote-metadata'; export const isValidQuoteRequest = ( partialRequest: Partial, diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts index 07c33bfb418..35c6cffab6b 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -25,7 +25,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ minDestTokenAmount: '970000000000000000', destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -61,7 +61,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ }, srcAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', chainId: 10, symbol: 'ETH', decimals: 18, @@ -70,7 +70,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ }, destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -121,7 +121,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ minDestTokenAmount: '969000000000000000', destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -171,7 +171,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ }, destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -226,7 +226,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ minDestTokenAmount: '968000000000000000', destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -284,7 +284,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ }, destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -343,7 +343,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ minDestTokenAmount: '967000000000000000', destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -387,7 +387,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ }, srcAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', chainId: 10, symbol: 'ETH', decimals: 18, @@ -400,7 +400,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ }, destAsset: { address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, symbol: 'ETH', decimals: 18, @@ -456,7 +456,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ destAsset: { chainId: 42161, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -510,7 +510,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ destAsset: { chainId: 42161, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -576,7 +576,7 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ id: '42161_0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:42161/slip44:614', + assetId: 'eip155:42161/slip44:60', chainId: 42161, name: 'ETH', decimals: 18, diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts index ed44c75a924..389098897a4 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -13,7 +13,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ srcAsset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -38,7 +38,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ asset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -56,7 +56,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ srcAsset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -106,7 +106,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ srcAsset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ether', decimals: 18, @@ -114,7 +114,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ destAsset: { chainId: 137, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:137/slip44:614', + assetId: 'eip155:137/slip44:966', symbol: 'MATIC', name: 'Matic', decimals: 18, @@ -139,7 +139,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ srcAsset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -164,7 +164,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ asset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -182,7 +182,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ srcAsset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ethereum', decimals: 18, @@ -232,7 +232,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ srcAsset: { chainId: 10, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:10/slip44:614', + assetId: 'eip155:10/slip44:60', symbol: 'ETH', name: 'Ether', decimals: 18, @@ -240,7 +240,7 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ destAsset: { chainId: 137, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:137/slip44:614', + assetId: 'eip155:137/slip44:966', symbol: 'MATIC', name: 'Matic', decimals: 18, From 77daa4fe3b4845c09f8da1f9b04f71919d53976f Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 14 Jul 2026 11:02:34 -0700 Subject: [PATCH 2/8] chore: mergeQuoteMetadata --- packages/bridge-controller/CHANGELOG.md | 5 +- packages/bridge-controller/src/index.ts | 1 + .../bridge-controller/src/selectors.test.ts | 10 ++- packages/bridge-controller/src/selectors.ts | 11 +-- .../src/utils/quote-metadata.ts | 30 ++++--- .../bridge-controller/src/utils/quote.test.ts | 72 ++++++++-------- packages/bridge-controller/src/utils/quote.ts | 81 +++++++++--------- .../bridge-status-controller/CHANGELOG.md | 4 + ...ridge-status-controller.batch-sell.test.ts | 42 ++++++---- .../bridge-status-controller.intent.test.ts | 83 ++++++++++++------- .../src/bridge-status-controller.test.ts | 59 +++++++------ .../bridge-status-controller/src/types.ts | 10 +-- .../src/utils/history.test.ts | 20 +++-- .../src/utils/history.ts | 10 +-- .../src/utils/metrics.test.ts | 19 +++-- .../src/utils/metrics.ts | 6 +- .../src/utils/snaps.test.ts | 42 ++++++---- .../src/utils/snaps.ts | 2 +- 18 files changed, 291 insertions(+), 216 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 92c11b577bd..7dbacdc8161 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -13,7 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Extract `QuoteMetadata` type and calculation to a new file +- **BREAKING:** Make `QuoteMetadata` fields optional, remove `0` and `null` amount fallbacks +- Refactor quoteMetadata calculation and data access to prepare for metadata migration + - Extract `QuoteMetadata` type and calculation to a new file + - Implement `mergeQuoteMetadata` util which appends QuoteMetadata to QuoteResponse - Bump `@metamask/assets-controller` from `^10.2.1` to `^11.0.0` ([#9485](https://github.com/MetaMask/core/pull/9485)) ## [77.4.1] diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index e6e2fb1592c..f38a2e723ce 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -38,6 +38,7 @@ export { } from './utils/metrics/properties'; export type { QuoteMetadata, TokenAmountValues } from './utils/quote-metadata'; +export { mergeQuoteMetadata } from './utils/quote-metadata'; export type { ChainConfiguration, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 1ede161451d..0966ffe61c7 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -39,6 +39,7 @@ import { import { BatchSellTransactionType } from './validators/batch-sell'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; import { validateQuoteResponseV1 } from './validators/quote-response-v1'; +import { mergeQuoteMetadata } from './utils/quote-metadata'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -1892,7 +1893,10 @@ describe('Bridge Selectors', () => { }, }; const solanaQuote = solanaState.quotes[1]; - const quoteResponseV1 = { ...solanaQuote, ...expectedQuoteMetadata }; + const quoteResponseV1 = mergeQuoteMetadata( + solanaQuote, + expectedQuoteMetadata, + ); validateQuoteResponseV1(quoteResponseV1); expect(result.recommendedQuote).toStrictEqual(quoteResponseV1); }); @@ -2028,7 +2032,7 @@ describe('Bridge Selectors', () => { ] `); expect( - recommendedQuotes.map((quote) => quote?.sentAmount.usd), + recommendedQuotes.map((quote) => quote?.sentAmount?.usd), ).toStrictEqual(['18', '140']); }); @@ -2311,7 +2315,7 @@ describe('Bridge Selectors', () => { batchSellTrades: null, }); - expect(result.totalNetworkFee).toMatchInlineSnapshot(`undefined`); + expect(result.totalNetworkFee?.usd).toMatchInlineSnapshot(`undefined`); expect(result.isBatchSellTradeAvailable).toBe(false); expect(result.isLoading).toBe(false); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 9386aba2e26..e39b9a657ad 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -36,7 +36,7 @@ import { processFeatureFlags } from './utils/feature-flags'; import { calcBatchFees } from './utils/quote'; import type { ExchangeRate } from './utils/quote-metadata'; import type { TokenAmountValues } from './utils/quote-metadata'; -import { calcQuoteMetadata } from './utils/quote-metadata'; +import { calcQuoteMetadata, mergeQuoteMetadata } from './utils/quote-metadata'; import type { QuoteMetadata } from './utils/quote-metadata'; import { getDefaultSlippagePercentage } from './utils/slippage'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; @@ -363,10 +363,7 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( destTokenExchangeRate, nativeExchangeRate, }); - return { - ...quote, - ...quoteMetadata, - }; + return mergeQuoteMetadata(quote, quoteMetadata); }); }, ); @@ -385,10 +382,10 @@ const selectSortedBridgeQuotes = createBridgeSelector( 'asc', ); default: - if (quotesWithMetadata.every((quote) => quote.cost.valueInCurrency)) { + if (quotesWithMetadata.every((quote) => quote?.cost?.valueInCurrency)) { return orderBy( quotesWithMetadata, - ({ cost }) => Number(cost.valueInCurrency), + ({ cost }) => Number(cost?.valueInCurrency), 'asc', ); } diff --git a/packages/bridge-controller/src/utils/quote-metadata.ts b/packages/bridge-controller/src/utils/quote-metadata.ts index 12fc42dd155..564b2f99972 100644 --- a/packages/bridge-controller/src/utils/quote-metadata.ts +++ b/packages/bridge-controller/src/utils/quote-metadata.ts @@ -1,4 +1,5 @@ import { QuoteResponse } from '..'; +import type { DeepPartial } from '../types'; import { isEvmQuoteResponse } from './bridge'; import { calcAdjustedReturn, @@ -48,7 +49,7 @@ export type TokenAmountValues = { * * @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead */ -export type QuoteMetadata = { +type QuoteMetadataV1 = { /** * If gas is included, this is the value of the src or dest token that was used to pay for the gas. * Show this value to indicate transaction fees for gasless quotes. @@ -100,16 +101,7 @@ export type QuoteMetadata = { */ cost: Omit; // sentAmount - adjustedReturn }; - -export const mergeQuoteMetadata = ( - quote: QuoteResponse, - quoteMetadata: QuoteMetadata, -) => { - return { - ...quote, - ...quoteMetadata, - }; -}; +export type QuoteMetadata = DeepPartial; export const calcQuoteMetadata = ( quote: QuoteResponse, @@ -209,3 +201,19 @@ export const calcQuoteMetadata = ( includedTxFees, }); }; + +/** + * Merges the quote metadata into the quote + * + * @param quote The quote to merge the metadata into + * @param quoteMetadata The metadata to merge into the quote + * @returns The quote with the metadata merged in + */ +export function mergeQuoteMetadata< + QuoteType extends DeepPartial, +>(quote: QuoteType, quoteMetadata: QuoteMetadata): QuoteType & QuoteMetadata { + return { + ...quote, + ...quoteMetadata, + }; +} diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 35a09ca722a..37782f34e8f 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -547,8 +547,8 @@ describe('Quote Metadata Utils', () => { }, } `); - expect(result.total.amount).toBeDefined(); - expect(result.max.amount).toBeDefined(); + expect(result?.total?.amount).toBeDefined(); + expect(result?.max?.amount).toBeDefined(); }); it('should calculate estimated and max gas fees correctly when effectiveGas is available', () => { @@ -583,8 +583,8 @@ describe('Quote Metadata Utils', () => { }, } `); - expect(result.total.amount).toBeDefined(); - expect(result.max.amount).toBeDefined(); + expect(result?.total?.amount).toBeDefined(); + expect(result?.max?.amount).toBeDefined(); }); it('should handle missing exchange rates', () => { @@ -596,12 +596,12 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: undefined, }); - expect(result.total.valueInCurrency).toBeNull(); - expect(result.max.valueInCurrency).toBeNull(); - expect(result.total.usd).toBeNull(); - expect(result.max.usd).toBeNull(); - expect(result.total.amount).toBeDefined(); - expect(result.max.amount).toBeDefined(); + expect(result?.total?.valueInCurrency).toBeUndefined(); + expect(result?.max?.valueInCurrency).toBeUndefined(); + expect(result?.total?.usd).toBeUndefined(); + expect(result?.max?.usd).toBeUndefined(); + expect(result?.total?.amount).toBeDefined(); + expect(result?.max?.amount).toBeDefined(); }); it('should handle only display currency exchange rate', () => { @@ -613,10 +613,10 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: undefined, }); - expect(result.total.valueInCurrency).toBeDefined(); - expect(result.max.valueInCurrency).toBeDefined(); - expect(result.total.usd).toBeNull(); - expect(result.max.usd).toBeNull(); + expect(result?.total?.valueInCurrency).toBeDefined(); + expect(result?.max?.valueInCurrency).toBeDefined(); + expect(result?.total?.usd).toBeUndefined(); + expect(result?.max?.usd).toBeUndefined(); }); it('should handle only USD exchange rate', () => { @@ -628,10 +628,10 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '1500', }); - expect(result.total.valueInCurrency).toBeNull(); - expect(result.max.valueInCurrency).toBeNull(); - expect(result.total.usd).toBeDefined(); - expect(result.max.usd).toBeDefined(); + expect(result?.total?.valueInCurrency).toBeUndefined(); + expect(result?.max?.valueInCurrency).toBeUndefined(); + expect(result?.total?.usd).toBeDefined(); + expect(result?.max?.usd).toBeDefined(); }); it('should handle zero gas limits', () => { @@ -651,10 +651,10 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '1500', }); - expect(result.total.amount).toBe('0'); - expect(result.max.amount).toBe('0'); - expect(result.total.valueInCurrency).toBe('0'); - expect(result.total.usd).toBe('0'); + expect(result?.total?.amount).toBeUndefined(); + expect(result?.max?.amount).toBeUndefined(); + expect(result?.total?.valueInCurrency).toBeUndefined(); + expect(result?.total?.usd).toBeUndefined(); }); it('should handle missing approval', () => { @@ -674,10 +674,10 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '1500', }); - expect(result.total.amount).toBeDefined(); - expect(result.max.amount).toBeDefined(); - expect(parseFloat(result.max.amount)).toStrictEqual( - parseFloat(result.total.amount), + expect(result?.total?.amount).toBeDefined(); + expect(result?.max?.amount).toBeDefined(); + expect(parseFloat(result?.max?.amount ?? '0')).toStrictEqual( + parseFloat(result?.total?.amount ?? '0'), ); }); @@ -698,8 +698,8 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '1500', }); - expect(result.total.amount).toBeDefined(); - expect(result.max.amount).toBeDefined(); + expect(result?.total?.amount).toBeUndefined(); + expect(result?.max?.amount).toBeUndefined(); }); it('should handle large gas limits and fees', () => { @@ -719,16 +719,18 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '2500', }); - expect(parseFloat(result.total.amount)).toBeGreaterThan(2); // Should be > 2 ETH due to L1 fees - expect(parseFloat(result.max.amount)).toStrictEqual( - parseFloat(result.total.amount), + expect(parseFloat(result?.total?.amount ?? '0')).toBeGreaterThan(2); // Should be > 2 ETH due to L1 fees + expect(parseFloat(result?.max?.amount ?? '0')).toStrictEqual( + parseFloat(result?.total?.amount ?? '0'), ); - expect(result.total.valueInCurrency).toBeDefined(); - expect(result.total.usd).toBeDefined(); + expect(result?.total?.valueInCurrency).toBeDefined(); + expect(result?.total?.usd).toBeDefined(); expect( - parseFloat(result.total.valueInCurrency as string), + parseFloat((result?.total?.valueInCurrency as string) ?? '0'), ).toBeGreaterThan(6000); - expect(parseFloat(result.total.usd as string)).toBeGreaterThan(5000); + expect(parseFloat((result?.total?.usd as string) ?? '0')).toBeGreaterThan( + 5000, + ); }); }); diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 739c6b28025..fd24fc02d8b 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -108,14 +108,18 @@ export const calcNonEvmTotalNetworkFee = ( ) => { const { nonEvmFeesInNative } = bridgeQuote; // Fees are now stored directly in native units (SOL, BTC) without conversion - const feeInNative = new BigNumber(nonEvmFeesInNative ?? '0'); + const feeInNative = nonEvmFeesInNative + ? new BigNumber(nonEvmFeesInNative) + : undefined; return { - amount: feeInNative.toString(), + amount: feeInNative?.toString(), valueInCurrency: exchangeRate - ? feeInNative.times(exchangeRate).toString() + ? feeInNative?.times(exchangeRate).toString() + : null, + usd: usdExchangeRate + ? feeInNative?.times(usdExchangeRate).toString() : null, - usd: usdExchangeRate ? feeInNative.times(usdExchangeRate).toString() : null, }; }; @@ -232,27 +236,29 @@ const calcTotalGasFee = ({ nativeToDisplayCurrencyExchangeRate?: string; nativeToUsdExchangeRate?: string; }) => { - const totalGasLimitInDec = new BigNumber(tradeGasLimit?.toString() ?? '0') - .plus(approvalGasLimit?.toString() ?? '0') - .plus(resetApprovalGasLimit?.toString() ?? '0'); + const totalGasLimitInDec = tradeGasLimit + ? new BigNumber(tradeGasLimit.toString()) + .plus(approvalGasLimit?.toString() ?? '0') + .plus(resetApprovalGasLimit?.toString() ?? '0') + : undefined; const l1GasFeesInDecGWei = weiHexToGweiDec(toHex(l1GasFeesInHexWei ?? '0')); const gasFeesInDecGwei = totalGasLimitInDec - .times(feePerGasInDecGwei ?? '0') + ?.times(feePerGasInDecGwei ?? '0') .plus(l1GasFeesInDecGWei); - const gasFeesInDecEth = gasFeesInDecGwei.times(new BigNumber(10).pow(-9)); + const gasFeesInDecEth = gasFeesInDecGwei?.times(new BigNumber(10).pow(-9)); const gasFeesInDisplayCurrency = nativeToDisplayCurrencyExchangeRate - ? gasFeesInDecEth.times(nativeToDisplayCurrencyExchangeRate.toString()) + ? gasFeesInDecEth?.times(nativeToDisplayCurrencyExchangeRate.toString()) : null; const gasFeesInUSD = nativeToUsdExchangeRate - ? gasFeesInDecEth.times(nativeToUsdExchangeRate.toString()) + ? gasFeesInDecEth?.times(nativeToUsdExchangeRate.toString()) : null; return { - amount: gasFeesInDecEth.toString(), - valueInCurrency: gasFeesInDisplayCurrency?.toString() ?? null, - usd: gasFeesInUSD?.toString() ?? null, + amount: gasFeesInDecEth?.toString(), + valueInCurrency: gasFeesInDisplayCurrency?.toString(), + usd: gasFeesInUSD?.toString(), }; }; @@ -338,23 +344,22 @@ export const calcEstimatedAndMaxTotalGasFee = ({ * @returns The total estimated network fee for the bridge transaction, including the relayer fee paid to bridge providers */ export const calcTotalEstimatedNetworkFee = ( - { total: gasFeeToDisplay }: ReturnType, + gasFee: ReturnType, relayerFee: ReturnType, ) => { + const { total: gasFeeToDisplay } = gasFee ?? {}; return { - amount: new BigNumber(gasFeeToDisplay?.amount ?? '0') - .plus(relayerFee.amount) - .toString(), - valueInCurrency: gasFeeToDisplay?.valueInCurrency - ? new BigNumber(gasFeeToDisplay.valueInCurrency) - .plus(relayerFee.valueInCurrency ?? '0') - .toString() - : null, - usd: gasFeeToDisplay?.usd - ? new BigNumber(gasFeeToDisplay.usd) - .plus(relayerFee.usd ?? '0') - .toString() - : null, + amount: + gasFeeToDisplay?.amount && + new BigNumber(gasFeeToDisplay.amount).plus(relayerFee.amount).toString(), + valueInCurrency: + gasFeeToDisplay?.valueInCurrency && + new BigNumber(gasFeeToDisplay.valueInCurrency) + .plus(relayerFee.valueInCurrency ?? '0') + .toString(), + usd: + gasFeeToDisplay?.usd && + new BigNumber(gasFeeToDisplay.usd).plus(relayerFee.usd ?? '0').toString(), }; }; @@ -363,15 +368,17 @@ export const calcTotalMaxNetworkFee = ( relayerFee: ReturnType, ) => { return { - amount: new BigNumber(gasFee.max.amount).plus(relayerFee.amount).toString(), - valueInCurrency: gasFee.max.valueInCurrency - ? new BigNumber(gasFee.max.valueInCurrency) - .plus(relayerFee.valueInCurrency ?? '0') - .toString() - : null, - usd: gasFee.max.usd - ? new BigNumber(gasFee.max.usd).plus(relayerFee.usd ?? '0').toString() - : null, + amount: + gasFee?.max?.amount && + new BigNumber(gasFee.max.amount).plus(relayerFee.amount).toString(), + valueInCurrency: + gasFee?.max?.valueInCurrency && + new BigNumber(gasFee.max.valueInCurrency) + .plus(relayerFee.valueInCurrency ?? '0') + .toString(), + usd: + gasFee?.max?.usd && + new BigNumber(gasFee.max.usd).plus(relayerFee.usd ?? '0').toString(), }; }; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 37834521cf0..145d2c28d5e 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Include `transaction_internal_id` in `Unified SwapBridge Completed` events for EVM source transactions. ([#9494](https://github.com/MetaMask/core/pull/9494)) +### Changed + +- + ## [74.1.2] ### Changed diff --git a/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts index 8281f5754fb..d20a415c5f2 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.batch-sell.test.ts @@ -7,6 +7,7 @@ import type { import { BatchSellTransactionType, FeatureId, + mergeQuoteMetadata, } from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; @@ -153,24 +154,29 @@ const mockSelectedAccount = { }, }; const batchId = '0xBatchId1'; -const mockQuotes = mockBatchSellErc20Erc20.map((quote) => ({ - ...quote, - quote: { - ...quote.quote, - // BatchSell quotes have no gasless params because they are not simulated - gasIncluded7702: undefined, - gasIncluded: undefined, - gasSponsored: undefined, - }, - sentAmount: { - usd: '100', - valueInCurrency: '200', - }, - toTokenAmount: { - usd: '101', - valueInCurrency: '201', - }, -})); +const mockQuotes = mockBatchSellErc20Erc20 + .map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + // BatchSell quotes have no gasless params because they are not simulated + gasIncluded7702: undefined, + gasIncluded: undefined, + gasSponsored: undefined, + }, + })) + .map((quote) => + mergeQuoteMetadata(quote, { + sentAmount: { + usd: '100', + valueInCurrency: '200', + }, + toTokenAmount: { + usd: '101', + valueInCurrency: '201', + }, + }), + ); const mockTransferTx: BatchSellTradesResponse['transactions'][number] = { chainId: 10, from: '0xaccount1', diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index a4c6824222a..337a2d3616a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -3,8 +3,10 @@ /* eslint-disable jest/no-restricted-matchers */ import { BridgeClientId, - StatusTypes, UnifiedSwapBridgeEventName, + mergeQuoteMetadata, + StatusTypes, + QuoteResponse as QuoteResponseV1, } from '@metamask/bridge-controller'; import type { GasFeeEstimates, @@ -28,8 +30,10 @@ jest .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') .mockImplementation(jest.fn()); -const minimalIntentQuoteResponse = (overrides?: Partial): any => { - return { +const minimalIntentQuoteResponse = ( + overrides?: Partial, +): any => { + const quote = { quote: { requestId: 'req-1', srcChainId: 1, @@ -43,7 +47,7 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { symbol: 'ETH', chainId: 1, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:1/slip44:60', + assetId: 'eip155:1/slip44:60' as const, name: 'ETH', decimals: 18, }, @@ -51,11 +55,13 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { symbol: 'ETH', chainId: 1, address: '0x0000000000000000000000000000000000000000', - assetId: 'eip155:1/slip44:60', + assetId: 'eip155:1/slip44:60' as const, name: 'ETH', decimals: 18, }, - feeData: { txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, + feeData: { + txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' }, + } as never, intent: { protocol: 'cowswap', order: { some: 'order' }, @@ -68,9 +74,6 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { }, }, }, - sentAmount: { amount: '1', usd: '1' }, - gasFee: { effective: { amount: '0', usd: '0' } }, - toTokenAmount: { usd: '1' }, estimatedProcessingTimeInSeconds: 15, featureId: undefined, approval: undefined, @@ -85,13 +88,18 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { }, ...overrides, }; + return mergeQuoteMetadata(quote as never, { + sentAmount: { amount: '1', usd: '1' }, + gasFee: { effective: { amount: '0', usd: '0' } }, + toTokenAmount: { usd: '1' }, + }); }; const minimalBridgeQuoteResponse = ( accountAddress: string, - overrides?: Partial, + overrides?: Partial, ): any => { - return { + const quote = { quote: { requestId: 'req-bridge-1', srcChainId: 1, @@ -119,9 +127,7 @@ const minimalBridgeQuoteResponse = ( bridges: ['across'], bridgeId: 'socket', }, - sentAmount: { amount: '1', usd: '1' }, - gasFee: { effective: { amount: '0', usd: '0' } }, - toTokenAmount: { usd: '1' }, + estimatedProcessingTimeInSeconds: 15, featureId: undefined, approval: undefined, @@ -136,6 +142,11 @@ const minimalBridgeQuoteResponse = ( }, ...overrides, }; + return mergeQuoteMetadata(quote as never, { + sentAmount: { amount: '1', usd: '1' }, + gasFee: { effective: { amount: '0', usd: '0' } }, + toTokenAmount: { usd: '1' }, + }); }; const createMessengerHarness = ( @@ -920,13 +931,17 @@ describe('BridgeStatusController (target uncovered branches)', () => { // make startPolling return different tokens for the same tx startPollingSpy.mockReturnValueOnce('tok1').mockReturnValueOnce('tok2'); - const quoteResponse: any = { - quote: { srcChainId: 1, destChainId: 10, destAsset: { assetId: 'x' } }, - estimatedProcessingTimeInSeconds: 1, - sentAmount: { amount: '0' }, - gasFee: { effective: { amount: '0' } }, - toTokenAmount: { usd: '0' }, - }; + const quoteResponse: any = mergeQuoteMetadata( + { + quote: { srcChainId: 1, destChainId: 10, destAsset: { assetId: 'x' } }, + estimatedProcessingTimeInSeconds: 1, + }, + { + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }, + ); // first time => starts polling tok1 controller.startPollingForBridgeTxStatus({ @@ -979,18 +994,22 @@ describe('BridgeStatusController (target uncovered branches)', () => { mockTxHistory, }); - const quoteResponse: any = { - quote: { - srcChainId: 1, - destChainId: 10, - destAsset: { assetId: 'x' }, - bridges: ['across'], + const quoteResponse: any = mergeQuoteMetadata( + { + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'x' }, + bridges: ['across'], + }, + estimatedProcessingTimeInSeconds: 1, }, - estimatedProcessingTimeInSeconds: 1, - sentAmount: { amount: '0' }, - gasFee: { effective: { amount: '0' } }, - toTokenAmount: { usd: '0' }, - }; + { + sentAmount: { amount: '0' }, + gasFee: { effective: { amount: '0' } }, + toTokenAmount: { usd: '0' }, + }, + ); const statusResponse = { status: { 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 acf32d7a62f..0f82ad8ca33 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -18,6 +18,7 @@ import { getQuotesReceivedProperties, UnifiedSwapBridgeEventName, MetaMetricsSwapsEventSource, + mergeQuoteMetadata, } from '@metamask/bridge-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -307,32 +308,36 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ id: txMetaId, hash: srcTxHash === 'undefined' ? undefined : srcTxHash, } as TransactionMeta, - quoteResponse: { - quote: getMockQuote({ srcChainId, destChainId }), - trade: { - chainId: srcChainId, - to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', - from: account as Hex, - value: '0x038d7ea4c68000', - data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', - gasLimit: 282915, + quoteResponse: mergeQuoteMetadata( + { + quote: getMockQuote({ srcChainId, destChainId }), + trade: { + chainId: srcChainId, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: account as Hex, + value: '0x038d7ea4c68000', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', + gasLimit: 282915, + }, + approval: null as never, + estimatedProcessingTimeInSeconds: 15, }, - approval: null as never, - estimatedProcessingTimeInSeconds: 15, - sentAmount: { amount: '1.234', valueInCurrency: null, usd: null }, - toTokenAmount: { amount: '1.234', valueInCurrency: null, usd: null }, - minToTokenAmount: { amount: '1.17', valueInCurrency: null, usd: null }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, + { + sentAmount: { amount: '1.234', valueInCurrency: null, usd: null }, + toTokenAmount: { amount: '1.234', valueInCurrency: null, usd: null }, + minToTokenAmount: { amount: '1.17', valueInCurrency: null, usd: null }, + totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + gasFee: { + effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, + total: { amount: '1.234', valueInCurrency: null, usd: null }, + max: { amount: '1.234', valueInCurrency: null, usd: null }, + }, + adjustedReturn: { valueInCurrency: null, usd: null }, + swapRate: '1.234', + cost: { valueInCurrency: null, usd: null }, }, - adjustedReturn: { valueInCurrency: null, usd: null }, - swapRate: '1.234', - cost: { valueInCurrency: null, usd: null }, - }, + ), accountAddress: account, startTime: 1729964825189, slippagePercentage: 0, @@ -2170,7 +2175,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana bridge', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuote: QuoteResponse = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2243,6 +2248,8 @@ describe('BridgeStatusController', () => { estimatedProcessingTimeInSeconds: 300, trade: 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=', + }; + const mockQuoteResponse = mergeQuoteMetadata(mockQuote, { sentAmount: { amount: '1', valueInCurrency: '100', @@ -2282,7 +2289,7 @@ describe('BridgeStatusController', () => { usd: '15', }, swapRate: '0.5', - }; + }); const mockSolanaAccount = { id: 'solana-account-1', diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index c9bdd98f7f1..9b7e05ae319 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -166,11 +166,11 @@ export type BridgeHistoryItem = { /** * The actual amount sent by user in non-atomic decimal form */ - amountSent: QuoteMetadata['sentAmount']['amount']; - amountSentInUsd?: QuoteMetadata['sentAmount']['usd']; - quotedGasInUsd?: QuoteMetadata['gasFee']['effective']['usd']; - quotedGasAmount?: QuoteMetadata['gasFee']['effective']['amount']; - quotedReturnInUsd?: QuoteMetadata['toTokenAmount']['usd']; + amountSent: string; + amountSentInUsd?: string; + quotedGasInUsd?: string; + quotedGasAmount?: string; + quotedReturnInUsd?: string; quotedRefuelSrcAmountInUsd?: string; quotedRefuelDestAmountInUsd?: string; }; diff --git a/packages/bridge-status-controller/src/utils/history.test.ts b/packages/bridge-status-controller/src/utils/history.test.ts index 695e9d4ab32..77a802e22d6 100644 --- a/packages/bridge-status-controller/src/utils/history.test.ts +++ b/packages/bridge-status-controller/src/utils/history.test.ts @@ -1,4 +1,4 @@ -import { StatusTypes } from '@metamask/bridge-controller'; +import { StatusTypes, mergeQuoteMetadata } from '@metamask/bridge-controller'; import type { Quote } from '@metamask/bridge-controller'; import type { @@ -114,13 +114,17 @@ describe('History Utils', () => { describe('getInitialHistoryItem', () => { const baseArgs = { bridgeTxMeta: { id: 'tx1', hash: '0xhash' }, - quoteResponse: { - quote: { srcChainId: 1, destChainId: 10 }, - estimatedProcessingTimeInSeconds: 60, - sentAmount: { amount: '1', usd: '2' }, - gasFee: { effective: { amount: '0.001', usd: '3' } }, - toTokenAmount: { amount: '1', usd: '4' }, - }, + quoteResponse: mergeQuoteMetadata( + { + quote: { srcChainId: 1, destChainId: 10 }, + estimatedProcessingTimeInSeconds: 60, + }, + { + sentAmount: { amount: '1', usd: '2' }, + gasFee: { effective: { amount: '0.001', usd: '3' } }, + toTokenAmount: { amount: '1', usd: '4' }, + }, + ), startTime: 1, slippagePercentage: 0, accountAddress: '0xaccount', diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 3eb541926a6..272f8e62d40 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -225,11 +225,11 @@ export const getInitialHistoryItem = ( quoteResponse.estimatedProcessingTimeInSeconds, slippagePercentage, pricingData: { - amountSent: quoteResponse.sentAmount?.amount ?? '0', - amountSentInUsd: quoteResponse.sentAmount?.usd ?? undefined, - quotedGasInUsd: quoteResponse.gasFee?.effective?.usd ?? undefined, - quotedReturnInUsd: quoteResponse.toTokenAmount?.usd ?? undefined, - quotedGasAmount: quoteResponse.gasFee?.effective?.amount ?? undefined, + amountSent: quoteResponse?.sentAmount?.amount ?? '0', + amountSentInUsd: quoteResponse?.sentAmount?.usd ?? undefined, + quotedGasInUsd: quoteResponse?.gasFee?.effective?.usd ?? undefined, + quotedReturnInUsd: quoteResponse?.toTokenAmount?.usd ?? undefined, + quotedGasAmount: quoteResponse?.gasFee?.effective?.amount ?? undefined, }, initialDestAssetBalance, targetContractAddress, diff --git a/packages/bridge-status-controller/src/utils/metrics.test.ts b/packages/bridge-status-controller/src/utils/metrics.test.ts index bff10092277..c23d92afb02 100644 --- a/packages/bridge-status-controller/src/utils/metrics.test.ts +++ b/packages/bridge-status-controller/src/utils/metrics.test.ts @@ -3,6 +3,7 @@ import { FeeType, ActionTypes, MetaMetricsSwapsEventSource, + mergeQuoteMetadata, } from '@metamask/bridge-controller'; import { MetricsSwapType, @@ -1013,13 +1014,17 @@ describe('metrics utils', () => { { key: 'bridge_quote_sorting', value: 'variant_b' }, ]; const result = getPreConfirmationPropertiesFromQuote( - { - quote: mockHistoryItem.quote, - estimatedProcessingTimeInSeconds: 900, - adjustedReturn: { usd: '1980' }, - sentAmount: { usd: '2000' }, - gasFee: { effective: { usd: '2.54739' } }, - } as never, + mergeQuoteMetadata( + { + quote: mockHistoryItem.quote, + estimatedProcessingTimeInSeconds: 900, + }, + { + adjustedReturn: { usd: '1980' }, + sentAmount: { usd: '2000' }, + gasFee: { effective: { usd: '2.54739' } }, + }, + ) as never, false, null, MetaMetricsSwapsEventSource.MainView, diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 99510c4aa57..5fa982fae52 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -158,7 +158,7 @@ export const getTradeDataFromQuote = ( batchSellTrades?: BatchSellTradesResponse | null, ): TradeData => { return { - usd_quoted_gas: Number(quoteResponse.gasFee?.effective?.usd ?? 0), + usd_quoted_gas: Number(quoteResponse?.gasFee?.effective?.usd ?? 0), gas_included: quoteResponse.quote.gasIncluded ?? batchSellTrades?.gasIncluded ?? false, gas_included_7702: @@ -169,7 +169,7 @@ export const getTradeDataFromQuote = ( quoted_time_minutes: Number( quoteResponse.estimatedProcessingTimeInSeconds / 60, ), - usd_quoted_return: Number(quoteResponse.adjustedReturn?.usd ?? 0), + usd_quoted_return: Number(quoteResponse?.adjustedReturn?.usd ?? 0), }; }; @@ -222,7 +222,7 @@ export const getPreConfirmationPropertiesFromQuote = ( quoteResponse.quote.srcChainId, quoteResponse.quote.destChainId, ), - usd_amount_source: Number(quoteResponse.sentAmount?.usd ?? 0), + usd_amount_source: Number(quoteResponse?.sentAmount?.usd ?? 0), stx_enabled: isStxEnabled, action_type: MetricsActionType.SWAPBRIDGE_V1, custom_slippage: false, // TODO detect whether the user changed the default slippage diff --git a/packages/bridge-status-controller/src/utils/snaps.test.ts b/packages/bridge-status-controller/src/utils/snaps.test.ts index 2b7fe7ed1b4..ecff3ce8485 100644 --- a/packages/bridge-status-controller/src/utils/snaps.test.ts +++ b/packages/bridge-status-controller/src/utils/snaps.test.ts @@ -1,7 +1,7 @@ /* eslint-disable consistent-return */ import { v4 as uuid } from 'uuid'; -import { ChainId } from '../../../bridge-controller/src/types'; +import { ChainId, mergeQuoteMetadata } from '@metamask/bridge-controller'; import { BridgeStatusControllerMessenger } from '../types'; import { createClientTransactionRequest, handleNonEvmTx } from './snaps'; @@ -74,16 +74,20 @@ describe('Snaps Utils', () => { const { time, ...result } = await handleNonEvmTx( messenger, transaction, - { - quote: { - srcChainId: ChainId.SOLANA, - srcAsset: { symbol: 'SOL' }, - destAsset: { symbol: 'MATIC' }, + mergeQuoteMetadata( + { + quote: { + srcChainId: ChainId.SOLANA, + srcAsset: { symbol: 'SOL' }, + destAsset: { symbol: 'MATIC' }, + }, }, - sentAmount: { - amount: '1000000000', + { + sentAmount: { + amount: '1000000000', + }, }, - } as never, + ) as never, { id: accountId, metadata: { snap: { id: snapId } } } as never, ); @@ -157,16 +161,20 @@ describe('Snaps Utils', () => { const { time, ...result } = await handleNonEvmTx( messenger, transaction, - { - quote: { - srcChainId: ChainId.SOLANA, - srcAsset: { symbol: 'SOL' }, - destAsset: { symbol: 'MATIC' }, + mergeQuoteMetadata( + { + quote: { + srcChainId: ChainId.SOLANA, + srcAsset: { symbol: 'SOL' }, + destAsset: { symbol: 'MATIC' }, + }, }, - sentAmount: { - amount: '1000000000', + { + sentAmount: { + amount: '1000000000', + }, }, - } as never, + ) as never, { id: accountId, metadata: { snap: { id: snapId } } } as never, ); diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts index 94ce3483344..6ed5bec9f63 100644 --- a/packages/bridge-status-controller/src/utils/snaps.ts +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -156,7 +156,7 @@ export const getTxMetaFields = ( // chainId is now excluded from this function and handled by the caller approvalTxId, // this is the decimal (non atomic) amount (not USD value) of source token to swap - swapTokenValue: quoteResponse.sentAmount.amount, + swapTokenValue: quoteResponse?.sentAmount?.amount, }; }; From ba828f9e4de9a1aeb0cb99ec89436c3052f15308 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 14 Jul 2026 11:26:34 -0700 Subject: [PATCH 3/8] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 4 ++-- packages/bridge-status-controller/CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 7dbacdc8161..90904eb8237 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -13,8 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Make `QuoteMetadata` fields optional, remove `0` and `null` amount fallbacks -- Refactor quoteMetadata calculation and data access to prepare for metadata migration +- **BREAKING:** Make `QuoteMetadata` fields optional, remove `0` and `null` amount fallbacks ([#9507](https://github.com/MetaMask/core/pull/9507)) +- Refactor quoteMetadata calculation and data access to prepare for metadata migration ([#9507](https://github.com/MetaMask/core/pull/9507)) - Extract `QuoteMetadata` type and calculation to a new file - Implement `mergeQuoteMetadata` util which appends QuoteMetadata to QuoteResponse - Bump `@metamask/assets-controller` from `^10.2.1` to `^11.0.0` ([#9485](https://github.com/MetaMask/core/pull/9485)) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 145d2c28d5e..450c7b945c6 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- +- Update utils to use `mergeQuoteMetadata` and optional QuoteMetadata access patterns. ([#9507](https://github.com/MetaMask/core/pull/9507)) ## [74.1.2] From 2fcfcbc07071b252c8b3a364d4e1bf5516f80ec6 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 14 Jul 2026 11:43:16 -0700 Subject: [PATCH 4/8] fix: types and lint --- .../bridge-controller/src/selectors.test.ts | 4 +-- packages/bridge-controller/src/selectors.ts | 7 ++--- packages/bridge-controller/src/types.ts | 6 +++- .../bridge-controller/src/utils/assets.ts | 3 +- .../src/utils/quote-metadata.ts | 7 +---- .../bridge-controller/src/utils/quote.test.ts | 28 +++---------------- packages/bridge-controller/src/utils/quote.ts | 9 +++--- .../src/utils/snaps.test.ts | 2 +- 8 files changed, 22 insertions(+), 44 deletions(-) diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 0966ffe61c7..ba3f9fae3f9 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -36,10 +36,10 @@ import { formatChainIdToDec, formatChainIdToHex, } from './utils/caip-formatters'; +import { mergeQuoteMetadata } from './utils/quote-metadata'; import { BatchSellTransactionType } from './validators/batch-sell'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; import { validateQuoteResponseV1 } from './validators/quote-response-v1'; -import { mergeQuoteMetadata } from './utils/quote-metadata'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -2315,7 +2315,7 @@ describe('Bridge Selectors', () => { batchSellTrades: null, }); - expect(result.totalNetworkFee?.usd).toMatchInlineSnapshot(`undefined`); + expect(result.totalNetworkFee).toMatchInlineSnapshot(`undefined`); expect(result.isBatchSellTradeAvailable).toBe(false); expect(result.isLoading).toBe(false); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index e39b9a657ad..9afd5158277 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -19,7 +19,7 @@ import { } from 'reselect'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from './constants/bridge'; -import type { BridgeControllerState } from './types'; +import type { BridgeControllerState, ExchangeRate } from './types'; import { RequestStatus, SortOrder } from './types'; import { getNativeAssetForChainId, @@ -34,7 +34,6 @@ import { } from './utils/caip-formatters'; import { processFeatureFlags } from './utils/feature-flags'; import { calcBatchFees } from './utils/quote'; -import type { ExchangeRate } from './utils/quote-metadata'; import type { TokenAmountValues } from './utils/quote-metadata'; import { calcQuoteMetadata, mergeQuoteMetadata } from './utils/quote-metadata'; import type { QuoteMetadata } from './utils/quote-metadata'; @@ -458,8 +457,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/types.ts b/packages/bridge-controller/src/types.ts index 932afc9ef54..04c9513344a 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -30,7 +30,6 @@ import type { import type { BridgeController } from './bridge-controller'; import type { BridgeControllerMethodActions } from './bridge-controller-method-action-types'; import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; -import { ExchangeRate } from './utils/quote-metadata'; import type { SimulatedGasFeeLimitsSchema } from './validators/batch-sell'; import type { BatchSellTradesResponseSchema } from './validators/batch-sell'; import type { BridgeAssetSchema } from './validators/bridge-asset'; @@ -91,6 +90,11 @@ export type NonEvmFees = { export type InputPrimaryDenomination = 'token_amount' | 'fiat_value'; +/** + * Asset exchange rate values for a given chain and address + */ +export type ExchangeRate = { exchangeRate?: string; usdExchangeRate?: string }; + /** * Sort order set by the user */ diff --git a/packages/bridge-controller/src/utils/assets.ts b/packages/bridge-controller/src/utils/assets.ts index c3055ece981..41bc81f3f50 100644 --- a/packages/bridge-controller/src/utils/assets.ts +++ b/packages/bridge-controller/src/utils/assets.ts @@ -1,9 +1,8 @@ import type { CaipAssetType } from '@metamask/utils'; -import type { GenericQuoteRequest } from '../types'; +import type { ExchangeRate, GenericQuoteRequest } from '../types'; import { getNativeAssetForChainId } from './bridge'; import { formatAddressToAssetId } from './caip-formatters'; -import type { ExchangeRate } from './quote-metadata'; export const getAssetIdsForToken = ( tokenAddress: GenericQuoteRequest['srcTokenAddress'], diff --git a/packages/bridge-controller/src/utils/quote-metadata.ts b/packages/bridge-controller/src/utils/quote-metadata.ts index 564b2f99972..aae7cbcd25b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata.ts +++ b/packages/bridge-controller/src/utils/quote-metadata.ts @@ -1,5 +1,5 @@ import { QuoteResponse } from '..'; -import type { DeepPartial } from '../types'; +import type { DeepPartial, ExchangeRate } from '../types'; import { isEvmQuoteResponse } from './bridge'; import { calcAdjustedReturn, @@ -15,11 +15,6 @@ import { calcTotalMaxNetworkFee, } from './quote'; -/** - * Asset exchange rate values for a given chain and address - */ -export type ExchangeRate = { exchangeRate?: string; usdExchangeRate?: string }; - /** * The types of values for the token amount and its values when converted to the user's selected currency and USD */ diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 37782f34e8f..19ddb893eac 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -219,19 +219,9 @@ describe('Quote Metadata Utils', () => { it('should handle missing exchange rates', () => { const mockQuote: Quote = { srcTokenAmount: '1000000000', - srcAsset: { - decimals: 6, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, - }, + srcAsset: { decimals: 6 }, feeData: { - metabridge: { - amount: '100000000', - asset: { - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, - }, - }, + metabridge: { amount: '100000000' }, }, } as unknown as Quote; const result = calcSentAmount(mockQuote, {}); @@ -244,19 +234,9 @@ describe('Quote Metadata Utils', () => { it('should handle zero values', () => { const mockQuote: Quote = { srcTokenAmount: '0', - srcAsset: { - decimals: 6, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, - }, + srcAsset: { decimals: 6 }, feeData: { - metabridge: { - amount: '0', - asset: { - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, - }, - }, + metabridge: { amount: '0' }, }, } as unknown as Quote; const zeroQuote = { diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index fd24fc02d8b..65abe467db7 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -6,10 +6,12 @@ import { } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; +import { TokenAmountValues } from '..'; import type { BridgeAsset, GenericQuoteRequest, L1GasFees, + ExchangeRate, NonEvmFees, } from '../types'; import { FeatureId } from '../validators/feature-flags'; @@ -17,7 +19,6 @@ import type { Quote } from '../validators/quote'; import type { QuoteResponseV1 } from '../validators/quote-response-v1'; import { TxData } from '../validators/trade'; import { isNativeAddress, isNonEvmChainId } from './bridge'; -import type { ExchangeRate, QuoteMetadata } from './quote-metadata'; export const isValidQuoteRequest = ( partialRequest: Partial, @@ -272,7 +273,7 @@ export const calcEstimatedAndMaxTotalGasFee = ({ bridgeQuote: QuoteResponseV1 & L1GasFees; maxFeePerGasInDecGwei?: string; feePerGasInDecGwei?: string; -} & ExchangeRate): QuoteMetadata['gasFee'] => { +} & ExchangeRate) => { // Estimated gas fees spent after receiving refunds, this is shown to the user const { amount: amountEffective, @@ -344,7 +345,7 @@ export const calcEstimatedAndMaxTotalGasFee = ({ * @returns The total estimated network fee for the bridge transaction, including the relayer fee paid to bridge providers */ export const calcTotalEstimatedNetworkFee = ( - gasFee: ReturnType, + gasFee: { total?: Partial } | undefined, relayerFee: ReturnType, ) => { const { total: gasFeeToDisplay } = gasFee ?? {}; @@ -364,7 +365,7 @@ export const calcTotalEstimatedNetworkFee = ( }; export const calcTotalMaxNetworkFee = ( - gasFee: ReturnType, + gasFee: { max?: Partial } | undefined, relayerFee: ReturnType, ) => { return { diff --git a/packages/bridge-status-controller/src/utils/snaps.test.ts b/packages/bridge-status-controller/src/utils/snaps.test.ts index ecff3ce8485..52ead41e5be 100644 --- a/packages/bridge-status-controller/src/utils/snaps.test.ts +++ b/packages/bridge-status-controller/src/utils/snaps.test.ts @@ -1,7 +1,7 @@ +import { ChainId, mergeQuoteMetadata } from '@metamask/bridge-controller'; /* eslint-disable consistent-return */ import { v4 as uuid } from 'uuid'; -import { ChainId, mergeQuoteMetadata } from '@metamask/bridge-controller'; import { BridgeStatusControllerMessenger } from '../types'; import { createClientTransactionRequest, handleNonEvmTx } from './snaps'; From 4bc4aad73ea121594e6dfb047bcd74b5b65cb275 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 14 Jul 2026 11:45:46 -0700 Subject: [PATCH 5/8] fix: calcTotalEstimatedNetworkFee --- packages/bridge-controller/src/utils/quote.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 65abe467db7..96c95255e6e 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -351,16 +351,20 @@ export const calcTotalEstimatedNetworkFee = ( const { total: gasFeeToDisplay } = gasFee ?? {}; return { amount: - gasFeeToDisplay?.amount && - new BigNumber(gasFeeToDisplay.amount).plus(relayerFee.amount).toString(), + (gasFeeToDisplay?.amount || relayerFee.amount) && + new BigNumber(gasFeeToDisplay?.amount ?? '0') + .plus(relayerFee.amount ?? '0') + .toString(), valueInCurrency: - gasFeeToDisplay?.valueInCurrency && - new BigNumber(gasFeeToDisplay.valueInCurrency) + (gasFeeToDisplay?.valueInCurrency || relayerFee.valueInCurrency) && + new BigNumber(gasFeeToDisplay?.valueInCurrency ?? '0') .plus(relayerFee.valueInCurrency ?? '0') .toString(), usd: - gasFeeToDisplay?.usd && - new BigNumber(gasFeeToDisplay.usd).plus(relayerFee.usd ?? '0').toString(), + (gasFeeToDisplay?.usd || relayerFee.usd) && + new BigNumber(gasFeeToDisplay?.usd ?? '0') + .plus(relayerFee.usd ?? '0') + .toString(), }; }; From 8cffbc7d6673798a395456888765934943ada782 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 14 Jul 2026 11:57:16 -0700 Subject: [PATCH 6/8] fix: type error --- packages/bridge-controller/src/utils/quote.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 96c95255e6e..c6bbf367e7f 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -351,17 +351,17 @@ export const calcTotalEstimatedNetworkFee = ( const { total: gasFeeToDisplay } = gasFee ?? {}; return { amount: - (gasFeeToDisplay?.amount || relayerFee.amount) && + (gasFeeToDisplay?.amount ?? relayerFee.amount) && new BigNumber(gasFeeToDisplay?.amount ?? '0') .plus(relayerFee.amount ?? '0') .toString(), valueInCurrency: - (gasFeeToDisplay?.valueInCurrency || relayerFee.valueInCurrency) && + (gasFeeToDisplay?.valueInCurrency ?? relayerFee.valueInCurrency) && new BigNumber(gasFeeToDisplay?.valueInCurrency ?? '0') .plus(relayerFee.valueInCurrency ?? '0') .toString(), usd: - (gasFeeToDisplay?.usd || relayerFee.usd) && + (gasFeeToDisplay?.usd ?? relayerFee.usd) && new BigNumber(gasFeeToDisplay?.usd ?? '0') .plus(relayerFee.usd ?? '0') .toString(), @@ -419,7 +419,7 @@ export const calcIncludedTxFees = ( export const calcAdjustedReturn = ( toTokenAmount: ReturnType, - totalEstimatedNetworkFee: ReturnType, + totalEstimatedNetworkFee: Partial, { feeData: { txFee }, destAsset: { assetId: destAssetId } }: Quote, ) => { // If gas is included and is taken from the dest token, don't subtract network fee from return From d088ab97e29ad234d11ad3805711eb57fee2bdae Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 14 Jul 2026 12:14:05 -0700 Subject: [PATCH 7/8] fix: relayer --- .../bridge-controller/src/utils/quote.test.ts | 6 +++--- packages/bridge-controller/src/utils/quote.ts | 20 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 19ddb893eac..d2800f7ad39 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -444,9 +444,9 @@ describe('Quote Metadata Utils', () => { }, ); - expect(result.amount).toStrictEqual(new BigNumber(0)); - expect(result.valueInCurrency).toStrictEqual(new BigNumber(0)); - expect(result.usd).toStrictEqual(new BigNumber(0)); + expect(result.amount).toBeUndefined(); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); }); it('should handle native token address', () => { diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index c6bbf367e7f..681fd08ba8c 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -197,10 +197,12 @@ export const calcRelayerFee = ( { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const { quote, trade } = quoteResponse; - const relayerFeeAmount = new BigNumber( - convertHexToDecimal(trade.value || '0x0'), - ); - let relayerFeeInNative = calcTokenAmount(relayerFeeAmount, 18); + const relayerFeeAmount = trade.value + ? new BigNumber(convertHexToDecimal(trade.value)) + : undefined; + let relayerFeeInNative = relayerFeeAmount + ? calcTokenAmount(relayerFeeAmount, 18) + : undefined; // Subtract srcAmount and other fees from trade value if srcAsset is native if (isNativeAddress(quote.srcAsset.address)) { @@ -208,15 +210,15 @@ export const calcRelayerFee = ( exchangeRate, usdExchangeRate, }).amount; - relayerFeeInNative = relayerFeeInNative.minus(sentAmountInNative); + relayerFeeInNative = relayerFeeInNative?.minus(sentAmountInNative); } return { amount: relayerFeeInNative, valueInCurrency: exchangeRate - ? relayerFeeInNative.times(exchangeRate) + ? relayerFeeInNative?.times(exchangeRate) : null, - usd: usdExchangeRate ? relayerFeeInNative.times(usdExchangeRate) : null, + usd: usdExchangeRate ? relayerFeeInNative?.times(usdExchangeRate) : null, }; }; @@ -375,7 +377,9 @@ export const calcTotalMaxNetworkFee = ( return { amount: gasFee?.max?.amount && - new BigNumber(gasFee.max.amount).plus(relayerFee.amount).toString(), + new BigNumber(gasFee.max.amount) + .plus(relayerFee.amount ?? '0') + .toString(), valueInCurrency: gasFee?.max?.valueInCurrency && new BigNumber(gasFee.max.valueInCurrency) From 24f077bb05396caf40c3cfedc43e66cc47df29d1 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 16 Jul 2026 20:25:18 -0700 Subject: [PATCH 8/8] chore: update tests --- packages/bridge-controller/CHANGELOG.md | 9 + .../bridge-controller.sse.batch.test.ts.snap | 2 +- .../src/bridge-controller.sse.batch.test.ts | 7 +- .../src/bridge-controller.sse.test.ts | 96 ++- .../src/bridge-controller.test.ts | 72 +- .../src/bridge-controller.ts | 4 +- packages/bridge-controller/src/index.ts | 29 +- .../bridge-controller/src/selectors.test.ts | 667 ++++++++---------- packages/bridge-controller/src/selectors.ts | 26 +- packages/bridge-controller/src/types.ts | 18 +- .../bridge-controller/src/utils/bridge.ts | 5 +- .../bridge-controller/src/utils/fetch.test.ts | 4 +- .../src/utils/metrics/properties.ts | 2 +- .../src/utils/number-formatters.ts | 34 + .../src/utils/quote-metadata.ts | 214 ------ .../calculators.test.ts} | 517 ++++++++------ .../src/utils/quote-metadata/calculators.ts | 418 +++++++++++ .../src/utils/quote-metadata/merge.ts | 18 + .../utils/quote-metadata/quote-metadata.ts | 126 ++++ .../src/utils/quote-metadata/types.ts | 89 +++ packages/bridge-controller/src/utils/quote.ts | 525 -------------- .../src/utils/sort-quotes.ts | 18 + .../src/validators/quote-request.ts | 71 ++ .../bridge-controller/src/validators/quote.ts | 3 + .../tests/mock-quotes-erc20-erc20.ts | 23 +- .../bridge-status-controller/CHANGELOG.md | 1 + .../bridge-status-controller.test.ts.snap | 24 +- .../bridge-status-controller.intent.test.ts | 69 +- .../src/bridge-status-controller.test.ts | 156 ++-- .../bridge-status-controller/src/utils/gas.ts | 2 +- .../src/utils/history.ts | 4 +- .../src/utils/metrics.ts | 2 +- 32 files changed, 1759 insertions(+), 1496 deletions(-) create mode 100644 packages/bridge-controller/src/utils/number-formatters.ts delete mode 100644 packages/bridge-controller/src/utils/quote-metadata.ts rename packages/bridge-controller/src/utils/{quote.test.ts => quote-metadata/calculators.test.ts} (68%) create mode 100644 packages/bridge-controller/src/utils/quote-metadata/calculators.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/merge.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/quote-metadata.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/types.ts delete mode 100644 packages/bridge-controller/src/utils/quote.ts create mode 100644 packages/bridge-controller/src/utils/sort-quotes.ts create mode 100644 packages/bridge-controller/src/validators/quote-request.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 5d39db0ac78..101e8ed78a8 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** Make `QuoteMetadata` fields optional, remove `0` and `null` amount fallbacks ([#9507](https://github.com/MetaMask/core/pull/9507)) +- Return priceImpact and relayerFee +- Remove gasFee max and effective, totalMaxNetworkFee - Refactor quoteMetadata calculation and data access to prepare for metadata migration ([#9507](https://github.com/MetaMask/core/pull/9507)) - Extract `QuoteMetadata` type and calculation to a new file - Implement `mergeQuoteMetadata` util which appends QuoteMetadata to QuoteResponse @@ -43,6 +45,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Phase 1 of migration, in which `QuoteResponseV2 & QuoteMetadata` are returned, but still uses v1 metadata +- Migration utilities + + - toQuoteResponseV1, toQuoteResponseV2 + - migrationMerge + - mergeQuoteMetadata, extractQuoteMetadata (test util) + - Split up validators into smaller files to prepare for QuoteResponse V2 migration ([#9413](https://github.com/MetaMask/core/pull/9413)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) - Bump `@metamask/assets-controller` from `^10.0.1` to `^10.2.0` ([#9411](https://github.com/MetaMask/core/pull/9411), [#9450](https://github.com/MetaMask/core/pull/9450)) diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap index 523ec40f635..4d4b7ffc49b 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.batch.test.ts.snap @@ -28,7 +28,7 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes s }, { "destChainId": "137", - "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "destTokenAddress": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", "destWalletAddress": "SolanaWalletAddres1234", "insufficientBal": false, "resetApproval": false, 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 23baf3f531c..bfb22fc5161 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -213,6 +213,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () }); it('should trigger quote polling if request is valid', async function () { + const consoleWarnSpy = jest.spyOn(console, 'warn'); await withController( async ({ controller: bridgeController, @@ -227,7 +228,10 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () mockFetchFn.mockImplementationOnce(async () => { return mockSseBatchSellEventSource([ mockBridgeQuotesNativeErc20V1, - mockBridgeQuotesErc20Erc20V1, + mockBridgeQuotesErc20Erc20V1.map((quote) => ({ + ...quote, + quoteRequestIndex: 1, + })), ]); }); hasSufficientBalanceSpy.mockResolvedValue(true); @@ -399,6 +403,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () // After first fetch jest.advanceTimersByTime(5000); await flushPromises(); + expect(consoleWarnSpy.mock.calls).toMatchInlineSnapshot(`[]`); expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); expect(bridgeController.state).toStrictEqual({ ...expectedState, diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index e4a27087208..3ca2c1b3bfd 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -39,6 +39,8 @@ import { FeatureId } from './validators/feature-flags'; import { QuoteStreamCompleteReason } from './validators/quote-stream-complete'; import { TokenFeatureType } from './validators/token-feature'; import type { TxData } from './validators/trade'; +import { merge } from 'lodash'; +import { validateQuoteResponseV1 } from './validators/quote-response-v1'; type RootMessenger = Messenger< MockAnyNamespace, @@ -377,7 +379,15 @@ describe('BridgeController SSE', function () { ...quote, quote: { ...quote.quote, - srcTokenAddress, + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}` as const, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, srcChainId: 1, destChainId: formatChainIdToDec(destChainId), }, @@ -488,16 +498,33 @@ describe('BridgeController SSE', function () { resetApproval, }, ], - quotes: mockUSDTQuoteResponse.map((quote) => ({ - ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, - resetApproval: tradeData - ? { - ...quote.approval, - data: tradeData, - } - : undefined, - })), + 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, + }, + }), + ) + .map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: tradeData + ? { + ...quote.approval, + data: tradeData, + } + : undefined, + })), quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, @@ -541,11 +568,23 @@ describe('BridgeController SSE', function () { ...quote, quote: { ...quote.quote, - srcTokenAddress: ETH_USDT_ADDRESS, + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}` as const, + chainId: 1, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, srcChainId: 1, }, }), ); + mockUSDTQuoteResponse.forEach((quote) => + validateQuoteResponseV1(quote), + ); + mockFetchFn.mockImplementationOnce(async () => { return mockSseEventSource(mockUSDTQuoteResponse); }); @@ -647,14 +686,31 @@ describe('BridgeController SSE', function () { resetApproval: true, }, ], - quotes: mockUSDTQuoteResponse.map((quote) => ({ - ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, - resetApproval: { - ...quote.approval, - data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', - }, - })), + 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', + }, + })), quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index dbdc5b1e964..0fd8ba20e72 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -247,7 +247,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -266,7 +266,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }, metricsContext, @@ -328,7 +328,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -347,7 +347,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }, metricsContext, @@ -403,7 +403,7 @@ describe('BridgeController', function () { } return { remoteFeatureFlags: { bridgeConfig: { ...bridgeConfig } }, - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), } as never; }, @@ -420,7 +420,7 @@ describe('BridgeController', function () { srcTokenAddress: '0x0000000000000000000000000000000000000000', destTokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -1867,6 +1867,11 @@ describe('BridgeController', function () { }); it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()) + .mockImplementationOnce(jest.fn()); + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); jest.useFakeTimers(); await withController( async ({ controller: bridgeController, rootMessenger }) => { @@ -1880,7 +1885,7 @@ describe('BridgeController', function () { ); default: return { - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), currentCurrency: 'usd', currencyRates: {}, @@ -1911,9 +1916,9 @@ describe('BridgeController', function () { srcChainId: '0x1', destChainId: '0xa', srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: '0x123', + destTokenAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', srcTokenAmount: '1000000000000000000', - walletAddress: '0x123', + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -1926,12 +1931,27 @@ describe('BridgeController', function () { await advanceToNthTimerThenFlush(); expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBeUndefined(); + expect(consoleErrorSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + ] + `); }, ); }); it('updateBridgeQuoteRequestParams should include auth token as Authentication header', async function () { + jest.spyOn(balanceUtils, 'hasSufficientBalance').mockResolvedValue(true); + jest.useFakeTimers(); await withController( async ({ controller: bridgeController, rootMessenger }) => { const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); @@ -1942,7 +1962,7 @@ describe('BridgeController', function () { return 'AUTH_TOKEN'; default: return { - address: '0x123', + address: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', provider: jest.fn(), currentCurrency: 'usd', currencyRates: {}, @@ -1961,18 +1981,24 @@ describe('BridgeController', function () { .mockResolvedValue(true); const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') - .mockResolvedValueOnce({ - quotes: mockBridgeQuotesNativeErc20EthV1, - validationFailures: [], + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20EthV1, + validationFailures: [], + }); + }, 5000); + }); }); const quoteParams = { srcChainId: '0x1', destChainId: '0xa', srcTokenAddress: '0x0000000000000000000000000000000000000000', - destTokenAddress: ETH_USDT_ADDRESS, + destTokenAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', srcTokenAmount: '1000000000000000000', - walletAddress: ETH_USDT_ADDRESS, + walletAddress: '0x141d32a89a1e0a5Ef360034a2f60a4B917c18838', slippage: 0.5, }; @@ -2620,6 +2646,7 @@ describe('BridgeController', function () { jest.advanceTimersByTime(100); await flushPromises(); const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(expectedQuotesLength); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ quotesLoadingStatus: RequestStatus.FETCHED, @@ -2643,8 +2670,6 @@ describe('BridgeController', function () { expect(snapCalls).toMatchSnapshot(); - expect(quotes).toHaveLength(expectedQuotesLength); - // Verify validation failure tracking expect(trackMetaMetricsFn).toHaveBeenCalledTimes( 6 + (validationFailures.length ? 1 : 0), @@ -3996,11 +4021,9 @@ describe('BridgeController', function () { ...overrides, }); - let getBridgeFeatureFlagsSpy: jest.SpyInstance; - beforeEach(() => { jest.clearAllMocks(); - getBridgeFeatureFlagsSpy = jest + jest .spyOn(featureFlagUtils, 'getBridgeFeatureFlags') .mockReturnValueOnce({ ...defaultFlags, @@ -4010,7 +4033,10 @@ describe('BridgeController', function () { bridgeIds: ['bridge1', 'bridge2'], fee: 0, }, + [FeatureId.QUICK_BUY_FOLLOW_TRADING]: undefined, [FeatureId.QUICK_BUY_TOKEN_DETAILS]: undefined, + [FeatureId.BATCH_SELL]: undefined, + [FeatureId.UNIFIED_SWAP_BRIDGE]: undefined, [FeatureId.DAPP_SWAP]: undefined, }, }); @@ -4137,7 +4163,7 @@ describe('BridgeController', function () { const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') .mockResolvedValueOnce({ - quotes: quotesByDecreasingProcessingTime as never, + quotes: quotesByDecreasingProcessingTime, validationFailures: [], }); const expectedControllerState = bridgeController.state; @@ -4263,6 +4289,10 @@ describe('BridgeController', function () { it('should not add aggIds and fee if quoteRequestOverrides is not set', async () => { await withController( async ({ controller: bridgeController, rootMessenger }) => { + const getBridgeFeatureFlagsSpy = jest.spyOn( + featureFlagUtils, + 'getBridgeFeatureFlags', + ); getBridgeFeatureFlagsSpy.mockRestore(); getBridgeFeatureFlagsSpy.mockReturnValueOnce({ ...defaultFlags, diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 8e81ad76817..d083e5ad40c 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -86,11 +86,11 @@ import type { RequiredEventContextFromClient, } from './utils/metrics/types'; import type { CrossChainSwapsEventProperties } from './utils/metrics/types'; +import { sortQuotes } from './utils/sort-quotes'; import { isValidQuoteRequest, isValidBatchSellQuoteRequest, - sortQuotes, -} from './utils/quote'; +} from './validators/quote-request'; import { appendFeesToQuotes } from './utils/quote-fees'; import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; import type { FeatureId } from './validators/feature-flags'; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index f38a2e723ce..46dc1481286 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -37,9 +37,6 @@ export { getQuotesReceivedProperties, } from './utils/metrics/properties'; -export type { QuoteMetadata, TokenAmountValues } from './utils/quote-metadata'; -export { mergeQuoteMetadata } from './utils/quote-metadata'; - export type { ChainConfiguration, L1GasFees, @@ -68,6 +65,7 @@ export type { QuoteStreamCompleteData, BridgeControllerGetStateAction, BridgeControllerStateChangeEvent, + DeepPartial, } from './types'; export { @@ -110,13 +108,30 @@ export type { QuoteResponseV1 as QuoteResponse } from './validators/quote-respon export type { Quote } from './validators/quote'; export { FeeType, DiscountType } from './validators/quote'; export { ActionTypes } from './validators/step'; +export { + type QuoteResponseV1, + validateQuoteResponseV1, + QuoteResponseSchemaV1, +} from './validators/quote-response-v1'; + +export { + type QuoteMetadata, + type TokenAmountValues, +} from './utils/quote-metadata/types'; + +export { calcQuoteMetadata } from './utils/quote-metadata/quote-metadata'; +export { mergeQuoteMetadata } from './utils/quote-metadata/merge'; + export { validateQuoteStreamComplete, QuoteStreamCompleteReason, } from './validators/quote-stream-complete'; export { BatchSellTransactionType } from './validators/batch-sell'; export { TokenFeatureType } from './validators/token-feature'; -export { BridgeAssetSchema } from './validators/bridge-asset'; +export { + BridgeAssetSchema, + validateBridgeAsset, +} from './validators/bridge-asset'; export { FeatureId } from './validators/feature-flags'; export { @@ -173,9 +188,9 @@ export { export { isValidQuoteRequest, isValidBatchSellQuoteRequest, - formatEtaInMinutes, - calcSlippagePercentage, -} from './utils/quote'; +} from './validators/quote-request'; + +export { calcSlippagePercentage } from './utils/quote-metadata/calculators'; export { calcLatestSrcBalance } from './utils/balance'; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index ba3f9fae3f9..586946ef7df 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -36,10 +36,11 @@ import { formatChainIdToDec, formatChainIdToHex, } from './utils/caip-formatters'; -import { mergeQuoteMetadata } from './utils/quote-metadata'; +import { mergeQuoteMetadata } from './utils/quote-metadata/merge'; import { BatchSellTransactionType } from './validators/batch-sell'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; import { validateQuoteResponseV1 } from './validators/quote-response-v1'; +import { calcQuoteMetadata } from './utils/quote-metadata/quote-metadata'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -592,36 +593,29 @@ describe('Bridge Selectors', () => { const expectedQuoteMetadata = { adjustedReturn: { - usd: '13.099927', - valueInCurrency: '2597.985546', + usd: '2.099927', + valueInCurrency: '419.985546', }, cost: { - usd: '-2.099927', - valueInCurrency: '-419.985546', + usd: '8.900073', + valueInCurrency: '1758.014454', }, gasFee: { - effective: { - amount: '0.0000067', - usd: '0.000067', - valueInCurrency: '0.013266', - }, - max: { - amount: '0.0000146', - usd: '0.000146', - valueInCurrency: '0.028908', - }, total: { amount: '0.0000073', usd: '0.000073', valueInCurrency: '0.014454', }, }, - includedTxFees: null, minToTokenAmount: { amount: '1.8', usd: '1.8', valueInCurrency: '360', }, + priceImpact: { + usd: '8.9', + valueInCurrency: '1758', + }, sentAmount: { amount: '1.1', usd: '11', @@ -633,26 +627,21 @@ describe('Bridge Selectors', () => { usd: '2.1', valueInCurrency: '420', }, - totalMaxNetworkFee: { - amount: '-1.0999854', - usd: '-10.999854', - valueInCurrency: '-2177.971092', - }, totalNetworkFee: { - amount: '-1.0999927', - usd: '-10.999927', - valueInCurrency: '-2177.985546', + amount: '0.0000073', + usd: '0.000073', + valueInCurrency: '0.014454', }, }; - const quoteResponseV1 = { - ...mockState.quotes[1], - ...expectedQuoteMetadata, - }; + const quoteResponseV1 = mergeQuoteMetadata( + mockState.quotes[1], + expectedQuoteMetadata, + ); validateQuoteResponseV1(quoteResponseV1); expect(result.sortedQuotes[0]).toStrictEqual(quoteResponseV1); - expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('-419.985546'); + expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('1758.014454'); }); it('should return sorted quotes with metadata (no assetId)', () => { @@ -674,21 +663,17 @@ describe('Bridge Selectors', () => { { ...mockState, assetExchangeRates: { - [mockQuote.quote.srcAsset.assetId?.toLowerCase() ?? - formatAddressToAssetId( + [formatAddressToAssetId( mockQuote.quote.srcAsset.address, mockQuote.quote.srcChainId, - ) ?? - '']: { + ) ?? '']: { exchangeRate: '1980', usdExchangeRate: '10', }, - [mockQuote.quote.destAsset.assetId?.toLowerCase() ?? - formatAddressToAssetId( + [formatAddressToAssetId( mockQuote.quote.destAsset.address, mockQuote.quote.destChainId, - ) ?? - '']: { + ) ?? '']: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -757,37 +742,26 @@ describe('Bridge Selectors', () => { mockClientParams, ); - const expectedActiveQuote = { + const expectedQuoteMetadata = { adjustedReturn: { - usd: null, - valueInCurrency: null, + usd: undefined, + valueInCurrency: undefined, }, cost: { - usd: null, - valueInCurrency: null, + usd: undefined, + valueInCurrency: undefined, }, gasFee: { - effective: { - amount: '0.0000067', - usd: '0.01206', - valueInCurrency: '0.01206', - }, - max: { - amount: '0.0000146', - usd: '0.02628', - valueInCurrency: '0.02628', - }, total: { amount: '0.0000073', usd: '0.01314', valueInCurrency: '0.01314', }, }, - includedTxFees: null, minToTokenAmount: { amount: '1.8', - usd: null, - valueInCurrency: null, + usd: undefined, + valueInCurrency: undefined, }, sentAmount: { amount: '1.1', @@ -797,29 +771,21 @@ describe('Bridge Selectors', () => { swapRate: '1.90909090909090909091', toTokenAmount: { amount: '2.1', - usd: null, - valueInCurrency: null, - }, - totalMaxNetworkFee: { - amount: '-1.0999854', - usd: '-1979.97372', - valueInCurrency: '-1979.97372', + usd: undefined, + valueInCurrency: undefined, }, totalNetworkFee: { - amount: '-1.0999927', - usd: '-1979.98686', - valueInCurrency: '-1979.98686', + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', }, }; - const quoteResponseV1 = { - ...mockState.quotes[1], - ...expectedActiveQuote, - }; - validateQuoteResponseV1(quoteResponseV1); - - expect(result.sortedQuotes[0]).toStrictEqual(quoteResponseV1); - expect(result.sortedQuotes[0]?.cost?.valueInCurrency).toBeNull(); + const expectedQuoteV2 = mockState.quotes[1]; + expect(result.sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), + ); + expect(result.sortedQuotes[0]?.cost?.valueInCurrency).toBeUndefined(); expect(result.recommendedQuote?.toTokenAmount?.amount).toBe('2.1'); }); @@ -854,35 +820,17 @@ describe('Bridge Selectors', () => { const expectedQuoteMetadata = { adjustedReturn: { - usd: null, - valueInCurrency: null, + usd: undefined, + valueInCurrency: undefined, }, cost: { - usd: null, - valueInCurrency: null, - }, - gasFee: { - effective: { - amount: '0.0000067', - usd: '0.01206', - valueInCurrency: '0.01206', - }, - max: { - amount: '0.0000146', - usd: '0.02628', - valueInCurrency: '0.02628', - }, - total: { - amount: '0.0000073', - usd: '0.01314', - valueInCurrency: '0.01314', - }, + usd: undefined, + valueInCurrency: undefined, }, - includedTxFees: null, minToTokenAmount: { amount: '1.8', - usd: null, - valueInCurrency: null, + usd: undefined, + valueInCurrency: undefined, }, sentAmount: { amount: '1.1', @@ -892,32 +840,32 @@ describe('Bridge Selectors', () => { swapRate: '1.90909090909090909091', toTokenAmount: { amount: '2.1', - usd: null, - valueInCurrency: null, - }, - totalMaxNetworkFee: { - amount: '-1.0999854', - usd: '-1979.97372', - valueInCurrency: '-1979.97372', + usd: undefined, + valueInCurrency: undefined, }, totalNetworkFee: { - amount: '-1.0999927', - usd: '-1979.98686', - valueInCurrency: '-1979.98686', + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, }, }; - const quoteResponseV1 = { - ...quotesWithPriceImpact[1], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); + const expectedQuoteV2 = quotesWithPriceImpact[1]; + expect(result.sortedQuotes[0].cost?.valueInCurrency).toBeUndefined(); + expect(result.recommendedQuote).toStrictEqual( + mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), + ); expect(result.recommendedQuote?.quote.priceData?.priceImpact).toBe( '-0.02', ); - expect(result.sortedQuotes[0]?.cost?.valueInCurrency).toBeNull(); - expect(result.recommendedQuote).toStrictEqual(quoteResponseV1); }); describe('returns swap metadata', () => { @@ -1069,10 +1017,11 @@ describe('Bridge Selectors', () => { }, }; validateQuoteResponseV1(quoteResponse); - const mockState = getMockState(1); + const mockState = getMockState(gasEstimatesChainId ?? chainId); + return { - ...getMockState(gasEstimatesChainId ?? chainId), - quotes: [quoteResponse as never], + ...mockState, + quotes: [quoteResponse], currencyRates, marketData, quoteRequest: [ @@ -1090,8 +1039,7 @@ describe('Bridge Selectors', () => { it('for native -> erc20', () => { const srcAsset = { decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000' as const, + assetId: getNativeAssetForChainId(1).assetId, symbol: 'ETH', name: 'Ethereum', address: '0x0000000000000000000000000000000000000000', @@ -1121,23 +1069,12 @@ describe('Bridge Selectors', () => { valueInCurrency: '1.004463862259999726625700576488242768526', }, gasFee: { - effective: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - max: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, total: { amount: '0.000008087', usd: '0.00521708544', valueInCurrency: '0.00446386226', }, }, - includedTxFees: null, minToTokenAmount: { amount: '9.994389353314869106', usd: '9.992709880792782347418849595400950831104', @@ -1154,23 +1091,20 @@ describe('Bridge Selectors', () => { usd: '10.518641979781876155230359150867612640256', valueInCurrency: '9.000000000000000000254299423511757231474', }, - totalMaxNetworkFee: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, totalNetworkFee: { amount: '0.000008087', usd: '0.00521708544', valueInCurrency: '0.00446386226', }, + priceImpact: { + usd: '1.168737997753541475489640849132387359744', + valueInCurrency: '0.999999999999999726625700576488242768526', + }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(newState.quotes[0], expectedQuoteMetadata), + ); }); it('erc20 -> native', () => { @@ -1198,6 +1132,10 @@ describe('Bridge Selectors', () => { const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); const expectedQuoteMetadata = { + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, adjustedReturn: { usd: '10.51342489434187625472', valueInCurrency: '8.99553613774000008538', @@ -1206,24 +1144,6 @@ describe('Bridge Selectors', () => { usd: '1.173955083193541376', valueInCurrency: '1.0044638622599996415', }, - gasFee: { - effective: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - max: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, - total: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - }, - includedTxFees: null, minToTokenAmount: { amount: '0.015489691655494764', usd: '9.99270988079278215168', @@ -1240,23 +1160,24 @@ describe('Bridge Selectors', () => { usd: '10.51864197978187625472', valueInCurrency: '9.00000000000000008538', }, - totalMaxNetworkFee: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, totalNetworkFee: { amount: '0.000008087', usd: '0.00521708544', valueInCurrency: '0.00446386226', }, + gasFee: { + total: { + amount: '0.000008087', + usd: '0.00521708544', + valueInCurrency: '0.00446386226', + }, + }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('erc20 -> native but gas estimates are not available', () => { @@ -1295,24 +1216,10 @@ describe('Bridge Selectors', () => { usd: '1.168737997753541376', valueInCurrency: '0.9999999999999996415', }, - gasFee: { - effective: { - amount: '0', - usd: '0', - valueInCurrency: '0', - }, - max: { - amount: '0', - usd: '0', - valueInCurrency: '0', - }, - total: { - amount: '0', - usd: '0', - valueInCurrency: '0', - }, + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', }, - includedTxFees: null, minToTokenAmount: { amount: '0.015489691655494764', usd: '9.99270988079278215168', @@ -1323,29 +1230,30 @@ describe('Bridge Selectors', () => { usd: '11.68737997753541763072', valueInCurrency: '9.99999999999999972688', }, + gasFee: { + total: { + amount: '0', + usd: '0', + valueInCurrency: '0', + }, + }, swapRate: '0.90000000000000003312', toTokenAmount: { amount: '0.016304938584731331', usd: '10.51864197978187625472', valueInCurrency: '9.00000000000000008538', }, - totalMaxNetworkFee: { - amount: '0', - usd: '0', - valueInCurrency: '0', - }, totalNetworkFee: { amount: '0', usd: '0', valueInCurrency: '0', }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('when gas is included and is taken from dest token', () => { @@ -1394,16 +1302,6 @@ describe('Bridge Selectors', () => { valueInCurrency: '0.9999999999999996415', }, gasFee: { - effective: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - max: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, total: { amount: '0.000008087', usd: '0.00521708544', @@ -1415,6 +1313,10 @@ describe('Bridge Selectors', () => { usd: '0.64512', valueInCurrency: '0.55198', }, + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, minToTokenAmount: { amount: '0.015489691655494764', usd: '9.99270988079278215168', @@ -1431,27 +1333,21 @@ describe('Bridge Selectors', () => { usd: '10.51864197978187625472', valueInCurrency: '9.00000000000000008538', }, - totalMaxNetworkFee: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, totalNetworkFee: { amount: '0.000008087', usd: '0.00521708544', valueInCurrency: '0.00446386226', }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); it('when gas is included and is taken from src token', () => { - const newState = getMockSwapState( + const state = getMockSwapState( { symbol: 'USDC', name: 'USD Coin', @@ -1464,8 +1360,7 @@ describe('Bridge Selectors', () => { { address: '0x0000000000000000000000000000000000000000', decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + assetId: 'eip155:1/slip44:60', symbol: 'ETH', name: 'Ethereum', chainId: 1, @@ -1484,6 +1379,31 @@ describe('Bridge Selectors', () => { }, ); + const newState = { + ...state, + quotes: state.quotes.map((quote, index) => ({ + ...quote, + quote: { + ...quote.quote, + priceData: { + cost: { + usd: '1935.36', + }, + swapRate: '1', + }, + feeData: { + ...quote.quote.feeData, + txFee: { + amount: `${(3 + index) * 1000000000000000000}`, + usd: '1935.36', + asset: quote.quote.srcAsset, + maxFeePerGas: '1000000000000000000', + maxPriorityFeePerGas: '1000000000000000000', + }, + }, + }, + })), + }; const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); const expectedQuoteMetadata = { @@ -1492,20 +1412,10 @@ describe('Bridge Selectors', () => { valueInCurrency: '8.99553613774000008538', }, cost: { - usd: '1936.533955083193541376', - valueInCurrency: '1656.9444638622599996415', + usd: '1.173955083193541376', + valueInCurrency: '1.0044638622599996415', }, gasFee: { - effective: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - max: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, total: { amount: '0.000008087', usd: '0.00521708544', @@ -1523,21 +1433,20 @@ describe('Bridge Selectors', () => { valueInCurrency: '8.54999999999999983272', }, sentAmount: { - amount: '3.018116598427479256', - usd: '1947.04737997753541763072', - valueInCurrency: '1665.93999999999999972688', + amount: '0.018116598427479256', + usd: '11.68737997753541763072', + valueInCurrency: '9.99999999999999972688', }, - swapRate: '0.0054023554269661573', + priceImpact: { + usd: '1.168737997753541376', + valueInCurrency: '0.9999999999999996415', + }, + swapRate: '0.90000000000000003312', toTokenAmount: { amount: '0.016304938584731331', usd: '10.51864197978187625472', valueInCurrency: '9.00000000000000008538', }, - totalMaxNetworkFee: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, totalNetworkFee: { amount: '0.000008087', usd: '0.00521708544', @@ -1545,12 +1454,29 @@ describe('Bridge Selectors', () => { }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + expect(sortedQuotes[0].quote.feeData.txFee).toStrictEqual([ + { + amount: '3000000000000000000', + asset: { + assetId: + 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + decimals: 18, + name: 'USD Coin', + symbol: 'USDC', + }, + maxFeePerGas: '1000000000000000000', + maxPriorityFeePerGas: '1000000000000000000', + normalizedAmount: '3', + usd: '1935.36', + valueInCurrency: '1655.94', + }, + ]); + expect(newState.quotes[0].sentAmount?.amount).toBe('18116598427479256'); + const expectedQuoteV2 = mergeQuoteMetadata( + newState.quotes[0], + expectedQuoteMetadata, + ); + expect(sortedQuotes[0]).toStrictEqual(expectedQuoteV2); }); it('when gasIncluded7702=true and is taken from dest token', () => { @@ -1600,22 +1526,16 @@ describe('Bridge Selectors', () => { valueInCurrency: '0.999999999999999777099019372367208086', }, gasFee: { - effective: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - max: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, total: { amount: '0.000008087', usd: '0.00521708544', valueInCurrency: '0.00446386226', }, }, + priceImpact: { + usd: '1.168737997753541534479726398604176384', + valueInCurrency: '0.999999999999999777099019372367208086', + }, includedTxFees: { amount: '1', usd: '999.831958465623542784', @@ -1637,11 +1557,6 @@ describe('Bridge Selectors', () => { usd: '10.518641979781876096240273601395823616', valueInCurrency: '8.999999999999999949780980627632791914', }, - totalMaxNetworkFee: { - amount: '0.000016174', - usd: '0.01043417088', - valueInCurrency: '0.00892772452', - }, totalNetworkFee: { amount: '0.000008087', usd: '0.00521708544', @@ -1649,12 +1564,10 @@ describe('Bridge Selectors', () => { }, }; - const quoteResponseV1 = { - ...newState.quotes[0], - ...expectedQuoteMetadata, - }; - validateQuoteResponseV1(quoteResponseV1); - expect(sortedQuotes[0]).toStrictEqual(quoteResponseV1); + const quoteResponseV2 = newState.quotes[0]; + expect(sortedQuotes[0]).toStrictEqual( + mergeQuoteMetadata(quoteResponseV2, expectedQuoteMetadata), + ); }); }); @@ -1710,18 +1623,55 @@ describe('Bridge Selectors', () => { const selectedQuote = { ...mockState.quotes[0], quote: { ...mockState.quotes[0].quote, requestId: '123' }, - } as never; + }; const result = selectBridgeQuotes(mockState, { ...mockClientParams, selectedQuote, }); - expect(result.recommendedQuote).toStrictEqual( - expect.objectContaining(mockState.quotes[1]), - ); - expect(result.activeQuote).toStrictEqual( - expect.objectContaining(selectedQuote), + const recommendedQuoteV2 = mergeQuoteMetadata(mockState.quotes[1], { + minToTokenAmount: { + amount: '1.8', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + toTokenAmount: { + amount: '2.1', + usd: undefined, + valueInCurrency: undefined, + }, + swapRate: '1.90909090909090909091', + cost: { + usd: undefined, + valueInCurrency: undefined, + }, + adjustedReturn: { + usd: undefined, + valueInCurrency: undefined, + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }, + }); + expect(result.recommendedQuote).toStrictEqual(recommendedQuoteV2); + expect(result.recommendedQuote).not.toStrictEqual(selectedQuote); + expect(result.activeQuote?.quote.requestId).toStrictEqual( + selectedQuote.quote.requestId, ); }); @@ -1737,9 +1687,45 @@ describe('Bridge Selectors', () => { selectedQuote, }); - expect(result.recommendedQuote).toStrictEqual( - expect.objectContaining(mockState.quotes[1]), - ); + const expectedQuote = mergeQuoteMetadata(mockState.quotes[1], { + minToTokenAmount: { + amount: '1.8', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '1.1', + usd: '1980', + valueInCurrency: '1980', + }, + toTokenAmount: { + amount: '2.1', + usd: undefined, + valueInCurrency: undefined, + }, + swapRate: '1.90909090909090909091', + cost: { + usd: undefined, + valueInCurrency: undefined, + }, + adjustedReturn: { + usd: undefined, + valueInCurrency: undefined, + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + }, + }); + expect(result.recommendedQuote).toStrictEqual(expectedQuote); expect(result.activeQuote).toStrictEqual(result.recommendedQuote); }); @@ -1817,6 +1803,7 @@ describe('Bridge Selectors', () => { }, currencyRates: { SOL: { + conversionDate: Date.now(), conversionRate: 100, usdConversionRate: 10000, }, @@ -1824,81 +1811,31 @@ describe('Bridge Selectors', () => { }, ); - const result = selectBridgeQuotes(solanaState, mockClientParams); - expect(result.sortedQuotes).toHaveLength(2); - const expectedQuoteMetadata = { - adjustedReturn: { - usd: '160000', - valueInCurrency: '102510.5', - }, - approval: { - chainId: ChainId.SOLANA, - data: '0x0', - effectiveGas: 46000, - from: '0x0000000000000000000000000000000000000000', - gasLimit: 49000, - to: '0x0000000000000000000000000000000000000000', - value: '0x0', - }, - cost: { - usd: '9999840000', - valueInCurrency: '499897489.5', - }, - estimatedProcessingTimeInSeconds: 300, - gasFee: { - effective: { - amount: '5000', - usd: '50000', - valueInCurrency: '2500', - }, - max: { - amount: '5000', - usd: '50000', - valueInCurrency: '2500', - }, - total: { - amount: '5000', - usd: '50000', - valueInCurrency: '2500', - }, - }, - includedTxFees: null, - minToTokenAmount: { - amount: '1.8', - usd: '180000', - valueInCurrency: '90009', - }, - quote: solanaState.quotes[1].quote, - sentAmount: { - amount: '1000000000', - usd: '10000000000', - valueInCurrency: '500000000', - }, - nonEvmFeesInNative: '5000', - swapRate: '2.1e-9', - toTokenAmount: { - amount: '2.1', - usd: '210000', - valueInCurrency: '105010.5', - }, - totalMaxNetworkFee: { - amount: '5000', - usd: '50000', - valueInCurrency: '2500', + const solanaQuote = solanaState.quotes[1]; + // expect(solanaQuote.toTokenAmount?.amount).toBe('2.1'); + + const expectedQuoteMetadata = calcQuoteMetadata(solanaQuote, { + srcTokenExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', + maxFeePerGasInDecGwei: '.2', }, - totalNetworkFee: { - amount: '5000', - usd: '50000', - valueInCurrency: '2500', + destTokenExchangeRate: { + exchangeRate: '50005', + usdExchangeRate: '100000', }, - }; - const solanaQuote = solanaState.quotes[1]; - const quoteResponseV1 = mergeQuoteMetadata( + nativeExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, + }); + const expectedQuoteV2 = mergeQuoteMetadata( solanaQuote, expectedQuoteMetadata, ); - validateQuoteResponseV1(quoteResponseV1); - expect(result.recommendedQuote).toStrictEqual(quoteResponseV1); + expect(expectedQuoteV2?.toTokenAmount?.amount).toBe('2.1'); + + const result = selectBridgeQuotes(solanaState, mockClientParams); + expect(result.sortedQuotes).toHaveLength(2); + expect(result.recommendedQuote).toStrictEqual(expectedQuoteV2); }); }); @@ -1914,7 +1851,7 @@ describe('Bridge Selectors', () => { ...quote, quoteRequestIndex: 0, })), - ].map((quote) => quote), + ], quoteRequest: [ { srcChainId: '10', @@ -1985,6 +1922,13 @@ describe('Bridge Selectors', () => { selectBatchSellQuotes( { ...mockState, + currencyRates: { + ETH: { + conversionRate: 1800, + usdConversionRate: 10, + conversionDate: Date.now(), + }, + }, assetExchangeRates: { 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { exchangeRate: '1980', @@ -2031,9 +1975,21 @@ describe('Bridge Selectors', () => { "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", ] `); - expect( - recommendedQuotes.map((quote) => quote?.sentAmount?.usd), - ).toStrictEqual(['18', '140']); + expect(recommendedQuotes.map((quote) => quote?.sentAmount)) + .toMatchInlineSnapshot(` + [ + { + "amount": "0.01", + "usd": "0.1", + "valueInCurrency": "18", + }, + { + "amount": "14", + "usd": "140", + "valueInCurrency": "27720", + }, + ] + `); }); it('should return metadata when quotes are empty', () => { @@ -2230,6 +2186,7 @@ describe('Bridge Selectors', () => { ETH: { conversionRate: 1, usdConversionRate: 1, + conversionDate: Date.now(), }, }, marketData: { @@ -2237,7 +2194,7 @@ describe('Bridge Selectors', () => { [getAddress(mockBatchSellTrades.fee.asset.address)]: { price: 1, currency: 'ETH', - }, + } as never, }, }, batchSellTradesLoadingStatus: RequestStatus.FETCHED, @@ -2821,7 +2778,7 @@ describe('Bridge Selectors', () => { }); it('should return an empty array when there are no warnings', () => { - const state = { tokenWarnings: [] } as BridgeAppState; + const state = { tokenWarnings: [] } as unknown as BridgeAppState; expect(selectTokenWarnings(state)).toStrictEqual([]); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 9afd5158277..131148c1ffd 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -33,10 +33,13 @@ import { formatChainIdToHex, } from './utils/caip-formatters'; import { processFeatureFlags } from './utils/feature-flags'; -import { calcBatchFees } from './utils/quote'; -import type { TokenAmountValues } from './utils/quote-metadata'; -import { calcQuoteMetadata, mergeQuoteMetadata } from './utils/quote-metadata'; -import type { QuoteMetadata } from './utils/quote-metadata'; +import { calcBatchFees } from './utils/quote-metadata/calculators'; +import { mergeQuoteMetadata } from './utils/quote-metadata/merge'; +import { calcQuoteMetadata } from './utils/quote-metadata/quote-metadata'; +import type { + QuoteMetadata, + TokenAmountValues, +} from './utils/quote-metadata/types'; import { getDefaultSlippagePercentage } from './utils/slippage'; import type { QuoteResponseV1 } from './validators/quote-response-v1'; @@ -344,14 +347,7 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( ) => { return quotes.map((quote) => { // This is a fallback for client unit tests - const fallbackSourceAssetId = formatAddressToAssetId( - quote.quote.srcAsset.address, - quote.quote.srcChainId, - ); - const sourceAssetId = - quote.quote.srcAsset.assetId ?? - /* c8 ignore next */ - fallbackSourceAssetId; + const sourceAssetId = quote.quote.srcAsset.assetId; const srcTokenExchangeRate = selectExchangeRateByAssetId( exchangeRateSources, sourceAssetId, @@ -457,8 +453,8 @@ export const selectIsQuoteExpired = createBridgeSelector( (isQuoteGoingToRefresh, quotesLastFetched, refreshRate, currentTimeInMs) => Boolean( !isQuoteGoingToRefresh && - quotesLastFetched && - currentTimeInMs - quotesLastFetched > refreshRate, + quotesLastFetched && + currentTimeInMs - quotesLastFetched > refreshRate, ), ); @@ -530,7 +526,7 @@ const selectMetadataSum = createBridgeSelector( .toString(); return acc; }, - { usd: null, valueInCurrency: null, amount: '0' }, + { usd: '0', valueInCurrency: '0', amount: '0' }, ), ); diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 04c9513344a..8ae8a70a526 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -218,16 +218,20 @@ export type TxFeeGasLimits = Infer; export type GaslessProperties = Infer; +type DeepPartialValue = + NonNullable extends (infer U)[] + ? DeepPartial[] + : NonNullable extends readonly (infer U)[] + ? readonly DeepPartial[] + : NonNullable extends object + ? DeepPartial> + : Type; export type DeepPartial = Type extends string ? Type : { - [K in keyof Type]?: Type[K] extends (infer U)[] - ? DeepPartial[] - : Type[K] extends readonly (infer U)[] - ? readonly DeepPartial[] - : Type[K] extends object - ? DeepPartial - : Type[K]; + [K in keyof Type]?: null extends Type[K] + ? DeepPartialValue | null + : DeepPartialValue; }; export enum ChainId { diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index a51bf2d4eff..459168e167a 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -142,7 +142,10 @@ export const sumHexes = (...hexStrings: string[]): Hex => { return '0x0'; } - const sum = hexStrings.reduce((acc, hex) => acc + BigInt(hex), BigInt(0)); + const sum = hexStrings.reduce( + (acc, hexString) => acc + BigInt(hexString), + BigInt(0), + ); return `0x${sum.toString(16)}`; }; diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 8b2718a03bb..de1450cd5f9 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -29,7 +29,7 @@ describe('fetch', () => { coingeckoId: 'ethereum', aggregators: [], iconUrl: - 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png', + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/60.png', metadata: { honeypotStatus: {}, isContractVerified: false, @@ -108,7 +108,7 @@ describe('fetch', () => { coingeckoId: 'ethereum', decimals: 18, iconUrl: - 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/614.png', + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/10/native/60.png', metadata: { createdAt: '2023-10-31T22:16:37.494Z', description: {}, diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 74e639fc806..6dba87917fc 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -12,7 +12,6 @@ import { formatAddressToAssetId, formatChainIdToCaip, } from '../caip-formatters'; -import type { QuoteMetadata } from '../quote-metadata'; import { MetricsSwapType } from './constants'; import type { AccountHardwareType, @@ -21,6 +20,7 @@ import type { QuoteWarning, RequestParams, } from './types'; +import { QuoteMetadata } from '../quote-metadata/types'; export const toInputChangedPropertyKey: Partial< Record diff --git a/packages/bridge-controller/src/utils/number-formatters.ts b/packages/bridge-controller/src/utils/number-formatters.ts new file mode 100644 index 00000000000..9cc02387cfd --- /dev/null +++ b/packages/bridge-controller/src/utils/number-formatters.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { BigNumber } from 'bignumber.js'; + +/** + * 1500000 -> 1.5 + * + * @param value - The value to convert to token amount + * @param decimals - The number of decimals to convert to + * @returns The token amount in string format + */ +export const calcTokenAmount = ( + value: string | BigNumber | undefined, + decimals: number | undefined, +) => { + if (value === undefined || decimals === undefined) { + return undefined; + } + const divisor = new BigNumber(10).pow(decimals ?? 0); + return new BigNumber(value).div(divisor); +}; + +/** + * @deprecated No longer used + * @param estimatedProcessingTimeInSeconds - The estimated processing time in seconds + * @returns The estimated processing time in minutes + */ +export const formatEtaInMinutes = ( + estimatedProcessingTimeInSeconds: number, +) => { + if (estimatedProcessingTimeInSeconds < 60) { + return `< 1`; + } + return (estimatedProcessingTimeInSeconds / 60).toFixed(); +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata.ts b/packages/bridge-controller/src/utils/quote-metadata.ts deleted file mode 100644 index aae7cbcd25b..00000000000 --- a/packages/bridge-controller/src/utils/quote-metadata.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { QuoteResponse } from '..'; -import type { DeepPartial, ExchangeRate } from '../types'; -import { isEvmQuoteResponse } from './bridge'; -import { - calcAdjustedReturn, - calcCost, - calcEstimatedAndMaxTotalGasFee, - calcIncludedTxFees, - calcNonEvmTotalNetworkFee, - calcRelayerFee, - calcSentAmount, - calcSwapRate, - calcToAmount, - calcTotalEstimatedNetworkFee, - calcTotalMaxNetworkFee, -} from './quote'; - -/** - * The types of values for the token amount and its values when converted to the user's selected currency and USD - */ -export type TokenAmountValues = { - /** - * The amount of the token - * - * @example "1.005" - */ - amount: string; - /** - * The amount of the token in the user's selected currency - * - * @example "4.55" - */ - valueInCurrency: string | null; - /** - * The amount of the token in USD - * - * @example "1.234" - */ - usd: string | null; -}; - -/** - * Values derived from the quote response - * - * @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead - */ -type QuoteMetadataV1 = { - /** - * If gas is included, this is the value of the src or dest token that was used to pay for the gas. - * Show this value to indicate transaction fees for gasless quotes. - */ - includedTxFees?: TokenAmountValues | null; - /** - * The gas fee for the bridge transaction. - * effective is the gas fee that is shown to the user. If this value is not - * included in the trade, the calculation falls back to the gasLimit (total) - * total is the gas fee that is spent by the user, including refunds. - * max is the max gas fee that will be used by the transaction. - */ - gasFee: Record<'effective' | 'total' | 'max', TokenAmountValues>; - /** - * The total network fee required to submit the trade and any approvals. This includes - * the relayer fee or other native fees. Should be used for balance checks and tx submission. - * Note: This is only accurate for non-gasless transactions. Use {@link QuoteMetadata.includedTxFees} to - * get the total network fee for gasless transactions. - */ - totalNetworkFee: TokenAmountValues; // estimatedGasFees + relayerFees - totalMaxNetworkFee: TokenAmountValues; // maxGasFees + relayerFees - - /** - * The amount that the user will receive (destTokenAmount) - */ - toTokenAmount: TokenAmountValues; - /** - * The minimum amount that the user will receive (minDestTokenAmount) - */ - minToTokenAmount: TokenAmountValues; - /** - * If gas is included: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.includedTxFees}. - * Otherwise: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.totalNetworkFee}. - */ - adjustedReturn: Omit; - /** - * The amount that the user will send, including fees that are paid in the src token - * {@link Quote.srcTokenAmount} + {@link Quote.feeData[FeeType.METABRIDGE].amount} + {@link Quote.feeData[FeeType.TX_FEE].amount} - */ - sentAmount: TokenAmountValues; - /** - * The swap rate is the amount that the user will receive per amount sent. Accounts for fees paid in the src or dest token. - * This is calculated as {@link QuoteMetadata.toTokenAmount} / {@link QuoteMetadata.sentAmount}. - */ - swapRate: string; - /** - * The cost of the trade, which is the difference between the amount sent and the adjusted return. - * This is calculated as {@link QuoteMetadata.sentAmount} - {@link QuoteMetadata.adjustedReturn}. - */ - cost: Omit; // sentAmount - adjustedReturn -}; -export type QuoteMetadata = DeepPartial; - -export const calcQuoteMetadata = ( - quote: QuoteResponse, - options: { - bridgeFeesPerGas: null | { - estimatedBaseFeeInDecGwei: string | null; - feePerGasInDecGwei?: string; - maxFeePerGasInDecGwei?: string; - }; - srcTokenExchangeRate: ExchangeRate; - destTokenExchangeRate: ExchangeRate; - nativeExchangeRate: ExchangeRate; - }, -): QuoteMetadata => { - const { - bridgeFeesPerGas, - srcTokenExchangeRate, - destTokenExchangeRate, - nativeExchangeRate, - } = options; - - const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); - const toTokenAmount = calcToAmount( - quote.quote.destTokenAmount, - quote.quote.destAsset, - destTokenExchangeRate, - ); - const minToTokenAmount = calcToAmount( - quote.quote.minDestTokenAmount, - quote.quote.destAsset, - destTokenExchangeRate, - ); - - const includedTxFees = calcIncludedTxFees( - quote.quote, - srcTokenExchangeRate, - destTokenExchangeRate, - ); - - let totalEstimatedNetworkFee, - totalMaxNetworkFee, - relayerFee, - gasFee: QuoteMetadata['gasFee']; - - if (isEvmQuoteResponse(quote)) { - relayerFee = calcRelayerFee(quote, nativeExchangeRate); - gasFee = calcEstimatedAndMaxTotalGasFee({ - bridgeQuote: quote, - ...bridgeFeesPerGas, - ...nativeExchangeRate, - }); - // Uses effectiveGasFee to calculate the total estimated network fee - totalEstimatedNetworkFee = calcTotalEstimatedNetworkFee(gasFee, relayerFee); - totalMaxNetworkFee = calcTotalMaxNetworkFee(gasFee, relayerFee); - } else { - // Use the new generic function for all non-EVM chains - totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( - quote, - nativeExchangeRate, - ); - gasFee = { - effective: totalEstimatedNetworkFee, - total: totalEstimatedNetworkFee, - max: totalEstimatedNetworkFee, - }; - totalMaxNetworkFee = totalEstimatedNetworkFee; - } - - const adjustedReturn = calcAdjustedReturn( - toTokenAmount, - totalEstimatedNetworkFee, - quote.quote, - ); - const cost = calcCost(adjustedReturn, sentAmount); - - return mergeQuoteMetadata(quote, { - // QuoteMetadata fields - sentAmount, - toTokenAmount, - minToTokenAmount, - swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), - /** - This is the amount required to submit all the transactions. - Includes the relayer fee or other native fees. - Should be used for balance checks and tx submission. - */ - totalNetworkFee: totalEstimatedNetworkFee, - totalMaxNetworkFee, - /** - This contains gas fee estimates for the bridge transaction - Does not include the relayer fee (if needed), just the gasLimit and effectiveGas returned by the bridge API. - Should only be used for display purposes. - */ - gasFee, - adjustedReturn, - cost, - includedTxFees, - }); -}; - -/** - * Merges the quote metadata into the quote - * - * @param quote The quote to merge the metadata into - * @param quoteMetadata The metadata to merge into the quote - * @returns The quote with the metadata merged in - */ -export function mergeQuoteMetadata< - QuoteType extends DeepPartial, ->(quote: QuoteType, quoteMetadata: QuoteMetadata): QuoteType & QuoteMetadata { - return { - ...quote, - ...quoteMetadata, - }; -} diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts similarity index 68% rename from packages/bridge-controller/src/utils/quote.test.ts rename to packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts index d2800f7ad39..337e3b63a10 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts @@ -2,14 +2,15 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import type { GenericQuoteRequest, NonEvmFees, L1GasFees } from '../types'; -import type { Quote } from '../validators/quote'; -import type { QuoteResponseV1 } from '../validators/quote-response-v1'; -import type { TxData } from '../validators/trade'; -import { getNativeAssetForChainId } from './bridge'; +import { mockBridgeQuotesErc20Erc20V1 } from '../../../tests/mock-quotes-erc20-erc20'; +import { mockBridgeQuotesNativeErc20V1 } from '../../../tests/mock-quotes-native-erc20'; +import { getMockBridgeQuotesSolErc20V2 } from '../../../tests/mock-quotes-sol-erc20'; +import type { GenericQuoteRequest, L1GasFees } from '../../types'; +import type { Quote } from '../../validators/quote'; +import { QuoteResponseV1 as QuoteResponse } from '../../validators/quote-response-v1'; +import type { TxData } from '../../validators/trade'; +import { getNativeAssetForChainId, isNativeAddress } from '../bridge'; import { - isValidQuoteRequest, - getQuoteIdentifier, calcNonEvmTotalNetworkFee, calcToAmount, calcSentAmount, @@ -20,9 +21,12 @@ import { calcAdjustedReturn, calcSwapRate, calcCost, - formatEtaInMinutes, calcSlippagePercentage, -} from './quote'; + calcPriceImpact, +} from './calculators'; +import { formatEtaInMinutes } from '../number-formatters'; +import { isValidQuoteRequest } from '../../validators/quote-request'; +import { merge } from 'lodash'; describe('Quote Utils', () => { describe('isValidQuoteRequest', () => { @@ -150,102 +154,77 @@ describe('Quote Utils', () => { }); describe('Quote Metadata Utils', () => { - describe('getQuoteIdentifier', () => { - it('should generate correct identifier from quote', () => { - const quote = { - bridgeId: 'bridge1', - bridges: ['bridge-a'], - steps: ['step1', 'step2'], - } as unknown as Quote; - expect(getQuoteIdentifier(quote)).toBe('bridge1-bridge-a-2'); - }); - }); - describe('calcSentAmount', () => { - it('should calculate sent amount correctly with exchange rates (no assetId)', () => { - const mockQuote: Quote = { - srcTokenAmount: '12555423', - srcAsset: { - decimals: 6, - address: '0x0000000000000000000000000000000000000000', - chainId: 1, - }, - feeData: { - metabridge: { - amount: '100000000', - asset: { - address: '0x0000000000000000000000000000000000000000', - chainId: 1, - }, - }, - }, - } as unknown as Quote; - const result = calcSentAmount(mockQuote, { - exchangeRate: '2.14', - usdExchangeRate: '1.5', - }); - - expect(result.amount).toBe('112.555423'); - expect(result.valueInCurrency).toBe('240.86860522'); - expect(result.usd).toBe('168.8331345'); - }); - it('should calculate sent amount correctly with exchange rates', () => { - const mockQuote: Quote = { - srcTokenAmount: '12555423', - srcAsset: { - decimals: 6, - assetId: getNativeAssetForChainId(1).assetId, - }, - feeData: { - metabridge: { - amount: '100000000', - asset: { - assetId: getNativeAssetForChainId(1).assetId, + const mockQuote = merge({}, mockBridgeQuotesErc20Erc20V1, { + quote: { + srcTokenAmount: '2555423', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { + amount: '110000000', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + }, }, }, }, - } as unknown as Quote; + })[0].quote; + expect(mockQuote.feeData.metabridge.asset?.assetId).toBe( + mockQuote.srcAsset.assetId, + ); + const result = calcSentAmount(mockQuote, { exchangeRate: '2.14', usdExchangeRate: '1.5', }); - expect(result.amount).toBe('112.555423'); - expect(result.valueInCurrency).toBe('240.86860522'); - expect(result.usd).toBe('168.8331345'); + expect(result).toMatchInlineSnapshot(` + { + "amount": "14", + "usd": "21", + "valueInCurrency": "29.96", + } + `); }); it('should handle missing exchange rates', () => { - const mockQuote: Quote = { - srcTokenAmount: '1000000000', - srcAsset: { decimals: 6 }, - feeData: { - metabridge: { amount: '100000000' }, + const mockQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + quote: { + srcTokenAmount: '1000000000', + srcAsset: { decimals: 6 }, + feeData: { + metabridge: { amount: '100000000' }, + }, }, - } as unknown as Quote; + }).quote; const result = calcSentAmount(mockQuote, {}); expect(result.amount).toBe('1100'); - expect(result.valueInCurrency).toBeNull(); - expect(result.usd).toBeNull(); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); }); it('should handle zero values', () => { - const mockQuote: Quote = { - srcTokenAmount: '0', - srcAsset: { decimals: 6 }, - feeData: { - metabridge: { amount: '0' }, - }, - } as unknown as Quote; - const zeroQuote = { - ...mockQuote, - srcTokenAmount: '0', - feeData: { - metabridge: { amount: '0' }, + const zeroQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + quote: { + srcTokenAmount: '0', + srcAsset: { decimals: 6 }, + + feeData: { + metabridge: { amount: '0' }, + }, }, - } as unknown as Quote; + // } as unknown as Quote; + // const zeroQuote = { + // ...mockQuote, + // srcTokenAmount: '0', + // feeData: { + // metabridge: { amount: '0' }, + // }, + // } as unknown as Quote; + }).quote; const result = calcSentAmount(zeroQuote, { exchangeRate: '2', @@ -258,24 +237,27 @@ describe('Quote Metadata Utils', () => { }); it('should handle large numbers', () => { - const largeQuote = { - srcTokenAmount: '1000000000000000000', - srcAsset: { - decimals: 18, - assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', - }, - feeData: { - metabridge: { - amount: '100000000000000000', - asset: { - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', - address: '0x0000000000000000000000000000000000000000', - decimals: 18, + const largeQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + quote: { + srcTokenAmount: '1000000000000000000', + srcAsset: { + decimals: 18, + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + }, + feeData: { + metabridge: { + amount: '100000000000000000', + asset: { + assetId: + 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + }, }, }, }, - } as unknown as Quote; + }).quote; const result = calcSentAmount(largeQuote, { exchangeRate: '2', @@ -292,25 +274,48 @@ describe('Quote Metadata Utils', () => { // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is already // the total fixed commitment including protocol fees. Adding feeData fees // on top would double-count them. - const intentQuote = { - srcTokenAmount: '10000000', // 10 USDT (6 decimals), fee already included - srcAsset: { - decimals: 6, - assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', - }, - feeData: { - metabridge: { - amount: '500000', // 0.5 USDT protocol fee — already inside srcTokenAmount - asset: { - assetId: - 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', - address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', - decimals: 6, + + const intentQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + quote: { + srcTokenAmount: '10000000', // 10 USDT (6 decimals), fee already included + srcAsset: { + decimals: 6, + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + feeData: { + metabridge: { + amount: '500000', // 0.5 USDT protocol fee — already inside srcTokenAmount + asset: { + assetId: + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + }, + }, + intent: { + protocol: 'cow', + order: { + sellToken: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + buyToken: '0x0000000000000000000000000000000000000000', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }, + typedData: { + types: {}, + domain: {}, + primaryType: 'Order', + message: {}, }, }, }, - intent: { protocol: 'cow', order: {} }, - } as unknown as Quote; + }).quote; const result = calcSentAmount(intentQuote, { exchangeRate: '1', @@ -325,11 +330,9 @@ describe('Quote Metadata Utils', () => { }); describe('calcNonEvmTotalNetworkFee', () => { - const mockBridgeQuote: QuoteResponseV1 & NonEvmFees = { + const mockBridgeQuote = getMockBridgeQuotesSolErc20V2({ nonEvmFeesInNative: '1', - quote: {} as Quote, - trade: {}, - } as QuoteResponseV1 & NonEvmFees; + })[0]; it('should calculate Solana fees correctly with exchange rates', () => { const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, { @@ -343,11 +346,9 @@ describe('Quote Metadata Utils', () => { }); it('should calculate Bitcoin fees correctly with exchange rates', () => { - const btcQuote: QuoteResponseV1 & NonEvmFees = { + const btcQuote = getMockBridgeQuotesSolErc20V2({ nonEvmFeesInNative: '0.00005', // BTC fee in native units - quote: {} as Quote, - trade: {}, - } as QuoteResponseV1 & NonEvmFees; + })[0]; const result = calcNonEvmTotalNetworkFee(btcQuote, { exchangeRate: '60000', @@ -363,8 +364,8 @@ describe('Quote Metadata Utils', () => { const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, {}); expect(result.amount).toBe('1'); - expect(result.valueInCurrency).toBeNull(); - expect(result.usd).toBeNull(); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); }); it('should handle zero fees', () => { @@ -409,20 +410,24 @@ describe('Quote Metadata Utils', () => { ); expect(result.amount).toBe('1000'); - expect(result.valueInCurrency).toBeNull(); - expect(result.usd).toBeNull(); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); }); }); describe('calcRelayerFee', () => { - const mockBridgeQuote: QuoteResponseV1 = { + const mockBridgeQuote = merge({}, mockBridgeQuotesNativeErc20V1[0], { quote: { srcAsset: { address: '0x123', decimals: 18 }, srcTokenAmount: '1000000000000000000', - feeData: { metabridge: { amount: '100000000000000000' } }, + feeData: { + metabridge: { + amount: '10000000000000000', + }, + }, }, trade: { value: '0x10A741A462780000' }, - } as unknown as QuoteResponseV1; + }); it('should calculate relayer fee correctly with exchange rates', () => { const result = calcRelayerFee(mockBridgeQuote, { @@ -430,30 +435,42 @@ describe('Quote Metadata Utils', () => { usdExchangeRate: '1.5', }); - expect(result.amount).toStrictEqual(new BigNumber(1.2)); - expect(result.valueInCurrency).toStrictEqual(new BigNumber(2.4)); - expect(result.usd).toStrictEqual(new BigNumber(1.8)); + expect(new BigNumber(mockBridgeQuote.trade.value, 16).toFixed()).toBe( + '11000000000000000', + ); + expect(mockBridgeQuote.sentAmount?.amount).toBe('1010000000000000000'); + + expect(mockBridgeQuote.quote.srcAsset.assetId).toStrictEqual( + mockBridgeQuote.quote.feeData.metabridge.asset?.assetId, + ); + expect(isNativeAddress(mockBridgeQuote.quote.src.asset.assetId)).toBe( + true, + ); + + expect(result?.amount).toStrictEqual(new BigNumber(0.19).toFixed()); + expect(result?.valueInCurrency).toStrictEqual( + new BigNumber(0.38).toFixed(), + ); + expect(result?.usd).toStrictEqual(new BigNumber(0.285).toFixed()); }); it('should calculate relayer fee correctly with no trade.value', () => { const result = calcRelayerFee( - { ...mockBridgeQuote, trade: {} as TxData }, + merge({}, mockBridgeQuotesNativeErc20V1[0], { + trade: { ...mockBridgeQuote.trade, value: '0x0' }, + }), { exchangeRate: '2', usdExchangeRate: '1.5', }, ); - expect(result.amount).toBeUndefined(); - expect(result.valueInCurrency).toBeUndefined(); - expect(result.usd).toBeUndefined(); + expect(result).toBeUndefined(); }); it('should handle native token address', () => { - const nativeBridgeQuote = { - ...mockBridgeQuote, + const nativeBridgeQuote = merge({}, mockBridgeQuotesNativeErc20V1, { quote: { - ...mockBridgeQuote.quote, srcTokenAmount: '1000000000000000000', feeData: { metabridge: { @@ -461,19 +478,20 @@ describe('Quote Metadata Utils', () => { asset: { address: AddressZero, decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + assetId: getNativeAssetForChainId(1).assetId, }, }, }, srcAsset: { address: AddressZero, decimals: 18, - assetId: - 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + assetId: getNativeAssetForChainId(1).assetId, }, }, - } as unknown as QuoteResponseV1; + trade: { + value: '0x10A741A462780000', + }, + })[0]; const result = calcRelayerFee(nativeBridgeQuote, { exchangeRate: '2', @@ -482,22 +500,22 @@ describe('Quote Metadata Utils', () => { expect( convertHexToDecimal(nativeBridgeQuote.trade.value).toString(), - ).toBe('1200000000000000000'); + ).toBe('11000000000000000'); expect(result).toStrictEqual({ - amount: new BigNumber(0.1), - valueInCurrency: new BigNumber(0.2), - usd: new BigNumber(0.15), + amount: new BigNumber(0.1).toFixed(), + valueInCurrency: new BigNumber(0.2).toFixed(), + usd: new BigNumber(0.15).toFixed(), }); }); }); describe('calcEstimatedAndMaxTotalGasFee', () => { - const mockBridgeQuote: QuoteResponseV1 & L1GasFees = { + const mockBridgeQuote: QuoteResponse & L1GasFees = { quote: {} as Quote, trade: { gasLimit: 21000 }, approval: { gasLimit: 46000 }, l1GasFeesInHexWei: '0x5AF3107A4000', - } as unknown as QuoteResponseV1 & L1GasFees; + }; it('should calculate estimated and max gas fees correctly', () => { const result = calcEstimatedAndMaxTotalGasFee({ @@ -510,16 +528,6 @@ describe('Quote Metadata Utils', () => { expect(result).toMatchInlineSnapshot(` { - "effective": { - "amount": "0.003584", - "usd": "5.376", - "valueInCurrency": "7.168", - }, - "max": { - "amount": "0.006934", - "usd": "10.401", - "valueInCurrency": "13.868", - }, "total": { "amount": "0.003584", "usd": "5.376", @@ -528,7 +536,6 @@ describe('Quote Metadata Utils', () => { } `); expect(result?.total?.amount).toBeDefined(); - expect(result?.max?.amount).toBeDefined(); }); it('should calculate estimated and max gas fees correctly when effectiveGas is available', () => { @@ -537,7 +544,7 @@ describe('Quote Metadata Utils', () => { ...mockBridgeQuote, trade: { gasLimit: 21000, effectiveGas: 10000 }, approval: { gasLimit: 46000, effectiveGas: 20000 }, - } as QuoteResponseV1 & L1GasFees, + }, feePerGasInDecGwei: '52', maxFeePerGasInDecGwei: '102', exchangeRate: '2000', @@ -546,16 +553,6 @@ describe('Quote Metadata Utils', () => { expect(result).toMatchInlineSnapshot(` { - "effective": { - "amount": "0.00166", - "usd": "2.49", - "valueInCurrency": "3.32", - }, - "max": { - "amount": "0.006934", - "usd": "10.401", - "valueInCurrency": "13.868", - }, "total": { "amount": "0.003584", "usd": "5.376", @@ -564,7 +561,6 @@ describe('Quote Metadata Utils', () => { } `); expect(result?.total?.amount).toBeDefined(); - expect(result?.max?.amount).toBeDefined(); }); it('should handle missing exchange rates', () => { @@ -577,11 +573,8 @@ describe('Quote Metadata Utils', () => { }); expect(result?.total?.valueInCurrency).toBeUndefined(); - expect(result?.max?.valueInCurrency).toBeUndefined(); expect(result?.total?.usd).toBeUndefined(); - expect(result?.max?.usd).toBeUndefined(); expect(result?.total?.amount).toBeDefined(); - expect(result?.max?.amount).toBeDefined(); }); it('should handle only display currency exchange rate', () => { @@ -594,9 +587,7 @@ describe('Quote Metadata Utils', () => { }); expect(result?.total?.valueInCurrency).toBeDefined(); - expect(result?.max?.valueInCurrency).toBeDefined(); expect(result?.total?.usd).toBeUndefined(); - expect(result?.max?.usd).toBeUndefined(); }); it('should handle only USD exchange rate', () => { @@ -609,9 +600,7 @@ describe('Quote Metadata Utils', () => { }); expect(result?.total?.valueInCurrency).toBeUndefined(); - expect(result?.max?.valueInCurrency).toBeUndefined(); expect(result?.total?.usd).toBeDefined(); - expect(result?.max?.usd).toBeDefined(); }); it('should handle zero gas limits', () => { @@ -621,7 +610,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 0 }, l1GasFeesInHexWei: '0x0', estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: zeroGasQuote, @@ -632,7 +621,6 @@ describe('Quote Metadata Utils', () => { }); expect(result?.total?.amount).toBeUndefined(); - expect(result?.max?.amount).toBeUndefined(); expect(result?.total?.valueInCurrency).toBeUndefined(); expect(result?.total?.usd).toBeUndefined(); }); @@ -644,7 +632,7 @@ describe('Quote Metadata Utils', () => { approval: undefined, l1GasFeesInHexWei: '0x5AF3107A4000', estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: noApprovalQuote, @@ -655,10 +643,6 @@ describe('Quote Metadata Utils', () => { }); expect(result?.total?.amount).toBeDefined(); - expect(result?.max?.amount).toBeDefined(); - expect(parseFloat(result?.max?.amount ?? '0')).toStrictEqual( - parseFloat(result?.total?.amount ?? '0'), - ); }); it('should handle missing trade gasLimit', () => { @@ -668,7 +652,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 46000 }, l1GasFeesInHexWei: '0x5AF3107A4000', estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: noGasLimitQuote, @@ -679,7 +663,6 @@ describe('Quote Metadata Utils', () => { }); expect(result?.total?.amount).toBeUndefined(); - expect(result?.max?.amount).toBeUndefined(); }); it('should handle large gas limits and fees', () => { @@ -689,7 +672,7 @@ describe('Quote Metadata Utils', () => { approval: { gasLimit: 500000 } as TxData, l1GasFeesInHexWei: '0x1BC16D674EC80000', // 2 ETH in wei estimatedProcessingTimeInSeconds: 60, - } as QuoteResponseV1 & L1GasFees; + }; const result = calcEstimatedAndMaxTotalGasFee({ bridgeQuote: largeGasQuote, @@ -700,9 +683,6 @@ describe('Quote Metadata Utils', () => { }); expect(parseFloat(result?.total?.amount ?? '0')).toBeGreaterThan(2); // Should be > 2 ETH due to L1 fees - expect(parseFloat(result?.max?.amount ?? '0')).toStrictEqual( - parseFloat(result?.total?.amount ?? '0'), - ); expect(result?.total?.valueInCurrency).toBeDefined(); expect(result?.total?.usd).toBeDefined(); expect( @@ -753,9 +733,9 @@ describe('Quote Metadata Utils', () => { }; const mockRelayerFee = { - amount: new BigNumber(0.05), - valueInCurrency: new BigNumber(100), - usd: new BigNumber(75), + amount: '0.05', + valueInCurrency: '100', + usd: '75', }; it('should calculate total estimated network fee correctly', () => { @@ -776,7 +756,7 @@ describe('Quote Metadata Utils', () => { it('should calculate total estimated network fee correctly with no relayer fee', () => { const result = calcTotalEstimatedNetworkFee(mockGasFee, { - amount: new BigNumber(0), + amount: '0', valueInCurrency: null, usd: null, }); @@ -788,7 +768,7 @@ describe('Quote Metadata Utils', () => { it('should calculate total max network fee correctly with no relayer fee', () => { const result = calcTotalMaxNetworkFee(mockGasFee, { - amount: new BigNumber(0), + amount: '0', valueInCurrency: null, usd: null, }); @@ -824,7 +804,7 @@ describe('Quote Metadata Utils', () => { destAsset: { assetId: 'eip155:10/erc20:0x0000000000000000000000000000000000000000', }, - } as unknown as Quote; + }; it('should calculate adjusted return correctly', () => { const result = calcAdjustedReturn( mockToAmount, @@ -838,13 +818,13 @@ describe('Quote Metadata Utils', () => { it('should handle null values', () => { const result = calcAdjustedReturn( - { amount: '1000', valueInCurrency: null, usd: null }, + { amount: '1000', valueInCurrency: undefined, usd: undefined }, mockNetworkFee, mockQuote, ); - expect(result.valueInCurrency).toBeNull(); - expect(result.usd).toBeNull(); + expect(result.valueInCurrency).toBeUndefined(); + expect(result.usd).toBeUndefined(); }); }); @@ -919,4 +899,135 @@ describe('Quote Metadata Utils', () => { expect(result).toBe('100'); }); }); + + describe('calcPriceImpact', () => { + it('returns undefined when activeQuote is null', () => { + expect(calcPriceImpact(null)).toBeUndefined(); + }); + + it('returns undefined when activeQuote is undefined', () => { + expect(calcPriceImpact(undefined)).toBeUndefined(); + }); + + it('returns undefined when sentAmount.valueInCurrency is null', () => { + expect( + calcPriceImpact({ + sentAmount: { valueInCurrency: null }, + toTokenAmount: { valueInCurrency: '900' }, + }), + ).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": undefined, + } + `); + }); + + it('returns undefined when toTokenAmount.valueInCurrency is undefined', () => { + expect( + calcPriceImpact({ + sentAmount: { valueInCurrency: '1000' }, + toTokenAmount: { valueInCurrency: undefined }, + }), + ).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": undefined, + } + `); + }); + + it('returns undefined when sentAmount is missing', () => { + expect( + calcPriceImpact({ + sentAmount: {}, + toTokenAmount: { valueInCurrency: '900' }, + }), + ).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": undefined, + } + `); + }); + + it('returns undefined when toTokenAmount is missing', () => { + expect( + calcPriceImpact({ + sentAmount: { valueInCurrency: '1000' }, + toTokenAmount: {}, + }), + ).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": undefined, + } + `); + }); + + it('formats the absolute difference between source and destination fiat amounts', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '1000', usd: '995.77' }, + toTokenAmount: { valueInCurrency: '995.77', usd: '1000' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": "4.23", + "valueInCurrency": "4.23", + } + `); + }); + + it('uses the absolute value so a favourable quote does not produce a negative result', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '900' }, + toTokenAmount: { valueInCurrency: '1000' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": "100", + } + `); + }); + + it('handles string numeric inputs', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '500.50', usd: '5' }, + toTokenAmount: { valueInCurrency: '496.27' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": undefined, + "valueInCurrency": "4.23", + } + `); + }); + + it('handles numeric inputs', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: '1000', usd: '1.5' }, + toTokenAmount: { valueInCurrency: '10', usd: '2.49' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": "0.99", + "valueInCurrency": "990", + } + `); + }); + + it('handles NaN inputs', () => { + const result = calcPriceImpact({ + sentAmount: { valueInCurrency: 'a', usd: '-1.5' }, + toTokenAmount: { valueInCurrency: '10', usd: '2.49' }, + }); + expect(result).toMatchInlineSnapshot(` + { + "usd": "3.99", + "valueInCurrency": undefined, + } + `); + }); + }); }); diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts new file mode 100644 index 00000000000..2f981fda0a2 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -0,0 +1,418 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { + convertHexToDecimal, + toHex, + weiHexToGweiDec, +} from '@metamask/controller-utils'; +import { is } from '@metamask/superstruct'; +import { KnownCaipNamespace } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { + L1GasFees, + ExchangeRate, + NonEvmFees, + DeepPartial, + BridgeAsset, +} from '../../types'; +import { FloatStringSchema } from '../../validators/number'; +import type { QuoteResponseV1 as QuoteResponse } from '../../validators/quote-response-v1'; +import { isNativeAddress } from '../bridge'; +import type { QuoteMetadata, TokenAmountValues } from './types'; +import { calcTokenAmount } from '../number-formatters'; +import { TxData } from '../../validators/trade'; + +export const calcNonEvmTotalNetworkFee = ( + bridgeQuote: QuoteResponse & NonEvmFees, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const { nonEvmFeesInNative } = bridgeQuote; + // Fees are now stored directly in native units (SOL, BTC) without conversion + const feeInNative = nonEvmFeesInNative + ? new BigNumber(nonEvmFeesInNative) + : undefined; + + return { + amount: feeInNative?.toFixed(), + valueInCurrency: exchangeRate && feeInNative?.times(exchangeRate).toFixed(), + usd: usdExchangeRate && feeInNative?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcToAmount = ( + destTokenAmount: string | undefined, + destAsset: BridgeAsset, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const normalizedDestAmount = calcTokenAmount( + destTokenAmount, + destAsset.decimals, + ); + return { + amount: normalizedDestAmount?.toFixed(), + valueInCurrency: + exchangeRate && normalizedDestAmount?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && normalizedDestAmount?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcSentAmount = ( + { srcTokenAmount, srcAsset, feeData, intent }: QuoteResponse['quote'], + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is the total + // fixed commitment the user makes to the protocol — the protocol fee is + // already baked in. Adding feeData fees on top would double-count them. + // For conventional swaps, srcTokenAmount is the net routing amount (fees + // excluded), so the src-token fees must be added to get the wallet deduction. + const sentAmount = intent + ? new BigNumber(srcTokenAmount) + : Object.values(feeData) + .filter((fee) => fee?.amount && fee.asset?.assetId === srcAsset.assetId) + .reduce( + (acc, { amount }) => acc.plus(amount), + new BigNumber(srcTokenAmount), + ); + const normalizedSentAmount = calcTokenAmount(sentAmount, srcAsset.decimals); + return { + amount: normalizedSentAmount?.toString(), + valueInCurrency: + exchangeRate && normalizedSentAmount?.times(exchangeRate).toString(), + usd: + usdExchangeRate && + normalizedSentAmount?.times(usdExchangeRate).toString(), + }; +}; + +export const calcBatchFees = ( + amount: string, + asset: BridgeAsset, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const normalizedAmount = calcTokenAmount(amount, asset.decimals); + + return { + amount: normalizedAmount?.toString(), + valueInCurrency: exchangeRate + ? normalizedAmount?.times(exchangeRate).toString() + : null, + usd: usdExchangeRate + ? normalizedAmount?.times(usdExchangeRate).toString() + : null, + asset, + }; +}; + +export const calcRelayerFee = ( + quoteResponse: QuoteResponse, + { exchangeRate, usdExchangeRate }: ExchangeRate, +) => { + const { quote, trade } = quoteResponse; + const relayerFeeAmount = trade.value + ? new BigNumber(convertHexToDecimal(trade.value)) + : undefined; + let relayerFeeInNative = relayerFeeAmount + ? calcTokenAmount(relayerFeeAmount, 18) + : undefined; + + // Subtract srcAmount and other fees from trade value if srcAsset is native + if (isNativeAddress(quote.srcAsset.assetId)) { + const sentAmountInNative = calcSentAmount(quote, { + exchangeRate, + usdExchangeRate, + }).amount; + relayerFeeInNative = relayerFeeInNative?.minus(sentAmountInNative ?? '0'); + } + + if (relayerFeeInNative?.lte(0)) { + return undefined; + } + + return { + amount: relayerFeeInNative?.toFixed(), + valueInCurrency: + exchangeRate && relayerFeeInNative?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && relayerFeeInNative?.times(usdExchangeRate).toFixed(), + }; +}; + +const calcTotalGasFee = ({ + approvalGasLimit, + resetApprovalGasLimit, + tradeGasLimit, + l1GasFeesInHexWei, + feePerGasInDecGwei, + nativeToDisplayCurrencyExchangeRate, + nativeToUsdExchangeRate, +}: { + approvalGasLimit?: number | null; + resetApprovalGasLimit?: number | null; + tradeGasLimit?: number | null; + l1GasFeesInHexWei?: string | null; + feePerGasInDecGwei?: string; + nativeToDisplayCurrencyExchangeRate?: string; + nativeToUsdExchangeRate?: string; +}) => { + const totalGasLimitInDec = tradeGasLimit + ? new BigNumber(tradeGasLimit.toString()) + .plus(approvalGasLimit?.toString() ?? '0') + .plus(resetApprovalGasLimit?.toString() ?? '0') + : undefined; + + const l1GasFeesInDecGWei = weiHexToGweiDec(toHex(l1GasFeesInHexWei ?? '0')); + const gasFeesInDecGwei = totalGasLimitInDec + ?.times(feePerGasInDecGwei ?? '0') + .plus(l1GasFeesInDecGWei); + const gasFeesInDecEth = gasFeesInDecGwei?.times(new BigNumber(10).pow(-9)); + + const gasFeesInDisplayCurrency = nativeToDisplayCurrencyExchangeRate + ? gasFeesInDecEth?.times(nativeToDisplayCurrencyExchangeRate.toString()) + : null; + const gasFeesInUSD = nativeToUsdExchangeRate + ? gasFeesInDecEth?.times(nativeToUsdExchangeRate.toString()) + : null; + + return { + amount: gasFeesInDecEth?.toFixed(), + valueInCurrency: gasFeesInDisplayCurrency?.toFixed(), + usd: gasFeesInUSD?.toFixed(), + }; +}; + +export const calcEstimatedAndMaxTotalGasFee = ({ + bridgeQuote: { approval, trade, l1GasFeesInHexWei, resetApproval }, + feePerGasInDecGwei, + exchangeRate: nativeToDisplayCurrencyExchangeRate, + usdExchangeRate: nativeToUsdExchangeRate, +}: { + bridgeQuote: QuoteResponse & L1GasFees; + feePerGasInDecGwei?: string; +} & ExchangeRate) => { + // Estimated total gas fee, including refunded fees (medium) + const { amount, valueInCurrency, usd } = calcTotalGasFee({ + approvalGasLimit: approval?.gasLimit, + resetApprovalGasLimit: resetApproval?.gasLimit, + tradeGasLimit: trade?.gasLimit, + l1GasFeesInHexWei, + feePerGasInDecGwei, + nativeToDisplayCurrencyExchangeRate, + nativeToUsdExchangeRate, + }); + + return { + total: { + amount, + valueInCurrency, + usd, + }, + }; +}; + +/** + * Calculates the total estimated network fees for the bridge transaction + * + * @param gasFee - The gas fee for the bridge transaction + * @param gasFee.total - The fee to display to the user. If not available, this is equal to the gasLimit (total) + * @param relayerFee - The relayer fee paid to bridge providers + * @returns The total estimated network fee for the bridge transaction, including the relayer fee paid to bridge providers + */ +export const calcTotalEstimatedNetworkFee = ( + gasFee: { total?: Partial } | undefined, + relayerFee: ReturnType, +) => { + const { total: gasFeeToDisplay } = gasFee ?? {}; + return { + amount: + (gasFeeToDisplay?.amount ?? relayerFee?.amount) && + new BigNumber(gasFeeToDisplay?.amount ?? '0') + .plus(relayerFee?.amount ?? '0') + .toFixed(), + valueInCurrency: + (gasFeeToDisplay?.valueInCurrency ?? relayerFee?.valueInCurrency) && + new BigNumber(gasFeeToDisplay?.valueInCurrency ?? '0') + .plus(relayerFee?.valueInCurrency ?? '0') + .toFixed(), + usd: + (gasFeeToDisplay?.usd ?? relayerFee?.usd) && + new BigNumber(gasFeeToDisplay?.usd ?? '0') + .plus(relayerFee?.usd ?? '0') + .toFixed(), + }; +}; + +export const calcTotalMaxNetworkFee = ( + gasFee: { max?: Partial } | undefined, + relayerFee: ReturnType, +) => { + return { + amount: + gasFee?.max?.amount && + new BigNumber(gasFee.max.amount) + .plus(relayerFee?.amount ?? '0') + .toFixed(), + valueInCurrency: + gasFee?.max?.valueInCurrency && + new BigNumber(gasFee.max.valueInCurrency) + .plus(relayerFee?.valueInCurrency ?? '0') + .toString(), + usd: + gasFee?.max?.usd && + new BigNumber(gasFee.max.usd).plus(relayerFee?.usd ?? '0').toString(), + }; +}; + +// Gas is included for some swap quotes and this is the value displayed in the client +export const calcIncludedTxFees = ( + { + gasIncluded, + gasIncluded7702, + srcAsset, + feeData: { txFee }, + }: QuoteResponse['quote'], + srcTokenExchangeRate: ExchangeRate, + destTokenExchangeRate: ExchangeRate, +) => { + if (!txFee || !(gasIncluded || gasIncluded7702)) { + return undefined; + } + // Use exchange rate of the token that is being used to pay for the transaction + const { exchangeRate, usdExchangeRate } = + txFee?.asset.assetId === srcAsset.assetId + ? srcTokenExchangeRate + : destTokenExchangeRate; + const normalizedTxFeeAmount = calcTokenAmount( + txFee?.amount, + txFee?.asset.decimals, + ); + + return { + amount: normalizedTxFeeAmount?.toFixed(), + valueInCurrency: + exchangeRate && normalizedTxFeeAmount?.times(exchangeRate).toFixed(), + usd: + usdExchangeRate && + normalizedTxFeeAmount?.times(usdExchangeRate).toFixed(), + }; +}; + +export const calcAdjustedReturn = ( + toTokenAmount: Partial, + totalEstimatedNetworkFee: Partial, + { + feeData: { txFee }, + destAsset: { assetId: destAssetId }, + }: QuoteResponse['quote'], +) => { + // If gas is included and is taken from the dest token, don't subtract network fee from return + if (txFee?.asset?.assetId?.toLowerCase() === destAssetId.toLowerCase()) { + return { + valueInCurrency: toTokenAmount.valueInCurrency, + usd: toTokenAmount.usd, + }; + } + return { + valueInCurrency: + toTokenAmount.valueInCurrency && + totalEstimatedNetworkFee.valueInCurrency && + new BigNumber(toTokenAmount.valueInCurrency) + .minus(totalEstimatedNetworkFee.valueInCurrency) + .toFixed(), + usd: + toTokenAmount.usd && + totalEstimatedNetworkFee.usd && + new BigNumber(toTokenAmount.usd) + .minus(totalEstimatedNetworkFee.usd) + .toFixed(), + }; +}; + +export const calcSwapRate = (sentAmount?: string, destTokenAmount?: string) => + destTokenAmount && sentAmount + ? new BigNumber(destTokenAmount).div(sentAmount).toFixed() + : undefined; + +export const calcCost = ( + adjustedReturn: ReturnType, + sentAmount: ReturnType, +) => ({ + valueInCurrency: + adjustedReturn.valueInCurrency && + sentAmount.valueInCurrency && + new BigNumber(sentAmount.valueInCurrency) + .minus(adjustedReturn.valueInCurrency) + .toFixed(), + usd: + adjustedReturn.usd && + sentAmount.usd && + new BigNumber(sentAmount.usd).minus(adjustedReturn.usd).toFixed(), +}); + +/** + * Calculates the slippage absolute value percentage based on the adjusted return and sent amount. + * + * @param adjustedReturn - Adjusted return value + * @param sentAmount - Sent amount value + * @returns the slippage in percentage + */ +export const calcSlippagePercentage = ( + adjustedReturn: ReturnType, + sentAmount: ReturnType, +): string | null => { + const cost = calcCost(adjustedReturn, sentAmount); + + if (cost.valueInCurrency && sentAmount.valueInCurrency) { + return new BigNumber(cost.valueInCurrency) + .div(sentAmount.valueInCurrency) + .times(100) + .abs() + .toFixed(); + } + + if (cost.usd && sentAmount.usd) { + return new BigNumber(cost.usd) + .div(sentAmount.usd) + .times(100) + .abs() + .toFixed(); + } + + return null; +}; + +/** + * Returns the fiat price impact for a bridge quote — the difference between + * the source input fiat amount and the destination output fiat amount + * + * @param quote - The active quote + * @returns Formatted fiat impact string, or `undefined` when either fiat value is unavailable. + */ +export const calcPriceImpact = ( + quote?: DeepPartial< + Pick + > | null, +) => { + if (!quote?.sentAmount || !quote?.toTokenAmount) { + return undefined; + } + + const sourceFiat = quote.sentAmount.valueInCurrency; + const destFiat = quote.toTokenAmount.valueInCurrency; + const sourceUsd = quote.sentAmount.usd; + const destUsd = quote.toTokenAmount.usd; + + const isSourceFiatValid = (value: unknown): value is string[] => + is(value, FloatStringSchema); + + return { + valueInCurrency: + isSourceFiatValid(sourceFiat) && isSourceFiatValid(destFiat) + ? new BigNumber(sourceFiat).minus(destFiat).abs().toFixed() + : undefined, + usd: + isSourceFiatValid(sourceUsd) && isSourceFiatValid(destUsd) + ? new BigNumber(sourceUsd).minus(destUsd).abs().toFixed() + : undefined, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts new file mode 100644 index 00000000000..d6a53a8f00f --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -0,0 +1,18 @@ +import { merge } from 'lodash'; + +import type { QuoteResponseV1 } from '../../validators/quote-response-v1'; +import type { QuoteMetadata } from './types'; + +/** + * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} + * + * @param quoteResponseV2 - The {@link QuoteResponse} to merge the metadata into + * @param quoteMetadata - The {@link QuoteMetadata} values to merge + * @returns The {@link QuoteResponse} with the metadata merged in + */ +export function mergeQuoteMetadata( + quoteResponseV2: QuoteResponseV1, + quoteMetadata: QuoteMetadata, +): QuoteResponseV1 & QuoteMetadata { + return merge({}, quoteResponseV2, quoteMetadata); +} diff --git a/packages/bridge-controller/src/utils/quote-metadata/quote-metadata.ts b/packages/bridge-controller/src/utils/quote-metadata/quote-metadata.ts new file mode 100644 index 00000000000..75dcfec99be --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/quote-metadata.ts @@ -0,0 +1,126 @@ +import type { ExchangeRate } from '../../types'; +import type { QuoteResponseV1 as QuoteResponse } from '../../validators/quote-response-v1'; +import { + calcAdjustedReturn, + calcCost, + calcEstimatedAndMaxTotalGasFee, + calcIncludedTxFees, + calcNonEvmTotalNetworkFee, + calcPriceImpact, + calcRelayerFee, + calcSentAmount, + calcSwapRate, + calcToAmount, + calcTotalEstimatedNetworkFee, +} from './calculators'; +import type { QuoteMetadata } from './types'; +import { isEvmQuoteResponse } from '../bridge'; + +/** + * Calculates quote metadata, such as converted fiat amounts and fees, + * based on the controller state and the quote response + * + * @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 + * @param options.srcTokenExchangeRate - The exchange rate for the source token + * @param options.destTokenExchangeRate - The exchange rate for the destination token + * @param options.nativeExchangeRate - The exchange rate for the native token + * @returns The calculated metadata + */ +export const calcQuoteMetadata = ( + quote: QuoteResponse, + options?: { + bridgeFeesPerGas: null | { + estimatedBaseFeeInDecGwei: string | null; + feePerGasInDecGwei?: string; + maxFeePerGasInDecGwei?: string; + }; + srcTokenExchangeRate: ExchangeRate; + destTokenExchangeRate: ExchangeRate; + nativeExchangeRate: ExchangeRate; + }, +): QuoteMetadata => { + const { + bridgeFeesPerGas = {}, + srcTokenExchangeRate = {}, + destTokenExchangeRate = {}, + nativeExchangeRate = {}, + } = options ?? {}; + + const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); + const toTokenAmount = calcToAmount( + quote.quote.destTokenAmount, + quote.quote.destAsset, + destTokenExchangeRate, + ); + const minToTokenAmount = calcToAmount( + quote.quote.minDestTokenAmount, + quote.quote.destAsset, + destTokenExchangeRate, + ); + + const includedTxFees = calcIncludedTxFees( + quote.quote, + srcTokenExchangeRate, + destTokenExchangeRate, + ); + + let totalEstimatedNetworkFee, relayerFee, gasFee; + + if (isEvmQuoteResponse(quote)) { + relayerFee = calcRelayerFee(quote, nativeExchangeRate); + gasFee = calcEstimatedAndMaxTotalGasFee({ + bridgeQuote: quote, + ...bridgeFeesPerGas, + ...nativeExchangeRate, + }); + // Uses effectiveGasFee to calculate the total estimated network fee + totalEstimatedNetworkFee = calcTotalEstimatedNetworkFee(gasFee, relayerFee); + } else { + // Use the new generic function for all non-EVM chains + totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( + quote, + nativeExchangeRate, + ); + gasFee = { + total: totalEstimatedNetworkFee, + }; + } + + const adjustedReturn = calcAdjustedReturn( + toTokenAmount, + totalEstimatedNetworkFee, + quote.quote, + ); + const cost = calcCost(adjustedReturn, sentAmount); + + // The quote has not been updated at this point, so we need to calculate the price impact using sentAmount and toTokenAmount + const priceImpact = calcPriceImpact({ sentAmount, toTokenAmount }); + + return { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate: calcSwapRate(sentAmount.amount, toTokenAmount.amount), + /** + This is the amount required to submit all the transactions. + Includes the relayer fee or other native fees. + Should be used for balance checks and tx submission. + */ + totalNetworkFee: totalEstimatedNetworkFee, + /** + This contains gas fee estimates for the bridge transaction + Does not include the relayer fee (if needed), just the gasLimit and effectiveGas returned by the bridge API. + Should only be used for display purposes. + */ + gasFee, + ...(adjustedReturn && { adjustedReturn }), + ...(cost && { cost }), + ...(includedTxFees && { includedTxFees }), + ...(relayerFee && { relayerFee }), + ...((priceImpact?.valueInCurrency ?? priceImpact?.usd) && { + priceImpact, + }), + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/types.ts b/packages/bridge-controller/src/utils/quote-metadata/types.ts new file mode 100644 index 00000000000..51aeab6850f --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/types.ts @@ -0,0 +1,89 @@ +import type { DeepPartial } from '../../types'; + +/** + * The types of values for the token amount and its values when converted to the user's selected currency and USD + */ +export type TokenAmountValues = { + /** + * The amount of the token + * + * @example "1.005" + */ + amount: string; + /** + * The amount of the token in the user's selected currency + * + * @example "4.55" + */ + valueInCurrency: string; + /** + * The amount of the token in USD + * + * @example "1.234" + */ + usd: string; +}; + +/** + * Values derived from the quote response + * + * @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead + */ +type QuoteMetadataV1 = { + /** + * If gas is included, this is the value of the src or dest token that was used to pay for the gas. + * Show this value to indicate transaction fees for gasless quotes. + */ + includedTxFees?: Partial; + /** + * The gas fee for the bridge transaction. + * effective is the gas fee that is shown to the user. If this value is not + * included in the trade, the calculation falls back to the gasLimit (total) + * total is the gas fee that is spent by the user, including refunds. + * max is the max gas fee that will be used by the transaction. + */ + gasFee: Record<'effective' | 'total' | 'max', TokenAmountValues>; + relayerFee?: Partial; // relayer/provider fee in native units + /** + * The total network fee required to submit the trade and any approvals. This includes + * the relayer fee or other native fees. Should be used for balance checks and tx submission. + * Note: This is only accurate for non-gasless transactions. Use {@link QuoteMetadata.includedTxFees} to + * get the total network fee for gasless transactions. + */ + totalNetworkFee: TokenAmountValues; // gasFee.total + relayerFee + /** + * The amount that the user will receive (destTokenAmount) + */ + toTokenAmount: TokenAmountValues; + /** + * The minimum amount that the user will receive (minDestTokenAmount) + */ + minToTokenAmount: TokenAmountValues; + /** + * If gas is included: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.includedTxFees}. + * Otherwise: {@link QuoteMetadata.toTokenAmount} - {@link QuoteMetadata.totalNetworkFee}. + */ + adjustedReturn: Omit; + /** + * The amount that the user will send, including fees that are paid in the src token + * {@link Quote.srcTokenAmount} + {@link Quote.feeData[FeeType.METABRIDGE].amount} + {@link Quote.feeData[FeeType.TX_FEE].amount} + */ + sentAmount: TokenAmountValues; + /** + * The swap rate is the amount that the user will receive per amount sent. Accounts for fees paid in the src or dest token. + * This is calculated as {@link QuoteMetadata.toTokenAmount} / {@link QuoteMetadata.sentAmount}. + */ + swapRate: string; + /** + * The cost of the trade, which is the difference between the amount sent and the adjusted return. + * This is calculated as {@link QuoteMetadata.sentAmount} - {@link QuoteMetadata.adjustedReturn}. + */ + cost: Omit; // sentAmount - adjustedReturn + + /** + * The price impact for the quote. + */ + priceImpact: Omit; // abs(sentAmount - toTokenAmount); +}; + +export type QuoteMetadata = DeepPartial; diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts deleted file mode 100644 index 681fd08ba8c..00000000000 --- a/packages/bridge-controller/src/utils/quote.ts +++ /dev/null @@ -1,525 +0,0 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { - convertHexToDecimal, - toHex, - weiHexToGweiDec, -} from '@metamask/controller-utils'; -import { BigNumber } from 'bignumber.js'; - -import { TokenAmountValues } from '..'; -import type { - BridgeAsset, - GenericQuoteRequest, - L1GasFees, - ExchangeRate, - NonEvmFees, -} from '../types'; -import { FeatureId } from '../validators/feature-flags'; -import type { Quote } from '../validators/quote'; -import type { QuoteResponseV1 } from '../validators/quote-response-v1'; -import { TxData } from '../validators/trade'; -import { isNativeAddress, isNonEvmChainId } from './bridge'; - -export const isValidQuoteRequest = ( - partialRequest: Partial, - requireAmount = true, -): partialRequest is GenericQuoteRequest => { - const stringFields = [ - 'srcTokenAddress', - 'destTokenAddress', - 'srcChainId', - 'destChainId', - 'walletAddress', - ]; - if (requireAmount) { - stringFields.push('srcTokenAmount'); - } - // If bridging between different chain types or different non-EVM chains, require dest wallet address - // Cases that need destWalletAddress: - // 1. EVM -> non-EVM - // 2. non-EVM -> EVM - // 3. non-EVM -> different non-EVM (e.g., SOL -> BTC) - // Only same-chain swaps don't need destWalletAddress - if ( - partialRequest.destChainId && - partialRequest.srcChainId && - partialRequest.destChainId !== partialRequest.srcChainId && // Different chains - (isNonEvmChainId(partialRequest.destChainId) || - isNonEvmChainId(partialRequest.srcChainId)) // At least one is non-EVM - ) { - stringFields.push('destWalletAddress'); - if (!partialRequest.destWalletAddress) { - return false; - } - } - const numberFields = []; - // if slippage is defined, require it to be a number - if (partialRequest.slippage !== undefined) { - numberFields.push('slippage'); - } - - return ( - stringFields.every( - (field) => - field in partialRequest && - typeof partialRequest[field as keyof typeof partialRequest] === - 'string' && - partialRequest[field as keyof typeof partialRequest] !== undefined && - partialRequest[field as keyof typeof partialRequest] !== '' && - partialRequest[field as keyof typeof partialRequest] !== null, - ) && - numberFields.every( - (field) => - field in partialRequest && - typeof partialRequest[field as keyof typeof partialRequest] === - 'number' && - partialRequest[field as keyof typeof partialRequest] !== undefined && - !isNaN(Number(partialRequest[field as keyof typeof partialRequest])) && - partialRequest[field as keyof typeof partialRequest] !== null, - ) && - (requireAmount - ? Boolean((partialRequest.srcTokenAmount ?? '').match(/^[1-9]\d*$/u)) - : true) - ); -}; - -export const isValidBatchSellQuoteRequest = ( - quoteRequests: Partial[], - requireAmount = true, -): quoteRequests is GenericQuoteRequest[] => - quoteRequests.every((req) => isValidQuoteRequest(req, requireAmount)); - -/** - * Generates a pseudo-unique string that identifies each quote by aggregator, bridge, and steps - * - * @param quote - The quote to generate an identifier for - * @returns A pseudo-unique string that identifies the quote - */ -export const getQuoteIdentifier = (quote: QuoteResponseV1['quote']) => - `${quote.bridgeId}-${quote.bridges[0]}-${quote.steps.length}`; - -const calcTokenAmount = (value: string | BigNumber, decimals: number) => { - const divisor = new BigNumber(10).pow(decimals ?? 0); - return new BigNumber(value).div(divisor); -}; - -export const calcNonEvmTotalNetworkFee = ( - bridgeQuote: QuoteResponseV1 & NonEvmFees, - { exchangeRate, usdExchangeRate }: ExchangeRate, -) => { - const { nonEvmFeesInNative } = bridgeQuote; - // Fees are now stored directly in native units (SOL, BTC) without conversion - const feeInNative = nonEvmFeesInNative - ? new BigNumber(nonEvmFeesInNative) - : undefined; - - return { - amount: feeInNative?.toString(), - valueInCurrency: exchangeRate - ? feeInNative?.times(exchangeRate).toString() - : null, - usd: usdExchangeRate - ? feeInNative?.times(usdExchangeRate).toString() - : null, - }; -}; - -export const calcToAmount = ( - destTokenAmount: string, - destAsset: BridgeAsset, - { exchangeRate, usdExchangeRate }: ExchangeRate, -) => { - const normalizedDestAmount = calcTokenAmount( - destTokenAmount, - destAsset.decimals, - ); - return { - amount: normalizedDestAmount.toString(), - valueInCurrency: exchangeRate - ? normalizedDestAmount.times(exchangeRate).toString() - : null, - usd: usdExchangeRate - ? normalizedDestAmount.times(usdExchangeRate).toString() - : null, - }; -}; - -export const calcSentAmount = ( - { srcTokenAmount, srcAsset, feeData, intent }: Quote, - { exchangeRate, usdExchangeRate }: ExchangeRate, -) => { - // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is the total - // fixed commitment the user makes to the protocol — the protocol fee is - // already baked in. Adding feeData fees on top would double-count them. - // For conventional swaps, srcTokenAmount is the net routing amount (fees - // excluded), so the src-token fees must be added to get the wallet deduction. - const sentAmount = intent - ? new BigNumber(srcTokenAmount) - : Object.values(feeData) - .filter((fee) => fee?.amount && fee.asset?.assetId === srcAsset.assetId) - .reduce( - (acc, { amount }) => acc.plus(amount), - new BigNumber(srcTokenAmount), - ); - const normalizedSentAmount = calcTokenAmount(sentAmount, srcAsset.decimals); - return { - amount: normalizedSentAmount.toString(), - valueInCurrency: exchangeRate - ? normalizedSentAmount.times(exchangeRate).toString() - : null, - usd: usdExchangeRate - ? normalizedSentAmount.times(usdExchangeRate).toString() - : null, - }; -}; - -export const calcBatchFees = ( - amount: string, - asset: BridgeAsset, - { exchangeRate, usdExchangeRate }: ExchangeRate, -) => { - const normalizedAmount = calcTokenAmount(amount, asset.decimals); - - return { - amount: normalizedAmount.toString(), - valueInCurrency: exchangeRate - ? normalizedAmount.times(exchangeRate).toString() - : null, - usd: usdExchangeRate - ? normalizedAmount.times(usdExchangeRate).toString() - : null, - asset, - }; -}; - -export const calcRelayerFee = ( - quoteResponse: QuoteResponseV1, - { exchangeRate, usdExchangeRate }: ExchangeRate, -) => { - const { quote, trade } = quoteResponse; - const relayerFeeAmount = trade.value - ? new BigNumber(convertHexToDecimal(trade.value)) - : undefined; - let relayerFeeInNative = relayerFeeAmount - ? calcTokenAmount(relayerFeeAmount, 18) - : undefined; - - // Subtract srcAmount and other fees from trade value if srcAsset is native - if (isNativeAddress(quote.srcAsset.address)) { - const sentAmountInNative = calcSentAmount(quote, { - exchangeRate, - usdExchangeRate, - }).amount; - relayerFeeInNative = relayerFeeInNative?.minus(sentAmountInNative); - } - - return { - amount: relayerFeeInNative, - valueInCurrency: exchangeRate - ? relayerFeeInNative?.times(exchangeRate) - : null, - usd: usdExchangeRate ? relayerFeeInNative?.times(usdExchangeRate) : null, - }; -}; - -const calcTotalGasFee = ({ - approvalGasLimit, - resetApprovalGasLimit, - tradeGasLimit, - l1GasFeesInHexWei, - feePerGasInDecGwei, - nativeToDisplayCurrencyExchangeRate, - nativeToUsdExchangeRate, -}: { - approvalGasLimit?: number | null; - resetApprovalGasLimit?: number | null; - tradeGasLimit?: number | null; - l1GasFeesInHexWei?: string | null; - feePerGasInDecGwei?: string; - nativeToDisplayCurrencyExchangeRate?: string; - nativeToUsdExchangeRate?: string; -}) => { - const totalGasLimitInDec = tradeGasLimit - ? new BigNumber(tradeGasLimit.toString()) - .plus(approvalGasLimit?.toString() ?? '0') - .plus(resetApprovalGasLimit?.toString() ?? '0') - : undefined; - - const l1GasFeesInDecGWei = weiHexToGweiDec(toHex(l1GasFeesInHexWei ?? '0')); - const gasFeesInDecGwei = totalGasLimitInDec - ?.times(feePerGasInDecGwei ?? '0') - .plus(l1GasFeesInDecGWei); - const gasFeesInDecEth = gasFeesInDecGwei?.times(new BigNumber(10).pow(-9)); - - const gasFeesInDisplayCurrency = nativeToDisplayCurrencyExchangeRate - ? gasFeesInDecEth?.times(nativeToDisplayCurrencyExchangeRate.toString()) - : null; - const gasFeesInUSD = nativeToUsdExchangeRate - ? gasFeesInDecEth?.times(nativeToUsdExchangeRate.toString()) - : null; - - return { - amount: gasFeesInDecEth?.toString(), - valueInCurrency: gasFeesInDisplayCurrency?.toString(), - usd: gasFeesInUSD?.toString(), - }; -}; - -export const calcEstimatedAndMaxTotalGasFee = ({ - bridgeQuote: { approval, trade, l1GasFeesInHexWei, resetApproval }, - feePerGasInDecGwei, - maxFeePerGasInDecGwei, - exchangeRate: nativeToDisplayCurrencyExchangeRate, - usdExchangeRate: nativeToUsdExchangeRate, -}: { - bridgeQuote: QuoteResponseV1 & L1GasFees; - maxFeePerGasInDecGwei?: string; - feePerGasInDecGwei?: string; -} & ExchangeRate) => { - // Estimated gas fees spent after receiving refunds, this is shown to the user - const { - amount: amountEffective, - valueInCurrency: valueInCurrencyEffective, - usd: usdEffective, - } = calcTotalGasFee({ - // Fallback to gasLimit if effectiveGas is not available - approvalGasLimit: approval?.effectiveGas ?? approval?.gasLimit, - resetApprovalGasLimit: - resetApproval?.effectiveGas ?? resetApproval?.gasLimit, - tradeGasLimit: trade?.effectiveGas ?? trade?.gasLimit, - l1GasFeesInHexWei, - feePerGasInDecGwei, - nativeToDisplayCurrencyExchangeRate, - nativeToUsdExchangeRate, - }); - - // Estimated total gas fee, including refunded fees (medium) - const { amount, valueInCurrency, usd } = calcTotalGasFee({ - approvalGasLimit: approval?.gasLimit, - resetApprovalGasLimit: resetApproval?.gasLimit, - tradeGasLimit: trade?.gasLimit, - l1GasFeesInHexWei, - feePerGasInDecGwei, - nativeToDisplayCurrencyExchangeRate, - nativeToUsdExchangeRate, - }); - - // Max gas fee (high), used to disable submission of the transaction - const { - amount: amountMax, - valueInCurrency: valueInCurrencyMax, - usd: usdMax, - } = calcTotalGasFee({ - approvalGasLimit: approval?.gasLimit, - resetApprovalGasLimit: resetApproval?.gasLimit, - tradeGasLimit: trade?.gasLimit, - l1GasFeesInHexWei, - feePerGasInDecGwei: maxFeePerGasInDecGwei, - nativeToDisplayCurrencyExchangeRate, - nativeToUsdExchangeRate, - }); - - return { - effective: { - amount: amountEffective, - valueInCurrency: valueInCurrencyEffective, - usd: usdEffective, - }, - total: { - amount, - valueInCurrency, - usd, - }, - max: { - amount: amountMax, - valueInCurrency: valueInCurrencyMax, - usd: usdMax, - }, - }; -}; - -/** - * Calculates the total estimated network fees for the bridge transaction - * - * @param gasFee - The gas fee for the bridge transaction - * @param gasFee.total - The fee to display to the user. If not available, this is equal to the gasLimit (total) - * @param relayerFee - The relayer fee paid to bridge providers - * @returns The total estimated network fee for the bridge transaction, including the relayer fee paid to bridge providers - */ -export const calcTotalEstimatedNetworkFee = ( - gasFee: { total?: Partial } | undefined, - relayerFee: ReturnType, -) => { - const { total: gasFeeToDisplay } = gasFee ?? {}; - return { - amount: - (gasFeeToDisplay?.amount ?? relayerFee.amount) && - new BigNumber(gasFeeToDisplay?.amount ?? '0') - .plus(relayerFee.amount ?? '0') - .toString(), - valueInCurrency: - (gasFeeToDisplay?.valueInCurrency ?? relayerFee.valueInCurrency) && - new BigNumber(gasFeeToDisplay?.valueInCurrency ?? '0') - .plus(relayerFee.valueInCurrency ?? '0') - .toString(), - usd: - (gasFeeToDisplay?.usd ?? relayerFee.usd) && - new BigNumber(gasFeeToDisplay?.usd ?? '0') - .plus(relayerFee.usd ?? '0') - .toString(), - }; -}; - -export const calcTotalMaxNetworkFee = ( - gasFee: { max?: Partial } | undefined, - relayerFee: ReturnType, -) => { - return { - amount: - gasFee?.max?.amount && - new BigNumber(gasFee.max.amount) - .plus(relayerFee.amount ?? '0') - .toString(), - valueInCurrency: - gasFee?.max?.valueInCurrency && - new BigNumber(gasFee.max.valueInCurrency) - .plus(relayerFee.valueInCurrency ?? '0') - .toString(), - usd: - gasFee?.max?.usd && - new BigNumber(gasFee.max.usd).plus(relayerFee.usd ?? '0').toString(), - }; -}; - -// Gas is included for some swap quotes and this is the value displayed in the client -export const calcIncludedTxFees = ( - { gasIncluded, gasIncluded7702, srcAsset, feeData: { txFee } }: Quote, - srcTokenExchangeRate: ExchangeRate, - destTokenExchangeRate: ExchangeRate, -) => { - if (!txFee || !(gasIncluded || gasIncluded7702)) { - return null; - } - // Use exchange rate of the token that is being used to pay for the transaction - const { exchangeRate, usdExchangeRate } = - txFee.asset.assetId === srcAsset.assetId - ? srcTokenExchangeRate - : destTokenExchangeRate; - const normalizedTxFeeAmount = calcTokenAmount( - txFee.amount, - txFee.asset.decimals, - ); - - return { - amount: normalizedTxFeeAmount.toString(), - valueInCurrency: exchangeRate - ? normalizedTxFeeAmount.times(exchangeRate).toString() - : null, - usd: usdExchangeRate - ? normalizedTxFeeAmount.times(usdExchangeRate).toString() - : null, - }; -}; - -export const calcAdjustedReturn = ( - toTokenAmount: ReturnType, - totalEstimatedNetworkFee: Partial, - { feeData: { txFee }, destAsset: { assetId: destAssetId } }: Quote, -) => { - // If gas is included and is taken from the dest token, don't subtract network fee from return - if (txFee?.asset?.assetId === destAssetId) { - return { - valueInCurrency: toTokenAmount.valueInCurrency, - usd: toTokenAmount.usd, - }; - } - return { - valueInCurrency: - toTokenAmount.valueInCurrency && totalEstimatedNetworkFee.valueInCurrency - ? new BigNumber(toTokenAmount.valueInCurrency) - .minus(totalEstimatedNetworkFee.valueInCurrency) - .toString() - : null, - usd: - toTokenAmount.usd && totalEstimatedNetworkFee.usd - ? new BigNumber(toTokenAmount.usd) - .minus(totalEstimatedNetworkFee.usd) - .toString() - : null, - }; -}; - -export const calcSwapRate = (sentAmount: string, destTokenAmount: string) => - new BigNumber(destTokenAmount).div(sentAmount).toString(); - -export const calcCost = ( - adjustedReturn: ReturnType, - sentAmount: ReturnType, -) => ({ - valueInCurrency: - adjustedReturn.valueInCurrency && sentAmount.valueInCurrency - ? new BigNumber(sentAmount.valueInCurrency) - .minus(adjustedReturn.valueInCurrency) - .toString() - : null, - usd: - adjustedReturn.usd && sentAmount.usd - ? new BigNumber(sentAmount.usd).minus(adjustedReturn.usd).toString() - : null, -}); - -/** - * Calculates the slippage absolute value percentage based on the adjusted return and sent amount. - * - * @param adjustedReturn - Adjusted return value - * @param sentAmount - Sent amount value - * @returns the slippage in percentage - */ -export const calcSlippagePercentage = ( - adjustedReturn: ReturnType, - sentAmount: ReturnType, -): string | null => { - const cost = calcCost(adjustedReturn, sentAmount); - - if (cost.valueInCurrency && sentAmount.valueInCurrency) { - return new BigNumber(cost.valueInCurrency) - .div(sentAmount.valueInCurrency) - .times(100) - .abs() - .toString(); - } - - if (cost.usd && sentAmount.usd) { - return new BigNumber(cost.usd) - .div(sentAmount.usd) - .times(100) - .abs() - .toString(); - } - - return null; -}; - -export const formatEtaInMinutes = ( - estimatedProcessingTimeInSeconds: number, -) => { - if (estimatedProcessingTimeInSeconds < 60) { - return `< 1`; - } - return (estimatedProcessingTimeInSeconds / 60).toFixed(); -}; - -export const sortQuotes = ( - quotes: QuoteResponseV1[], - featureId: FeatureId | null, -) => { - // Sort perps quotes by increasing estimated processing time (fastest first) - if (featureId === FeatureId.PERPS) { - return quotes.sort((a, b) => { - return ( - a.estimatedProcessingTimeInSeconds - b.estimatedProcessingTimeInSeconds - ); - }); - } - return quotes; -}; diff --git a/packages/bridge-controller/src/utils/sort-quotes.ts b/packages/bridge-controller/src/utils/sort-quotes.ts new file mode 100644 index 00000000000..e0be24f53ac --- /dev/null +++ b/packages/bridge-controller/src/utils/sort-quotes.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { FeatureId } from '../validators/feature-flags'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1'; + +export const sortQuotes = ( + quotes: QuoteResponseV1[], + featureId: FeatureId | null, +) => { + // Sort perps quotes by increasing estimated processing time (fastest first) + if (featureId === FeatureId.PERPS) { + return quotes.sort((a, b) => { + return ( + a.estimatedProcessingTimeInSeconds - b.estimatedProcessingTimeInSeconds + ); + }); + } + return quotes; +}; diff --git a/packages/bridge-controller/src/validators/quote-request.ts b/packages/bridge-controller/src/validators/quote-request.ts new file mode 100644 index 00000000000..f31f9ff785f --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-request.ts @@ -0,0 +1,71 @@ +import type { GenericQuoteRequest } from '../types'; +import { isNonEvmChainId } from '../utils/bridge'; + +export const isValidQuoteRequest = ( + partialRequest: Partial, + requireAmount = true, +): partialRequest is GenericQuoteRequest => { + const stringFields = [ + 'srcTokenAddress', + 'destTokenAddress', + 'srcChainId', + 'destChainId', + 'walletAddress', + ]; + if (requireAmount) { + stringFields.push('srcTokenAmount'); + } + // If bridging between different chain types or different non-EVM chains, require dest wallet address + // Cases that need destWalletAddress: + // 1. EVM -> non-EVM + // 2. non-EVM -> EVM + // 3. non-EVM -> different non-EVM (e.g., SOL -> BTC) + // Only same-chain swaps don't need destWalletAddress + if ( + partialRequest.destChainId && + partialRequest.srcChainId && + partialRequest.destChainId !== partialRequest.srcChainId && // Different chains + (isNonEvmChainId(partialRequest.destChainId) || + isNonEvmChainId(partialRequest.srcChainId)) // At least one is non-EVM + ) { + stringFields.push('destWalletAddress'); + if (!partialRequest.destWalletAddress) { + return false; + } + } + const numberFields = []; + // if slippage is defined, require it to be a number + if (partialRequest.slippage !== undefined) { + numberFields.push('slippage'); + } + + return ( + stringFields.every( + (field) => + field in partialRequest && + typeof partialRequest[field as keyof typeof partialRequest] === + 'string' && + partialRequest[field as keyof typeof partialRequest] !== undefined && + partialRequest[field as keyof typeof partialRequest] !== '' && + partialRequest[field as keyof typeof partialRequest] !== null, + ) && + numberFields.every( + (field) => + field in partialRequest && + typeof partialRequest[field as keyof typeof partialRequest] === + 'number' && + partialRequest[field as keyof typeof partialRequest] !== undefined && + !isNaN(Number(partialRequest[field as keyof typeof partialRequest])) && + partialRequest[field as keyof typeof partialRequest] !== null, + ) && + (requireAmount + ? Boolean((partialRequest.srcTokenAmount ?? '').match(/^[1-9]\d*$/u)) + : true) + ); +}; + +export const isValidBatchSellQuoteRequest = ( + quoteRequests: Partial[], + requireAmount = true, +): quoteRequests is GenericQuoteRequest[] => + quoteRequests.every((req) => isValidQuoteRequest(req, requireAmount)); diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index f27dc1926d4..423a1597400 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -92,6 +92,8 @@ export const QuoteSchema = intersection([ }), bridgeId: string(), bridges: array(string()), + // TODO require this after v2 migration + aggregator: optional(string()), steps: array(StepSchema), refuel: optional(RefuelDataSchema), priceData: optional( @@ -106,6 +108,7 @@ export const QuoteSchema = intersection([ walletAddress: optional(string()), destWalletAddress: optional(string()), slippage: optional(number()), + // TODO require this after v2 migration protocols: optional(array(string())), }), ]); diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts index e43025d3721..645a90b1968 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -23,18 +23,19 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', }, srcTokenAmount: '14000000', + destWalletAddress: undefined, destChainId: 137, destAsset: { chainId: 137, - address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', symbol: 'USDC', name: 'Native USD Coin (POS)', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', }, destTokenAmount: '13984280', minDestTokenAmount: '13700000', @@ -49,12 +50,12 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', }, }, }, bridgeId: 'socket', bridges: ['across'], + aggregator: 'socket', protocols: ['across'], steps: [ { @@ -69,7 +70,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', }, destAsset: { chainId: 137, @@ -79,7 +80,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'Native USD Coin (POS)', decimals: 6, - icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', }, }, ], @@ -113,7 +114,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', }, srcTokenAmount: '14000000', destChainId: 137, @@ -124,7 +125,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'Native USD Coin (POS)', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', }, destTokenAmount: '13800000', minDestTokenAmount: '13530000', @@ -139,7 +140,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://media.socket.tech/tokens/all/USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', }, }, }, @@ -158,7 +159,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'USD Coin', decimals: 6, - icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', }, destAsset: { chainId: 137, @@ -168,7 +169,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ symbol: 'USDC', name: 'Native USD Coin (POS)', decimals: 6, - icon: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', }, }, ], diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 14ac9f405b3..c36983cd475 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 +- Use `totalNetworkFee` instead of `gasFee.effective` to calculate gas metrics properties - Update utils to use `mergeQuoteMetadata` and optional QuoteMetadata access patterns. ([#9507](https://github.com/MetaMask/core/pull/9507)) ## [74.3.0] diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 7bf3b83936a..d088f2285d5 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -19,7 +19,7 @@ exports[`BridgeStatusController constructor rehydrates the tx history state 1`] "pricingData": { "amountSent": "1.234", "amountSentInUsd": undefined, - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": undefined, }, @@ -276,7 +276,7 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx "pricingData": { "amountSent": "1.234", "amountSentInUsd": undefined, - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": undefined, }, @@ -1288,7 +1288,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac "token_symbol_destination": "ETH", "token_symbol_source": "ETH", "usd_balance_source": 0, - "usd_quoted_gas": 2.5778, + "usd_quoted_gas": 0, "usd_quoted_return": 0.134214, "warnings": [ "low_return", @@ -3639,7 +3639,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, @@ -3679,7 +3679,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap }, }, "txFee": { - "amount": "0", + "amount": "100", "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", @@ -3780,7 +3780,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, @@ -3835,7 +3835,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, @@ -4594,7 +4594,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, @@ -4723,7 +4723,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g } `; -exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 1`] = ` +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (null gasLimit) 1`] = ` { "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -4742,7 +4742,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g } `; -exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 2`] = ` +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (null gasLimit) 2`] = ` { "account": "0xaccount1", "actionId": "1234567891.456", @@ -4758,7 +4758,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g "pricingData": { "amountSent": "0", "amountSentInUsd": undefined, - "quotedGasAmount": ".00055", + "quotedGasAmount": "1.234", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, @@ -4798,7 +4798,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g }, }, "txFee": { - "amount": "8750000000000", + "amount": "100", "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 337a2d3616a..fdd877b85f9 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -7,6 +7,8 @@ import { mergeQuoteMetadata, StatusTypes, QuoteResponse as QuoteResponseV1, + getNativeAssetForChainId, + validateQuoteResponseV1, } from '@metamask/bridge-controller'; import type { GasFeeEstimates, @@ -60,11 +62,30 @@ const minimalIntentQuoteResponse = ( decimals: 18, }, feeData: { - txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' }, - } as never, + txFee: { + amount: '1', + asset: getNativeAssetForChainId(1), + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + metabridge: { + amount: '0', + asset: getNativeAssetForChainId(1), + }, + }, intent: { protocol: 'cowswap', - order: { some: 'order' }, + order: { + sellToken: '0x0000000000000000000000000000000000000001', + buyToken: '0x0000000000000000000000000000000000000002', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }, settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', typedData: { types: {}, @@ -73,6 +94,7 @@ const minimalIntentQuoteResponse = ( message: {}, }, }, + steps: [], }, estimatedProcessingTimeInSeconds: 15, featureId: undefined, @@ -94,6 +116,7 @@ const minimalIntentQuoteResponse = ( toTokenAmount: { usd: '1' }, }); }; +validateQuoteResponseV1(minimalIntentQuoteResponse()); const minimalBridgeQuoteResponse = ( accountAddress: string, @@ -123,9 +146,18 @@ const minimalBridgeQuoteResponse = ( name: 'ETH', decimals: 18, }, - feeData: { txFee: { maxFeePerGas: '1', maxPriorityFeePerGas: '1' } }, + feeData: { + metabridge: { amount: '1', asset: getNativeAssetForChainId(1) }, + txFee: { + amount: '1', + asset: getNativeAssetForChainId(1), + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + }, bridges: ['across'], bridgeId: 'socket', + steps: [], }, estimatedProcessingTimeInSeconds: 15, @@ -148,6 +180,7 @@ const minimalBridgeQuoteResponse = ( toTokenAmount: { usd: '1' }, }); }; +validateQuoteResponseV1(minimalBridgeQuoteResponse('0xAccount1')); const createMessengerHarness = ( accountAddress: string, @@ -256,7 +289,7 @@ const setup = (options?: { keyringType?: string; mockTxHistory?: any; }) => { - const accountAddress = '0xAccount1'; + const accountAddress = '0xAccount1' as const; const { messenger, transactions } = createMessengerHarness( accountAddress, options?.selectedChainId ?? '0x1', @@ -545,7 +578,15 @@ describe('BridgeStatusController (intent swaps)', () => { "params": { "aggregatorId": "cowswap", "order": { - "some": "order", + "appData": "some-app-data", + "appDataHash": "0xabcd", + "buyToken": "0x0000000000000000000000000000000000000002", + "feeAmount": "100", + "kind": "sell", + "partiallyFillable": false, + "sellAmount": "1000", + "sellToken": "0x0000000000000000000000000000000000000001", + "validTo": 1717027200, }, "quoteId": "req-1", "signature": "0xautosigned", @@ -931,9 +972,13 @@ describe('BridgeStatusController (target uncovered branches)', () => { // make startPolling return different tokens for the same tx startPollingSpy.mockReturnValueOnce('tok1').mockReturnValueOnce('tok2'); - const quoteResponse: any = mergeQuoteMetadata( + const quoteResponse = mergeQuoteMetadata( { - quote: { srcChainId: 1, destChainId: 10, destAsset: { assetId: 'x' } }, + quote: { + srcChainId: 1, + destChainId: 10, + destAsset: { assetId: 'eip155:10/slip44:60' }, + }, estimatedProcessingTimeInSeconds: 1, }, { @@ -952,7 +997,7 @@ describe('BridgeStatusController (target uncovered branches)', () => { slippagePercentage: 0, startTime: Date.now(), isStxEnabled: false, - } as any); + }); // second time => should stop tok1 and start tok2 controller.startPollingForBridgeTxStatus({ @@ -963,7 +1008,7 @@ describe('BridgeStatusController (target uncovered branches)', () => { slippagePercentage: 0, startTime: Date.now(), isStxEnabled: false, - } as any); + }); expect(stopPollingSpy).toHaveBeenCalledWith('tok1'); }); @@ -994,12 +1039,12 @@ describe('BridgeStatusController (target uncovered branches)', () => { mockTxHistory, }); - const quoteResponse: any = mergeQuoteMetadata( + const quoteResponse = mergeQuoteMetadata( { quote: { srcChainId: 1, destChainId: 10, - destAsset: { assetId: 'x' }, + destAsset: { assetId: 'eip155:10/slip44:60' }, bridges: ['across'], }, estimatedProcessingTimeInSeconds: 1, 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 679b9ea8d7e..3fbace2f6c2 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -322,23 +322,36 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', gasLimit: 282915, }, - approval: null as never, + approval: undefined, estimatedProcessingTimeInSeconds: 15, }, { - sentAmount: { amount: '1.234', valueInCurrency: null, usd: null }, - toTokenAmount: { amount: '1.234', valueInCurrency: null, usd: null }, - minToTokenAmount: { amount: '1.17', valueInCurrency: null, usd: null }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + sentAmount: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + toTokenAmount: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, + minToTokenAmount: { + amount: '1.17', + valueInCurrency: undefined, + usd: undefined, + }, + totalNetworkFee: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, + }, gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, + total: { amount: '1.234', valueInCurrency: undefined, usd: '2.5778' }, }, - adjustedReturn: { valueInCurrency: null, usd: null }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, swapRate: '1.234', - cost: { valueInCurrency: null, usd: null }, + cost: { valueInCurrency: undefined, usd: undefined }, }, ), accountAddress: account, @@ -438,7 +451,7 @@ const MockTxHistory = { pricingData: { amountSent: '1.234', amountSentInUsd: undefined, - quotedGasAmount: '.00055', + quotedGasAmount: '1.234', quotedGasInUsd: '2.5778', quotedReturnInUsd: undefined, }, @@ -558,7 +571,7 @@ const MockTxHistory = { pricingData: { amountSent: '1.234', amountSentInUsd: undefined, - quotedGasAmount: '.00055', + quotedGasAmount: '1.234', quotedGasInUsd: '2.5778', quotedReturnInUsd: undefined, }, @@ -2266,15 +2279,8 @@ describe('BridgeStatusController', () => { valueInCurrency: '10', usd: '10', }, - totalMaxNetworkFee: { - amount: '0.15', - valueInCurrency: '15', - usd: '15', - }, gasFee: { - effective: { amount: '0.05', valueInCurrency: '5', usd: '5' }, total: { amount: '0.05', valueInCurrency: '5', usd: '5' }, - max: { amount: '0', valueInCurrency: null, usd: null }, }, adjustedReturn: { valueInCurrency: '985', @@ -2504,15 +2510,8 @@ describe('BridgeStatusController', () => { valueInCurrency: '10', usd: '10', }, - totalMaxNetworkFee: { - amount: '0.15', - valueInCurrency: '15', - usd: '15', - }, gasFee: { - effective: { amount: '0.05', valueInCurrency: '5', usd: '5' }, total: { amount: '0.05', valueInCurrency: '5', usd: '5' }, - max: { amount: '0', valueInCurrency: null, usd: null }, }, adjustedReturn: { valueInCurrency: '985', @@ -2765,15 +2764,8 @@ describe('BridgeStatusController', () => { valueInCurrency: '0.01', usd: '0.01', }, - totalMaxNetworkFee: { - amount: '0.015', - valueInCurrency: '0.015', - usd: '0.015', - }, gasFee: { - effective: { amount: '0.005', valueInCurrency: '0.005', usd: '0.005' }, total: { amount: '0.005', valueInCurrency: '0.005', usd: '0.005' }, - max: { amount: '0', valueInCurrency: null, usd: null }, }, adjustedReturn: { valueInCurrency: '499.99', @@ -2907,8 +2899,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: EVM bridge', () => { - const mockEvmQuoteResponse = { - ...getMockQuote(), + const mockEvmQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { ...getMockQuote(), srcChainId: 42161, // Arbitrum @@ -2926,20 +2917,17 @@ describe('BridgeStatusController', () => { valueInCurrency: '2.85', usd: '0.127', }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { - amount: '1.234', - valueInCurrency: null, - usd: null, + totalNetworkFee: { + amount: '.00055', + valueInCurrency: undefined, + usd: '2.5778', }, gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, + total: { amount: '.00055', valueInCurrency: undefined, usd: '2.5778' }, }, - adjustedReturn: { valueInCurrency: null, usd: null }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, swapRate: '1.234', - cost: { valueInCurrency: null, usd: null }, + cost: { valueInCurrency: undefined, usd: undefined }, trade: { from: '0xaccount1', to: '0xbridgeContract', @@ -3168,16 +3156,21 @@ describe('BridgeStatusController', () => { startPollingForBridgeTxStatusSpy, }) => { const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const quoteResponseV2 = mergeQuoteMetadata( + quoteWithoutApproval, + quoteWithoutApproval, + ); + const quotesReceivedContext = getQuotesReceivedProperties( + quoteResponseV2, + ['low_return'], + true, + ); const result = await rootMessenger.call( 'BridgeStatusController:submitTx', (quoteWithoutApproval.trade as TxData).from, quoteWithoutApproval, true, - getQuotesReceivedProperties( - quoteWithoutApproval, - ['low_return'], - true, - ), + quotesReceivedContext, ); controller.stopAllPolling(); @@ -3500,7 +3493,7 @@ describe('BridgeStatusController', () => { quote: { ...mockEvmQuoteResponse.quote, srcChainId: 59144 }, trade: { ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined as never, + gasLimit: null, }, }; @@ -3547,7 +3540,7 @@ describe('BridgeStatusController', () => { quote: { ...mockEvmQuoteResponse.quote, srcChainId: 8453 }, trade: { ...(mockEvmQuoteResponse.trade as TxData), - gasLimit: undefined as never, + gasLimit: null, }, }; @@ -3909,20 +3902,17 @@ describe('BridgeStatusController', () => { valueInCurrency: '2.85', usd: '0.127', }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { + totalNetworkFee: { amount: '1.234', - valueInCurrency: null, - usd: null, + valueInCurrency: undefined, + usd: undefined, }, gasFee: { - effective: { amount: '.00055', valueInCurrency: null, usd: '2.5778' }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, + total: { amount: '1.234', valueInCurrency: undefined, usd: '2.5778' }, }, - adjustedReturn: { valueInCurrency: null, usd: null }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, swapRate: '1.234', - cost: { valueInCurrency: null, usd: null }, + cost: { valueInCurrency: undefined, usd: undefined }, trade: { from: '0xaccount1', to: '0xbridgeContract', @@ -3939,7 +3929,7 @@ describe('BridgeStatusController', () => { chainId: 42161, gasLimit: 21000, }, - } as QuoteResponse & QuoteMetadata; + } as const; const mockEvmTxMeta = { id: 'test-tx-id', @@ -4135,7 +4125,7 @@ describe('BridgeStatusController', () => { feeData: { ...mockEvmQuoteResponse.quote.feeData, txFee: { - amount: '0', + amount: '100', asset: getNativeAssetForChainId(42161), maxFeePerGas: '123', maxPriorityFeePerGas: '123', @@ -4219,7 +4209,7 @@ describe('BridgeStatusController', () => { feeData: { ...mockEvmQuoteResponse.quote.feeData, txFee: { - amount: '0', + amount: '100', asset: { address: '0x0000000000000000000000000000000000000032', symbol: 'WETH', @@ -4383,7 +4373,7 @@ describe('BridgeStatusController', () => { ); }); - it('should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit)', async () => { + it('should use quote txFee when gasIncluded is true and STX is off (null gasLimit)', async () => { setupEventTrackingMocks(mockMessengerCall); // Setup for single tx path - no gas estimation needed since gasIncluded=true mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); @@ -4418,8 +4408,7 @@ describe('BridgeStatusController', () => { feeData: { ...quoteWithoutApproval.quote.feeData, txFee: { - amount: - quoteWithoutApproval.quote.feeData.metabridge.amount, + amount: '100', asset: quoteWithoutApproval.quote.feeData.metabridge.asset, maxFeePerGas: '1395348', // Decimal string from quote maxPriorityFeePerGas: '1000001', @@ -4428,7 +4417,7 @@ describe('BridgeStatusController', () => { }, trade: { ...(quoteWithoutApproval.trade as TxData), - gasLimit: undefined as never, + gasLimit: null, }, sentAmount: { amount: null as never, @@ -4666,6 +4655,12 @@ describe('BridgeStatusController', () => { { ...mockEvmTxMeta, batchId: 'batchId1' }, ], }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [ + { ...mockApprovalTxMeta, batchId: 'batchId1' }, + { ...mockEvmTxMeta, batchId: 'batchId1' }, + ], + }); const getAddTransactionBatchParamsSpy = jest.spyOn( transactionUtils, @@ -6642,24 +6637,21 @@ describe('BridgeStatusController', () => { valueInCurrency: '2.85', usd: '0.127', }, - totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, - totalMaxNetworkFee: { + totalNetworkFee: { amount: '1.234', - valueInCurrency: null, - usd: null, + valueInCurrency: undefined, + usd: undefined, }, gasFee: { - effective: { - amount: '.00055', - valueInCurrency: null, - usd: '2.5778', + total: { + amount: '1.234', + valueInCurrency: undefined, + usd: undefined, }, - total: { amount: '1.234', valueInCurrency: null, usd: null }, - max: { amount: '1.234', valueInCurrency: null, usd: null }, }, - adjustedReturn: { valueInCurrency: null, usd: null }, + adjustedReturn: { valueInCurrency: undefined, usd: undefined }, swapRate: '1.234', - cost: { valueInCurrency: null, usd: null }, + cost: { valueInCurrency: undefined, usd: undefined }, trade: { from: '0xaccount1', to: '0xbridgeContract', @@ -6668,7 +6660,7 @@ describe('BridgeStatusController', () => { chainId: 42161, gasLimit: 21000, }, - } as unknown as QuoteResponse & QuoteMetadata; + }; const mockTradeTxMeta = { id: EVM_TX_META_ID, diff --git a/packages/bridge-status-controller/src/utils/gas.ts b/packages/bridge-status-controller/src/utils/gas.ts index b5afdfe0abf..e9255327de8 100644 --- a/packages/bridge-status-controller/src/utils/gas.ts +++ b/packages/bridge-status-controller/src/utils/gas.ts @@ -50,7 +50,7 @@ export const calcActualGasUsed = ( ? { amount: actualGasInHexWei.toString(10), usd: - usdExchangeRate?.multipliedBy(actualGasInDecEth).toString(10) ?? null, + usdExchangeRate?.multipliedBy(actualGasInDecEth).toString(10) ?? '0', } : null; }; diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 272f8e62d40..350c5ba5a8c 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -227,9 +227,9 @@ export const getInitialHistoryItem = ( pricingData: { amountSent: quoteResponse?.sentAmount?.amount ?? '0', amountSentInUsd: quoteResponse?.sentAmount?.usd ?? undefined, - quotedGasInUsd: quoteResponse?.gasFee?.effective?.usd ?? undefined, + quotedGasInUsd: quoteResponse?.gasFee?.total?.usd ?? undefined, quotedReturnInUsd: quoteResponse?.toTokenAmount?.usd ?? undefined, - quotedGasAmount: quoteResponse?.gasFee?.effective?.amount ?? undefined, + quotedGasAmount: quoteResponse?.gasFee?.total?.amount ?? undefined, }, initialDestAssetBalance, targetContractAddress, diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 5fa982fae52..e0f83e36082 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -158,7 +158,7 @@ export const getTradeDataFromQuote = ( batchSellTrades?: BatchSellTradesResponse | null, ): TradeData => { return { - usd_quoted_gas: Number(quoteResponse?.gasFee?.effective?.usd ?? 0), + usd_quoted_gas: Number(quoteResponse.gasFee?.total?.usd ?? 0), gas_included: quoteResponse.quote.gasIncluded ?? batchSellTrades?.gasIncluded ?? false, gas_included_7702: