diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 752d340a5c8..09216b23037 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Implement coercers between `QuoteResponse` v1 and v2 ([#9725](https://github.com/MetaMask/core/pull/9725)) + - `toQuoteResponseV2` and `toQuoteResponseV1` convert quote responses to required schema when needed + - `toQuoteMetadataV2` and `toQuoteMetadataV1` convert quote metadata to required schema when needed + - `toNormalizedAmounts`converts atomic amounts to display-ready values +- Export `sumAmounts` util that adds up fees or token amounts ([#9725](https://github.com/MetaMask/core/pull/9725)) + ### Changed - Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts new file mode 100644 index 00000000000..b38b530f5b9 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -0,0 +1,172 @@ +import { KnownCaipNamespace } from '@metamask/utils'; +import { QuoteMetadata } from 'src/utils/quote-metadata/types'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20.js'; +import { mergeQuoteMetadata } from '../utils/quote-metadata/merge.js'; +import { toQuoteMetadataV1 } from '../utils/quote-metadata/to-quote-metadata-v1.js'; +import { toQuoteResponseV2 } from './quote-response-v1-to-v2.js'; + +const TEST_METADATA: QuoteMetadata = { + sentAmount: { + amount: '14', + usd: undefined, + valueInCurrency: undefined, + }, + toTokenAmount: { + amount: '13.98428', + usd: undefined, + valueInCurrency: undefined, + }, + minToTokenAmount: { + amount: '13.7', + usd: undefined, + valueInCurrency: undefined, + }, + relayerFee: { + amount: '0.00001', + usd: undefined, + valueInCurrency: undefined, + }, + totalNetworkFee: { + amount: '0.001', + usd: undefined, + valueInCurrency: undefined, + }, + gasFee: { + total: { amount: '0.00099', usd: undefined, valueInCurrency: undefined }, + }, + swapRate: '0.99887714285714285714', + priceImpact: { + usd: '1.5', + valueInCurrency: '1.5', + }, +}; + +const quoteResponseV1WithMetadata = { + ...mockBridgeQuotesErc20Erc20V1[0], + ...TEST_METADATA, +}; + +describe('quote-response-v2 migration', () => { + describe('toQuoteResponseV2', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + expect(() => + toQuoteResponseV2(quoteResponse), + ).toThrowErrorMatchingInlineSnapshot( + `"At path: quote.src -- Expected an object, but received: undefined"`, + ); + }); + + it('should return QuoteResponse with no normalized amounts and no metadata (V1 input)', () => { + const quoteResponseV2 = toQuoteResponseV2(quoteResponseV1WithMetadata); + + const expectedQuoteResponseV2 = mockBridgeQuotesErc20Erc20V2Migration[0]; + delete expectedQuoteResponseV2.quote.feeData.network; + expect( + quoteResponseV2.quote.feeData?.network?.[0]?.amount, + ).toBeUndefined(); + + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + ...TEST_METADATA, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + + const extractedMetadata = toQuoteMetadataV1(quoteResponseV2); + expect(extractedMetadata).toStrictEqual(TEST_METADATA); + }); + + it('should return QuoteResponse with no normalized amounts and preserve metadata (V1 input)', () => { + const quoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(quoteResponseV1WithMetadata), + TEST_METADATA, + ); + const expectedQuoteResponseV2 = mergeQuoteMetadata( + mockBridgeQuotesErc20Erc20V2Migration[0], + TEST_METADATA, + ); + + expect(expectedQuoteResponseV2.quote.feeData).toMatchInlineSnapshot(` + { + "metabridge": [ + { + "amount": "0", + "asset": { + "assetId": "eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "decimals": 6, + "name": "USD Coin", + "symbol": "USDC", + }, + }, + ], + "network": [ + { + "amount": "990000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.00099", + "usd": undefined, + "valueInCurrency": undefined, + }, + ], + "relayer": [ + { + "amount": "10000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.00001", + "usd": undefined, + "valueInCurrency": undefined, + }, + ], + "txFee": undefined, + } + `); + + const extractedMetadata = toQuoteMetadataV1(quoteResponseV2); + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + ...TEST_METADATA, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + expect(extractedMetadata).toStrictEqual(TEST_METADATA); + }); + + it('should return QuoteResponse and preserve metadata (V2 input)', () => { + const quoteResponse = { + ...mockBridgeQuotesErc20Erc20V2Migration[0], + ...TEST_METADATA, + }; + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2).toStrictEqual({ + ...quoteResponse, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + expect(toQuoteMetadataV1(quoteResponseV2)).toStrictEqual(TEST_METADATA); + }); + + it('should throw an error for a null input', () => { + expect(() => toQuoteResponseV2(null)).toThrow( + 'Expected an object, but received: null', + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts new file mode 100644 index 00000000000..7c2419a212b --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -0,0 +1,199 @@ +import { create, coerce, Infer, is, intersection } from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { formatAddressToAssetId } from '../utils/caip-formatters.js'; +import { sumAmounts } from '../utils/number-formatters.js'; +import { + BridgeAssetSchema, + BridgeAssetV2Schema, + MinimalAssetSchema, +} from '../validators/bridge-asset.js'; +import { QuoteResponseSchemaV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import { + QuoteResponseSchemaV2, + validateQuoteResponse, +} from '../validators/quote-response.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; +import { QuoteSchemaV2, FeeType, QuoteSchema } from '../validators/quote.js'; +import { StepSchemaV2, StepSchema } from '../validators/step.js'; + +const BridgeAssetV2FromV1 = coerce( + BridgeAssetV2Schema, + intersection([BridgeAssetSchema, MinimalAssetSchema]), + (value) => { + const { chainId, address, iconUrl, icon, assetId, ...rest } = value; + + const resolvedIconUrl = iconUrl ?? icon; + + return { + assetId: + assetId ?? + /* istanbul ignore next */ formatAddressToAssetId(address, chainId), + ...(resolvedIconUrl && { iconUrl: resolvedIconUrl }), + ...rest, + }; + }, +); + +export const toBridgeAssetV2 = ( + data: unknown, +): Infer => { + return create(data, BridgeAssetV2FromV1); +}; + +const StepSchemaV2FromV1 = coerce(StepSchemaV2, StepSchema, (value) => { + const { srcAsset, destAsset, action } = value; + return { + action, + src: { + asset: toBridgeAssetV2(srcAsset), + }, + dest: { + asset: toBridgeAssetV2(destAsset), + }, + }; +}); +const toStepV2 = (step: Infer): Infer => + create(step, StepSchemaV2FromV1); + +const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { + const { + srcTokenAmount, + destTokenAmount, + minDestTokenAmount, + srcAsset, + destAsset, + srcChainId, + destChainId, + walletAddress, + destWalletAddress, + priceData, + feeData, + bridgeId, + bridges, + steps, + intent, + ...restQuote + } = value; + + return { + src: { + amount: new BigNumber(srcTokenAmount) + .plus( + intent + ? 0 + : (sumAmounts([ + feeData[FeeType.TX_FEE], + feeData[FeeType.METABRIDGE], + ])?.amount ?? 0), + ) + .toFixed(), + asset: toBridgeAssetV2(srcAsset), + ...(walletAddress && { walletAddress }), + }, + dest: { + amount: destTokenAmount, + asset: toBridgeAssetV2(destAsset), + ...(destWalletAddress && { walletAddress: destWalletAddress }), + minAmount: minDestTokenAmount, + }, + priceData: { + ...(priceData?.priceImpact && { + priceImpact: { + amount: priceData.priceImpact, + }, + }), + }, + feeData: { + [FeeType.METABRIDGE]: [ + { + ...feeData[FeeType.METABRIDGE], + asset: toBridgeAssetV2(feeData[FeeType.METABRIDGE].asset), + ...(priceData?.totalFeeAmountUsd && /* istanbul ignore next */ { + usd: priceData?.totalFeeAmountUsd, + }), + }, + ], + ...(feeData[FeeType.TX_FEE] && /* istanbul ignore next */ { + [FeeType.TX_FEE]: [ + { + ...feeData[FeeType.TX_FEE], + asset: toBridgeAssetV2(feeData[FeeType.TX_FEE].asset), + }, + ], + }), + }, + steps: steps?.map(toStepV2), + ...restQuote, + ...(intent && /* istanbul ignore next */ { intent }), + protocols: bridges, + aggregator: bridgeId, + }; +}); + +const toQuoteV2 = ( + quote: Infer, +): Infer => { + const quoteV2 = create(quote, QuoteV2FromV1); + return quoteV2; +}; + +const QuoteResponseV2FromV1 = coerce( + QuoteResponseSchemaV2, + QuoteResponseSchemaV1, + (value: QuoteResponseV1) => { + const { quote, l1GasFeesInHexWei, nonEvmFeesInNative, ...rest } = value; + const { srcAsset } = quote; + + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(srcAsset.assetId); + + return { + ...rest, + ...(nonEvmFeesInNative && { nonEvmFeesInNative }), + ...(l1GasFeesInHexWei && { l1GasFeesInHexWei }), + namespace, + chainId, + quote: toQuoteV2(quote), + }; + }, +); + +/** + * Converts a partial quote response to a {@link QuoteResponse}. + * This does not preserve any post-fetch metadata. + * + * @param quoteResponse - The {@link QuoteResponseV1} to convert + * @returns The {@link QuoteResponse} + */ +export function toQuoteResponseV2(quoteResponse: unknown): QuoteResponse { + let quoteResponseV2: QuoteResponse | null = null; + + // V1 quote + /* istanbul ignore else */ + if (is(quoteResponse, QuoteResponseSchemaV1)) { + quoteResponseV2 = create(quoteResponse, QuoteResponseV2FromV1); + } + // V2 quote + else if (validateQuoteResponse(quoteResponse)) { + quoteResponseV2 = quoteResponse; + } + + /* istanbul ignore else */ + if (quoteResponseV2) { + const { + chain: { namespace }, + chainId, + } = parseCaipAssetType(quoteResponseV2.quote.src.asset.assetId); + + // Add namespace, chainId + return { ...quoteResponseV2, namespace: namespace as never, chainId }; + } + + /* istanbul ignore next */ + throw new Error('QuoteResponseV1 to V2 conversion failed'); +} diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts new file mode 100644 index 00000000000..cee15a54f24 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts @@ -0,0 +1,295 @@ +import { Failure, StructError } from '@metamask/superstruct'; +import { KnownCaipNamespace } from '@metamask/utils'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20.js'; +import { mergeQuoteMetadata } from '../utils/quote-metadata/merge.js'; +import { toQuoteMetadataV1 } from '../utils/quote-metadata/to-quote-metadata-v1.js'; +import { formatStructErrors } from '../utils/struct-error.js'; +import { toQuoteResponseV2 } from './quote-response-v1-to-v2.js'; +import { toQuoteResponseV1 } from './quote-response-v2-to-v1.js'; + +const MOCK_QUOTE_METADATA = { + adjustedReturn: { + usd: '2.08686', + valueInCurrency: '419.98686', + }, + cost: { + usd: '8.91314', + valueInCurrency: '1758.01314', + }, + minToTokenAmount: { + amount: '13.7', + usd: undefined, + valueInCurrency: undefined, + }, + sentAmount: { + amount: '14', + usd: '11', + valueInCurrency: '2178', + }, + swapRate: '1.90909090909090909091', + toTokenAmount: { + amount: '13.984280', + usd: '2.1', + valueInCurrency: '420', + }, + totalNetworkFee: { + amount: '0.0000073', + usd: '0.01314', + valueInCurrency: '0.01314', + }, + gasFee: { + total: { + amount: '0.000007', + usd: '0.0131', + valueInCurrency: '0.0131', + }, + }, + relayerFee: { + amount: '0.000003', + usd: '0.00004', + valueInCurrency: '0.00004', + }, + priceImpact: { + valueInCurrency: '10', + usd: '10', + }, +}; + +describe('quote-response-v1 compatibility', () => { + describe('toQuoteResponseV1', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + const expectedError = new StructError( + { + value: '', + key: '', + type: '', + message: + 'Expected the value to satisfy a union of `intersection | intersection | intersection | intersection', + explanation: + 'Expected the value to satisfy a union of `intersection | intersection | intersection | intersection`, but received: [object Object]', + branch: [], + path: [], + refinement: undefined, + }, + function (): Generator { + return [ + { + path: ['quote', 'src'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'dest'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'feeData'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['quote', 'aggregator'], + message: 'Expected a string, but received: undefined', + }, + { + path: ['quote', 'protocols'], + message: 'Expected an array value, but received: undefined', + }, + { + path: ['estimatedProcessingTimeInSeconds'], + message: 'Expected a number, but received: undefined', + }, + { + path: ['namespace'], + message: + 'Expected the literal `"eip155"`, but received: undefined', + }, + { + path: ['chainId'], + message: + 'Expected a value of type `CaipChainId`, but received: `undefined`', + }, + { + path: ['trade'], + message: 'Expected an object, but received: undefined', + }, + { + path: ['namespace'], + message: + 'Expected the literal `"solana"`, but received: undefined', + }, + { + path: ['trade'], + message: 'Expected a string, but received: undefined', + }, + { + path: ['namespace'], + message: 'Expected the literal `"tron"`, but received: undefined', + }, + + { + path: ['namespace'], + message: + 'Expected the literal `"bip122"`, but received: undefined', + }, + ] as unknown as Generator; + }, + ); + // @ts-expect-error - invalid quote response + expect(() => toQuoteResponseV1(quoteResponse)) + .toThrowErrorMatchingInlineSnapshot(` + "Failed to convert QuoteResponseV2 to QuoteResponseV1. [ + "At path: quote.srcChainId (number) -- Expected a number, but received: undefined", + "At path: quote.srcAsset (number) -- Expected an object, but received: undefined", + "At path: quote.srcTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.destChainId (number) -- Expected a number, but received: undefined", + "At path: quote.destAsset (number) -- Expected an object, but received: undefined", + "At path: quote.destTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.minDestTokenAmount (number) -- Expected a string, but received: undefined", + "At path: quote.feeData (number) -- Expected an object, but received: undefined", + "At path: quote.bridgeId (number) -- Expected a string, but received: undefined", + "At path: quote.bridges (number) -- Expected an array value, but received: undefined", + "At path: quote.steps (number) -- Expected an array value, but received: undefined", + "At path: estimatedProcessingTimeInSeconds (number) -- Expected a number, but received: undefined", + "At path: trade (number) -- Expected the value to satisfy a union of \`type | type | type | union | string\`, but received: undefined", + "At path: trade (number) -- Expected an object, but received: undefined", + "At path: trade (number) -- Expected the value to satisfy a union of \`type | type\`, but received: undefined", + "At path: trade (number) -- Expected a string, but received: undefined" + ]" + `); + + expect(formatStructErrors(expectedError)).toMatchInlineSnapshot(` + [ + "At path: -- Expected the value to satisfy a union of \`intersection | intersection | intersection | intersection", + "At path: quote.src -- Expected an object, but received: undefined", + "At path: quote.dest -- Expected an object, but received: undefined", + "At path: quote.feeData -- Expected an object, but received: undefined", + "At path: quote.aggregator -- Expected a string, but received: undefined", + "At path: quote.protocols -- Expected an array value, but received: undefined", + "At path: estimatedProcessingTimeInSeconds -- Expected a number, but received: undefined", + "At path: namespace -- Expected the literal \`"eip155"\`, but received: undefined", + "At path: chainId -- Expected a value of type \`CaipChainId\`, but received: \`undefined\`", + "At path: trade -- Expected an object, but received: undefined", + "At path: namespace -- Expected the literal \`"solana"\`, but received: undefined", + "At path: trade -- Expected a string, but received: undefined", + "At path: namespace -- Expected the literal \`"tron"\`, but received: undefined", + "At path: namespace -- Expected the literal \`"bip122"\`, but received: undefined", + ] + `); + }); + + it('should return a valid QuoteResponseV1 with V2 input (no metadata)', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + expect(quoteResponse.quote.minDestTokenAmount).toBe('13700000'); + + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + expect(quoteResponseV2.quote.feeData.network).toBeUndefined(); + + const quoteMetadata = toQuoteMetadataV1(quoteResponseV2); + expect( + Object.values(quoteMetadata).every((value) => value === undefined), + ).toBe(true); + + expect(quoteResponseV2.quote.dest.minAmount).toBe('13700000'); + const expectedQuoteResponseV2 = mockBridgeQuotesErc20Erc20V2Migration[0]; + delete expectedQuoteResponseV2.quote.feeData.network; + + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + }); + expect(quoteResponseV2.quote.dest.minAmount).toMatchInlineSnapshot( + `"13700000"`, + ); + expect(quoteResponseV2.quote.feeData.network).toBeUndefined(); + + const quoteResponseV1 = toQuoteResponseV1(quoteResponseV2); + expect(quoteResponseV1.quote.minDestTokenAmount).toBe('13700000'); + + expect(quoteResponseV1).toStrictEqual(quoteResponse); + }); + + it('should return a valid QuoteResponseV1 with V2 input (remove metadata)', () => { + const quoteResponseV1WithMetadata = mergeQuoteMetadata( + mockBridgeQuotesErc20Erc20V1[0], + MOCK_QUOTE_METADATA, + ); + + // Build input data by converting V1 to V2 + const quoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(quoteResponseV1WithMetadata), + MOCK_QUOTE_METADATA, + ); + + const expectedQuoteResponseV2 = mergeQuoteMetadata( + toQuoteResponseV2(mockBridgeQuotesErc20Erc20V2Migration[0]), + MOCK_QUOTE_METADATA, + ); + + expect( + toQuoteResponseV2(quoteResponseV1WithMetadata).quote.feeData + ?.network?.[0], + ).toMatchInlineSnapshot(`undefined`); + expect(quoteResponseV2.quote.feeData?.network?.[0]) + .toMatchInlineSnapshot(` + { + "amount": "7000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "name": "Ether", + "symbol": "ETH", + }, + "normalizedAmount": "0.000007", + "usd": "0.0131", + "valueInCurrency": "0.0131", + } + `); + + expect(quoteResponseV2).toStrictEqual({ + ...expectedQuoteResponseV2, + namespace: KnownCaipNamespace.Eip155, + chainId: 'eip155:10', + ...MOCK_QUOTE_METADATA, + }); + + // Convert V2 to V1 + const quoteResponseV1 = toQuoteResponseV1(quoteResponseV2); + expect(quoteResponseV1).toStrictEqual(mockBridgeQuotesErc20Erc20V1[0]); + }); + + it('should return a valid QuoteResponse with V1 input', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V1[0]; + const quoteResponseV2 = toQuoteResponseV1(quoteResponse); + expect(quoteResponseV2).toStrictEqual(mockBridgeQuotesErc20Erc20V1[0]); + }); + + it('should return a valid QuoteResponseV1 with V1 input and metadata', () => { + const quoteResponse = mergeQuoteMetadata( + mockBridgeQuotesErc20Erc20V1[0], + MOCK_QUOTE_METADATA, + ); + + // Convert to V1 + const quoteResponseV1 = toQuoteResponseV1(quoteResponse); + expect(quoteResponseV1).toStrictEqual(quoteResponse); + expect(toQuoteMetadataV1(quoteResponseV1)).toStrictEqual( + MOCK_QUOTE_METADATA, + ); + }); + + it('should throw an error for a null input', () => { + // @ts-expect-error - null input + expect(() => toQuoteResponseV1(null)).toThrow( + 'Failed to convert QuoteResponseV2 + metadata to QuoteResponseV1. [\n "At path: (type) -- Expected an object, but received: null"\n]', + ); + }); + }); +}); diff --git a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts new file mode 100644 index 00000000000..c854ef9cd4f --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -0,0 +1,236 @@ +import { + create, + coerce, + is, + StructError, + intersection, + Infer, +} from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; + +import type { Step } from '../types.js'; +import { + formatAddressToCaipReference, + formatChainIdToDec, +} from '../utils/caip-formatters.js'; +import type { QuoteMetadata } from '../utils/quote-metadata/types.js'; +import { formatStructErrors } from '../utils/struct-error.js'; +import { + BridgeAssetSchema, + BridgeAssetV2Schema, + MinimalAssetSchema, +} from '../validators/bridge-asset.js'; +import type { BridgeAssetV2 } from '../validators/bridge-asset.js'; +import { QuoteResponseSchemaV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; +import { QuoteResponseSchemaV2 } from '../validators/quote-response.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; +import { + QuoteSchemaV2, + FeeType, + Quote, + QuoteSchema, +} from '../validators/quote.js'; +import { StepSchemaV2, StepSchema } from '../validators/step.js'; + +const BridgeAssetV1FromV2 = coerce( + intersection([BridgeAssetSchema, MinimalAssetSchema]), + BridgeAssetV2Schema, + (value) => { + const { assetId, ...rest } = value; + + const { chainId } = parseCaipAssetType(assetId); + return { + address: formatAddressToCaipReference(assetId), + chainId: formatChainIdToDec(chainId), + assetId, + ...rest, + }; + }, +); + +const toBridgeAssetV1 = ( + data: BridgeAssetV2, +): Infer => { + return create(data, BridgeAssetV1FromV2); +}; + +const StepSchemaV1FromV2 = coerce(StepSchema, StepSchemaV2, (value) => { + const { src, dest, action } = value; + const srcAsset = toBridgeAssetV1(src.asset); + const destAsset = toBridgeAssetV1(dest.asset); + + return { + action, + srcChainId: srcAsset.chainId, + destChainId: destAsset.chainId, + srcAsset, + destAsset, + }; +}); + +const toStepV1 = (step: Infer): Step => { + const stepV2 = create(step, StepSchemaV1FromV2); + return stepV2; +}; + +const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { + const { + priceData, + feeData, + steps, + protocols, + aggregator, + src, + dest, + intent, + ...restQuote + } = value; + + const { chainId: srcChainIdInCaip } = parseCaipAssetType(src.asset.assetId); + const { chainId: destChainIdInCaip } = parseCaipAssetType(dest.asset.assetId); + + const srcChainId = formatChainIdToDec(srcChainIdInCaip); + const destChainId = formatChainIdToDec(destChainIdInCaip); + + const { usd, ...metabridgeFeeData } = feeData[FeeType.METABRIDGE][0]; + + return { + bridges: protocols, + bridgeId: aggregator, + protocols, + aggregator, + srcChainId, + destChainId, + srcAsset: toBridgeAssetV1(src.asset), + destAsset: toBridgeAssetV1(dest.asset), + srcTokenAmount: src.amount, + destTokenAmount: dest.amount, + minDestTokenAmount: dest.minAmount, + feeData: { + [FeeType.METABRIDGE]: { + ...metabridgeFeeData, + asset: toBridgeAssetV1(metabridgeFeeData.asset), + }, + ...(feeData[FeeType.TX_FEE]?.length && /* istanbul ignore next */ { + [FeeType.TX_FEE]: { + ...feeData[FeeType.TX_FEE][0], + asset: toBridgeAssetV1(feeData[FeeType.TX_FEE][0].asset), + }, + }), + }, + ...(dest.walletAddress && /* istanbul ignore next */ { + destWalletAddress: dest.walletAddress, + }), + ...(src.walletAddress && /* istanbul ignore next */ { + walletAddress: src.walletAddress, + }), + ...(value.priceData?.priceImpact?.amount && /* istanbul ignore next */ { + priceData: { + priceImpact: value.priceData.priceImpact.amount, + }, + }), + ...(intent && /* istanbul ignore next */ { intent }), + /** + * @deprecated This field is deprecated. + */ + steps: steps?.map(toStepV1), + ...restQuote, + }; +}); + +const toQuoteV1 = (quote: Infer): Quote => { + const quoteV2 = create(quote, QuoteV1FromV2); + return quoteV2; +}; + +const QuoteResponseV1FromV2 = coerce( + QuoteResponseSchemaV1, + QuoteResponseSchemaV2, + (value: QuoteResponse | null) => { + if (!value) { + return null; + } + const { + quote, + estimatedProcessingTimeInSeconds, + approval, + // @ts-expect-error - Some networks don't have an approval field + resetApproval, + featureId, + trade, + quoteRequestIndex, + nonEvmFeesInNative, + l1GasFeesInHexWei, + quoteId, + } = value; + + const quoteV1 = toQuoteV1(quote); + return { + estimatedProcessingTimeInSeconds, + approval, + trade, + quote: quoteV1, + ...(featureId && /* istanbul ignore next */ { featureId }), + ...(quoteId && /* istanbul ignore next */ { quoteId }), + ...(resetApproval && /* istanbul ignore next */ { resetApproval }), + ...(quoteRequestIndex !== undefined && /* istanbul ignore next */ { + quoteRequestIndex, + }), + ...(nonEvmFeesInNative && /* istanbul ignore next */ { + nonEvmFeesInNative, + }), + ...(l1GasFeesInHexWei && /* istanbul ignore next */ { + l1GasFeesInHexWei, + }), + }; + }, +); + +/** + * Converts a {@link QuoteResponse} to a {@link QuoteResponseV1} for backwards compatibility. + * This does not preserve any post-fetch {@link QuoteMetadata}. + * + * @deprecated Avoid introducing new code that uses this function. It is only for backwards compatibility with the old quote response format. + * @param quoteResponse - The {@link QuoteResponse} to convert + * @returns The {@link QuoteResponseV1} + */ +export const toQuoteResponseV1 = ( + quoteResponse: + | QuoteResponse + | (QuoteResponseV1 & QuoteMetadata) + | QuoteResponseV1, +): QuoteResponseV1 & QuoteMetadata => { + let errorMessage = 'Failed to convert'; + + // V1 quote + if (is(quoteResponse, QuoteResponseSchemaV1)) { + errorMessage += ' unmodified QuoteResponseV1'; + return quoteResponse as QuoteResponseV1 & QuoteMetadata; + } + + try { + // V2 with namespace, chainId, maybe QuoteMetadata + if (is(quoteResponse, QuoteResponseSchemaV2)) { + errorMessage += ' QuoteResponseV2 + metadata to QuoteResponseV1'; + const quoteResponseV1 = create(quoteResponse, QuoteResponseV1FromV2); + return quoteResponseV1; + } + + // V2 with no namespace, chainId + errorMessage += ' QuoteResponseV2 to QuoteResponseV1'; + return create(quoteResponse, QuoteResponseV1FromV2); + } catch (error) { + /* istanbul ignore next */ + let errorDetails = error instanceof Error ? error.message : 'Unknown error'; + + /* istanbul ignore next */ + if (error instanceof StructError) { + const formattedErrors = formatStructErrors(error); + errorDetails = JSON.stringify(formattedErrors, null, 2); + console.warn(errorMessage, formatStructErrors(error)); + } + + throw new Error(`${errorMessage}. ${errorDetails}`); + } +}; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index c41675b24a5..e01df567583 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -77,6 +77,8 @@ export { } from './validators/quote-response-v1.js'; export { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; +export { sumAmounts } from './utils/number-formatters.js'; + export { AssetType, SortOrder, @@ -121,6 +123,12 @@ export { validateQuoteResponse } from './validators/quote-response.js'; export type { Quote } from './validators/quote.js'; export { FeeType, DiscountType } from './validators/quote.js'; export { ActionTypes } from './validators/step.js'; +export { toQuoteResponseV1 } from './coercers/quote-response-v2-to-v1.js'; +export { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; + +export { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; +export { toQuoteMetadataV2 } from './utils/quote-metadata/to-quote-metadata-v2.js'; + export { validateQuoteStreamComplete, QuoteStreamCompleteReason, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 705d7fd3d71..638dd3c1901 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -22,13 +22,7 @@ import { selectBatchSellQuotes, selectBatchSellTrades, } from './selectors.js'; -import { - SortOrder, - RequestStatus, - ChainId, - BridgeAsset, - NonEvmFees, -} from './types.js'; +import { SortOrder, RequestStatus, ChainId, NonEvmFees } from './types.js'; import { getNativeAssetForChainId, isNativeAddress } from './utils/bridge.js'; import { formatAddressToAssetId, @@ -39,6 +33,7 @@ import { import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; import { BatchSellTransactionType } from './validators/batch-sell.js'; +import type { BridgeAsset } from './validators/bridge-asset.js'; import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; @@ -749,54 +744,6 @@ describe('Bridge Selectors', () => { expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('1758.014454'); }); - 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: { - [formatAddressToAssetId( - mockQuote.quote.srcAsset.address, - mockQuote.quote.srcChainId, - ) ?? '']: { - exchangeRate: '1980', - usdExchangeRate: '10', - }, - [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).toBeUndefined(); - }); - 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 f458eb525ab..5ba66309cf8 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -186,13 +186,15 @@ export const selectExchangeRateByAssetId = ( if (rate) { // The multichain rate is denominated in the user's selected currency. // To get a USD rate, find the user's-currency-to-USD conversion factor from any EVM native currency rate. - const nativeCurrencyRate = Object.values(currencyRates ?? {}).find( - (rateEntry) => - rateEntry?.conversionRate !== undefined && - rateEntry?.conversionRate !== null && - rateEntry?.usdConversionRate !== undefined && - rateEntry?.usdConversionRate !== null, - ); + const nativeCurrencyRate = + currencyRates && + Object.values(currencyRates).find( + (rateEntry) => + rateEntry?.conversionRate !== undefined && + rateEntry?.conversionRate !== null && + rateEntry?.usdConversionRate !== undefined && + rateEntry?.usdConversionRate !== null, + ); const usersCurrencyToUsdRate = nativeCurrencyRate?.conversionRate !== undefined && nativeCurrencyRate?.conversionRate !== null && diff --git a/packages/bridge-controller/src/utils/number-formatters.ts b/packages/bridge-controller/src/utils/number-formatters.ts index 9cc02387cfd..931444880bf 100644 --- a/packages/bridge-controller/src/utils/number-formatters.ts +++ b/packages/bridge-controller/src/utils/number-formatters.ts @@ -1,6 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { BigNumber } from 'bignumber.js'; +import type { DeepPartial } from '../types.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; + /** * 1500000 -> 1.5 * @@ -8,7 +11,7 @@ import { BigNumber } from 'bignumber.js'; * @param decimals - The number of decimals to convert to * @returns The token amount in string format */ -export const calcTokenAmount = ( +export const calcNormalizedTokenAmount = ( value: string | BigNumber | undefined, decimals: number | undefined, ) => { @@ -19,6 +22,24 @@ export const calcTokenAmount = ( return new BigNumber(value).div(divisor); }; +/** + * 1.5 -> 1500000 + * + * @param value - The amount to convert to token value + * @param decimals - The number of decimals to convert to + * @returns The token value in string format + */ +export const calcAtomicTokenAmount = ( + value: string | BigNumber | undefined, + decimals: number | undefined, +) => { + if (value === undefined || decimals === undefined) { + return undefined; + } + const divisor = new BigNumber(10).pow(decimals); + return new BigNumber(value).times(divisor).toFixed(); +}; + /** * @deprecated No longer used * @param estimatedProcessingTimeInSeconds - The estimated processing time in seconds @@ -32,3 +53,65 @@ export const formatEtaInMinutes = ( } return (estimatedProcessingTimeInSeconds / 60).toFixed(); }; + +/** + * Aggregates a list of amounts into a single fee object. If fees have different assets, + * the returned object will only aggregate the usd and valueInCurrency values. + * + * @param maybeFees - The list of fees to aggregate + * @returns The aggregated fee object, or null if no fees are provided + */ +export const sumAmounts = ( + ...maybeFees: ( + | (DeepPartial | undefined | null)[] + | undefined + )[] +): DeepPartial | undefined => { + const fees = maybeFees + .flat() + .flat() + .filter( + (value): value is Partial => + value !== undefined && value !== null, + ); + + if (!fees || fees.length === 0) { + return undefined; + } + + const isSameAssetForAllFees = + new Set(fees.map((fee) => fee.asset?.assetId?.toLowerCase())).size === 1; + + /** + * Keys that require the asset to be the same for all fees + */ + const AMOUNT_KEYS = ['amount' as const]; + + /** + * Keys that can be aggregated across all fees + */ + const FIAT_OR_USD_KEYS = ['valueInCurrency' as const, 'usd' as const]; + + return fees.reduce((acc, fee) => { + const newAcc = { ...acc }; + if (isSameAssetForAllFees && fee.asset) { + newAcc.asset = fee.asset; + } + + AMOUNT_KEYS.forEach((key) => { + const value = fee[key]; + if (value && isSameAssetForAllFees) { + newAcc[key] = new BigNumber(acc[key] ?? 0).plus(value).toFixed(); + } + }); + + FIAT_OR_USD_KEYS.forEach((key) => { + const value = fee[key]; + if (value) { + newAcc[key] = new BigNumber(acc[key] ?? 0).plus(value).toFixed(); + } + }); + + return newAcc; + }, {}); +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index eaaf762c7e3..002e8495349 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -18,7 +18,7 @@ import { FloatStringSchema } from '../../validators/number.js'; import type { QuoteResponseV1 as QuoteResponse } from '../../validators/quote-response-v1.js'; import { TxData } from '../../validators/trade.js'; import { isEvmQuoteResponse, isNativeAddress } from '../bridge.js'; -import { calcTokenAmount } from '../number-formatters.js'; +import { calcNormalizedTokenAmount } from '../number-formatters.js'; import type { QuoteMetadata, TokenAmountValues } from './types.js'; export const calcNonEvmTotalNetworkFee = ( @@ -43,7 +43,7 @@ export const calcToAmount = ( destAsset: BridgeAsset, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { - const normalizedDestAmount = calcTokenAmount( + const normalizedDestAmount = calcNormalizedTokenAmount( destTokenAmount, destAsset.decimals, ); @@ -78,7 +78,10 @@ export const calcSentAmount = ( (acc, { amount }) => acc.plus(amount), new BigNumber(srcTokenAmount), ); - const normalizedSentAmount = calcTokenAmount(sentAmount, srcAsset.decimals); + const normalizedSentAmount = calcNormalizedTokenAmount( + sentAmount, + srcAsset.decimals, + ); return { amount: normalizedSentAmount?.toString(), valueInCurrency: @@ -94,7 +97,7 @@ export const calcBatchFees = ( asset: BridgeAsset, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { - const normalizedAmount = calcTokenAmount(amount, asset.decimals); + const normalizedAmount = calcNormalizedTokenAmount(amount, asset.decimals); return { amount: normalizedAmount?.toString(), @@ -117,7 +120,7 @@ export const calcRelayerFee = ( ? new BigNumber(convertHexToDecimal(trade.value)) : undefined; let relayerFeeInNative = relayerFeeAmount - ? calcTokenAmount(relayerFeeAmount, 18) + ? calcNormalizedTokenAmount(relayerFeeAmount, 18) : undefined; // Subtract srcAmount and other fees from trade value if srcAsset is native @@ -271,7 +274,7 @@ export const calcIncludedTxFees = ( txFee?.asset.assetId === srcAsset.assetId ? srcTokenExchangeRate : destTokenExchangeRate; - const normalizedTxFeeAmount = calcTokenAmount( + const normalizedTxFeeAmount = calcNormalizedTokenAmount( txFee?.amount, txFee?.asset.decimals, ); diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts new file mode 100644 index 00000000000..73220afee0f --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts @@ -0,0 +1,75 @@ +import { merge } from 'lodash'; + +import { getMockBridgeQuotesErc20Erc20V2 } from '../../../tests/mock-quotes-erc20-erc20.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { mergeQuoteMetadata } from './merge.js'; + +describe('mergeQuoteMetadata', () => { + it.each([ + { + title: 'when quoteMetadata is empty', + quoteResponse: getMockBridgeQuotesErc20Erc20V2()[0], + quoteMetadata: {}, + mergedQuote: getMockBridgeQuotesErc20Erc20V2()[0], + }, + { + title: 'with relayer fee, included tx fees, and total network fee', + quoteResponse: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { + quote: { + feeData: { + relayer: [ + { + amount: '100', + usd: '100', + }, + ], + txFee: [ + { + amount: '100', + usd: '100', + }, + ], + network: [ + { + amount: '100', + usd: '100', + }, + ], + }, + }, + }), + quoteMetadata: { + relayerFee: { + amount: '.000000000000000105', + valueInCurrency: '100', + usd: '10', + }, + includedTxFees: { + amount: '.000000000000000105', + valueInCurrency: '100', + usd: '10', + }, + }, + mergedQuote: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { + quote: { + feeData: { + relayer: [{ amount: '105', usd: '10' }], + }, + }, + }), + }, + { + title: 'when quoteResponse is invalid', + quoteResponse: { a: 1 }, + quoteMetadata: { b: 2 }, + mergedQuote: { a: 1, b: 2 }, + }, + ])( + 'should merge quote metadata $title', + ({ quoteResponse, quoteMetadata, mergedQuote }) => { + expect( + mergeQuoteMetadata(quoteResponse as QuoteResponse, quoteMetadata), + ).toMatchObject(mergedQuote); + }, + ); +}); diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 78d939eeffd..438f55b495b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -1,18 +1,43 @@ +import { is } from '@metamask/superstruct'; import { merge } from 'lodash'; +import { QuoteResponseSchemaV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { toNormalizedAmounts } from './to-normalized-amounts.js'; +import { toQuoteMetadataV2 } from './to-quote-metadata-v2.js'; import type { QuoteMetadata } from './types.js'; /** * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} * - * @param quoteResponse - The {@link QuoteResponseV1} to merge the metadata into - * @param quoteMetadata - The {@link QuoteMetadata} values to merge + * @param quoteResponse - The {@link QuoteResponse} or {@link QuoteResponseV1} to merge the metadata into + * @param legacyQuoteMetadata - The {@link QuoteMetadata} values to merge * @returns The {@link QuoteResponse} with the metadata merged in */ -export function mergeQuoteMetadata( - quoteResponse: QuoteResponseV1, - quoteMetadata: QuoteMetadata, -): QuoteResponseV1 & QuoteMetadata { - return merge({}, quoteResponse, quoteMetadata); +export function mergeQuoteMetadata< + QuoteType extends QuoteResponse | QuoteResponseV1 = QuoteResponse, +>( + quoteResponse: QuoteType, + legacyQuoteMetadata: QuoteMetadata, +): QuoteType & QuoteMetadata { + if (is(quoteResponse, QuoteResponseSchemaV1)) { + return merge({}, quoteResponse, legacyQuoteMetadata); + } + + const legacyQuoteMetadataV2 = toQuoteMetadataV2( + legacyQuoteMetadata, + quoteResponse, + ); + + const normalizedAmounts = toNormalizedAmounts(quoteResponse); + + // Phase 1 of migration uses calcQuoteMetadata's results + return merge( + {}, + quoteResponse, + normalizedAmounts, + legacyQuoteMetadataV2, // legacy metadata in v2 format + legacyQuoteMetadata, // return legacy metadata for client testing + ); } diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts new file mode 100644 index 00000000000..789459f4b26 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts @@ -0,0 +1,57 @@ +import type { DeepPartial } from '../../types.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { FeeType } from '../../validators/quote.js'; +import { calcNormalizedTokenAmount } from '../number-formatters.js'; + +/** + * Builds a partial {@link QuoteResponese} with normalized amounts + * + * @param quoteResponseV2 - The {@link QuoteResponse} to convert + * @returns The {@link DeepPartial} + */ +export const toNormalizedAmounts = ( + quoteResponseV2: DeepPartial, +): DeepPartial => { + const { src, dest, feeData } = quoteResponseV2.quote ?? {}; + + return { + quote: { + src: { + normalizedAmount: calcNormalizedTokenAmount( + src?.amount, + src?.asset?.decimals, + )?.toFixed(), + }, + dest: { + normalizedAmount: calcNormalizedTokenAmount( + dest?.amount, + dest?.asset?.decimals, + )?.toFixed(), + minAmountNormalized: calcNormalizedTokenAmount( + dest?.minAmount, + dest?.asset?.decimals, + )?.toFixed(), + }, + feeData: { + network: feeData?.[FeeType.NETWORK]?.map((networkFee) => ({ + normalizedAmount: calcNormalizedTokenAmount( + networkFee?.amount, + networkFee?.asset?.decimals, + )?.toFixed(), + })), + relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ + normalizedAmount: calcNormalizedTokenAmount( + relayerFee.amount, + relayerFee.asset?.decimals, + )?.toFixed(), + })), + txFee: feeData?.[FeeType.TX_FEE]?.map((txFee) => ({ + normalizedAmount: calcNormalizedTokenAmount( + txFee.amount, + txFee.asset?.decimals, + )?.toFixed(), + })), + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts new file mode 100644 index 00000000000..bf5bd21798d --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts @@ -0,0 +1,63 @@ +import { merge } from 'lodash'; + +import type { DeepPartial } from '../../types.js'; +import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import type { QuoteMetadata } from './types.js'; + +/** + * Extracts legacy {@link QuoteMetadata} values from a {@link QuoteResponse} or {@link QuoteResponseV1}. + * If a QuoteResponse is provided, this assumes that its `valueInCurrency` properties are set. + * + * @param quoteResponse - The quote to extract the metadata from + * @returns A partial {@link QuoteMetadata} object + */ +export const toQuoteMetadataV1 = ( + quoteResponse: + | (DeepPartial & QuoteMetadata) + | null, +): QuoteMetadata => { + /* istanbul ignore if */ + if (!quoteResponse) { + return {}; + } + + const { + toTokenAmount, + minToTokenAmount, + sentAmount, + swapRate, + adjustedReturn, + cost, + includedTxFees, + relayerFee, + totalNetworkFee, + gasFee, + priceImpact, + } = quoteResponse; + + const legacyMetadata = { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate, + gasFee, + totalNetworkFee, + ...(adjustedReturn && Object.values(adjustedReturn).some(Boolean) + ? { adjustedReturn } + : {}), + ...(cost && Object.values(cost).some(Boolean) ? { cost } : {}), + ...(priceImpact && Object.values(priceImpact).some(Boolean) + ? { priceImpact } + : {}), + ...(relayerFee && Object.values(relayerFee).some(Boolean) + ? { relayerFee } + : {}), + ...(includedTxFees && Object.values(includedTxFees).some(Boolean) + ? { includedTxFees } + : {}), + }; + + // Phase 1 only uses legacyMetadata + return merge({}, legacyMetadata); +}; diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts new file mode 100644 index 00000000000..8b177a55d5d --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts @@ -0,0 +1,138 @@ +import { parseCaipAssetType } from '@metamask/utils'; + +import { toBridgeAssetV2 } from '../../coercers/quote-response-v1-to-v2.js'; +import type { DeepPartial } from '../../types.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import { getNativeAssetForChainId } from '../bridge.js'; +import { calcAtomicTokenAmount } from '../number-formatters.js'; +import type { QuoteMetadata } from './types.js'; + +/** + * Converts a {@link QuoteMetadata} to a partial {@link QuoteResponse} containing only metadata + * + * @param quoteMetadata - The {@link QuoteMetadata} to convert + * @param quoteResponseV2 - The {@link QuoteResponse} to use for token data + * @returns The {@link DeepPartial} + */ +export const toQuoteMetadataV2 = ( + quoteMetadata: QuoteMetadata, + quoteResponseV2?: DeepPartial, +): DeepPartial => { + const { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate, + totalNetworkFee, + gasFee, + adjustedReturn, + cost, + includedTxFees, + relayerFee, + priceImpact, + ...rest + } = quoteMetadata; + + const srcAsset = quoteResponseV2?.quote?.src?.asset; + const destAsset = quoteResponseV2?.quote?.dest?.asset; + + const chainId = srcAsset?.assetId + ? parseCaipAssetType(srcAsset.assetId)?.chainId + : undefined; + const nativeAsset = chainId + ? toBridgeAssetV2(getNativeAssetForChainId(chainId)) + : undefined; + const txFeeAsset = quoteResponseV2?.quote?.feeData?.txFee?.[0]?.asset; + + const priceImpactToUse = { + usd: priceImpact?.usd ?? cost?.usd, + valueInCurrency: priceImpact?.valueInCurrency ?? cost?.valueInCurrency, + }; + const networkFeeToUse = gasFee?.total ?? totalNetworkFee; + + return { + ...rest, + quote: { + src: { + amount: calcAtomicTokenAmount(sentAmount?.amount, srcAsset?.decimals), + normalizedAmount: sentAmount?.amount, + valueInCurrency: sentAmount?.valueInCurrency, + usd: sentAmount?.usd, + }, + dest: { + amount: calcAtomicTokenAmount( + toTokenAmount?.amount, + destAsset?.decimals, + ), + normalizedAmount: toTokenAmount?.amount, + valueInCurrency: toTokenAmount?.valueInCurrency, + usd: toTokenAmount?.usd, + minAmount: calcAtomicTokenAmount( + minToTokenAmount?.amount, + destAsset?.decimals, + ), + minAmountNormalized: minToTokenAmount?.amount, + minAmountUsd: minToTokenAmount?.usd, + minAmountValueInCurrency: minToTokenAmount?.valueInCurrency, + }, + feeData: { + network: [ + { + amount: calcAtomicTokenAmount( + networkFeeToUse?.amount, + nativeAsset?.decimals, + ), + normalizedAmount: networkFeeToUse?.amount, + valueInCurrency: networkFeeToUse?.valueInCurrency, + usd: networkFeeToUse?.usd, + asset: nativeAsset, + }, + ], + ...(relayerFee && + Object.values(relayerFee).some(Boolean) && { + relayer: [ + { + amount: calcAtomicTokenAmount( + relayerFee?.amount, + nativeAsset?.decimals, + ), + normalizedAmount: relayerFee.amount, + valueInCurrency: relayerFee.valueInCurrency, + usd: relayerFee.usd, + asset: nativeAsset, + }, + ], + }), + ...(includedTxFees && + Object.values(includedTxFees).some(Boolean) && { + txFee: [ + { + amount: calcAtomicTokenAmount( + includedTxFees?.amount, + txFeeAsset?.decimals, + ), + normalizedAmount: includedTxFees?.amount, + valueInCurrency: includedTxFees?.valueInCurrency, + usd: includedTxFees?.usd, + asset: txFeeAsset, + }, + ], + }), + }, + priceData: { + ...(priceImpactToUse && + Object.values(priceImpactToUse).some(Boolean) && { + priceImpact: priceImpactToUse, + }), + ...(adjustedReturn && + Object.values(adjustedReturn).some(Boolean) && { + adjustedReturn: { + valueInCurrency: adjustedReturn?.valueInCurrency, + usd: adjustedReturn?.usd, + }, + }), + swapRate, + }, + }, + }; +}; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts index 2f1fd15ad5e..eb64b1806a6 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -1,10 +1,12 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/index.js'; import type { DeepPartial } from '../src/types.js'; import { validateQuoteResponseV1, QuoteResponseV1, } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ @@ -202,3 +204,9 @@ export const getMockBridgeQuotesErc20Erc20V1 = ( return mergedQuote; }); }; + +export const getMockBridgeQuotesErc20Erc20V2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return getMockBridgeQuotesErc20Erc20V1(quoteOverrides).map(toQuoteResponseV2); +};