From 158b79febfcc79cbf325adb7a690479a9ae3c8ee Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 10:55:09 -0700 Subject: [PATCH 01/45] wip --- .../coercers/quote-response-v1-to-v2.test.ts | 192 ++++++++++++ .../src/coercers/quote-response-v1-to-v2.ts | 199 ++++++++++++ .../coercers/quote-response-v2-to-v1.test.ts | 295 ++++++++++++++++++ .../src/coercers/quote-response-v2-to-v1.ts | 249 +++++++++++++++ .../src/utils/quote-metadata/merge.test.ts | 58 ++++ .../quote-metadata/to-normalized-amounts.ts | 57 ++++ .../quote-metadata/to-quote-metadata-v1.ts | 58 ++++ .../quote-metadata/to-quote-metadata-v2.ts | 120 +++++++ .../src/validators/amount-and-asset.ts | 33 ++ .../src/validators/quote-response.test.ts | 33 ++ .../src/validators/quote-response.ts | 214 +++++++++++++ .../mock-quotes-erc20-erc20-migration-v2.ts | 111 +++++++ 12 files changed, 1619 insertions(+) create mode 100644 packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts create mode 100644 packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts create mode 100644 packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts create mode 100644 packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/merge.test.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts create mode 100644 packages/bridge-controller/src/validators/amount-and-asset.ts create mode 100644 packages/bridge-controller/src/validators/quote-response.test.ts create mode 100644 packages/bridge-controller/src/validators/quote-response.ts create mode 100644 packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts 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..95e35336154 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -0,0 +1,192 @@ +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 }, + }, + includedTxFees: { + amount: undefined, + usd: undefined, + valueInCurrency: undefined, + }, + cost: { + usd: undefined, + valueInCurrency: undefined, + }, + adjustedReturn: { + 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": [ + { + "amount": undefined, + "normalizedAmount": undefined, + "usd": undefined, + "valueInCurrency": 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..95a96eb1cc1 --- /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 { + 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, + // @ts-expect-error - chainAgnosticId is not in the schema + chainAgnosticId, + // @ts-expect-error - logoURI is not in the schema + logoURI, + iconUrl, + icon, + assetId, + ...rest + } = value; + + const resolvedIconUrl = iconUrl ?? logoURI ?? 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: unknown): 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 : feeData[FeeType.METABRIDGE].amount) + .toFixed(), + asset: toBridgeAssetV2(srcAsset), + ...(walletAddress && { walletAddress }), + }, + dest: { + amount: destTokenAmount, + asset: toBridgeAssetV2(destAsset), + ...(destWalletAddress && { walletAddress: destWalletAddress }), + minAmount: minDestTokenAmount, + }, + priceData: { + ...(value.priceData?.priceImpact && { + priceImpact: { + amount: value.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] && { + [FeeType.TX_FEE]: [ + { + ...feeData[FeeType.TX_FEE], + asset: toBridgeAssetV2(feeData[FeeType.TX_FEE].asset), + }, + ], + }), + }, + steps: steps?.map(toStepV2), + ...restQuote, + protocols: bridges, + aggregator: bridgeId, + }; +}); + +const toQuoteV2 = (quote: unknown): 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..a1df33ce1ad --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -0,0 +1,249 @@ +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 { 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: unknown): 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: unknown): Step => { + const stepV2 = create(step, StepSchemaV1FromV2); + return stepV2; +}; + +const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { + const { + priceData, + feeData, + steps, + protocols, + aggregator, + src, + dest, + ...restQuote + } = value; + + const { chainId: srcChainIdInCaip, assetReference: srcReference } = + parseCaipAssetType(src.asset.assetId); + const { chainId: destChainIdInCaip, assetReference: destReference } = + parseCaipAssetType(dest.asset.assetId); + + const srcChainId = formatChainIdToDec(srcChainIdInCaip); + const destChainId = formatChainIdToDec(destChainIdInCaip); + const srcTokenAddress = formatAddressToCaipReference(srcReference); + const destTokenAddress = formatAddressToCaipReference(destReference); + + const { usd, ...metabridgeFeeData } = feeData[FeeType.METABRIDGE][0]; + + return { + bridges: protocols, + bridgeId: aggregator, + protocols, + aggregator, + srcChainId, + destChainId, + srcAsset: { + ...src.asset, + chainId: srcChainId, + address: srcTokenAddress, + }, + destAsset: { + ...dest.asset, + chainId: destChainId, + address: destTokenAddress, + }, + srcTokenAmount: src.amount, + destTokenAmount: dest.amount, + minDestTokenAmount: dest.minAmount, + feeData: { + [FeeType.METABRIDGE]: { + ...metabridgeFeeData, + asset: { + ...metabridgeFeeData.asset, + chainId: formatChainIdToDec( + parseCaipAssetType(metabridgeFeeData.asset.assetId).chainId, + ), + address: formatAddressToCaipReference( + parseCaipAssetType(metabridgeFeeData.asset.assetId).assetReference, + ), + }, + }, + ...(feeData[FeeType.TX_FEE]?.length && /* istanbul ignore next */ { + ...feeData[FeeType.TX_FEE][0], + asset: 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, + }, + }), + /** + * @deprecated This field is deprecated. + */ + steps: steps?.map(toStepV1), + ...restQuote, + }; +}); + +const toQuoteV1 = (quote: unknown): 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/utils/quote-metadata/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts new file mode 100644 index 00000000000..eaa8fab9c33 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts @@ -0,0 +1,58 @@ +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', + quoteResponse: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { + quote: { + feeData: { + relayer: [ + { + amount: '100', + usd: '100', + }, + ], + }, + }, + }), + quoteMetadata: { + relayerFee: { + 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/to-normalized-amounts.ts b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts new file mode 100644 index 00000000000..d044777f390 --- /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 { QuoteResponse } from '../../validators/quote-response.js'; +import { FeeType } from '../../validators/quote.js'; +import { calcTokenAmount } 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: calcTokenAmount( + src?.amount, + src?.asset?.decimals, + )?.toFixed(), + }, + dest: { + normalizedAmount: calcTokenAmount( + dest?.amount, + dest?.asset?.decimals, + )?.toFixed(), + minAmountNormalized: calcTokenAmount( + dest?.minAmount, + dest?.asset?.decimals, + )?.toFixed(), + }, + feeData: { + network: feeData?.[FeeType.NETWORK]?.map((networkFee) => ({ + normalizedAmount: calcTokenAmount( + networkFee?.amount, + networkFee?.asset?.decimals, + )?.toFixed(), + })), + relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ + normalizedAmount: calcTokenAmount( + relayerFee.amount, + relayerFee.asset?.decimals, + )?.toFixed(), + })), + txFee: feeData?.[FeeType.TX_FEE]?.map((txFee) => ({ + normalizedAmount: calcTokenAmount( + 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..382cb906b1d --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts @@ -0,0 +1,58 @@ +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, +): QuoteMetadata => { + const { + toTokenAmount, + minToTokenAmount, + sentAmount, + swapRate, + adjustedReturn, + cost, + includedTxFees, + relayerFee, + totalNetworkFee, + gasFee, + priceImpact, + } = quoteResponse; + + const legacyMetadata = { + sentAmount, + toTokenAmount, + minToTokenAmount, + swapRate, + adjustedReturn, + cost, + gasFee, + totalNetworkFee, + priceImpact, + ...(relayerFee && { relayerFee }), + ...(includedTxFees && { includedTxFees }), + }; + + // Phase 1 only uses legacyMetadata + return merge({}, legacyMetadata); + + // TODO Phase 1.5 uses legacyMetadata as fallback + // return merge( + // {}, + // legacyMetadata, + // toQuoteMetadataV1(quoteResponse), + // ); + + // TODO Phase 2 only uses metadata from the API response + // return toQuoteMetadataV1(quoteResponse); +}; 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..554ef444df9 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts @@ -0,0 +1,120 @@ +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 { calcTokenValue } 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; + + return { + ...rest, + quote: { + src: { + amount: calcTokenValue(sentAmount?.amount, srcAsset?.decimals), + normalizedAmount: sentAmount?.amount, + valueInCurrency: sentAmount?.valueInCurrency, + usd: sentAmount?.usd, + }, + dest: { + amount: calcTokenValue(toTokenAmount?.amount, destAsset?.decimals), + normalizedAmount: toTokenAmount?.amount, + valueInCurrency: toTokenAmount?.valueInCurrency, + usd: toTokenAmount?.usd, + minAmount: calcTokenValue( + minToTokenAmount?.amount, + destAsset?.decimals, + ), + minAmountNormalized: minToTokenAmount?.amount, + minAmountUsd: minToTokenAmount?.usd, + minAmountValueInCurrency: minToTokenAmount?.valueInCurrency, + }, + feeData: { + network: [ + { + amount: calcTokenValue( + gasFee?.total?.amount, + nativeAsset?.decimals, + ), + normalizedAmount: gasFee?.total?.amount, + valueInCurrency: gasFee?.total?.valueInCurrency, + usd: gasFee?.total?.usd, + asset: nativeAsset, + }, + ], + ...(relayerFee?.amount && { + relayer: [ + { + amount: calcTokenValue(relayerFee?.amount, nativeAsset?.decimals), + normalizedAmount: relayerFee?.amount, + valueInCurrency: relayerFee?.valueInCurrency, + usd: relayerFee?.usd, + asset: nativeAsset, + }, + ], + }), + ...(includedTxFees && { + txFee: [ + { + amount: calcTokenValue( + includedTxFees?.amount, + nativeAsset?.decimals, + ), + normalizedAmount: includedTxFees?.amount, + valueInCurrency: includedTxFees?.valueInCurrency, + usd: includedTxFees?.usd, + }, + ], + }), + }, + priceData: { + priceImpact: { + valueInCurrency: + cost?.valueInCurrency ?? priceImpact?.valueInCurrency, + usd: cost?.usd ?? priceImpact?.usd, + }, + adjustedReturn: { + valueInCurrency: adjustedReturn?.valueInCurrency, + usd: adjustedReturn?.usd, + }, + swapRate, + }, + }, + }; +}; diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts new file mode 100644 index 00000000000..8a084919112 --- /dev/null +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -0,0 +1,33 @@ +import { + type, + optional, + nullable, + union, + string, + Infer, +} from '@metamask/superstruct'; + +import { BridgeAssetV2Schema } from './bridge-asset.js'; +import { NumberStringSchema, FloatStringSchema } from './number.js'; + +export const AmountsAndAssetSchema = type({ + /* + * The atomic amount of the asset + * @example "1000000000000000000" + */ + amount: NumberStringSchema, + /* + * The normalized amount of the asset + * @example "1.5" + */ + normalizedAmount: optional(FloatStringSchema), + asset: BridgeAssetV2Schema, + // TODO remove string and fix usd in backend + usd: optional(nullable(union([FloatStringSchema, string()]))), + /* + * The value of the asset in the currency, calculated based on usd value + * @example "0.15" + */ + valueInCurrency: optional(nullable(FloatStringSchema)), +}); +export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/quote-response.test.ts b/packages/bridge-controller/src/validators/quote-response.test.ts new file mode 100644 index 00000000000..5f15b86e29c --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.test.ts @@ -0,0 +1,33 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { validateQuoteResponse } from './quote-response.js'; + +describe('quote-response-v2', () => { + describe('validateQuoteResponse', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + expect(() => + validateQuoteResponse(quoteResponse), + ).toThrowErrorMatchingInlineSnapshot( + `"At path: quote.src -- Expected an object, but received: undefined"`, + ); + }); + + it('should validate a valid quote response', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + expect(validateQuoteResponse(quoteResponse)).toBe(true); + }); + + it('should return false when quote namespace is not supported', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + quoteResponse.quote.src.asset.assetId = `${KnownCaipNamespace.Wallet}:123/token:test`; + expect(validateQuoteResponse(quoteResponse)).toBe(false); + }); + }); +}); diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts new file mode 100644 index 00000000000..9b5b40fcf47 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -0,0 +1,214 @@ +import { + assert, + Infer, + number, + optional, + string, + type, + union, + intersection, + Describe, + nullable, + enums, + literal, + AnyStruct, +} from '@metamask/superstruct'; +import { + CaipChainId, + CaipChainIdStruct, + KnownCaipNamespace, + parseCaipAssetType, + StrictHexStruct, +} from '@metamask/utils'; + +import { FeatureId } from './feature-flags.js'; +import { FloatStringSchema } from './number.js'; +import { QuoteSchemaV2 } from './quote.js'; +import { + BitcoinTradeData, + BitcoinTradeDataSchema, + StellarTradeData, + StellarTradeDataSchema, + TronTradeData, + TronTradeDataSchema, + TxData, + TxDataSchema, +} from './trade.js'; + +const CommonQuoteResponseSchema = type({ + quoteId: optional(string()), + quote: QuoteSchemaV2, + estimatedProcessingTimeInSeconds: number(), + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex: optional(number()), + /** + * Appended to the quote response based on the quote requested featureId + */ + featureId: optional(enums(Object.values(FeatureId))), + /** + * Appended to the quote response based on the quote request nonEvmFeesInNative flag + * + * @deprecated Use network feeData + */ + nonEvmFeesInNative: optional(FloatStringSchema), + /** + * Appended to the quote response based on the quote request l1GasFeesInHexWei flag + * + * @deprecated Use network feeData + */ + l1GasFeesInHexWei: optional(StrictHexStruct), +}); + +const EvmQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Eip155), + chainId: CaipChainIdStruct, + trade: TxDataSchema, + approval: optional(TxDataSchema), + resetApproval: optional(TxDataSchema), + }), +]); + +const TronQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Tron), + chainId: CaipChainIdStruct, + trade: TronTradeDataSchema, + approval: optional(TronTradeDataSchema), + }), +]); + +const SolanaQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Solana), + chainId: CaipChainIdStruct, + trade: string(), + approval: optional(TxDataSchema), + }), +]); + +const BitcoinQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Bip122), + chainId: CaipChainIdStruct, + trade: BitcoinTradeDataSchema, + approval: optional(TxDataSchema), + }), +]); + +const StellarQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Stellar), + chainId: CaipChainIdStruct, + trade: StellarTradeDataSchema, + approval: optional(TxDataSchema), + }), +]); + +export const QuoteResponseSchemaV2 = nullable( + union([ + EvmQuoteResponseSchema, + SolanaQuoteResponseSchema, + TronQuoteResponseSchema, + BitcoinQuoteResponseSchema, + StellarQuoteResponseSchema, + ]), +); + +/** + * This is the V2 QuoteResponse type, including metadata calculated after quote fetch + */ +export type QuoteResponse = Omit< + NonNullable>, + 'trade' | 'approval' | 'resetApproval' +> & + ( + | { + namespace: KnownCaipNamespace.Eip155; + chainId: CaipChainId; + trade: TxData & { + data: string; + }; + approval?: TxData; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; + } + | { + namespace: KnownCaipNamespace.Solana; + chainId: CaipChainId; + trade: string; + approval?: TxData; + } + | { + namespace: KnownCaipNamespace.Tron; + chainId: CaipChainId; + trade: TronTradeData & { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: string; + }; + approval?: TronTradeData; + } + | { + namespace: KnownCaipNamespace.Bip122; + chainId: CaipChainId; + trade: BitcoinTradeData; + approval?: TxData; + } + | { + namespace: KnownCaipNamespace.Stellar; + chainId: CaipChainId; + trade: StellarTradeData; + approval?: TxData; + } + ); +// This ensures the QuoteResponse type is in sync with the QuoteResponseSchemaV2 +const QuoteResponse: Describe = QuoteResponseSchemaV2; + +const NAMESPACE_TO_TRADE_SCHEMA: Record = + { + [KnownCaipNamespace.Eip155]: EvmQuoteResponseSchema, + [KnownCaipNamespace.Tron]: TronQuoteResponseSchema, + [KnownCaipNamespace.Solana]: SolanaQuoteResponseSchema, + [KnownCaipNamespace.Bip122]: BitcoinQuoteResponseSchema, + [KnownCaipNamespace.Stellar]: StellarQuoteResponseSchema, + }; + +export const validateQuoteResponse = ( + quoteResponse: unknown, +): quoteResponse is QuoteResponse => { + // Validate common fields first + assert(quoteResponse, CommonQuoteResponseSchema); + + // Extract the namespace and chainId from the src asset + const { + chain: { namespace: namespaceString }, + chainId, + } = parseCaipAssetType(quoteResponse.quote.src.asset.assetId); + const namespace = namespaceString as QuoteResponse['namespace']; + + if (!NAMESPACE_TO_TRADE_SCHEMA[namespace]) { + return false; + } + + // Validate the trade and approval fields based on the src chain's namespace + assert( + { + ...quoteResponse, + namespace, + chainId, + }, + NAMESPACE_TO_TRADE_SCHEMA[namespace], + ); + return true; +}; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts new file mode 100644 index 00000000000..83e014168ed --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts @@ -0,0 +1,111 @@ +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; +import { ActionTypes } from '../src/validators/step.js'; + +/** + * This is the V2 QuoteResponse + */ +export const mockBridgeQuotesErc20Erc20V2Migration = [ + { + approval: { + chainId: 10, + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 61865, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + value: '0x00', + }, + estimatedProcessingTimeInSeconds: 60, + quote: { + dest: { + amount: '13984280', + asset: { + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + decimals: 6, + name: 'Native USD Coin (POS)', + symbol: 'USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + minAmount: '13700000', + }, + feeData: { + metabridge: [ + { + amount: '0', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + }, + ], + network: [ + { + amount: '150000000', + asset: { + assetId: 'eip155:10/slip44:60', + decimals: 18, + name: 'Ether', + symbol: 'ETH', + }, + usd: undefined, + valueInCurrency: undefined, + }, + ], + }, + gasIncluded: false, + gasIncluded7702: false, + gasSponsored: false, + priceData: {}, + protocols: ['across'], + aggregator: 'socket', + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + slippage: 2, + src: { + amount: '14000000', + asset: { + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + steps: [ + { + action: ActionTypes.BRIDGE, + src: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + dest: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + }, + ], + }, + trade: { + chainId: 10, + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 287227, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + value: '0x038d7ea4c68000', + }, + }, +].map(toQuoteResponseV2); From 06bb2247359dc7038acfe5d726d43b5cda276512 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 10:55:15 -0700 Subject: [PATCH 02/45] Revert "wip" This reverts commit 158b79febfcc79cbf325adb7a690479a9ae3c8ee. --- .../coercers/quote-response-v1-to-v2.test.ts | 192 ------------ .../src/coercers/quote-response-v1-to-v2.ts | 199 ------------ .../coercers/quote-response-v2-to-v1.test.ts | 295 ------------------ .../src/coercers/quote-response-v2-to-v1.ts | 249 --------------- .../src/utils/quote-metadata/merge.test.ts | 58 ---- .../quote-metadata/to-normalized-amounts.ts | 57 ---- .../quote-metadata/to-quote-metadata-v1.ts | 58 ---- .../quote-metadata/to-quote-metadata-v2.ts | 120 ------- .../src/validators/amount-and-asset.ts | 33 -- .../src/validators/quote-response.test.ts | 33 -- .../src/validators/quote-response.ts | 214 ------------- .../mock-quotes-erc20-erc20-migration-v2.ts | 111 ------- 12 files changed, 1619 deletions(-) delete mode 100644 packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts delete mode 100644 packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts delete mode 100644 packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts delete mode 100644 packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts delete mode 100644 packages/bridge-controller/src/utils/quote-metadata/merge.test.ts delete mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts delete mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts delete mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts delete mode 100644 packages/bridge-controller/src/validators/amount-and-asset.ts delete mode 100644 packages/bridge-controller/src/validators/quote-response.test.ts delete mode 100644 packages/bridge-controller/src/validators/quote-response.ts delete mode 100644 packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts 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 deleted file mode 100644 index 95e35336154..00000000000 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -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 }, - }, - includedTxFees: { - amount: undefined, - usd: undefined, - valueInCurrency: undefined, - }, - cost: { - usd: undefined, - valueInCurrency: undefined, - }, - adjustedReturn: { - 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": [ - { - "amount": undefined, - "normalizedAmount": undefined, - "usd": undefined, - "valueInCurrency": 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 deleted file mode 100644 index 95a96eb1cc1..00000000000 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ /dev/null @@ -1,199 +0,0 @@ -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 { - 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, - // @ts-expect-error - chainAgnosticId is not in the schema - chainAgnosticId, - // @ts-expect-error - logoURI is not in the schema - logoURI, - iconUrl, - icon, - assetId, - ...rest - } = value; - - const resolvedIconUrl = iconUrl ?? logoURI ?? 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: unknown): 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 : feeData[FeeType.METABRIDGE].amount) - .toFixed(), - asset: toBridgeAssetV2(srcAsset), - ...(walletAddress && { walletAddress }), - }, - dest: { - amount: destTokenAmount, - asset: toBridgeAssetV2(destAsset), - ...(destWalletAddress && { walletAddress: destWalletAddress }), - minAmount: minDestTokenAmount, - }, - priceData: { - ...(value.priceData?.priceImpact && { - priceImpact: { - amount: value.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] && { - [FeeType.TX_FEE]: [ - { - ...feeData[FeeType.TX_FEE], - asset: toBridgeAssetV2(feeData[FeeType.TX_FEE].asset), - }, - ], - }), - }, - steps: steps?.map(toStepV2), - ...restQuote, - protocols: bridges, - aggregator: bridgeId, - }; -}); - -const toQuoteV2 = (quote: unknown): 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 deleted file mode 100644 index cee15a54f24..00000000000 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -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 deleted file mode 100644 index a1df33ce1ad..00000000000 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ /dev/null @@ -1,249 +0,0 @@ -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 { 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: unknown): 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: unknown): Step => { - const stepV2 = create(step, StepSchemaV1FromV2); - return stepV2; -}; - -const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { - const { - priceData, - feeData, - steps, - protocols, - aggregator, - src, - dest, - ...restQuote - } = value; - - const { chainId: srcChainIdInCaip, assetReference: srcReference } = - parseCaipAssetType(src.asset.assetId); - const { chainId: destChainIdInCaip, assetReference: destReference } = - parseCaipAssetType(dest.asset.assetId); - - const srcChainId = formatChainIdToDec(srcChainIdInCaip); - const destChainId = formatChainIdToDec(destChainIdInCaip); - const srcTokenAddress = formatAddressToCaipReference(srcReference); - const destTokenAddress = formatAddressToCaipReference(destReference); - - const { usd, ...metabridgeFeeData } = feeData[FeeType.METABRIDGE][0]; - - return { - bridges: protocols, - bridgeId: aggregator, - protocols, - aggregator, - srcChainId, - destChainId, - srcAsset: { - ...src.asset, - chainId: srcChainId, - address: srcTokenAddress, - }, - destAsset: { - ...dest.asset, - chainId: destChainId, - address: destTokenAddress, - }, - srcTokenAmount: src.amount, - destTokenAmount: dest.amount, - minDestTokenAmount: dest.minAmount, - feeData: { - [FeeType.METABRIDGE]: { - ...metabridgeFeeData, - asset: { - ...metabridgeFeeData.asset, - chainId: formatChainIdToDec( - parseCaipAssetType(metabridgeFeeData.asset.assetId).chainId, - ), - address: formatAddressToCaipReference( - parseCaipAssetType(metabridgeFeeData.asset.assetId).assetReference, - ), - }, - }, - ...(feeData[FeeType.TX_FEE]?.length && /* istanbul ignore next */ { - ...feeData[FeeType.TX_FEE][0], - asset: 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, - }, - }), - /** - * @deprecated This field is deprecated. - */ - steps: steps?.map(toStepV1), - ...restQuote, - }; -}); - -const toQuoteV1 = (quote: unknown): 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/utils/quote-metadata/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts deleted file mode 100644 index eaa8fab9c33..00000000000 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -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', - quoteResponse: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { - quote: { - feeData: { - relayer: [ - { - amount: '100', - usd: '100', - }, - ], - }, - }, - }), - quoteMetadata: { - relayerFee: { - 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/to-normalized-amounts.ts b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts deleted file mode 100644 index d044777f390..00000000000 --- a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { DeepPartial } from '../../types.js'; -import { QuoteResponse } from '../../validators/quote-response.js'; -import { FeeType } from '../../validators/quote.js'; -import { calcTokenAmount } 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: calcTokenAmount( - src?.amount, - src?.asset?.decimals, - )?.toFixed(), - }, - dest: { - normalizedAmount: calcTokenAmount( - dest?.amount, - dest?.asset?.decimals, - )?.toFixed(), - minAmountNormalized: calcTokenAmount( - dest?.minAmount, - dest?.asset?.decimals, - )?.toFixed(), - }, - feeData: { - network: feeData?.[FeeType.NETWORK]?.map((networkFee) => ({ - normalizedAmount: calcTokenAmount( - networkFee?.amount, - networkFee?.asset?.decimals, - )?.toFixed(), - })), - relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ - normalizedAmount: calcTokenAmount( - relayerFee.amount, - relayerFee.asset?.decimals, - )?.toFixed(), - })), - txFee: feeData?.[FeeType.TX_FEE]?.map((txFee) => ({ - normalizedAmount: calcTokenAmount( - 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 deleted file mode 100644 index 382cb906b1d..00000000000 --- a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts +++ /dev/null @@ -1,58 +0,0 @@ -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, -): QuoteMetadata => { - const { - toTokenAmount, - minToTokenAmount, - sentAmount, - swapRate, - adjustedReturn, - cost, - includedTxFees, - relayerFee, - totalNetworkFee, - gasFee, - priceImpact, - } = quoteResponse; - - const legacyMetadata = { - sentAmount, - toTokenAmount, - minToTokenAmount, - swapRate, - adjustedReturn, - cost, - gasFee, - totalNetworkFee, - priceImpact, - ...(relayerFee && { relayerFee }), - ...(includedTxFees && { includedTxFees }), - }; - - // Phase 1 only uses legacyMetadata - return merge({}, legacyMetadata); - - // TODO Phase 1.5 uses legacyMetadata as fallback - // return merge( - // {}, - // legacyMetadata, - // toQuoteMetadataV1(quoteResponse), - // ); - - // TODO Phase 2 only uses metadata from the API response - // return toQuoteMetadataV1(quoteResponse); -}; 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 deleted file mode 100644 index 554ef444df9..00000000000 --- a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 { calcTokenValue } 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; - - return { - ...rest, - quote: { - src: { - amount: calcTokenValue(sentAmount?.amount, srcAsset?.decimals), - normalizedAmount: sentAmount?.amount, - valueInCurrency: sentAmount?.valueInCurrency, - usd: sentAmount?.usd, - }, - dest: { - amount: calcTokenValue(toTokenAmount?.amount, destAsset?.decimals), - normalizedAmount: toTokenAmount?.amount, - valueInCurrency: toTokenAmount?.valueInCurrency, - usd: toTokenAmount?.usd, - minAmount: calcTokenValue( - minToTokenAmount?.amount, - destAsset?.decimals, - ), - minAmountNormalized: minToTokenAmount?.amount, - minAmountUsd: minToTokenAmount?.usd, - minAmountValueInCurrency: minToTokenAmount?.valueInCurrency, - }, - feeData: { - network: [ - { - amount: calcTokenValue( - gasFee?.total?.amount, - nativeAsset?.decimals, - ), - normalizedAmount: gasFee?.total?.amount, - valueInCurrency: gasFee?.total?.valueInCurrency, - usd: gasFee?.total?.usd, - asset: nativeAsset, - }, - ], - ...(relayerFee?.amount && { - relayer: [ - { - amount: calcTokenValue(relayerFee?.amount, nativeAsset?.decimals), - normalizedAmount: relayerFee?.amount, - valueInCurrency: relayerFee?.valueInCurrency, - usd: relayerFee?.usd, - asset: nativeAsset, - }, - ], - }), - ...(includedTxFees && { - txFee: [ - { - amount: calcTokenValue( - includedTxFees?.amount, - nativeAsset?.decimals, - ), - normalizedAmount: includedTxFees?.amount, - valueInCurrency: includedTxFees?.valueInCurrency, - usd: includedTxFees?.usd, - }, - ], - }), - }, - priceData: { - priceImpact: { - valueInCurrency: - cost?.valueInCurrency ?? priceImpact?.valueInCurrency, - usd: cost?.usd ?? priceImpact?.usd, - }, - adjustedReturn: { - valueInCurrency: adjustedReturn?.valueInCurrency, - usd: adjustedReturn?.usd, - }, - swapRate, - }, - }, - }; -}; diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts deleted file mode 100644 index 8a084919112..00000000000 --- a/packages/bridge-controller/src/validators/amount-and-asset.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - type, - optional, - nullable, - union, - string, - Infer, -} from '@metamask/superstruct'; - -import { BridgeAssetV2Schema } from './bridge-asset.js'; -import { NumberStringSchema, FloatStringSchema } from './number.js'; - -export const AmountsAndAssetSchema = type({ - /* - * The atomic amount of the asset - * @example "1000000000000000000" - */ - amount: NumberStringSchema, - /* - * The normalized amount of the asset - * @example "1.5" - */ - normalizedAmount: optional(FloatStringSchema), - asset: BridgeAssetV2Schema, - // TODO remove string and fix usd in backend - usd: optional(nullable(union([FloatStringSchema, string()]))), - /* - * The value of the asset in the currency, calculated based on usd value - * @example "0.15" - */ - valueInCurrency: optional(nullable(FloatStringSchema)), -}); -export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/quote-response.test.ts b/packages/bridge-controller/src/validators/quote-response.test.ts deleted file mode 100644 index 5f15b86e29c..00000000000 --- a/packages/bridge-controller/src/validators/quote-response.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { KnownCaipNamespace } from '@metamask/utils'; - -import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; -import { validateQuoteResponse } from './quote-response.js'; - -describe('quote-response-v2', () => { - describe('validateQuoteResponse', () => { - it('should return a validation error for an invalid quote response', () => { - const quoteResponse = { - quote: { - requestId: '123', - }, - }; - - expect(() => - validateQuoteResponse(quoteResponse), - ).toThrowErrorMatchingInlineSnapshot( - `"At path: quote.src -- Expected an object, but received: undefined"`, - ); - }); - - it('should validate a valid quote response', () => { - const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; - expect(validateQuoteResponse(quoteResponse)).toBe(true); - }); - - it('should return false when quote namespace is not supported', () => { - const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; - quoteResponse.quote.src.asset.assetId = `${KnownCaipNamespace.Wallet}:123/token:test`; - expect(validateQuoteResponse(quoteResponse)).toBe(false); - }); - }); -}); diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts deleted file mode 100644 index 9b5b40fcf47..00000000000 --- a/packages/bridge-controller/src/validators/quote-response.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { - assert, - Infer, - number, - optional, - string, - type, - union, - intersection, - Describe, - nullable, - enums, - literal, - AnyStruct, -} from '@metamask/superstruct'; -import { - CaipChainId, - CaipChainIdStruct, - KnownCaipNamespace, - parseCaipAssetType, - StrictHexStruct, -} from '@metamask/utils'; - -import { FeatureId } from './feature-flags.js'; -import { FloatStringSchema } from './number.js'; -import { QuoteSchemaV2 } from './quote.js'; -import { - BitcoinTradeData, - BitcoinTradeDataSchema, - StellarTradeData, - StellarTradeDataSchema, - TronTradeData, - TronTradeDataSchema, - TxData, - TxDataSchema, -} from './trade.js'; - -const CommonQuoteResponseSchema = type({ - quoteId: optional(string()), - quote: QuoteSchemaV2, - estimatedProcessingTimeInSeconds: number(), - /** - * Appended to the quote if there are multiple quote requests in a batch. This - * indicates which quoteRequest the quote is for - */ - quoteRequestIndex: optional(number()), - /** - * Appended to the quote response based on the quote requested featureId - */ - featureId: optional(enums(Object.values(FeatureId))), - /** - * Appended to the quote response based on the quote request nonEvmFeesInNative flag - * - * @deprecated Use network feeData - */ - nonEvmFeesInNative: optional(FloatStringSchema), - /** - * Appended to the quote response based on the quote request l1GasFeesInHexWei flag - * - * @deprecated Use network feeData - */ - l1GasFeesInHexWei: optional(StrictHexStruct), -}); - -const EvmQuoteResponseSchema = intersection([ - CommonQuoteResponseSchema, - type({ - namespace: literal(KnownCaipNamespace.Eip155), - chainId: CaipChainIdStruct, - trade: TxDataSchema, - approval: optional(TxDataSchema), - resetApproval: optional(TxDataSchema), - }), -]); - -const TronQuoteResponseSchema = intersection([ - CommonQuoteResponseSchema, - type({ - namespace: literal(KnownCaipNamespace.Tron), - chainId: CaipChainIdStruct, - trade: TronTradeDataSchema, - approval: optional(TronTradeDataSchema), - }), -]); - -const SolanaQuoteResponseSchema = intersection([ - CommonQuoteResponseSchema, - type({ - namespace: literal(KnownCaipNamespace.Solana), - chainId: CaipChainIdStruct, - trade: string(), - approval: optional(TxDataSchema), - }), -]); - -const BitcoinQuoteResponseSchema = intersection([ - CommonQuoteResponseSchema, - type({ - namespace: literal(KnownCaipNamespace.Bip122), - chainId: CaipChainIdStruct, - trade: BitcoinTradeDataSchema, - approval: optional(TxDataSchema), - }), -]); - -const StellarQuoteResponseSchema = intersection([ - CommonQuoteResponseSchema, - type({ - namespace: literal(KnownCaipNamespace.Stellar), - chainId: CaipChainIdStruct, - trade: StellarTradeDataSchema, - approval: optional(TxDataSchema), - }), -]); - -export const QuoteResponseSchemaV2 = nullable( - union([ - EvmQuoteResponseSchema, - SolanaQuoteResponseSchema, - TronQuoteResponseSchema, - BitcoinQuoteResponseSchema, - StellarQuoteResponseSchema, - ]), -); - -/** - * This is the V2 QuoteResponse type, including metadata calculated after quote fetch - */ -export type QuoteResponse = Omit< - NonNullable>, - 'trade' | 'approval' | 'resetApproval' -> & - ( - | { - namespace: KnownCaipNamespace.Eip155; - chainId: CaipChainId; - trade: TxData & { - data: string; - }; - approval?: TxData; - /** - * Appended to the quote response based on the quote request resetApproval flag - * If defined, the quote's total network fee will include the reset approval's gas limit. - */ - resetApproval?: TxData; - } - | { - namespace: KnownCaipNamespace.Solana; - chainId: CaipChainId; - trade: string; - approval?: TxData; - } - | { - namespace: KnownCaipNamespace.Tron; - chainId: CaipChainId; - trade: TronTradeData & { - // eslint-disable-next-line @typescript-eslint/naming-convention - raw_data_hex: string; - }; - approval?: TronTradeData; - } - | { - namespace: KnownCaipNamespace.Bip122; - chainId: CaipChainId; - trade: BitcoinTradeData; - approval?: TxData; - } - | { - namespace: KnownCaipNamespace.Stellar; - chainId: CaipChainId; - trade: StellarTradeData; - approval?: TxData; - } - ); -// This ensures the QuoteResponse type is in sync with the QuoteResponseSchemaV2 -const QuoteResponse: Describe = QuoteResponseSchemaV2; - -const NAMESPACE_TO_TRADE_SCHEMA: Record = - { - [KnownCaipNamespace.Eip155]: EvmQuoteResponseSchema, - [KnownCaipNamespace.Tron]: TronQuoteResponseSchema, - [KnownCaipNamespace.Solana]: SolanaQuoteResponseSchema, - [KnownCaipNamespace.Bip122]: BitcoinQuoteResponseSchema, - [KnownCaipNamespace.Stellar]: StellarQuoteResponseSchema, - }; - -export const validateQuoteResponse = ( - quoteResponse: unknown, -): quoteResponse is QuoteResponse => { - // Validate common fields first - assert(quoteResponse, CommonQuoteResponseSchema); - - // Extract the namespace and chainId from the src asset - const { - chain: { namespace: namespaceString }, - chainId, - } = parseCaipAssetType(quoteResponse.quote.src.asset.assetId); - const namespace = namespaceString as QuoteResponse['namespace']; - - if (!NAMESPACE_TO_TRADE_SCHEMA[namespace]) { - return false; - } - - // Validate the trade and approval fields based on the src chain's namespace - assert( - { - ...quoteResponse, - namespace, - chainId, - }, - NAMESPACE_TO_TRADE_SCHEMA[namespace], - ); - return true; -}; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts deleted file mode 100644 index 83e014168ed..00000000000 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; -import { ActionTypes } from '../src/validators/step.js'; - -/** - * This is the V2 QuoteResponse - */ -export const mockBridgeQuotesErc20Erc20V2Migration = [ - { - approval: { - chainId: 10, - data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', - from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', - gasLimit: 61865, - to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', - value: '0x00', - }, - estimatedProcessingTimeInSeconds: 60, - quote: { - dest: { - amount: '13984280', - asset: { - assetId: - 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', - decimals: 6, - name: 'Native USD Coin (POS)', - symbol: 'USDC', - iconUrl: 'https://media.socket.tech/tokens/all/USDC', - }, - minAmount: '13700000', - }, - feeData: { - metabridge: [ - { - amount: '0', - asset: { - assetId: - 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - }, - }, - ], - network: [ - { - amount: '150000000', - asset: { - assetId: 'eip155:10/slip44:60', - decimals: 18, - name: 'Ether', - symbol: 'ETH', - }, - usd: undefined, - valueInCurrency: undefined, - }, - ], - }, - gasIncluded: false, - gasIncluded7702: false, - gasSponsored: false, - priceData: {}, - protocols: ['across'], - aggregator: 'socket', - requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', - slippage: 2, - src: { - amount: '14000000', - asset: { - assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', - decimals: 6, - name: 'USD Coin', - symbol: 'USDC', - iconUrl: 'https://media.socket.tech/tokens/all/USDC', - }, - }, - steps: [ - { - action: ActionTypes.BRIDGE, - src: { - asset: { - symbol: 'USDC', - assetId: - 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', - name: 'USD Coin', - decimals: 6, - iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', - }, - }, - dest: { - asset: { - symbol: 'USDC', - assetId: - 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', - name: 'Native USD Coin (POS)', - decimals: 6, - iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', - }, - }, - }, - ], - }, - trade: { - chainId: 10, - data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', - from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', - gasLimit: 287227, - to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', - value: '0x038d7ea4c68000', - }, - }, -].map(toQuoteResponseV2); From 3397c5026a31a955aeb1c0ca3ce7e074689bc625 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 11:17:24 -0700 Subject: [PATCH 03/45] feat: v2 schema schema --- packages/bridge-controller/CHANGELOG.md | 6 + .../bridge-controller.sse.batch.test.ts.snap | 4 +- packages/bridge-controller/src/index.ts | 11 +- packages/bridge-controller/src/types.ts | 7 - .../bridge-controller/src/utils/bridge.ts | 7 +- .../src/utils/caip-formatters.ts | 3 - packages/bridge-controller/src/utils/fetch.ts | 2 +- .../src/utils/quote-metadata/calculators.ts | 2 +- .../src/validators/amount-and-asset.ts | 33 +++ .../src/validators/bridge-asset.ts | 92 ++++++++ .../src/validators/quote-response.test.ts | 33 +++ .../src/validators/quote-response.ts | 214 ++++++++++++++++++ .../bridge-controller/src/validators/quote.ts | 83 ++++++- .../bridge-controller/src/validators/step.ts | 9 +- .../mock-quotes-erc20-erc20-migration-v2.ts | 114 ++++++++++ .../tests/mock-quotes-erc20-erc20.ts | 6 +- 16 files changed, 600 insertions(+), 26 deletions(-) create mode 100644 packages/bridge-controller/src/validators/amount-and-asset.ts create mode 100644 packages/bridge-controller/src/validators/quote-response.test.ts create mode 100644 packages/bridge-controller/src/validators/quote-response.ts create mode 100644 packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 4315bd64365..e7f027d35d3 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Define `QuoteResponse` v2 ([#9085](https://github.com/MetaMask/core/pull/9085)) + - `validateQuoteResponse` validates data +- Export `BridgeAsset` and `validateBridgeAsset`, used by QuoteResponse v2, and token endpoints ([#9085](https://github.com/MetaMask/core/pull/9085)) + ### Changed - Bump `@metamask/assets-controllers` from `^110.0.0` to `^110.0.1` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706)) 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 a3e3ffbe8c1..157a704c069 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,13 +28,13 @@ exports[`BridgeController BatchSell (multiple quote requests) SSE fetch quotes s }, { "destChainId": "137", - "destTokenAddress": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "destTokenAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", "destWalletAddress": "SolanaWalletAddres1234", "insufficientBal": false, "resetApproval": false, "slippage": 0.5, "srcChainId": "10", - "srcTokenAddress": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", + "srcTokenAddress": "0x0b2c639c533813f4aa9d7837caf62653d097ff85", "srcTokenAmount": "1000000000000000000", "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", }, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index b5216dc7e2f..a8555ced3c8 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -43,7 +43,6 @@ export type { NonEvmFees, GasMultiplierByChainId, FeatureFlagResponse, - BridgeAsset, GenericQuoteRequest, BatchSellTradesResponse, GaslessProperties, @@ -118,6 +117,7 @@ export type { QuoteResponseV1 as QuoteResponse, QuoteResponseV1, } from './validators/quote-response-v1.js'; +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'; @@ -127,9 +127,18 @@ export { } from './validators/quote-stream-complete.js'; export { BatchSellTransactionType } from './validators/batch-sell.js'; export { TokenFeatureType } from './validators/token-feature.js'; +export type { + BridgeAsset, + BridgeAssetV2, + MinimalAsset, +} from './validators/bridge-asset.js'; export { BridgeAssetSchema, validateBridgeAsset, + validateBridgeAssetV2, + MinimalAssetSchema, + BridgeAssetV2Schema, + BridgeAssetSecurityDataType, } from './validators/bridge-asset.js'; export { FeatureId } from './validators/feature-flags.js'; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index b4fb0ea4212..23e1421c4b8 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -28,7 +28,6 @@ import type { BridgeController } from './bridge-controller.js'; import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge.js'; import type { SimulatedGasFeeLimitsSchema } from './validators/batch-sell.js'; import type { BatchSellTradesResponseSchema } from './validators/batch-sell.js'; -import type { BridgeAssetSchema } from './validators/bridge-asset.js'; import type { ChainConfigurationSchema, ChainRankingSchema, @@ -99,12 +98,6 @@ export enum SortOrder { ETA_ASC = 'time_descending', } -/** - * This is the interface for the asset object returned by the bridge-api - * This type is used in the QuoteResponse and in the fetchBridgeTokens response - */ -export type BridgeAsset = Infer; - /** * This is the interface for the token object used in the extension client * In addition to the {@link BridgeAsset} fields, it includes balance information diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index 821cf883043..cc4fb259300 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -18,12 +18,9 @@ import { SYMBOL_TO_SLIP44_MAP, } from '../constants/tokens.js'; import type { SupportedSwapsNativeCurrencySymbols } from '../constants/tokens.js'; -import type { - BridgeAsset, - BridgeControllerState, - GenericQuoteRequest, -} from '../types.js'; +import type { BridgeControllerState, GenericQuoteRequest } from '../types.js'; import { ChainId } from '../types.js'; +import type { BridgeAsset } from '../validators/bridge-asset.js'; import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; import type { TxData } from '../validators/trade.js'; import { diff --git a/packages/bridge-controller/src/utils/caip-formatters.ts b/packages/bridge-controller/src/utils/caip-formatters.ts index c3626eb79c2..377b731540d 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -54,9 +54,6 @@ export const formatChainIdToCaip = ( return TrxScope.Mainnet; } if (isStellarChainId(chainId)) { - if (chainId === XlmScope.Testnet) { - return XlmScope.Testnet; - } return XlmScope.Pubnet; } return toEvmCaipChainId(numberToHex(Number(chainId))); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index d8f60476941..9fca0814ef5 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -6,13 +6,13 @@ import type { FetchFunction, GenericQuoteRequest, QuoteRequest, - BridgeAsset, TokenFeature, QuoteStreamCompleteData, BatchSellTradesRequest, BatchSellTradesResponse, } from '../types.js'; import { validateBatchSellTradesResponse } from '../validators/batch-sell.js'; +import type { BridgeAsset } from '../validators/bridge-asset.js'; import { validateBridgeAsset } from '../validators/bridge-asset.js'; import type { FeatureId } from '../validators/feature-flags.js'; import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index 5aa42e24a23..eaaf762c7e3 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -12,8 +12,8 @@ import type { ExchangeRate, NonEvmFees, DeepPartial, - BridgeAsset, } from '../../types.js'; +import type { BridgeAsset } from '../../validators/bridge-asset.js'; import { FloatStringSchema } from '../../validators/number.js'; import type { QuoteResponseV1 as QuoteResponse } from '../../validators/quote-response-v1.js'; import { TxData } from '../../validators/trade.js'; diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts new file mode 100644 index 00000000000..8a084919112 --- /dev/null +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -0,0 +1,33 @@ +import { + type, + optional, + nullable, + union, + string, + Infer, +} from '@metamask/superstruct'; + +import { BridgeAssetV2Schema } from './bridge-asset.js'; +import { NumberStringSchema, FloatStringSchema } from './number.js'; + +export const AmountsAndAssetSchema = type({ + /* + * The atomic amount of the asset + * @example "1000000000000000000" + */ + amount: NumberStringSchema, + /* + * The normalized amount of the asset + * @example "1.5" + */ + normalizedAmount: optional(FloatStringSchema), + asset: BridgeAssetV2Schema, + // TODO remove string and fix usd in backend + usd: optional(nullable(union([FloatStringSchema, string()]))), + /* + * The value of the asset in the currency, calculated based on usd value + * @example "0.15" + */ + valueInCurrency: optional(nullable(FloatStringSchema)), +}); +export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/bridge-asset.ts b/packages/bridge-controller/src/validators/bridge-asset.ts index 0e8092a691b..686b23dc9f3 100644 --- a/packages/bridge-controller/src/validators/bridge-asset.ts +++ b/packages/bridge-controller/src/validators/bridge-asset.ts @@ -5,12 +5,81 @@ import { optional, nullable, is, + intersection, + enums, + array, + boolean, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { CaipAssetTypeStruct } from '@metamask/utils'; export const ChainIdSchema = number(); +export const MinimalAssetSchema = type({ + /** + * Case-sensitive for non-EVM chains, case-insensitive for EVM chains + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of token object + */ + symbol: string(), + /** + * The name for the network + */ + name: string(), + decimals: number(), +}); + +export type MinimalAsset = Infer; + +export enum BridgeAssetSecurityDataType { + INFO = 'Info', + BENIGN = 'Benign', + VERIFIED = 'Verified', + WARNING = 'Warning', + SPAM = 'Spam', + MALICIOUS = 'Malicious', +} + +const BridgeAssetSecurityData = type({ + isVerified: optional(boolean()), + securityData: optional( + type({ + type: enums(Object.values(BridgeAssetSecurityDataType)), + metadata: optional( + type({ + features: array( + type({ + featureId: string(), + type: enums(Object.values(BridgeAssetSecurityDataType)), + description: string(), + }), + ), + }), + ), + }), + ), +}); + +export const BridgeAssetV2Schema = intersection([ + MinimalAssetSchema, + BridgeAssetSecurityData, + type({ + /** + * URL for token icon + */ + iconUrl: nullable(optional(string())), + noFee: optional( + type({ + isDestination: nullable(optional(boolean())), + isSource: nullable(optional(boolean())), + }), + ), + }), +]); +export type BridgeAssetV2 = Infer; + export const BridgeAssetSchema = type({ /** * The chainId of the token @@ -43,8 +112,31 @@ export const BridgeAssetSchema = type({ iconUrl: optional(nullable(string())), }); +/** + * This is the interface for the asset object returned by the bridge-api + * This type is used in the QuoteResponse and in the fetchBridgeTokens response + * + * @deprecated Avoid introducing new code that uses this function. Use BridgeAssetV2 instead + */ +export type BridgeAsset = Infer; + +/** + * Validates a token object from the bridge-api + * + * @deprecated Avoid introducing new code that uses this function. Use BridgeAssetV2 instead + * + * @param data - The data to validate + * @returns Whether the data satisfies the {@link BridgeAssetSchema} + */ export const validateBridgeAsset = ( data: unknown, ): data is Infer => { return is(data, BridgeAssetSchema); }; + +/* istanbul ignore next */ +export const validateBridgeAssetV2 = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetV2Schema); +}; diff --git a/packages/bridge-controller/src/validators/quote-response.test.ts b/packages/bridge-controller/src/validators/quote-response.test.ts new file mode 100644 index 00000000000..5f15b86e29c --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.test.ts @@ -0,0 +1,33 @@ +import { KnownCaipNamespace } from '@metamask/utils'; + +import { mockBridgeQuotesErc20Erc20V2Migration } from '../../tests/mock-quotes-erc20-erc20-migration-v2.js'; +import { validateQuoteResponse } from './quote-response.js'; + +describe('quote-response-v2', () => { + describe('validateQuoteResponse', () => { + it('should return a validation error for an invalid quote response', () => { + const quoteResponse = { + quote: { + requestId: '123', + }, + }; + + expect(() => + validateQuoteResponse(quoteResponse), + ).toThrowErrorMatchingInlineSnapshot( + `"At path: quote.src -- Expected an object, but received: undefined"`, + ); + }); + + it('should validate a valid quote response', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + expect(validateQuoteResponse(quoteResponse)).toBe(true); + }); + + it('should return false when quote namespace is not supported', () => { + const quoteResponse = mockBridgeQuotesErc20Erc20V2Migration[0]; + quoteResponse.quote.src.asset.assetId = `${KnownCaipNamespace.Wallet}:123/token:test`; + expect(validateQuoteResponse(quoteResponse)).toBe(false); + }); + }); +}); diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts new file mode 100644 index 00000000000..9b5b40fcf47 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -0,0 +1,214 @@ +import { + assert, + Infer, + number, + optional, + string, + type, + union, + intersection, + Describe, + nullable, + enums, + literal, + AnyStruct, +} from '@metamask/superstruct'; +import { + CaipChainId, + CaipChainIdStruct, + KnownCaipNamespace, + parseCaipAssetType, + StrictHexStruct, +} from '@metamask/utils'; + +import { FeatureId } from './feature-flags.js'; +import { FloatStringSchema } from './number.js'; +import { QuoteSchemaV2 } from './quote.js'; +import { + BitcoinTradeData, + BitcoinTradeDataSchema, + StellarTradeData, + StellarTradeDataSchema, + TronTradeData, + TronTradeDataSchema, + TxData, + TxDataSchema, +} from './trade.js'; + +const CommonQuoteResponseSchema = type({ + quoteId: optional(string()), + quote: QuoteSchemaV2, + estimatedProcessingTimeInSeconds: number(), + /** + * Appended to the quote if there are multiple quote requests in a batch. This + * indicates which quoteRequest the quote is for + */ + quoteRequestIndex: optional(number()), + /** + * Appended to the quote response based on the quote requested featureId + */ + featureId: optional(enums(Object.values(FeatureId))), + /** + * Appended to the quote response based on the quote request nonEvmFeesInNative flag + * + * @deprecated Use network feeData + */ + nonEvmFeesInNative: optional(FloatStringSchema), + /** + * Appended to the quote response based on the quote request l1GasFeesInHexWei flag + * + * @deprecated Use network feeData + */ + l1GasFeesInHexWei: optional(StrictHexStruct), +}); + +const EvmQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Eip155), + chainId: CaipChainIdStruct, + trade: TxDataSchema, + approval: optional(TxDataSchema), + resetApproval: optional(TxDataSchema), + }), +]); + +const TronQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Tron), + chainId: CaipChainIdStruct, + trade: TronTradeDataSchema, + approval: optional(TronTradeDataSchema), + }), +]); + +const SolanaQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Solana), + chainId: CaipChainIdStruct, + trade: string(), + approval: optional(TxDataSchema), + }), +]); + +const BitcoinQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Bip122), + chainId: CaipChainIdStruct, + trade: BitcoinTradeDataSchema, + approval: optional(TxDataSchema), + }), +]); + +const StellarQuoteResponseSchema = intersection([ + CommonQuoteResponseSchema, + type({ + namespace: literal(KnownCaipNamespace.Stellar), + chainId: CaipChainIdStruct, + trade: StellarTradeDataSchema, + approval: optional(TxDataSchema), + }), +]); + +export const QuoteResponseSchemaV2 = nullable( + union([ + EvmQuoteResponseSchema, + SolanaQuoteResponseSchema, + TronQuoteResponseSchema, + BitcoinQuoteResponseSchema, + StellarQuoteResponseSchema, + ]), +); + +/** + * This is the V2 QuoteResponse type, including metadata calculated after quote fetch + */ +export type QuoteResponse = Omit< + NonNullable>, + 'trade' | 'approval' | 'resetApproval' +> & + ( + | { + namespace: KnownCaipNamespace.Eip155; + chainId: CaipChainId; + trade: TxData & { + data: string; + }; + approval?: TxData; + /** + * Appended to the quote response based on the quote request resetApproval flag + * If defined, the quote's total network fee will include the reset approval's gas limit. + */ + resetApproval?: TxData; + } + | { + namespace: KnownCaipNamespace.Solana; + chainId: CaipChainId; + trade: string; + approval?: TxData; + } + | { + namespace: KnownCaipNamespace.Tron; + chainId: CaipChainId; + trade: TronTradeData & { + // eslint-disable-next-line @typescript-eslint/naming-convention + raw_data_hex: string; + }; + approval?: TronTradeData; + } + | { + namespace: KnownCaipNamespace.Bip122; + chainId: CaipChainId; + trade: BitcoinTradeData; + approval?: TxData; + } + | { + namespace: KnownCaipNamespace.Stellar; + chainId: CaipChainId; + trade: StellarTradeData; + approval?: TxData; + } + ); +// This ensures the QuoteResponse type is in sync with the QuoteResponseSchemaV2 +const QuoteResponse: Describe = QuoteResponseSchemaV2; + +const NAMESPACE_TO_TRADE_SCHEMA: Record = + { + [KnownCaipNamespace.Eip155]: EvmQuoteResponseSchema, + [KnownCaipNamespace.Tron]: TronQuoteResponseSchema, + [KnownCaipNamespace.Solana]: SolanaQuoteResponseSchema, + [KnownCaipNamespace.Bip122]: BitcoinQuoteResponseSchema, + [KnownCaipNamespace.Stellar]: StellarQuoteResponseSchema, + }; + +export const validateQuoteResponse = ( + quoteResponse: unknown, +): quoteResponse is QuoteResponse => { + // Validate common fields first + assert(quoteResponse, CommonQuoteResponseSchema); + + // Extract the namespace and chainId from the src asset + const { + chain: { namespace: namespaceString }, + chainId, + } = parseCaipAssetType(quoteResponse.quote.src.asset.assetId); + const namespace = namespaceString as QuoteResponse['namespace']; + + if (!NAMESPACE_TO_TRADE_SCHEMA[namespace]) { + return false; + } + + // Validate the trade and approval fields based on the src chain's namespace + assert( + { + ...quoteResponse, + namespace, + chainId, + }, + NAMESPACE_TO_TRADE_SCHEMA[namespace], + ); + return true; +}; diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index 66bd8106497..5b07a7e8581 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -10,10 +10,15 @@ import { } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import { AmountsAndAssetSchema } from './amount-and-asset.js'; import { ChainIdSchema, BridgeAssetSchema } from './bridge-asset.js'; import { IntentSchema } from './intent.js'; -import { TruthyDigitStringSchema, NumberStringSchema } from './number.js'; -import { RefuelDataSchema, StepSchema } from './step.js'; +import { + TruthyDigitStringSchema, + NumberStringSchema, + FloatStringSchema, +} from './number.js'; +import { RefuelDataSchema, StepSchema, StepSchemaV2 } from './step.js'; export enum FeeType { METABRIDGE = 'metabridge', @@ -114,3 +119,77 @@ export const QuoteSchema = intersection([ ]); export type Quote = Infer; + +export const QuoteSchemaV2 = intersection([ + GaslessPropertiesSchema, + type({ + requestId: string(), + src: intersection([ + AmountsAndAssetSchema, + type({ + walletAddress: optional(string()), + }), + ]), + dest: intersection([ + AmountsAndAssetSchema, + type({ + minAmount: optional(string()), + minAmountUsd: optional(string()), + minAmountValueInCurrency: optional(string()), + minAmountNormalized: optional(string()), + walletAddress: optional(string()), + }), + ]), + priceData: optional( + type({ + swapRate: optional(FloatStringSchema), + priceImpact: optional( + type({ + usd: optional(nullable(FloatStringSchema)), + amount: optional(nullable(FloatStringSchema)), + valueInCurrency: optional(FloatStringSchema), + }), + ), + adjustedReturn: optional( + type({ + usd: nullable(optional(FloatStringSchema)), + valueInCurrency: nullable(optional(FloatStringSchema)), + }), + ), + }), + ), + feeData: type({ + [FeeType.METABRIDGE]: array( + intersection([ + AmountsAndAssetSchema, + type({ + quoteBpsFee: optional(number()), + baseBpsFee: optional(number()), + discountType: optional(nullable(string())), + }), + ]), + ), + [FeeType.REFUEL]: optional(array(AmountsAndAssetSchema)), + /** + * The tx fees included in the quote for gasless execution + */ + [FeeType.TX_FEE]: optional( + array(intersection([AmountsAndAssetSchema, TxFeeGasLimitsSchema])), + ), + /** + * The gas fees for the quote, excluding any provider or relayer fees + */ + [FeeType.NETWORK]: optional(array(AmountsAndAssetSchema)), + /** + * The relayer or provider fees for the quote, + */ + [FeeType.RELAYER]: optional(array(AmountsAndAssetSchema)), + }), + aggregator: string(), + protocols: array(string()), + steps: optional(array(StepSchemaV2)), + refuel: optional(StepSchema), + intent: optional(IntentSchema), + slippage: optional(number()), + }), +]); diff --git a/packages/bridge-controller/src/validators/step.ts b/packages/bridge-controller/src/validators/step.ts index cf3fff44b72..64b185fca76 100644 --- a/packages/bridge-controller/src/validators/step.ts +++ b/packages/bridge-controller/src/validators/step.ts @@ -1,5 +1,6 @@ -import { type, enums, optional } from '@metamask/superstruct'; +import { type, enums, optional, pick } from '@metamask/superstruct'; +import { AmountsAndAssetSchema } from './amount-and-asset.js'; import { BridgeAssetSchema, ChainIdSchema } from './bridge-asset.js'; export enum ActionTypes { @@ -17,3 +18,9 @@ export const StepSchema = type({ }); export const RefuelDataSchema = StepSchema; + +export const StepSchemaV2 = type({ + action: enums(Object.values(ActionTypes)), + src: pick(AmountsAndAssetSchema, ['asset']), + dest: pick(AmountsAndAssetSchema, ['asset']), +}); diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts new file mode 100644 index 00000000000..1befe07bd44 --- /dev/null +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20-migration-v2.ts @@ -0,0 +1,114 @@ +import type { QuoteResponse } from '../src/validators/quote-response.js'; +import { ActionTypes } from '../src/validators/step.js'; + +/** + * This is the V2 QuoteResponse + */ +export const mockBridgeQuotesErc20Erc20V2Migration: Omit< + QuoteResponse, + 'chainId' | 'namespace' +>[] = [ + { + approval: { + chainId: 10, + data: '0x095ea7b3000000000000000000000000b90357f2b86dbfd59c3502215d4060f71df8ca0e0000000000000000000000000000000000000000000000000000000000d59f80', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 61865, + to: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', + value: '0x00', + }, + estimatedProcessingTimeInSeconds: 60, + quote: { + dest: { + amount: '13984280', + asset: { + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + decimals: 6, + name: 'Native USD Coin (POS)', + symbol: 'USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + minAmount: '13700000', + }, + feeData: { + metabridge: [ + { + amount: '0', + asset: { + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + }, + }, + ], + network: [ + { + amount: '150000000', + asset: { + assetId: 'eip155:10/slip44:60', + decimals: 18, + name: 'Ether', + symbol: 'ETH', + }, + usd: undefined, + valueInCurrency: undefined, + }, + ], + }, + gasIncluded: false, + gasIncluded7702: false, + gasSponsored: false, + priceData: {}, + protocols: ['across'], + aggregator: 'socket', + requestId: '90ae8e69-f03a-4cf6-bab7-ed4e3431eb37', + slippage: 2, + src: { + amount: '14000000', + asset: { + assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + decimals: 6, + name: 'USD Coin', + symbol: 'USDC', + iconUrl: 'https://media.socket.tech/tokens/all/USDC', + }, + }, + steps: [ + { + action: ActionTypes.BRIDGE, + src: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + name: 'USD Coin', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + dest: { + asset: { + symbol: 'USDC', + assetId: + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', + name: 'Native USD Coin (POS)', + decimals: 6, + iconUrl: 'https://assets.polygon.technology/tokenAssets/usdc.svg', + }, + }, + }, + ], + }, + trade: { + chainId: 10, + data: '0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + gasLimit: 287227, + to: '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e', + value: '0x038d7ea4c68000', + }, + }, +]; diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts index cf705916861..2f1fd15ad5e 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -18,7 +18,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ srcChainId: 10, srcAsset: { chainId: 10, - address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', symbol: 'USDC', name: 'USD Coin', @@ -29,7 +29,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ destChainId: 137, destAsset: { chainId: 137, - address: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', + address: '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', assetId: 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359', symbol: 'USDC', name: 'Native USD Coin (POS)', @@ -43,7 +43,7 @@ export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ amount: '0', asset: { chainId: 10, - address: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', + address: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', assetId: 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', symbol: 'USDC', From 267d7f3725c194512d5f5917e9577686b0d88e86 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 12:06:14 -0700 Subject: [PATCH 04/45] feat: V2 coercers coercers sumAmounts --- packages/bridge-controller/CHANGELOG.md | 4 + .../coercers/quote-response-v1-to-v2.test.ts | 171 ++++++++++ .../src/coercers/quote-response-v1-to-v2.ts | 207 ++++++++++++ .../coercers/quote-response-v2-to-v1.test.ts | 295 ++++++++++++++++++ .../src/coercers/quote-response-v2-to-v1.ts | 231 ++++++++++++++ packages/bridge-controller/src/index.ts | 6 + .../bridge-controller/src/selectors.test.ts | 57 +--- packages/bridge-controller/src/selectors.ts | 16 +- .../src/utils/number-formatters.ts | 83 +++++ .../src/utils/quote-metadata/merge.test.ts | 58 ++++ .../src/utils/quote-metadata/merge.ts | 41 ++- .../quote-metadata/to-normalized-amounts.ts | 57 ++++ .../quote-metadata/to-quote-metadata-v1.ts | 62 ++++ .../quote-metadata/to-quote-metadata-v2.ts | 136 ++++++++ .../tests/mock-quotes-erc20-erc20.ts | 8 + 15 files changed, 1363 insertions(+), 69 deletions(-) create mode 100644 packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts create mode 100644 packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts create mode 100644 packages/bridge-controller/src/coercers/quote-response-v2-to-v1.test.ts create mode 100644 packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/merge.test.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts create mode 100644 packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index e7f027d35d3..4b8fc861fd6 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Define `QuoteResponse` v2 ([#9085](https://github.com/MetaMask/core/pull/9085)) - `validateQuoteResponse` validates data - Export `BridgeAsset` and `validateBridgeAsset`, used by QuoteResponse v2, and token endpoints ([#9085](https://github.com/MetaMask/core/pull/9085)) +- Implement coercers between `QuoteResponse` v1 and v2 ([#9085](https://github.com/MetaMask/core/pull/9085)) + - `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 human-readable values ### Changed 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..f077653ad4f --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -0,0 +1,171 @@ +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, + }, + ], + } + `); + + 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..fbccfb049b3 --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -0,0 +1,207 @@ +import { create, coerce, Infer, is, intersection } from '@metamask/superstruct'; +import { parseCaipAssetType } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { sumAmounts } from '../utils/number-formatters.js'; +import { formatAddressToAssetId } from '../utils/caip-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, + // @ts-expect-error - chainAgnosticId is not in the schema + chainAgnosticId, + // @ts-expect-error - logoURI is not in the schema + logoURI, + iconUrl, + icon, + assetId, + ...rest + } = value; + + const resolvedIconUrl = iconUrl ?? logoURI ?? 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: unknown): 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: { + ...(value.priceData?.priceImpact && { + priceImpact: { + amount: value.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] && { + [FeeType.TX_FEE]: [ + { + ...feeData[FeeType.TX_FEE], + asset: toBridgeAssetV2(feeData[FeeType.TX_FEE].asset), + }, + ], + }), + }, + steps: steps?.map(toStepV2), + ...restQuote, + protocols: bridges, + aggregator: bridgeId, + }; +}); + +const toQuoteV2 = (quote: unknown): 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..9ccdbefc9cf --- /dev/null +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -0,0 +1,231 @@ +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 { 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: unknown): 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: unknown): Step => { + const stepV2 = create(step, StepSchemaV1FromV2); + return stepV2; +}; + +const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { + const { + priceData, + feeData, + steps, + protocols, + aggregator, + src, + dest, + ...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, + }, + }), + /** + * @deprecated This field is deprecated. + */ + steps: steps?.map(toStepV1), + ...restQuote, + }; +}); + +const toQuoteV1 = (quote: unknown): 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 a8555ced3c8..3d819ea12de 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -121,6 +121,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..b6b2b6d76f9 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 * @@ -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 calcTokenValue = ( + 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/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts new file mode 100644 index 00000000000..eaa8fab9c33 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts @@ -0,0 +1,58 @@ +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', + quoteResponse: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { + quote: { + feeData: { + relayer: [ + { + amount: '100', + usd: '100', + }, + ], + }, + }, + }), + quoteMetadata: { + relayerFee: { + 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..7c7c8e2e5f6 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -1,18 +1,45 @@ +import { is } from '@metamask/superstruct'; import { merge } from 'lodash'; +import { + QuoteResponseSchemaV1, + validateQuoteResponseV1, +} 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 normalizedAmountsV2 = toNormalizedAmounts(quoteResponse); + + // Phase 1 of migration uses calcQuoteMetadata's results + return merge( + {}, + quoteResponse, + // normalizedAmountsV2, + legacyQuoteMetadataV2, + legacyQuoteMetadata, // return for client testing + ); } diff --git a/packages/bridge-controller/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..d044777f390 --- /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 { QuoteResponse } from '../../validators/quote-response.js'; +import { FeeType } from '../../validators/quote.js'; +import { calcTokenAmount } 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: calcTokenAmount( + src?.amount, + src?.asset?.decimals, + )?.toFixed(), + }, + dest: { + normalizedAmount: calcTokenAmount( + dest?.amount, + dest?.asset?.decimals, + )?.toFixed(), + minAmountNormalized: calcTokenAmount( + dest?.minAmount, + dest?.asset?.decimals, + )?.toFixed(), + }, + feeData: { + network: feeData?.[FeeType.NETWORK]?.map((networkFee) => ({ + normalizedAmount: calcTokenAmount( + networkFee?.amount, + networkFee?.asset?.decimals, + )?.toFixed(), + })), + relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ + normalizedAmount: calcTokenAmount( + relayerFee.amount, + relayerFee.asset?.decimals, + )?.toFixed(), + })), + txFee: feeData?.[FeeType.TX_FEE]?.map((txFee) => ({ + normalizedAmount: calcTokenAmount( + 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..dc27284fb51 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts @@ -0,0 +1,62 @@ +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 => { + 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..2ce31647cf6 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v2.ts @@ -0,0 +1,136 @@ +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 { calcTokenValue } 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; + + return { + ...rest, + quote: { + src: { + amount: calcTokenValue(sentAmount?.amount, srcAsset?.decimals), + normalizedAmount: sentAmount?.amount, + valueInCurrency: sentAmount?.valueInCurrency, + usd: sentAmount?.usd, + }, + dest: { + amount: calcTokenValue(toTokenAmount?.amount, destAsset?.decimals), + normalizedAmount: toTokenAmount?.amount, + valueInCurrency: toTokenAmount?.valueInCurrency, + usd: toTokenAmount?.usd, + minAmount: calcTokenValue( + minToTokenAmount?.amount, + destAsset?.decimals, + ), + minAmountNormalized: minToTokenAmount?.amount, + minAmountUsd: minToTokenAmount?.usd, + minAmountValueInCurrency: minToTokenAmount?.valueInCurrency, + }, + feeData: { + network: [ + { + amount: calcTokenValue( + gasFee?.total?.amount, + nativeAsset?.decimals, + ), + normalizedAmount: gasFee?.total?.amount, + valueInCurrency: gasFee?.total?.valueInCurrency, + usd: gasFee?.total?.usd, + asset: nativeAsset, + }, + ], + ...(relayerFee && + Object.values(relayerFee).some(Boolean) && { + relayer: [ + { + amount: calcTokenValue( + relayerFee?.amount, + nativeAsset?.decimals, + ), + normalizedAmount: relayerFee?.amount, + valueInCurrency: relayerFee?.valueInCurrency, + usd: relayerFee?.usd, + asset: nativeAsset, + }, + ], + }), + ...(includedTxFees && + Object.values(includedTxFees).some(Boolean) && { + txFee: [ + { + amount: calcTokenValue( + includedTxFees?.amount, + txFeeAsset?.decimals, + ), + normalizedAmount: includedTxFees?.amount, + valueInCurrency: includedTxFees?.valueInCurrency, + usd: includedTxFees?.usd, + asset: txFeeAsset, + }, + ], + }), + }, + priceData: { + ...((priceImpact && Object.values(priceImpact).some(Boolean)) || + (cost && Object.values(cost).some(Boolean)) + ? { + priceImpact: { + valueInCurrency: + priceImpact?.valueInCurrency ?? cost?.valueInCurrency, + usd: priceImpact?.usd ?? cost?.usd, + }, + } + : {}), + ...(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); +}; From c4fc87c2bb875dde41db2b02b977d22f2d9aa882 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 20:02:24 -0700 Subject: [PATCH 05/45] chore: calcQuoteMetadata V1/V2 --- packages/bridge-controller/CHANGELOG.md | 6 + .../bridge-controller/src/selectors.test.ts | 362 +++++++----------- .../src/utils/number-formatters.ts | 17 +- .../utils/quote-metadata/calculators.test.ts | 129 ++++--- .../src/utils/quote-metadata/calculators.ts | 154 ++++---- .../src/utils/quote-metadata/merge.ts | 8 +- .../tests/mock-quotes-native-erc20.ts | 9 +- .../tests/mock-quotes-sol-erc20.ts | 2 +- 8 files changed, 315 insertions(+), 372 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 4b8fc861fd6..10264f4bb25 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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 human-readable values +- Selector compatibility + - deprecate cost and use priceImpact + +### Removed + +- Clean up unused unsupporteed `XlmScope.Testnet` chainId mapping ([#9085](https://github.com/MetaMask/core/pull/9085)) ### Changed diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 638dd3c1901..772f3f79778 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -2,12 +2,19 @@ import { getAddress } from '@ethersproject/address'; import type { MarketDataDetails } from '@metamask/assets-controllers'; import { toHex } from '@metamask/controller-utils'; import { SolScope } from '@metamask/keyring-api'; -import { parseCaipAssetType } from '@metamask/utils'; +import { + KnownCaipNamespace, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { merge } from 'lodash'; import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; +import { + getMockBridgeQuotesNativeErc20V1, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; import { DEFAULT_CHAIN_RANKING, ETH_USDT_ADDRESS } from './constants/bridge.js'; import type { BridgeAppState } from './selectors.js'; import { @@ -23,19 +30,23 @@ import { selectBatchSellTrades, } from './selectors.js'; import { SortOrder, RequestStatus, ChainId, NonEvmFees } from './types.js'; +import type { DeepPartial } from './types.js'; import { getNativeAssetForChainId, isNativeAddress } from './utils/bridge.js'; import { formatAddressToAssetId, formatAddressToCaipReference, + formatChainIdToCaip, formatChainIdToDec, formatChainIdToHex, } from './utils/caip-formatters.js'; import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; +import { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; import { BatchSellTransactionType } from './validators/batch-sell.js'; import type { BridgeAsset } from './validators/bridge-asset.js'; -import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; @@ -509,12 +520,12 @@ describe('Bridge Selectors', () => { describe('selectBridgeQuotes', () => { const getMockState = ( chainId: ChainId, - quoteOverrides?: Partial, + quoteOverrides: DeepPartial = {}, stateOverrides?: Partial, ): BridgeAppState => { const decChainId = formatChainIdToDec(chainId); - - const mockQuote = { + const caipChainId = formatChainIdToCaip(chainId); + const mockQuoteV1 = { quote: { requestId: '123', srcChainId: decChainId, @@ -526,7 +537,9 @@ describe('Bridge Selectors', () => { chainId: decChainId, address: '0x0000000000000000000000000000000000000000', decimals: 18, - assetId: getNativeAssetForChainId(chainId).assetId.toLowerCase(), + assetId: getNativeAssetForChainId( + chainId, + ).assetId.toLowerCase() as `${string}:${string}/${string}:${string}`, symbol: 'ETH', name: 'Ethereum', }, @@ -534,7 +547,9 @@ describe('Bridge Selectors', () => { chainId: 137, address: '0x0000000000000000000000000000000000000000', decimals: 18, - assetId: getNativeAssetForChainId(137).assetId.toLowerCase(), + assetId: getNativeAssetForChainId( + 137, + ).assetId.toLowerCase() as `${string}:${string}/${string}:${string}`, symbol: 'POL', name: 'Polygon', }, @@ -557,51 +572,69 @@ describe('Bridge Selectors', () => { }, }, estimatedProcessingTimeInSeconds: 300, - trade: { - value: '0x0', - gasLimit: 24000, - effectiveGas: 21000, - chainId: decChainId, - from: '0x0000000000000000000000000000000000000000', - to: '0x0000000000000000000000000000000000000000', - data: '0x0', - }, - approval: { - gasLimit: 49000, - effectiveGas: 46000, - chainId: decChainId, - from: '0x0000000000000000000000000000000000000000', - to: '0x0000000000000000000000000000000000000000', - data: '0x0', - value: '0x0', - }, + ...(parseCaipChainId(caipChainId).namespace === + KnownCaipNamespace.Eip155 + ? { + trade: { + value: '0x0', + gasLimit: 24000, + effectiveGas: 21000, + chainId: decChainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + }, + } + : { trade: 'SOLANATRADE' }), + ...(parseCaipChainId(caipChainId).namespace === + KnownCaipNamespace.Eip155 + ? { + approval: { + gasLimit: 49000, + effectiveGas: 46000, + chainId: decChainId, + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000000', + data: '0x0', + value: '0x0', + }, + } + : {}), }; + const mockQuoteV2 = [ + mockQuoteV1, + { + ...mockQuoteV1, + quote: { + ...mockQuoteV1.quote, + requestId: '456', + destTokenAmount: '2100000000000000000', + }, + }, + ].map((quote) => ({ + ...merge({}, quote, quoteOverrides), + quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), + })); + + const srcChainId = parseCaipAssetType( + mockQuoteV2[0].quote.srcAsset.assetId, + ).chainId; + const destChainId = parseCaipAssetType( + mockQuoteV2[0].quote.destAsset.assetId, + ).chainId; + return { - quotes: [ - merge({}, mockQuote, quoteOverrides), - merge( - {}, - { - ...mockQuote, - quote: { - ...mockQuote.quote, - requestId: '456', - destTokenAmount: '2100000000000000000', - }, - }, - quoteOverrides, - ), - ], + quotes: mockQuoteV2, quoteRequest: [ { - srcChainId: quoteOverrides?.quote?.srcAsset?.chainId ?? decChainId, - destChainId: quoteOverrides?.quote?.destAsset?.chainId ?? 137, + srcChainId: srcChainId ?? decChainId, + destChainId: destChainId ?? 137, srcTokenAddress: - quoteOverrides?.quote?.srcAsset?.address ?? + mockQuoteV2[0].quote.srcAsset.assetId ?? '0x0000000000000000000000000000000000000000', destTokenAddress: - quoteOverrides?.quote?.destAsset?.address ?? + mockQuoteV2[0].quote.destAsset.assetId ?? '0x0000000000000000000000000000000000000000', insufficientBal: false, }, @@ -668,21 +701,11 @@ describe('Bridge Selectors', () => { { ...mockState, assetExchangeRates: { - [mockQuote.quote.srcAsset.assetId.toLowerCase() ?? - formatAddressToAssetId( - mockQuote.quote.srcAsset.address, - mockQuote.quote.srcChainId, - ) ?? - '']: { + [mockQuote.quote.srcAsset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [mockQuote.quote.destAsset.assetId.toLowerCase() ?? - formatAddressToAssetId( - mockQuote.quote.destAsset.address, - mockQuote.quote.destChainId, - ) ?? - '']: { + [mockQuote.quote.destAsset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -690,57 +713,28 @@ describe('Bridge Selectors', () => { }, mockClientParams, ); - - const expectedQuoteMetadata = { - adjustedReturn: { - usd: '2.099927', - valueInCurrency: '419.985546', - }, - cost: { - usd: '8.900073', - valueInCurrency: '1758.014454', - }, - gasFee: { - total: { - amount: '0.0000073', - usd: '0.000073', - valueInCurrency: '0.014454', - }, - }, - minToTokenAmount: { - amount: '1.8', - usd: '1.8', - valueInCurrency: '360', - }, - priceImpact: { - usd: '8.9', - valueInCurrency: '1758', - }, - sentAmount: { - amount: '1.1', - usd: '11', - valueInCurrency: '2178', - }, - swapRate: '1.90909090909090909091', - toTokenAmount: { - amount: '2.1', - usd: '2.1', - valueInCurrency: '420', - }, - totalNetworkFee: { - amount: '0.0000073', - usd: '0.000073', - valueInCurrency: '0.014454', + const quote = mockState.quotes[1]; + const expectedQuoteMetadata = calcQuoteMetadata(quote, { + srcTokenExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', }, - }; + destTokenExchangeRate: { exchangeRate: '200', usdExchangeRate: '1' }, + nativeExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, + }); + const expectedQuoteV2 = mergeQuoteMetadata(quote, expectedQuoteMetadata); + + expect(result.sortedQuotes[0]).toStrictEqual(expectedQuoteV2); - const quoteResponseV1 = mergeQuoteMetadata( - mockState.quotes[1], + expect(result.recommendedQuote?.priceImpact?.valueInCurrency).toBe( + expectedQuoteV2.priceImpact?.valueInCurrency, + ); + expect(toQuoteMetadataV1(result.recommendedQuote)).toStrictEqual( expectedQuoteMetadata, ); - validateQuoteResponseV1(quoteResponseV1); - expect(result.sortedQuotes[0]).toStrictEqual(quoteResponseV1); + expect(result.sortedQuotes[0]).toStrictEqual(expectedQuoteV2); expect(result.sortedQuotes[0].cost?.valueInCurrency).toBe('1758.014454'); }); @@ -753,17 +747,11 @@ describe('Bridge Selectors', () => { ...mockState, quotes: [], assetExchangeRates: { - [formatAddressToAssetId( - mockQuote.quote.srcAsset.address, - mockQuote.quote.srcChainId, - ) ?? '']: { + [mockQuote.quote.srcAsset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [formatAddressToAssetId( - mockQuote.quote.destAsset.address, - mockQuote.quote.destChainId, - ) ?? '']: { + [mockQuote.quote.destAsset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -795,14 +783,6 @@ describe('Bridge Selectors', () => { ); const expectedQuoteMetadata = { - adjustedReturn: { - usd: undefined, - valueInCurrency: undefined, - }, - cost: { - usd: undefined, - valueInCurrency: undefined, - }, gasFee: { total: { amount: '0.0000073', @@ -837,7 +817,12 @@ describe('Bridge Selectors', () => { expect(result.sortedQuotes[0]).toStrictEqual( mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), ); - expect(result.sortedQuotes[0]?.cost?.valueInCurrency).toBeUndefined(); + expect( + result.sortedQuotes[0].quote.priceData?.priceImpact, + ).toBeUndefined(); + expect(result.recommendedQuote?.quote.destTokenAmount).toBe( + '2100000000000000000', + ); expect(result.recommendedQuote?.toTokenAmount?.amount).toBe('2.1'); }); @@ -871,14 +856,6 @@ describe('Bridge Selectors', () => { ); const expectedQuoteMetadata = { - adjustedReturn: { - usd: undefined, - valueInCurrency: undefined, - }, - cost: { - usd: undefined, - valueInCurrency: undefined, - }, minToTokenAmount: { amount: '1.8', usd: undefined, @@ -922,11 +899,11 @@ describe('Bridge Selectors', () => { describe('returns swap metadata', () => { const getMockSwapState = ( - srcAsset: BridgeAsset, - destAsset: BridgeAsset, + srcAsset: Omit, + destAsset: Omit, txFee?: { amount: string; - asset: BridgeAsset; + asset: Omit; }, gasIncluded7702?: boolean, gasEstimatesChainId?: number, @@ -1094,8 +1071,6 @@ describe('Bridge Selectors', () => { assetId: getNativeAssetForChainId(1).assetId, symbol: 'ETH', name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - chainId: 1, }; const destAsset = { decimals: 18, @@ -1103,8 +1078,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d' as const, symbol: 'USDC', name: 'USD Coin', - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, }; const newState = getMockSwapState(srcAsset, destAsset); @@ -1167,8 +1140,6 @@ describe('Bridge Selectors', () => { assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, }, { decimals: 18, @@ -1176,8 +1147,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - address: '0x0000000000000000000000000000000000000000', - chainId: 1, }, ); @@ -1235,22 +1204,18 @@ describe('Bridge Selectors', () => { it('erc20 -> native but gas estimates are not available', () => { const newState = getMockSwapState( { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', name: 'USD Coin', - chainId: 1, }, { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, undefined, undefined, @@ -1311,33 +1276,27 @@ describe('Bridge Selectors', () => { it('when gas is included and is taken from dest token', () => { const newState = getMockSwapState( { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', name: 'USD Coin', - chainId: 1, }, { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, { amount: '1000000000000000', asset: { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, }, ); @@ -1406,27 +1365,21 @@ describe('Bridge Selectors', () => { assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 6, - chainId: 1, - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', }, { - address: '0x0000000000000000000000000000000000000000', decimals: 18, assetId: 'eip155:1/slip44:60', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, { amount: '3000000', asset: { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 6, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'ETH', name: 'Ethereum', - chainId: 1, }, }, ); @@ -1451,53 +1404,24 @@ describe('Bridge Selectors', () => { }; const { sortedQuotes } = selectBridgeQuotes(newState, mockClientParams); - const expectedQuoteMetadata = { - adjustedReturn: { - usd: '10.51342489434187625472', - valueInCurrency: '8.99553613774000008538', + const expectedQuoteMetadata = calcQuoteMetadata(newState.quotes[0], { + srcTokenExchangeRate: { + exchangeRate: '551.98', + usdExchangeRate: '645.12', }, - cost: { - usd: '1936.53421414565812374528', - valueInCurrency: '1656.94468552225999991462', + bridgeFeesPerGas: { + estimatedBaseFeeInDecGwei: '0', + feePerGasInDecGwei: '.1', }, - gasFee: { - total: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - }, - includedTxFees: { - amount: '3', - usd: '1935.36', - valueInCurrency: '1655.94', - }, - minToTokenAmount: { - amount: '0.015489691655494764', - usd: '9.99270988079278215168', - valueInCurrency: '8.54999999999999983272', + destTokenExchangeRate: { + exchangeRate: '551.98', + usdExchangeRate: '645.12', }, - sentAmount: { - amount: '3.018117', - usd: '1947.04763904', - valueInCurrency: '1665.94022166', - }, - priceImpact: { - usd: '1936.52899706021812374528', - valueInCurrency: '1656.94022165999999991462', + nativeExchangeRate: { + exchangeRate: '551.98', + usdExchangeRate: '645.12', }, - swapRate: '0.00540235470816119156', - toTokenAmount: { - amount: '0.016304938584731331', - usd: '10.51864197978187625472', - valueInCurrency: '9.00000000000000008538', - }, - totalNetworkFee: { - amount: '0.000008087', - usd: '0.00521708544', - valueInCurrency: '0.00446386226', - }, - }; + }); expect(sortedQuotes[0].quote.feeData.txFee).toStrictEqual({ amount: '3000000', @@ -1524,22 +1448,18 @@ describe('Bridge Selectors', () => { it('when gasIncluded7702=true and is taken from dest token', () => { const newState = getMockSwapState( { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', name: 'USD Coin', - chainId: 1, }, { - address: '0x0000000000000000000000000000000000000001', decimals: 18, assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000001', symbol: 'WETH', name: 'Ethereum', - chainId: 1, }, { amount: '1000000000000000000', @@ -1549,8 +1469,6 @@ describe('Bridge Selectors', () => { 'eip155:1/erc20:0x0000000000000000000000000000000000000001', symbol: 'WETH', name: 'Ethereum', - chainId: 1, - address: '0x0000000000000000000000000000000000000001', }, }, true, @@ -1689,14 +1607,6 @@ describe('Bridge Selectors', () => { valueInCurrency: undefined, }, swapRate: '1.90909090909090909091', - cost: { - usd: undefined, - valueInCurrency: undefined, - }, - adjustedReturn: { - usd: undefined, - valueInCurrency: undefined, - }, totalNetworkFee: { amount: '0.0000073', usd: '0.01314', @@ -1746,14 +1656,6 @@ describe('Bridge Selectors', () => { valueInCurrency: undefined, }, swapRate: '1.90909090909090909091', - cost: { - usd: undefined, - valueInCurrency: undefined, - }, - adjustedReturn: { - usd: undefined, - valueInCurrency: undefined, - }, totalNetworkFee: { amount: '0.0000073', usd: '0.01314', @@ -1854,6 +1756,7 @@ describe('Bridge Selectors', () => { ); const solanaQuote = solanaState.quotes[1]; + expect(solanaQuote.quote.destTokenAmount).toBe('2100000000000000000'); const expectedQuoteMetadata = calcQuoteMetadata(solanaQuote, { srcTokenExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, @@ -1924,12 +1827,6 @@ describe('Bridge Selectors', () => { }, }, assetExchangeRates: {}, - currencyRates: { - ETH: { - conversionRate: 1800, - usdConversionRate: 1800, - }, - }, marketData: {}, conversionRates: {}, participateInMetaMetrics: true, @@ -1962,13 +1859,6 @@ describe('Bridge Selectors', () => { selectBatchSellQuotes( { ...mockState, - currencyRates: { - ETH: { - conversionRate: 1800, - usdConversionRate: 10, - conversionDate: Date.now(), - }, - }, assetExchangeRates: { 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85': { exchangeRate: '1980', @@ -1978,6 +1868,10 @@ describe('Bridge Selectors', () => { exchangeRate: '200', usdExchangeRate: '1', }, + 'eip155:10/slip44:60': { + exchangeRate: '1800', + usdExchangeRate: '10', + }, }, }, { ...mockClientParams, requestCount: 2 }, @@ -2085,7 +1979,7 @@ describe('Bridge Selectors', () => { const { recommendedQuotes } = selectBatchSellQuotes( { ...mockState, - quotes: mockBridgeQuotesNativeErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20V1().map((quote) => ({ ...quote, quoteRequestIndex: undefined, })), diff --git a/packages/bridge-controller/src/utils/number-formatters.ts b/packages/bridge-controller/src/utils/number-formatters.ts index b6b2b6d76f9..219831d253b 100644 --- a/packages/bridge-controller/src/utils/number-formatters.ts +++ b/packages/bridge-controller/src/utils/number-formatters.ts @@ -79,18 +79,31 @@ export const sumAmounts = ( return undefined; } + /** + * Fees and prices can be denominated in different assets, so we need to check if all fees have the same units + */ 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]; + const AMOUNT_KEYS = [ + 'amount' as const, + 'normalizedAmount' as const, + 'minAmount' as const, + 'minAmountNormalized' as const, + ]; /** * Keys that can be aggregated across all fees */ - const FIAT_OR_USD_KEYS = ['valueInCurrency' as const, 'usd' as const]; + const FIAT_OR_USD_KEYS = [ + 'valueInCurrency' as const, + 'usd' as const, + 'minAmountValueInCurrency' as const, + 'minAmountUsd' as const, + ]; return fees.reduce((acc, fee) => { const newAcc = { ...acc }; diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts index ec23cf5af25..b29c8df1b6b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.test.ts @@ -1,11 +1,10 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import { merge } from 'lodash'; -import { mockBridgeQuotesErc20Erc20V1 } from '../../../tests/mock-quotes-erc20-erc20.js'; -import { mockBridgeQuotesNativeErc20V1 } from '../../../tests/mock-quotes-native-erc20.js'; -import { getMockBridgeQuotesSolErc20V2 } from '../../../tests/mock-quotes-sol-erc20.js'; +import { getMockBridgeQuotesErc20Erc20V1 } from '../../../tests/mock-quotes-erc20-erc20.js'; +import { getMockBridgeQuotesNativeErc20V1 } from '../../../tests/mock-quotes-native-erc20.js'; +import { getMockBridgeQuotesSolErc20V1 } from '../../../tests/mock-quotes-sol-erc20.js'; import type { GenericQuoteRequest, L1GasFees } from '../../types.js'; import { isValidQuoteRequest } from '../../validators/quote-request.js'; import { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; @@ -155,10 +154,14 @@ describe('Quote Utils', () => { describe('Quote Metadata Utils', () => { describe('calcSentAmount', () => { it('should calculate sent amount correctly with exchange rates', () => { - const mockQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + const mockQuote = getMockBridgeQuotesErc20Erc20V1({ quote: { srcTokenAmount: '2555423', - srcAsset: { decimals: 6 }, + srcAsset: { + decimals: 6, + assetId: + 'eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85', + }, feeData: { metabridge: { amount: '110000000', @@ -169,7 +172,7 @@ describe('Quote Metadata Utils', () => { }, }, }, - }).quote; + })[0].quote; expect(mockQuote.feeData.metabridge.asset?.assetId).toBe( mockQuote.srcAsset.assetId, ); @@ -189,7 +192,7 @@ describe('Quote Metadata Utils', () => { }); it('should handle missing exchange rates', () => { - const mockQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + const mockQuote = getMockBridgeQuotesErc20Erc20V1({ quote: { srcTokenAmount: '1000000000', srcAsset: { decimals: 6 }, @@ -197,7 +200,7 @@ describe('Quote Metadata Utils', () => { metabridge: { amount: '100000000' }, }, }, - }).quote; + })[0].quote; const result = calcSentAmount(mockQuote, {}); expect(result.amount).toBe('1100'); @@ -206,7 +209,7 @@ describe('Quote Metadata Utils', () => { }); it('should handle zero values', () => { - const zeroQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + const zeroQuote = getMockBridgeQuotesErc20Erc20V1({ quote: { srcTokenAmount: '0', srcAsset: { decimals: 6 }, @@ -215,7 +218,7 @@ describe('Quote Metadata Utils', () => { metabridge: { amount: '0' }, }, }, - }).quote; + })[0].quote; const result = calcSentAmount(zeroQuote, { exchangeRate: '2', @@ -228,7 +231,7 @@ describe('Quote Metadata Utils', () => { }); it('should handle large numbers', () => { - const largeQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + const largeQuote = getMockBridgeQuotesErc20Erc20V1({ quote: { srcTokenAmount: '1000000000000000000', srcAsset: { @@ -248,7 +251,7 @@ describe('Quote Metadata Utils', () => { }, }, }, - }).quote; + })[0].quote; const result = calcSentAmount(largeQuote, { exchangeRate: '2', @@ -266,7 +269,7 @@ describe('Quote Metadata Utils', () => { // the total fixed commitment including protocol fees. Adding feeData fees // on top would double-count them. - const intentQuote = merge({}, mockBridgeQuotesErc20Erc20V1[0], { + const intentQuote = getMockBridgeQuotesErc20Erc20V1({ quote: { srcTokenAmount: '10000000', // 10 USDT (6 decimals), fee already included srcAsset: { @@ -306,7 +309,7 @@ describe('Quote Metadata Utils', () => { }, }, }, - }).quote; + })[0].quote; const result = calcSentAmount(intentQuote, { exchangeRate: '1', @@ -321,7 +324,7 @@ describe('Quote Metadata Utils', () => { }); describe('calcNonEvmTotalNetworkFee', () => { - const mockBridgeQuote = getMockBridgeQuotesSolErc20V2({ + const mockBridgeQuote = getMockBridgeQuotesSolErc20V1({ nonEvmFeesInNative: '1', })[0]; @@ -337,7 +340,7 @@ describe('Quote Metadata Utils', () => { }); it('should calculate Bitcoin fees correctly with exchange rates', () => { - const btcQuote = getMockBridgeQuotesSolErc20V2({ + const btcQuote = getMockBridgeQuotesSolErc20V1({ nonEvmFeesInNative: '0.00005', // BTC fee in native units })[0]; @@ -407,7 +410,7 @@ describe('Quote Metadata Utils', () => { }); describe('calcRelayerFee', () => { - const mockBridgeQuote = merge({}, mockBridgeQuotesNativeErc20V1[0], { + const mockBridgeQuote = getMockBridgeQuotesNativeErc20V1({ quote: { srcAsset: { address: '0x123', decimals: 18 }, srcTokenAmount: '1000000000000000000', @@ -418,13 +421,24 @@ describe('Quote Metadata Utils', () => { }, }, trade: { value: '0x10A741A462780000' }, - }) as unknown as QuoteResponseV1; + })[0]; it('should calculate relayer fee correctly with exchange rates', () => { - const result = calcRelayerFee(mockBridgeQuote, { - exchangeRate: '2', - usdExchangeRate: '1.5', - }); + const result = calcRelayerFee( + calcSentAmount( + mockBridgeQuote.quote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + false, + ), + mockBridgeQuote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + ); expect(new BigNumber(mockBridgeQuote.trade.value, 16).toFixed()).toBe( '1200000000000000000', @@ -445,11 +459,21 @@ describe('Quote Metadata Utils', () => { }); it('should calculate relayer fee correctly with no trade.value', () => { + const mockQuote = getMockBridgeQuotesNativeErc20V1({ + // @ts-expect-error - trade.value is an object + trade: { ...mockBridgeQuote.trade, value: '0x0' }, + })[0]; + const result = calcRelayerFee( - merge({}, mockBridgeQuotesNativeErc20V1[0], { - // @ts-expect-error - trade.value is an object - trade: { ...mockBridgeQuote.trade, value: '0x0' }, - }), + calcSentAmount( + mockQuote.quote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + false, + ), + mockQuote, { exchangeRate: '2', usdExchangeRate: '1.5', @@ -460,7 +484,7 @@ describe('Quote Metadata Utils', () => { }); it('should handle native token address', () => { - const nativeBridgeQuote = merge({}, mockBridgeQuotesNativeErc20V1[0], { + const nativeBridgeQuote = getMockBridgeQuotesNativeErc20V1({ quote: { srcTokenAmount: '1000000000000000000', feeData: { @@ -482,12 +506,23 @@ describe('Quote Metadata Utils', () => { trade: { value: '0x10A741A462780000', }, - }) as QuoteResponseV1; + })[0]; - const result = calcRelayerFee(nativeBridgeQuote, { - exchangeRate: '2', - usdExchangeRate: '1.5', - }); + const result = calcRelayerFee( + calcSentAmount( + nativeBridgeQuote.quote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + false, + ), + nativeBridgeQuote, + { + exchangeRate: '2', + usdExchangeRate: '1.5', + }, + ); expect( convertHexToDecimal(nativeBridgeQuote.trade.value).toString(), @@ -934,12 +969,7 @@ describe('Quote Metadata Utils', () => { sentAmount: { valueInCurrency: undefined }, toTokenAmount: { valueInCurrency: '900' }, }), - ).toMatchInlineSnapshot(` - { - "usd": undefined, - "valueInCurrency": undefined, - } - `); + ).toMatchInlineSnapshot(`undefined`); }); it('returns undefined when toTokenAmount.valueInCurrency is undefined', () => { @@ -948,12 +978,7 @@ describe('Quote Metadata Utils', () => { sentAmount: { valueInCurrency: '1000' }, toTokenAmount: { valueInCurrency: undefined }, }), - ).toMatchInlineSnapshot(` - { - "usd": undefined, - "valueInCurrency": undefined, - } - `); + ).toMatchInlineSnapshot(`undefined`); }); it('returns undefined when sentAmount is missing', () => { @@ -962,12 +987,7 @@ describe('Quote Metadata Utils', () => { sentAmount: {}, toTokenAmount: { valueInCurrency: '900' }, }), - ).toMatchInlineSnapshot(` - { - "usd": undefined, - "valueInCurrency": undefined, - } - `); + ).toBeUndefined(); }); it('returns undefined when toTokenAmount is missing', () => { @@ -976,12 +996,7 @@ describe('Quote Metadata Utils', () => { sentAmount: { valueInCurrency: '1000' }, toTokenAmount: {}, }), - ).toMatchInlineSnapshot(` - { - "usd": undefined, - "valueInCurrency": undefined, - } - `); + ).toBeUndefined(); }); it('formats the absolute difference between source and destination fiat amounts', () => { diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index eaaf762c7e3..7e02e6a56f2 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -7,6 +7,7 @@ import { import { is } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; +import { toQuoteResponseV1 } from '../../index.js'; import type { L1GasFees, ExchangeRate, @@ -15,14 +16,16 @@ import type { } from '../../types.js'; import type { BridgeAsset } from '../../validators/bridge-asset.js'; import { FloatStringSchema } from '../../validators/number.js'; -import type { QuoteResponseV1 as QuoteResponse } from '../../validators/quote-response-v1.js'; -import { TxData } from '../../validators/trade.js'; +import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { QuoteResponseSchemaV2 } from '../../validators/quote-response.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; +import type { TxData } from '../../validators/trade.js'; import { isEvmQuoteResponse, isNativeAddress } from '../bridge.js'; import { calcTokenAmount } from '../number-formatters.js'; import type { QuoteMetadata, TokenAmountValues } from './types.js'; export const calcNonEvmTotalNetworkFee = ( - bridgeQuote: QuoteResponse & NonEvmFees, + bridgeQuote: QuoteResponseV1 & NonEvmFees, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const { nonEvmFeesInNative } = bridgeQuote; @@ -57,35 +60,36 @@ export const calcToAmount = ( }; export const calcSentAmount = ( - { srcTokenAmount, srcAsset, feeData, intent }: QuoteResponse['quote'], + { srcTokenAmount, srcAsset, feeData, intent }: QuoteResponseV1['quote'], { exchangeRate, usdExchangeRate }: ExchangeRate, + isQuoteV2: boolean = false, ) => { - // For intent-based swaps (e.g. CoW Protocol), srcTokenAmount is the total + // For intent-based swaps or converted V2 quote responses, 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?.toLowerCase() === - srcAsset.assetId?.toLowerCase(), - ) - .reduce( - (acc, { amount }) => acc.plus(amount), - new BigNumber(srcTokenAmount), - ); + const sentAmount = + intent || isQuoteV2 + ? new BigNumber(srcTokenAmount) + : Object.values(feeData) + .filter( + (fee) => + fee?.amount && + fee.asset?.assetId?.toLowerCase() === + srcAsset.assetId?.toLowerCase(), + ) + .reduce( + (acc, { amount }) => acc.plus(amount), + new BigNumber(srcTokenAmount), + ); const normalizedSentAmount = calcTokenAmount(sentAmount, srcAsset.decimals); return { - amount: normalizedSentAmount?.toString(), + amount: normalizedSentAmount?.toFixed(), valueInCurrency: - exchangeRate && normalizedSentAmount?.times(exchangeRate).toString(), + exchangeRate && normalizedSentAmount?.times(exchangeRate).toFixed(), usd: - usdExchangeRate && - normalizedSentAmount?.times(usdExchangeRate).toString(), + usdExchangeRate && normalizedSentAmount?.times(usdExchangeRate).toFixed(), }; }; @@ -97,19 +101,20 @@ export const calcBatchFees = ( const normalizedAmount = calcTokenAmount(amount, asset.decimals); return { - amount: normalizedAmount?.toString(), + amount: normalizedAmount?.toFixed(), valueInCurrency: exchangeRate - ? normalizedAmount?.times(exchangeRate).toString() + ? normalizedAmount?.times(exchangeRate).toFixed() : null, usd: usdExchangeRate - ? normalizedAmount?.times(usdExchangeRate).toString() + ? normalizedAmount?.times(usdExchangeRate).toFixed() : null, asset, }; }; export const calcRelayerFee = ( - quoteResponse: QuoteResponse, + sentAmount: ReturnType, + quoteResponse: QuoteResponseV1, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { const { quote, trade } = quoteResponse; @@ -122,11 +127,7 @@ export const calcRelayerFee = ( // 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'); + relayerFeeInNative = relayerFeeInNative?.minus(sentAmount.amount ?? '0'); } if (relayerFeeInNative?.lte(0)) { @@ -161,9 +162,9 @@ const calcTotalGasFee = ({ }) => { const totalGasLimitInDec = tradeGasLimit || approvalGasLimit || resetApprovalGasLimit - ? new BigNumber(tradeGasLimit?.toString() ?? '0') - .plus(approvalGasLimit?.toString() ?? '0') - .plus(resetApprovalGasLimit?.toString() ?? '0') + ? new BigNumber(tradeGasLimit?.toFixed() ?? '0') + .plus(approvalGasLimit?.toFixed() ?? '0') + .plus(resetApprovalGasLimit?.toFixed() ?? '0') : undefined; const l1GasFeesInDecGWei = l1GasFeesInHexWei @@ -178,10 +179,10 @@ const calcTotalGasFee = ({ const gasFeesInDecEth = gasFeesInDecGwei?.times(new BigNumber(10).pow(-9)); const gasFeesInDisplayCurrency = nativeToDisplayCurrencyExchangeRate - ? gasFeesInDecEth?.times(nativeToDisplayCurrencyExchangeRate.toString()) + ? gasFeesInDecEth?.times(nativeToDisplayCurrencyExchangeRate) : undefined; const gasFeesInUSD = nativeToUsdExchangeRate - ? gasFeesInDecEth?.times(nativeToUsdExchangeRate.toString()) + ? gasFeesInDecEth?.times(nativeToUsdExchangeRate) : undefined; return { @@ -197,7 +198,7 @@ export const calcEstimatedAndMaxTotalGasFee = ({ exchangeRate: nativeToDisplayCurrencyExchangeRate, usdExchangeRate: nativeToUsdExchangeRate, }: { - bridgeQuote: QuoteResponse & L1GasFees; + bridgeQuote: QuoteResponseV1 & L1GasFees; feePerGasInDecGwei?: string; } & ExchangeRate) => { // Estimated total gas fee, including refunded fees (medium) @@ -259,7 +260,7 @@ export const calcIncludedTxFees = ( gasIncluded7702, srcAsset, feeData: { txFee }, - }: QuoteResponse['quote'], + }: QuoteResponseV1['quote'], srcTokenExchangeRate: ExchangeRate, destTokenExchangeRate: ExchangeRate, ) => { @@ -292,7 +293,7 @@ export const calcAdjustedReturn = ( { feeData: { txFee }, destAsset: { assetId: destAssetId }, - }: QuoteResponse['quote'], + }: QuoteResponseV1['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()) { @@ -394,15 +395,22 @@ export const calcPriceImpact = ( const isSourceFiatValid = (value: unknown): value is string[] => is(value, FloatStringSchema); + const valueInCurrency = + isSourceFiatValid(sourceFiat) && isSourceFiatValid(destFiat) + ? new BigNumber(sourceFiat).minus(destFiat).abs().toFixed() + : undefined; + const usd = + isSourceFiatValid(sourceUsd) && isSourceFiatValid(destUsd) + ? new BigNumber(sourceUsd).minus(destUsd).abs().toFixed() + : undefined; + + if (!valueInCurrency && !usd) { + return undefined; + } + 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, + valueInCurrency, + usd, }; }; @@ -419,7 +427,7 @@ export const calcPriceImpact = ( * @returns The calculated metadata */ export const calcQuoteMetadata = ( - quote: QuoteResponse, + quote: QuoteResponseV1 | QuoteResponse, options: { bridgeFeesPerGas: null | { estimatedBaseFeeInDecGwei: string | null; @@ -437,39 +445,47 @@ export const calcQuoteMetadata = ( nativeExchangeRate = {}, } = options; - const sentAmount = calcSentAmount(quote.quote, srcTokenExchangeRate); + const isQuoteV2 = is(quote, QuoteResponseSchemaV2); + const quoteV1 = isQuoteV2 ? toQuoteResponseV1(quote) : quote; + + const sentAmount = calcSentAmount( + quoteV1.quote, + srcTokenExchangeRate, + isQuoteV2, + ); + const toTokenAmount = calcToAmount( - quote.quote.destTokenAmount, - quote.quote.destAsset, + quoteV1.quote.destTokenAmount, + quoteV1.quote.destAsset, destTokenExchangeRate, ); const minToTokenAmount = calcToAmount( - quote.quote.minDestTokenAmount, - quote.quote.destAsset, + quoteV1.quote.minDestTokenAmount ?? quoteV1.quote.destTokenAmount, + quoteV1.quote.destAsset, destTokenExchangeRate, ); const includedTxFees = calcIncludedTxFees( - quote.quote, + quoteV1.quote, srcTokenExchangeRate, destTokenExchangeRate, ); let totalEstimatedNetworkFee, relayerFee, gasFee; - if (isEvmQuoteResponse(quote)) { - relayerFee = calcRelayerFee(quote, nativeExchangeRate); + if (isEvmQuoteResponse(quoteV1)) { + relayerFee = calcRelayerFee(sentAmount, quoteV1, nativeExchangeRate); gasFee = calcEstimatedAndMaxTotalGasFee({ - bridgeQuote: quote, + bridgeQuote: quoteV1, ...bridgeFeesPerGas, ...nativeExchangeRate, }); - // Uses effectiveGasFee to calculate the total estimated network fee + // Uses total gasFee 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, + quoteV1, nativeExchangeRate, ); gasFee = { @@ -480,7 +496,7 @@ export const calcQuoteMetadata = ( const adjustedReturn = calcAdjustedReturn( toTokenAmount, totalEstimatedNetworkFee, - quote.quote, + quoteV1.quote, ); const cost = calcCost(adjustedReturn, sentAmount); @@ -504,12 +520,16 @@ export const calcQuoteMetadata = ( Should only be used for display purposes. */ gasFee, - ...(adjustedReturn && { adjustedReturn }), - ...(cost && { cost }), - ...(includedTxFees && { includedTxFees }), - ...(relayerFee && { relayerFee }), - ...((priceImpact?.valueInCurrency ?? priceImpact?.usd) && { - priceImpact, - }), + ...(adjustedReturn && + Object.values(adjustedReturn).some(Boolean) && { adjustedReturn }), + ...(cost && Object.values(cost).some(Boolean) && { cost }), + ...(includedTxFees && + Object.values(includedTxFees).some(Boolean) && { includedTxFees }), + ...(relayerFee && + Object.values(relayerFee).some(Boolean) && { relayerFee }), + ...(priceImpact && + Object.values(priceImpact).some(Boolean) && { + priceImpact, + }), }; }; diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 7c7c8e2e5f6..40a4c91cbd0 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -1,13 +1,9 @@ import { is } from '@metamask/superstruct'; import { merge } from 'lodash'; -import { - QuoteResponseSchemaV1, - validateQuoteResponseV1, -} from '../../validators/quote-response-v1.js'; +import { QuoteResponseSchemaV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; -import { toNormalizedAmounts } from './to-normalized-amounts.js'; import { toQuoteMetadataV2 } from './to-quote-metadata-v2.js'; import type { QuoteMetadata } from './types.js'; @@ -32,13 +28,11 @@ export function mergeQuoteMetadata< legacyQuoteMetadata, quoteResponse, ); - // const normalizedAmountsV2 = toNormalizedAmounts(quoteResponse); // Phase 1 of migration uses calcQuoteMetadata's results return merge( {}, quoteResponse, - // normalizedAmountsV2, legacyQuoteMetadataV2, legacyQuoteMetadata, // return for client testing ); diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts index 186df0663b7..11cbd1575f2 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -4,6 +4,7 @@ import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { ActionTypes } from '../src/validators/step.js'; +import type { TxData } from '../src/validators/trade.js'; export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ { @@ -260,12 +261,12 @@ export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ }, ]; -export const getMockBridgeQuotesNativeErc20V2 = ( - quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +export const getMockBridgeQuotesNativeErc20V1 = ( + quoteOverrides?: DeepPartial>, +): QuoteResponseV1[] => { return mockBridgeQuotesNativeErc20V1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return mergedQuote as QuoteResponseV1; }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts index fc317b2891b..8aff2cea101 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -180,7 +180,7 @@ export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ }, ]; -export const getMockBridgeQuotesSolErc20V2 = ( +export const getMockBridgeQuotesSolErc20V1 = ( quoteOverrides?: DeepPartial, ): QuoteResponseV1[] => { return mockBridgeQuotesSolErc20V1.map((quote) => { From 3cc46f93f6ff8ae3e742a65abfed9add39f3d72e Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 19:39:13 -0700 Subject: [PATCH 06/45] chore: V1/V2 appendL1GasFees (breaking) --- packages/bridge-controller/CHANGELOG.md | 2 + .../src/bridge-controller.test.ts | 4 +- .../src/bridge-controller.ts | 23 ++++-- packages/bridge-controller/src/index.ts | 2 + .../bridge-controller/src/selectors.test.ts | 30 +++---- packages/bridge-controller/src/selectors.ts | 79 ++++++++----------- packages/bridge-controller/src/utils/fetch.ts | 8 +- .../bridge-controller/src/utils/quote-fees.ts | 79 +++++++++++-------- 8 files changed, 115 insertions(+), 112 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 10264f4bb25..646187836f6 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes + - Bump `@metamask/assets-controllers` from `^110.0.0` to `^110.0.1` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706)) - Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) - Bump `@metamask/assets-controller` from `^11.2.1` to `^11.3.1` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706)) diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 03bc146e07c..e338ac1e515 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2667,11 +2667,11 @@ describe('BridgeController', function () { expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalledTimes(2); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3:', + 'Failed to compute non-EVM fees for quote in bip122:000000000019d6689c085ae165831e93:', new Error('Failed to compute fees'), ); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d:', + 'Failed to compute non-EVM fees for quote in bip122:000000000019d6689c085ae165831e93:', new Error('Failed to compute fees'), ); }, diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 18937f3a950..0d7ad1cd185 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -427,13 +427,21 @@ export class BridgeController extends StaticIntervalPollingController quote.quote.srcChainId)), + ).filter(Boolean); + + const quotesWithFees = + srcChainIds.length > 1 || srcChainIds.length === 0 + ? // Don't append fees if there are multiple srcChainIds + baseQuotes + : await appendFeesToQuotes( + formatChainIdToCaip(srcChainIds[0]), + baseQuotes, + this.messenger, + this.#getLayer1GasFee, + this.#getMultichainSelectedAccount(quoteRequest.walletAddress), + ); return sortQuotes(quotesWithFees, featureId); }; @@ -971,6 +979,7 @@ export class BridgeController extends StaticIntervalPollingController { const feeAppendPromise = (async () => { const quotesWithFees = await appendFeesToQuotes( + formatChainIdToCaip(quote.quote.srcChainId), [quote], this.messenger, this.#getLayer1GasFee, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 3d819ea12de..7e044138197 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -127,6 +127,8 @@ export { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; export { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; export { toQuoteMetadataV2 } from './utils/quote-metadata/to-quote-metadata-v2.js'; +export { sumAmounts } from './utils/number-formatters.js'; + export { validateQuoteStreamComplete, QuoteStreamCompleteReason, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 772f3f79778..b2e4f2c5ed2 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -1882,16 +1882,16 @@ describe('Bridge Selectors', () => { expect(totalReceived).toMatchInlineSnapshot(` { - "amount": "38.423182", - "usd": "38.423182", - "valueInCurrency": "7684.6364", + "amount": "38.240503", + "usd": "38.240503", + "valueInCurrency": "7648.1006", } `); expect(minimumReceived).toMatchInlineSnapshot(` { - "amount": "37.6", - "usd": "37.6", - "valueInCurrency": "7520", + "amount": "37.46", + "usd": "37.46", + "valueInCurrency": "7492", } `); expect(rest).toMatchInlineSnapshot(` @@ -1905,7 +1905,7 @@ describe('Bridge Selectors', () => { expect(recommendedQuotes.map((quote) => quote?.quote.requestId)) .toMatchInlineSnapshot(` [ - "381c23bc-e3e4-48fe-bc53-257471e388ad", + "4277a368-40d7-4e82-aa67-74f29dc5f98a", "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", ] `); @@ -1949,20 +1949,8 @@ describe('Bridge Selectors', () => { const { totalReceived, minimumReceived, recommendedQuotes, ...rest } = result; - expect(totalReceived).toMatchInlineSnapshot(` - { - "amount": "0", - "usd": "0", - "valueInCurrency": "0", - } - `); - expect(minimumReceived).toMatchInlineSnapshot(` - { - "amount": "0", - "usd": "0", - "valueInCurrency": "0", - } - `); + expect(totalReceived).toBeUndefined(); + expect(minimumReceived).toBeUndefined(); expect(rest).toMatchInlineSnapshot(` { "isLoading": false, diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 5ba66309cf8..a9c63ee255d 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -33,13 +33,11 @@ import { formatChainIdToHex, } from './utils/caip-formatters.js'; import { processFeatureFlags } from './utils/feature-flags.js'; +import { sumAmounts } from './utils/number-formatters.js'; import { calcBatchFees } from './utils/quote-metadata/calculators.js'; import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; -import type { - QuoteMetadata, - TokenAmountValues, -} from './utils/quote-metadata/types.js'; +import type { QuoteMetadata } from './utils/quote-metadata/types.js'; import { getDefaultSlippagePercentage } from './utils/slippage.js'; import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; @@ -345,10 +343,11 @@ const selectMetadata = createBridgeSelector( bridgeFeesPerGas, destTokenExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - formatAddressToAssetId( - destTokenAddress ?? quote.quote.destAsset.assetId, - destChainId, - ), + quote.quote.destAsset.assetId ?? + formatAddressToAssetId( + destTokenAddress ?? quote.quote.destAsset.assetId, + destChainId, + ), ), nativeExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, @@ -383,10 +382,16 @@ const selectSortedBridgeQuotes = createBridgeSelector( 'asc', ); default: - if (quotesWithMetadata.every((quote) => quote?.cost?.valueInCurrency)) { + if ( + quotesWithMetadata.every( + (quote) => + quote.priceImpact?.valueInCurrency && + quote.priceImpact?.valueInCurrency !== '0', + ) + ) { return orderBy( quotesWithMetadata, - ({ cost }) => Number(cost?.valueInCurrency), + ({ priceImpact }) => Number(priceImpact?.valueInCurrency), 'asc', ); } @@ -419,12 +424,11 @@ const selectActiveQuote = createBridgeSelector( [ selectRecommendedQuote, selectSortedBridgeQuotes, - (_, { selectedQuote }) => selectedQuote, + (_, { selectedQuote }) => selectedQuote?.quote.requestId, ], - (recommendedQuote, sortedQuotes, selectedQuote) => - sortedQuotes.find( - (quote) => quote.quote.requestId === selectedQuote?.quote.requestId, - ) ?? recommendedQuote, + (recommendedQuote, sortedQuotes, requestId) => + sortedQuotes.find((quote) => quote.quote.requestId === requestId) ?? + recommendedQuote, ); const selectIsQuoteGoingToRefresh = createBridgeSelector( @@ -507,33 +511,20 @@ const selectRecommendedQuotes = createBridgeSelector( return acc; }, Array<(QuoteResponseV1 & QuoteMetadata) | null>(requestCount).fill(null)), ); +const selectDestAmountSum = createBridgeSelector( + [selectRecommendedQuotes], + (recommendedQuotes) => { + return sumAmounts(recommendedQuotes.map((quote) => quote?.toTokenAmount)); + }, +); -const selectMetadataSum = createBridgeSelector( - [ - selectRecommendedQuotes, - ( - _, - { - key, - }: { key: 'totalNetworkFee' | 'minToTokenAmount' | 'toTokenAmount' }, - ) => key, - ], - (recommendedQuotes, key) => - recommendedQuotes.reduce( - (acc, quote) => { - acc.usd = new BigNumber(acc.usd) - .plus(quote?.[key]?.usd ?? 0) - .toString(); - acc.valueInCurrency = new BigNumber(acc.valueInCurrency) - .plus(quote?.[key]?.valueInCurrency ?? 0) - .toString(); - acc.amount = new BigNumber(acc.amount) - .plus(quote?.[key]?.amount ?? 0) - .toString(); - return acc; - }, - { usd: '0', valueInCurrency: '0', amount: '0' }, - ), +const selectMinDestAmountSum = createBridgeSelector( + [selectRecommendedQuotes], + (recommendedQuotes) => { + return sumAmounts( + recommendedQuotes.map((quote) => quote?.minToTokenAmount), + ); + }, ); /** @@ -558,10 +549,8 @@ const selectMetadataSum = createBridgeSelector( */ export const selectBatchSellQuotes = createStructuredBridgeSelector({ recommendedQuotes: selectRecommendedQuotes, - totalReceived: (state, opts) => - selectMetadataSum(state, { ...opts, key: 'toTokenAmount' }), - minimumReceived: (state, opts) => - selectMetadataSum(state, { ...opts, key: 'minToTokenAmount' }), + totalReceived: selectDestAmountSum, + minimumReceived: selectMinDestAmountSum, quotesLastFetchedMs: (state) => state.quotesLastFetched, isLoading: (state) => state.quotesLoadingStatus === RequestStatus.LOADING, quoteFetchError: (state) => state.quoteFetchError, diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 9fca0814ef5..a263ee9d2f7 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -310,13 +310,13 @@ const getQuoteRequestId = ({ srcTokenAddress, destTokenAddress, }: QuoteRequest): string => - `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`; + `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); const getQuoteResponseId = ({ srcAsset: { address: srcTokenAddress, chainId: srcChainId }, destAsset: { address: destTokenAddress, chainId: destChainId }, }: QuoteResponseV1['quote']): string => - `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`; + `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); /** * Fetches quotes from the bridge-api @@ -348,7 +348,9 @@ export async function fetchBridgeQuoteStream( serverEventHandlers: { onClose: () => void | Promise; onQuoteValidationFailure: (validationFailures: string[]) => void; - onValidQuoteReceived: (quotes: QuoteResponseV1) => Promise; + onValidQuoteReceived: ( + quotes: QuoteResponseV1 & { resetApproval?: TxData }, + ) => Promise; onTokenWarning: (warning: TokenFeature) => void; onComplete: (data: QuoteStreamCompleteData) => void; }, diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index 6c15b6363a9..186fd6dd382 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -1,6 +1,6 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { TransactionController } from '@metamask/transaction-controller'; -import { numberToHex } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; import { CHAIN_IDS } from '../constants/chains.js'; import type { @@ -12,38 +12,39 @@ import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; import { isTronTrade } from '../validators/trade.js'; import type { TxData } from '../validators/trade.js'; import { isNonEvmChainId, sumHexes } from './bridge.js'; -import { formatChainIdToCaip } from './caip-formatters.js'; +import { formatChainIdToCaip, formatChainIdToHex } from './caip-formatters.js'; import { computeFeeRequest } from './snaps.js'; import { extractTradeData } from './trade-utils.js'; /** * Appends transaction fees for EVM chains to quotes * + * @param chainId - The CAIP srcChainId of the quotes * @param quotes - Array of quote responses to append fees to * @param getLayer1GasFee - The function to use to get the layer 1 gas fee * @returns Array of quotes with fees appended, or undefined if quotes are for non-EVM chains */ -const appendL1GasFees = async ( - quotes: QuoteResponseV1[], +const appendL1GasFees = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, -): Promise<(QuoteResponseV1 & L1GasFees)[] | undefined> => { +): Promise<(QuoteType & L1GasFees)[] | undefined> => { // Indicates whether some of the quotes are not for optimism or base - const hasInvalidQuotes = quotes.some(({ quote }) => { - const chainId = formatChainIdToCaip(quote.srcChainId); - return ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] - .map(formatChainIdToCaip) - .includes(chainId); - }); + const hasInvalidQuotes = ![CHAIN_IDS.OPTIMISM, CHAIN_IDS.BASE] + .map(formatChainIdToCaip) + .includes(chainId); // Only append L1 gas fees if all quotes are for either optimism or base if (hasInvalidQuotes) { return undefined; } + const hexChainId = formatChainIdToHex(chainId); const l1GasFeePromises = Promise.allSettled( quotes.map(async (quoteResponse) => { - const { quote, trade, approval } = quoteResponse; - const chainId = numberToHex(quote.srcChainId); + const { trade, approval } = quoteResponse; const getTxParams = (txData: TxData) => ({ from: txData.from, @@ -54,13 +55,13 @@ const appendL1GasFees = async ( }); const approvalL1GasFees = approval ? await getLayer1GasFee({ - transactionParams: getTxParams(approval), - chainId, + transactionParams: getTxParams(approval as TxData), + chainId: hexChainId, }) : '0x0'; const tradeL1GasFees = await getLayer1GasFee({ transactionParams: getTxParams(trade as TxData), - chainId, + chainId: hexChainId, }); if (approvalL1GasFees === undefined || tradeL1GasFees === undefined) { @@ -75,7 +76,7 @@ const appendL1GasFees = async ( ); const quotesWithL1GasFees = (await l1GasFeePromises).reduce< - (QuoteResponseV1 & L1GasFees)[] + (QuoteType & L1GasFees)[] >((acc, result) => { if (result.status === 'fulfilled' && result.value) { acc.push(result.value); @@ -85,31 +86,36 @@ const appendL1GasFees = async ( return acc; }, []); - return quotesWithL1GasFees; + if (quotesWithL1GasFees.length) { + return quotesWithL1GasFees; + } + return undefined; }; /** * Appends transaction fees for non-EVM chains to quotes * + * @param chainId - The CAIP chain ID of the quotes * @param quotes - Array of quote responses to append fees to * @param messenger - The messaging system to use to call the snap controller * @param selectedAccount - The selected account for which the quotes were requested * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains */ -const appendNonEvmFees = async ( - quotes: QuoteResponseV1[], +const appendNonEvmFees = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], messenger: BridgeControllerMessenger, selectedAccount?: InternalAccount, -): Promise<(QuoteResponseV1 & NonEvmFees)[] | undefined> => { - if ( - quotes.some(({ quote: { srcChainId } }) => !isNonEvmChainId(srcChainId)) - ) { +): Promise<(QuoteType & NonEvmFees)[] | undefined> => { + if (!isNonEvmChainId(chainId)) { return undefined; } const nonEvmFeePromises = Promise.allSettled( quotes.map(async (quoteResponse) => { - const { trade, quote } = quoteResponse; + const { trade } = quoteResponse; // Skip fee computation if no snap account or trade data if (!selectedAccount?.metadata?.snap?.id || !trade) { @@ -117,8 +123,6 @@ const appendNonEvmFees = async ( } try { - const scope = formatChainIdToCaip(quote.srcChainId); - const transaction = extractTradeData(trade); // Tron trades need the visible flag and contract type to be included in the request options @@ -136,7 +140,7 @@ const appendNonEvmFees = async ( selectedAccount?.metadata?.snap?.id, transaction, selectedAccount?.id, - scope, + chainId, options, ), )) as { @@ -164,7 +168,7 @@ const appendNonEvmFees = async ( // Return quote with undefined fee if snap fails (e.g., insufficient UTXO funds) // Client can render special UI or skip the quote card row for quotes with missing fee data console.error( - `Failed to compute non-EVM fees for quote ${quote.requestId}:`, + `Failed to compute non-EVM fees for quote in ${chainId}:`, error, ); return { @@ -176,7 +180,7 @@ const appendNonEvmFees = async ( ); const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce< - (QuoteResponseV1 & NonEvmFees)[] + (QuoteType & NonEvmFees)[] >((acc, result) => { if (result.status === 'fulfilled' && result.value) { acc.push(result.value); @@ -190,24 +194,31 @@ const appendNonEvmFees = async ( /** * Appends transaction fees to quotes * + * @param chainId - The CAIP chain ID of the quotes * @param quotes - Array of quote responses to append fees to * @param messenger - The bridge controller to use to call the snap controller * @param getLayer1GasFee - The function to use to get the layer 1 gas fee * @param selectedAccount - The selected account for which the quotes were requested * @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains */ -export const appendFeesToQuotes = async ( - quotes: QuoteResponseV1[], +export const appendFeesToQuotes = async < + QuoteType extends Omit, +>( + chainId: CaipChainId, + quotes: QuoteType[], messenger: BridgeControllerMessenger, getLayer1GasFee: typeof TransactionController.prototype.getLayer1GasFee, selectedAccount?: InternalAccount, -): Promise<(QuoteResponseV1 & L1GasFees & NonEvmFees)[]> => { +): Promise<(QuoteType & L1GasFees & NonEvmFees)[]> => { // Safe to cast: appendL1GasFees checks if all quotes are EVM and returns undefined otherwise const quotesWithL1GasFees = await appendL1GasFees( - quotes as QuoteResponseV1[], + chainId, + quotes, getLayer1GasFee, ); + const quotesWithNonEvmFees = await appendNonEvmFees( + chainId, quotes, messenger, selectedAccount, From 68d7b187c694e0e8d88d7a42e1e3b762265a16a3 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 18:56:21 -0700 Subject: [PATCH 07/45] feat: update bridge-status --- .../bridge-status-controller/CHANGELOG.md | 2 + .../src/bridge-status-controller.test.ts | 74 ++++++++++--------- .../src/bridge-status-controller.ts | 32 +++++--- .../src/strategy/types.ts | 6 +- .../bridge-status-controller/src/types.ts | 13 ++-- .../src/utils/bridge-status.ts | 6 +- .../src/utils/intent-api.ts | 4 +- .../src/utils/metrics.ts | 8 +- .../src/utils/snaps.ts | 8 +- .../src/utils/trace.ts | 6 +- .../src/utils/transaction.test.ts | 44 +++++------ .../src/utils/transaction.ts | 8 +- 12 files changed, 116 insertions(+), 95 deletions(-) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index aa05e55ca58..96d0cdec76a 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9085](https://github.com/MetaMask/core/pull/9085)) +- Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx`. Controller logic and utils still require V1, but clients can submit quotes in both formats ([#9085](https://github.com/MetaMask/core/pull/9085)) - Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) ## [74.6.0] diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index ad7731d81f5..a337b40e997 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -3,7 +3,7 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; import type { BridgeControllerMessenger, - QuoteResponse, + QuoteResponseV1, QuoteMetadata, TxData, TronTradeData, @@ -19,6 +19,8 @@ import { UnifiedSwapBridgeEventName, MetaMetricsSwapsEventSource, mergeQuoteMetadata, + validateQuoteResponseV1, + toQuoteResponseV2, } from '@metamask/bridge-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -2191,7 +2193,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana bridge', () => { - const mockQuote: QuoteResponse = { + const mockQuote: QuoteResponseV1 = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2424,7 +2426,7 @@ describe('BridgeStatusController', () => { }); describe('submitTx: Solana swap', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2677,7 +2679,7 @@ describe('BridgeStatusController', () => { '0a02aabb22084dde86d0f68ae3e5403a680801b2630a31747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e54726967676572536d617274436f6e747261637412330a15418f7ea8cce9f8bba67d7ae59cd49a1965d617e71b121541a614f803b6fd780986a42c78ec9c7f77e6ded13c', }; - const mockQuoteResponse: QuoteResponse & + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { requestId: '123', @@ -2944,7 +2946,8 @@ describe('BridgeStatusController', () => { chainId: 42161, gasLimit: 21000, }, - } as QuoteResponse & QuoteMetadata; + }; + validateQuoteResponseV1(mockEvmQuoteResponse); const mockEvmTxMeta = { id: 'test-tx-id', @@ -3157,7 +3160,7 @@ describe('BridgeStatusController', () => { }) => { const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; const quoteResponseV2 = mergeQuoteMetadata( - quoteWithoutApproval, + toQuoteResponseV2(quoteWithoutApproval), quoteWithoutApproval, ); const quotesReceivedContext = getQuotesReceivedProperties( @@ -4388,6 +4391,35 @@ describe('BridgeStatusController', () => { }); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const mockQuote = { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + amount: '100', + asset: mockEvmQuoteResponse.quote.feeData.metabridge.asset, + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + trade: { + ...(quoteWithoutApproval.trade as TxData), + gasLimit: null, + }, + sentAmount: { + amount: undefined, + valueInCurrency: undefined, + usd: undefined, + }, + }; + validateQuoteResponseV1(mockQuote); + await withController( { mockMessengerCall }, async ({ @@ -4395,36 +4427,10 @@ describe('BridgeStatusController', () => { rootMessenger, startPollingForBridgeTxStatusSpy, }) => { - const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; const result = await rootMessenger.call( 'BridgeStatusController:submitTx', (mockEvmQuoteResponse.trade as TxData).from, - { - ...quoteWithoutApproval, - quote: { - ...quoteWithoutApproval.quote, - gasIncluded: true, - gasIncluded7702: false, - feeData: { - ...quoteWithoutApproval.quote.feeData, - txFee: { - amount: '100', - asset: quoteWithoutApproval.quote.feeData.metabridge.asset, - maxFeePerGas: '1395348', // Decimal string from quote - maxPriorityFeePerGas: '1000001', - }, - }, - }, - trade: { - ...(quoteWithoutApproval.trade as TxData), - gasLimit: null, - }, - sentAmount: { - amount: null as never, - valueInCurrency: null, - usd: null, - }, - }, + mockQuote, false, // isStxEnabledOnClient = FALSE (key for this test) ); controller.stopAllPolling(); @@ -6617,7 +6623,7 @@ describe('BridgeStatusController', () => { const EVM_TX_META_ID = 'evmEarlyTxMetaId'; const EVM_QUOTE_ID = 'evm-early-quote-1'; - const mockEvmSwapQuoteResponse = { + const mockEvmSwapQuoteResponse: QuoteResponseV1 & QuoteMetadata = { ...getMockQuote({ srcChainId: 42161, destChainId: 42161 }), quoteId: EVM_QUOTE_ID, quote: { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 709d2ccbde0..c2540b43e76 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -2,11 +2,15 @@ import type { StateMetadata } from '@metamask/base-controller'; import { QuoteMetadata, RequiredEventContextFromClient, - QuoteResponse, + QuoteResponseV1, Trade, FeatureId, BatchSellTradesResponse, InputPrimaryDenomination, + QuoteResponse, + toQuoteMetadataV1, + mergeQuoteMetadata, + toQuoteResponseV1, } from '@metamask/bridge-controller'; import { isNonEvmChainId, @@ -19,8 +23,10 @@ import { PollingStatus, formatChainIdToHex, } from '@metamask/bridge-controller'; +import { QuoteResponseSchemaV1 } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { is } from '@metamask/superstruct'; import { TransactionStatus, TransactionType, @@ -1372,8 +1378,10 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata) - | (QuoteResponse & QuoteMetadata)[], + | QuoteResponse + | QuoteResponse[] + | (QuoteResponseV1 & QuoteMetadata) + | (QuoteResponseV1 & QuoteMetadata)[], isStxEnabled: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.Unknown, @@ -1388,9 +1396,17 @@ export class BridgeStatusController extends StaticIntervalPollingController { + if (is(quote, QuoteResponseSchemaV1)) { + return quote; + } + const quoteMetadata = toQuoteMetadataV1(quote); + return mergeQuoteMetadata(toQuoteResponseV1(quote), quoteMetadata); + }); const quoteResponse = quoteResponses[0]; const { quote } = quoteResponse; @@ -1519,7 +1535,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; + quoteResponse: QuoteResponse; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1557,7 +1573,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata) | null)[]; + quoteResponses: (QuoteResponse | null)[]; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1574,9 +1590,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata => + (quoteResponse): quoteResponse is QuoteResponse & QuoteMetadata => quoteResponse !== null, ), params.isStxEnabled ?? false, diff --git a/packages/bridge-status-controller/src/strategy/types.ts b/packages/bridge-status-controller/src/strategy/types.ts index 2d56a7f8d9a..00e7f39ea28 100644 --- a/packages/bridge-status-controller/src/strategy/types.ts +++ b/packages/bridge-status-controller/src/strategy/types.ts @@ -3,7 +3,7 @@ import type { BatchSellTradesResponse, BridgeClientId, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, Trade, TxData, } from '@metamask/bridge-controller'; @@ -61,7 +61,7 @@ export type SubmitStepResult = 'approvalTxId' | 'bridgeTxMeta' | 'originalTransactionId' | 'actionId' > & { historyKey: string; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; batchSellData?: BatchSellTradesResponse; quoteIds?: string[]; }; @@ -131,7 +131,7 @@ export type SubmitStrategyParams< */ isStxEnabled: boolean; messenger: BridgeStatusControllerMessenger; - quoteResponses: (QuoteResponse & QuoteMetadata)[]; + quoteResponses: (QuoteResponseV1 & QuoteMetadata)[]; /** * Set to true so hardware wallets get prompted for approval on mobile */ diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 64867050805..6f381beb066 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -6,9 +6,8 @@ import type { import type { ChainId, FeatureId, - Quote, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, MetaMetricsSwapsEventSource, SimulatedGasFeeLimits, TxData, @@ -77,7 +76,7 @@ export type StatusRequest = { bridge: string; // lifi, socket, squid srcChainId: ChainId; // lifi, socket, squid destChainId: ChainId; // lifi, socket, squid - quote?: Quote; // squid + quote?: QuoteResponseV1['quote']; // squid refuel?: boolean; // lifi }; @@ -120,7 +119,7 @@ export type RefuelStatusResponse = object & StatusResponse; */ export type QuoteAndTxMetadata = { type: TransactionType; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; /** * The approval or trade object from the quote response */ @@ -157,7 +156,7 @@ export type BridgeHistoryItem = { * persisted before this field was introduced. */ quoteIds?: string[]; - quote: Quote; + quote: QuoteResponseV1['quote']; /** * This is the the quote id used on single swaps/bridges. On batch sell, it is set * as the first item of `quoteIds`. @@ -290,7 +289,7 @@ export type StartPollingForBridgeTxStatusArgs = { * @deprecated the txMeta or orderUid should be used instead */ originalTransactionId?: string; - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; startTime: BridgeHistoryItem['startTime']; slippagePercentage: BridgeHistoryItem['slippagePercentage']; initialDestAssetBalance?: BridgeHistoryItem['initialDestAssetBalance']; @@ -319,7 +318,7 @@ export type StartPollingForBridgeTxStatusArgsSerialized = Omit< StartPollingForBridgeTxStatusArgs, 'quoteResponse' > & { - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; }; export type SourceChainTxMetaId = string; diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts index 15040f27032..c15bf33cd39 100644 --- a/packages/bridge-status-controller/src/utils/bridge-status.ts +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -3,7 +3,7 @@ import { isNonEvmChainId, StatusTypes, } from '@metamask/bridge-controller'; -import type { Quote, QuoteResponse } from '@metamask/bridge-controller'; +import type { QuoteResponseV1 } from '@metamask/bridge-controller'; import type { Provider } from '@metamask/network-controller'; import { StructError } from '@metamask/superstruct'; @@ -105,7 +105,7 @@ export const fetchBridgeTxStatus = async ( }; export const getStatusRequestWithSrcTxHash = ( - quote: Quote, + quote: QuoteResponseV1['quote'], srcTxHash: string, ): StatusRequestWithSrcTxHash => { const { bridgeId, bridges, srcChainId, destChainId, refuel } = quote; @@ -205,7 +205,7 @@ export const shouldWaitForFinalBridgeStatus = async ( * @returns The status request parameters */ export const getStatusRequestParams = ( - quoteResponse: QuoteResponse, + quoteResponse: QuoteResponseV1, ): StatusRequest => { return { bridgeId: quoteResponse.quote.bridgeId, diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index 38421882f08..8831ff0423b 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -3,7 +3,7 @@ import { ChainId, getClientHeaders, Intent, - QuoteResponse, + QuoteResponseV1, StatusTypes, } from '@metamask/bridge-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; @@ -150,7 +150,7 @@ export function mapIntentOrderStatusToTransactionStatus( * @returns The intent data from the quote * @throws Error if the quote does not contain intent data */ -export function getIntentFromQuote(quoteResponse: QuoteResponse): Intent { +export function getIntentFromQuote(quoteResponse: QuoteResponseV1): Intent { const { intent } = quoteResponse.quote; if (!intent) { throw new Error('submitIntent: missing intent data'); diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index dee8e2e7bc3..183f6f47989 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -21,7 +21,7 @@ import type { AccountHardwareType, QuoteFetchData, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, TxStatusData, RequestParams, TradeData, @@ -154,7 +154,7 @@ export const getRequestParamFromHistory = ( }; export const getTradeDataFromQuote = ( - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, batchSellTrades?: BatchSellTradesResponse | null, ): TradeData => { return { @@ -174,7 +174,7 @@ export const getTradeDataFromQuote = ( }; export const getPriceImpactFromQuote = ( - quote: QuoteResponse['quote'], + quote: QuoteResponseV1['quote'], ): Pick => { return { price_impact: Number(quote.priceData?.priceImpact ?? '0') }; }; @@ -195,7 +195,7 @@ export const getPriceImpactFromQuote = ( * @returns The properties for the pre-confirmation event */ export const getPreConfirmationPropertiesFromQuote = ( - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, isStxEnabled: boolean, accountHardwareType: AccountHardwareType, location?: MetaMetricsSwapsEventSource, diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts index 9540658b257..1117a074cd5 100644 --- a/packages/bridge-status-controller/src/utils/snaps.ts +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -2,7 +2,7 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { QuoteMetadata, - QuoteResponse, + QuoteResponseV1, Trade, } from '@metamask/bridge-controller'; import { @@ -125,7 +125,7 @@ export const getClientRequest = ( }; export const getTxMetaFields = ( - quoteResponse: Omit, 'approval' | 'trade'> & + quoteResponse: Omit, 'approval' | 'trade'> & QuoteMetadata, approvalTxId?: string, ): Omit< @@ -178,7 +178,7 @@ export const handleNonEvmTxResponse = ( | { result: Record } | { signature: string }, trade: Trade, - quoteResponse: Omit, 'trade' | 'approval'> & + quoteResponse: Omit, 'trade' | 'approval'> & QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ): TransactionMeta & SolanaTransactionMeta => { @@ -259,7 +259,7 @@ export const handleNonEvmTxResponse = ( export const handleNonEvmTx = async ( messenger: BridgeStatusControllerMessenger, trade: Trade, - quoteResponse: QuoteResponse & QuoteMetadata, + quoteResponse: QuoteResponseV1 & QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ): Promise => { if (!selectedAccount.metadata?.snap?.id) { diff --git a/packages/bridge-status-controller/src/utils/trace.ts b/packages/bridge-status-controller/src/utils/trace.ts index 3faf53d55ab..570cd46cf3a 100644 --- a/packages/bridge-status-controller/src/utils/trace.ts +++ b/packages/bridge-status-controller/src/utils/trace.ts @@ -2,13 +2,13 @@ import { formatChainIdToCaip, isCrossChain, - QuoteResponse, + QuoteResponseV1, } from '@metamask/bridge-controller'; import { TraceName } from '../constants.js'; export const getTraceParams = ( - quoteResponse: QuoteResponse, + quoteResponse: QuoteResponseV1, isStxEnabled: boolean, ) => { return { @@ -26,7 +26,7 @@ export const getTraceParams = ( }; export const getApprovalTraceParams = ( - quoteResponse: QuoteResponse, + quoteResponse: QuoteResponseV1, isStxEnabled: boolean, ) => { return { diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index f5d8be3aab3..7b9923e2a9f 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -8,7 +8,7 @@ import { } from '@metamask/bridge-controller'; import type { QuoteMetadata, - QuoteResponse, + QuoteResponseV1, TxData, } from '@metamask/bridge-controller'; import { @@ -121,7 +121,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('getStatusRequestParams', () => { it('should extract status request parameters from a quote response', () => { - const mockQuoteResponse: QuoteResponse = { + const mockQuoteResponse: QuoteResponseV1 = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -167,7 +167,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle quote with refuel flag set to true', () => { - const mockQuoteResponse: QuoteResponse = { + const mockQuoteResponse: QuoteResponseV1 = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -209,7 +209,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle quote with multiple bridges', () => { - const mockQuoteResponse: QuoteResponse = { + const mockQuoteResponse: QuoteResponseV1 = { quote: { bridgeId: 'bridge1', bridges: ['bridge1', 'bridge2'], @@ -253,7 +253,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('getTxMetaFields', () => { it('should extract transaction meta fields from a quote response', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -346,7 +346,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should include approvalTxId when provided', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -522,7 +522,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('handleNonEvmTxResponse', () => { it('should handle string response format', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -633,7 +633,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with signature', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -721,7 +721,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle onClientRequest response format with signature', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -808,7 +808,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with txid', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -896,7 +896,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with hash', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -984,7 +984,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle object response format with txHash', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -1072,7 +1072,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle new unified interface response with transactionId', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -1168,7 +1168,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }); it('should handle empty or invalid response', () => { - const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + const mockQuoteResponse: QuoteResponseV1 & QuoteMetadata = { quote: { bridgeId: 'bridge1', bridges: ['bridge1'], @@ -1371,10 +1371,10 @@ describe('Bridge Status Controller Transaction Utils', () => { steps: [], feeData: {}, }, - // Required properties for QuoteResponse + // Required properties for QuoteResponseV1 trade: {} as TxData, estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponse; + } as unknown as QuoteResponseV1; // Create a promise that will resolve after the delay const delayPromise = handleApprovalDelay( @@ -1411,10 +1411,10 @@ describe('Bridge Status Controller Transaction Utils', () => { steps: [], feeData: {}, }, - // Required properties for QuoteResponse + // Required properties for QuoteResponseV1 trade: {} as TxData, estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponse; + } as unknown as QuoteResponseV1; // Create a promise that will resolve after the delay const delayPromise = handleApprovalDelay( @@ -1451,10 +1451,10 @@ describe('Bridge Status Controller Transaction Utils', () => { steps: [], feeData: {}, }, - // Required properties for QuoteResponse + // Required properties for QuoteResponseV1 trade: {} as TxData, estimatedProcessingTimeInSeconds: 60, - } as unknown as QuoteResponse; + } as unknown as QuoteResponseV1; // Create a promise that will resolve after the delay const delayPromise = handleApprovalDelay( @@ -1516,7 +1516,7 @@ describe('Bridge Status Controller Transaction Utils', () => { describe('getClientRequest', () => { it('should generate a valid client request', () => { - const mockQuoteResponse: Omit, 'approval'> & + const mockQuoteResponse: Omit, 'approval'> & QuoteMetadata = { quote: { bridgeId: 'bridge1', @@ -1822,7 +1822,7 @@ describe('Bridge Status Controller Transaction Utils', () => { includeApproval?: boolean; includeResetApproval?: boolean; } = {}, - ): QuoteResponse & QuoteMetadata => + ): QuoteResponseV1 & QuoteMetadata => ({ quote: { bridgeId: 'bridge1', diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 519a2edf533..922e5ff1f6c 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -10,7 +10,7 @@ import { import type { BatchSellTradesResponse, QuoteMetadata, - QuoteResponse, + QuoteResponseV1, SimulatedGasFeeLimits, Trade, TxData, @@ -215,7 +215,7 @@ export const addSyntheticTransaction = async ( }; export const handleApprovalDelay = async ( - srcChainId: QuoteResponse['quote']['srcChainId'], + srcChainId: QuoteResponseV1['quote']['srcChainId'], ) => { if ([ChainId.LINEA, ChainId.BASE].includes(srcChainId)) { const debugLog = createProjectLogger('bridge'); @@ -295,7 +295,7 @@ export const toQuoteAndTxMetadata = ({ quoteResponse, isBridgeTx, }: { - quoteResponse: QuoteResponse & QuoteMetadata; + quoteResponse: QuoteResponseV1 & QuoteMetadata; isBridgeTx: boolean; }): Omit[] => { const tradeData: QuoteAndTxMetadata[] = []; @@ -347,7 +347,7 @@ export const toQuoteAndTxMetadataBatch = ({ quoteResponses, batchSellTrades, }: { - quoteResponses: (QuoteResponse & QuoteMetadata)[]; + quoteResponses: (QuoteResponseV1 & QuoteMetadata)[]; batchSellTrades: BatchSellTradesResponse; }): Omit[] => { const tradeData: QuoteAndTxMetadata[] = []; From a5b9cb2896285a59b5d7064ccd7a2f292477a239 Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 19:31:14 -0700 Subject: [PATCH 08/45] feat: save quotes as V2 (breaking) --- packages/bridge-controller/CHANGELOG.md | 6 + .../bridge-controller.sse.test.ts.snap | 21 +- .../src/bridge-controller.sse.batch.test.ts | 36 ++- .../src/bridge-controller.sse.test.ts | 216 +++++++-------- .../src/bridge-controller.test.ts | 98 +++---- .../src/bridge-controller.ts | 10 +- packages/bridge-controller/src/index.ts | 12 +- .../bridge-controller/src/selectors.test.ts | 254 +++++++++++++----- packages/bridge-controller/src/selectors.ts | 71 +++-- packages/bridge-controller/src/types.ts | 3 +- .../bridge-controller/src/utils/fetch.test.ts | 21 +- packages/bridge-controller/src/utils/fetch.ts | 109 ++++---- .../src/utils/metrics/properties.test.ts | 85 +++--- .../src/utils/metrics/properties.ts | 35 ++- .../src/utils/metrics/types.ts | 2 +- .../src/utils/quote-metadata/merge.ts | 3 + .../tests/mock-quotes-erc20-native.ts | 6 +- .../tests/mock-quotes-native-erc20-eth.ts | 6 +- .../tests/mock-quotes-native-erc20.ts | 11 + .../tests/mock-quotes-sol-erc20.ts | 8 + 20 files changed, 588 insertions(+), 425 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 646187836f6..f25389837c1 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes +- **BREAKING:** Use QuoteResponse V2 within the BridgeController; this affects the batch-sell, unified swap/bridge and quickBuy experiences ([#9085](https://github.com/MetaMask/core/pull/9085)) + - convert quotes to QuoteResponse v2 in `fetchBridgeQuoteStream` + - store quotes as QuoteResponse v2 in the BridgeController + - `QuoteResponse` export now means v2; v1 is still exported as `QuoteResponseV1` + - `fetchBridgeQuoteStream` and `fetchBatchSellTrades` now return `QuoteResponse` v2 + - `fetchBatchSellTrades` expects V2 quotes, then transforms them to V1 for backend compatibility - Bump `@metamask/assets-controllers` from `^110.0.0` to `^110.0.1` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706)) - Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index db9eb7e632a..7fcfe0b4439 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -9,18 +9,15 @@ exports[`BridgeController SSE should publish validation failures 4`] = ` "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "failures": [ - "lifi|trade", - "lifi|trade.chainId", - "lifi|trade.to", - "lifi|trade.from", - "lifi|trade.value", - "lifi|trade.data", - "lifi|trade.gasLimit", - "lifi|trade.unsignedPsbtBase64", - "lifi|trade.inputsToSign", - "lifi|trade.raw_data_hex", - "lifi|trade.xdrBase64", - "lifi|trade.xdr", + "lifi|quote.src", + "lifi|quote.dest", + "lifi|quote.feeData.metabridge", + "lifi|quote.aggregator", + "lifi|quote.protocols", + "lifi|quote.steps.0.src", + "lifi|quote.steps.0.dest", + "lifi|quote.steps.1.src", + "lifi|quote.steps.1.dest", ], "feature_id": "unified_swap_bridge", "location": "Unknown", diff --git a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts index b67e8b87a17..0eef36e57a5 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -7,8 +7,14 @@ import type { } from '@metamask/messenger'; import { flushPromises } from '../../../tests/helpers.js'; -import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; import { advanceToNthTimerThenFlush, mockSseBatchSellEventSource, @@ -419,7 +425,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20V1 + quotes: getMockBridgeQuotesNativeErc20V2() .map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -428,13 +434,15 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () featureId: FeatureId.BATCH_SELL, })) .concat( - mockBridgeQuotesErc20Erc20V1.map((quote) => ({ - ...quote, - l1GasFeesInHexWei: '0x2', - resetApproval: undefined, - quoteRequestIndex: 1, - featureId: FeatureId.BATCH_SELL, - })), + getMockBridgeQuotesErc20Erc20V2({ quoteRequestIndex: 1 }).map( + (quote) => ({ + ...quote, + l1GasFeesInHexWei: '0x2', + resetApproval: undefined, + quoteRequestIndex: 1, + featureId: FeatureId.BATCH_SELL, + }), + ), ), quotesRefreshCount: 1, quotesLoadingStatus: 1, @@ -642,7 +650,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () ); await rootMessenger.call( 'BridgeController:updateBatchSellTrades', - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, ); @@ -682,7 +690,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () '13.8.0', ]); expect(fetchBatchSellTradesSpy.mock.calls[1]).toStrictEqual([ - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, expect.any(AbortSignal), 'extension', @@ -879,7 +887,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () // 2nd fetch await rootMessenger.call( 'BridgeController:updateBatchSellTrades', - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, ); @@ -889,7 +897,7 @@ describe('BridgeController BatchSell (multiple quote requests) SSE', function () expect(stopAllPollingSpy).toHaveBeenCalledTimes(0); expect(abortControllerSpy).toHaveBeenCalledTimes(1); expect(fetchBatchSellTradesSpy.mock.calls[1][0]).toStrictEqual( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), ); expect(startPollingSpy).not.toHaveBeenCalled(); expect(bridgeController.state.batchSellTrades).toBeNull(); diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 56a789fd944..f0ebc848edd 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -8,12 +8,20 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; import { abiERC20 } from '@metamask/metamask-eth-abis'; -import { merge } from 'lodash'; import { flushPromises } from '../../../tests/helpers.js'; -import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; -import { mockBridgeQuotesNativeErc20EthV1 } from '../tests/mock-quotes-native-erc20-eth.js'; -import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; +import { + mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2, +} from '../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesNativeErc20EthV2, + mockBridgeQuotesNativeErc20EthV1, +} from '../tests/mock-quotes-native-erc20-eth.js'; +import { + getMockBridgeQuotesNativeErc20V2, + mockBridgeQuotesNativeErc20V1, +} from '../tests/mock-quotes-native-erc20.js'; import { advanceToNthTimer, advanceToNthTimerThenFlush, @@ -322,7 +330,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', resetApproval: undefined, @@ -358,7 +366,7 @@ describe('BridgeController SSE', function () { ], ['swapping', '0', '0x1', undefined, false, 1], ])( - 'should append resetApproval when %s USDT on Ethereum', + 'should append resetApproval when %s USDT on Ethereum (%s, %s)', async function ( _: string, allowance: string, @@ -377,31 +385,21 @@ describe('BridgeController SSE', function () { fetchBridgeQuotesSpy, consoleLogSpy, }) => { - const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20V1.map( - (quote) => ({ - ...quote, - quote: { - ...quote.quote, - srcAsset: { - address: ETH_USDT_ADDRESS, - assetId: - `${formatChainIdToCaip(1)}/erc20:${srcTokenAddress}` as const, - symbol: 'USDT', - name: 'Tether USD', - decimals: 6, - chainId: 1, - iconUrl: 'https://media.socket.tech/tokens/all/USDT', - }, - srcChainId: 1, - destChainId: formatChainIdToDec(destChainId), - destAsset: { - ...quote.quote.destAsset, - assetId: - `${formatChainIdToCaip(destChainId)}/erc20:${quote.quote.destAsset.address}` as const, - }, + const mockUSDTQuoteResponse = getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: + `${formatChainIdToCaip(1)}/erc20:${srcTokenAddress}` as const, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', }, - }), - ); + srcChainId: 1, + }, + }); mockFetchFn.mockImplementationOnce(async () => { return mockSseEventSource(mockUSDTQuoteResponse); }); @@ -507,39 +505,30 @@ describe('BridgeController SSE', function () { resetApproval, }, ], - quotes: mockBridgeQuotesErc20Erc20V1 - .map((quote) => - merge({}, quote, { - quote: { - srcAsset: { - address: ETH_USDT_ADDRESS, - assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, - symbol: 'USDT', - name: 'Tether USD', - decimals: 6, - chainId: 1, - iconUrl: 'https://media.socket.tech/tokens/all/USDT', - }, - srcChainId: 1, - destAsset: { - ...quote.quote.destAsset, - assetId: - `${formatChainIdToCaip(destChainId)}/erc20:${quote.quote.destAsset.address}` as const, - }, - destChainId: formatChainIdToDec(destChainId), - }, - }), - ) - .map((quote) => ({ - ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, - resetApproval: tradeData - ? { - ...quote.approval, - data: tradeData, - } - : undefined, - })), + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + symbol: 'USDT', + name: 'Tether USD', + decimals: 6, + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', + }, + srcChainId: 1, + destChainId: formatChainIdToDec(destChainId), + }, + }).map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: tradeData + ? { + ...quote.approval, + data: tradeData, + } + : undefined, + })), quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, @@ -701,31 +690,27 @@ describe('BridgeController SSE', function () { resetApproval: true, }, ], - quotes: mockBridgeQuotesErc20Erc20V1 - .map((quote) => - merge({}, quote, { - quote: { - srcAsset: { - address: ETH_USDT_ADDRESS, - assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, - name: 'Tether USD', - decimals: 6, - symbol: 'USDT', - chainId: 1, - iconUrl: 'https://media.socket.tech/tokens/all/USDT', - }, - srcChainId: 1, - }, - }), - ) - .map((quote) => ({ - ...quote, - featureId: FeatureId.UNIFIED_SWAP_BRIDGE, - resetApproval: { - ...quote.approval, - data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + quotes: getMockBridgeQuotesErc20Erc20V2({ + quote: { + srcAsset: { + address: ETH_USDT_ADDRESS, + assetId: `eip155:1/erc20:${ETH_USDT_ADDRESS}`, + name: 'Tether USD', + decimals: 6, + symbol: 'USDT', + chainId: 1, + iconUrl: 'https://media.socket.tech/tokens/all/USDT', }, - })), + srcChainId: 1, + }, + }).map((quote) => ({ + ...quote, + featureId: FeatureId.UNIFIED_SWAP_BRIDGE, + resetApproval: { + ...quote.approval, + data: '0x095ea7b30000000000000000000000000439e60f02a8900a951603950d8d4527f400c3f10000000000000000000000000000000000000000000000000000000000000000', + }, + })), quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, @@ -773,7 +758,7 @@ describe('BridgeController SSE', function () { jest.advanceTimersByTime(FIRST_FETCH_DELAY); await flushPromises(); expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20V1.map((quote) => ({ + getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', resetApproval: undefined, @@ -798,7 +783,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: [mockBridgeQuotesNativeErc20EthV1[0]].map((quote) => ({ + quotes: [getMockBridgeQuotesNativeErc20EthV2()[0]].map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -825,7 +810,7 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(); expect(bridgeController.state).toStrictEqual({ ...expectedState, - quotes: mockBridgeQuotesNativeErc20EthV1.map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20EthV2().map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -895,7 +880,7 @@ describe('BridgeController SSE', function () { FIRST_FETCH_DELAY, ); expect(bridgeController.state.quotes).toStrictEqual( - mockBridgeQuotesNativeErc20EthV1.map((quote) => ({ + getMockBridgeQuotesNativeErc20EthV2().map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -928,13 +913,13 @@ describe('BridgeController SSE', function () { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ).toBeGreaterThan(t2!); expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - "Failed to stream bridge quotes", - "Network error", - ], - ] - `); + [ + [ + "Failed to stream bridge quotes", + "Network error", + ], + ] + `); expect(hasSufficientBalanceSpy).toHaveBeenCalledTimes(1); expect(getLayer1GasFeeMock).toHaveBeenCalledTimes(2); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(8); @@ -1067,7 +1052,7 @@ describe('BridgeController SSE', function () { quotesInitialLoadTime: THIRD_FETCH_DELAY, quotes: [ { - ...mockBridgeQuotesNativeErc20V1[0], + ...getMockBridgeQuotesNativeErc20V2()[0], l1GasFeesInHexWei: '0x1', resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1102,8 +1087,8 @@ describe('BridgeController SSE', function () { quotesRefreshCount: 1, quotesLoadingStatus: RequestStatus.FETCHED, quotes: [ - ...mockBridgeQuotesNativeErc20V1, - ...mockBridgeQuotesNativeErc20V1, + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), ].map((quote) => ({ ...quote, l1GasFeesInHexWei: '0x1', @@ -1250,8 +1235,8 @@ describe('BridgeController SSE', function () { await advanceToNthTimerThenFlush(3); expect(bridgeController.state.quotes).toStrictEqual( [ - ...mockBridgeQuotesNativeErc20V1, - ...mockBridgeQuotesNativeErc20V1, + ...getMockBridgeQuotesNativeErc20V2(), + ...getMockBridgeQuotesNativeErc20V2(), ].map((quote) => ({ ...quote, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1281,7 +1266,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, ], - quotes: [mockBridgeQuotesNativeErc20EthV1[0]].map((quote) => ({ + quotes: [getMockBridgeQuotesNativeErc20EthV2()[0]].map((quote) => ({ ...quote, resetApproval: undefined, featureId: FeatureId.UNIFIED_SWAP_BRIDGE, @@ -1310,20 +1295,15 @@ describe('BridgeController SSE', function () { [ "Quote validation failed", [ - "At path: trade (union) -- Expected the value to satisfy a union of \`type | type | type | union | string\`, but received: [object Object]", - "At path: trade.chainId (union) -- Expected a number, but received: undefined", - "At path: trade.to (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.from (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.value (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.data (union) -- Expected a value of type \`HexString\`, but received: \`undefined\`", - "At path: trade.gasLimit (union) -- Expected a number, but received: undefined", - "At path: trade.unsignedPsbtBase64 (union) -- Expected a string, but received: undefined", - "At path: trade.inputsToSign (union) -- Expected an array value, but received: undefined", - "At path: trade.raw_data_hex (union) -- Expected a string, but received: undefined", - "At path: trade (union) -- Expected the value to satisfy a union of \`type | type\`, but received: [object Object]", - "At path: trade.xdrBase64 (union) -- Expected a string, but received: undefined", - "At path: trade.xdr (union) -- Expected a string, but received: undefined", - "At path: trade (union) -- Expected a string, but received: [object Object]", + "At path: quote.src (type) -- Expected an object, but received: undefined", + "At path: quote.dest (type) -- Expected an object, but received: undefined", + "At path: quote.feeData.metabridge (type) -- Expected an array value, but received: [object Object]", + "At path: quote.aggregator (type) -- Expected a string, but received: undefined", + "At path: quote.protocols (type) -- Expected an array value, but received: undefined", + "At path: quote.steps.0.src (type) -- Expected an object, but received: undefined", + "At path: quote.steps.0.dest (type) -- Expected an object, but received: undefined", + "At path: quote.steps.1.src (type) -- Expected an object, but received: undefined", + "At path: quote.steps.1.dest (type) -- Expected an object, but received: undefined", ], ] `); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index e338ac1e515..f573c7dc2f5 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -1,5 +1,6 @@ /* eslint-disable jest/no-restricted-matchers */ import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { handleFetch } from '@metamask/controller-utils'; import { BtcScope, EthAccountType, @@ -17,11 +18,16 @@ import type { CaipAssetType } from '@metamask/utils'; import nock from 'nock'; import { flushPromises } from '../../../tests/helpers.js'; -import { handleFetch } from '../../controller-utils/src/index.js'; import { mockBridgeQuotesErc20NativeV1 } from '../tests/mock-quotes-erc20-native.js'; -import { mockBridgeQuotesNativeErc20EthV1 } from '../tests/mock-quotes-native-erc20-eth.js'; +import { + mockBridgeQuotesNativeErc20EthV1, + getMockBridgeQuotesNativeErc20EthV2, +} from '../tests/mock-quotes-native-erc20-eth.js'; import { mockBridgeQuotesNativeErc20V1 } from '../tests/mock-quotes-native-erc20.js'; -import { mockBridgeQuotesSolErc20V1 } from '../tests/mock-quotes-sol-erc20.js'; +import { + getMockBridgeQuotesSolErc20V2, + mockBridgeQuotesSolErc20V1, +} from '../tests/mock-quotes-sol-erc20.js'; import { advanceToNthTimerThenFlush } from '../tests/mock-sse.js'; import { BridgeController } from './bridge-controller.js'; import { @@ -724,7 +730,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, }), ); @@ -747,8 +753,8 @@ describe('BridgeController', function () { }, ], quotes: [ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ], quotesLoadingStatus: 1, quoteFetchError: null, @@ -824,8 +830,8 @@ describe('BridgeController', function () { expect(stateWithoutTimestamp).toMatchSnapshot(); expect(quotes).toStrictEqual([ - ...mockBridgeQuotesNativeErc20EthV1, - ...mockBridgeQuotesNativeErc20EthV1, + ...getMockBridgeQuotesNativeErc20EthV2(), + ...getMockBridgeQuotesNativeErc20EthV2(), ]); expect( quotesLastFetched, @@ -1006,7 +1012,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1083,7 +1089,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '5000', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1132,7 +1138,7 @@ describe('BridgeController', function () { expect(bridgeController.state).toStrictEqual( expect.objectContaining({ minimumBalanceForRentExemptionInLamports: '0', - quotes: mockBridgeQuotesSolErc20V1.map((quote) => ({ + quotes: getMockBridgeQuotesSolErc20V2().map((quote) => ({ ...quote, nonEvmFeesInNative: '0.000000014', })), @@ -1328,7 +1334,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1370,7 +1376,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -1512,7 +1518,7 @@ describe('BridgeController', function () { resetApproval: false, }, ], - quotes: mockBridgeQuotesNativeErc20EthV1, + quotes: getMockBridgeQuotesNativeErc20EthV2(), quotesLoadingStatus: 1, quotesRefreshCount: 1, quotesInitialLoadTime: 11000, @@ -2101,7 +2107,7 @@ describe('BridgeController', function () { bridgeController.state; expect(stateWithoutQuotes).toMatchSnapshot(); - expect(quotes).toStrictEqual(mockBridgeQuotesNativeErc20EthV1); + expect(quotes).toStrictEqual(getMockBridgeQuotesNativeErc20EthV2()); expect(quotesLastFetched).toBeCloseTo(Date.now() - 10000); jest.advanceTimersByTime(10000); @@ -2113,7 +2119,7 @@ describe('BridgeController', function () { } = bridgeController.state; expect(stateWithoutQuotes2).toMatchSnapshot(); - expect(quotes2).toStrictEqual(mockBridgeQuotesNativeErc20EthV1); + expect(quotes2).toStrictEqual(getMockBridgeQuotesNativeErc20EthV2()); expect(quotesLastFetched2).toBe(quotesLastFetched); expect(consoleLogSpy).toHaveBeenCalledTimes(1); @@ -2419,9 +2425,7 @@ describe('BridgeController', function () { // Verify non-EVM fees quotes.forEach((quote) => { expect(quote.nonEvmFeesInNative).toBe( - isSolanaChainId(quote.quote.srcChainId) - ? expectedFees - : undefined, + isSolanaChainId(quote.chainId) ? expectedFees : undefined, ); }); @@ -3579,7 +3583,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3628,7 +3632,7 @@ describe('BridgeController', function () { slippage: 0.5, }, ], - quotes: mockBridgeQuotesSolErc20V1, + quotes: getMockBridgeQuotesSolErc20V2(), }, }, }, @@ -3901,12 +3905,12 @@ describe('BridgeController', function () { srcTokenAddress: 'NATIVE', destTokenAddress: '0x1234', srcTokenAmount: '1000000', - walletAddress: undefined as never, slippage: 0.5, aggIds: ['other'], bridgeIds: ['other', 'debridge'], gasIncluded: false, gasIncluded7702: false, + walletAddress: undefined as never, }, FeatureId.PERPS, null, @@ -4018,30 +4022,30 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` - [ - [ - { - "destChainId": "1", - "destTokenAddress": "0x1234", - "gasIncluded": false, - "gasIncluded7702": false, - "resetApproval": false, - "slippage": 0.5, - "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "srcTokenAddress": "NATIVE", - "srcTokenAmount": "1000000", - "walletAddress": "0x123", - }, - null, - "extension", - "AUTH_TOKEN", - [Function], - "https://bridge.api.cx.metamask.io", - "unified_swap_bridge", - "13.7.0", - ], - ] - `); + [ + [ + { + "destChainId": "1", + "destTokenAddress": "0x1234", + "gasIncluded": false, + "gasIncluded7702": false, + "resetApproval": false, + "slippage": 0.5, + "srcChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "srcTokenAddress": "NATIVE", + "srcTokenAmount": "1000000", + "walletAddress": "0x123", + }, + null, + "extension", + "AUTH_TOKEN", + [Function], + "https://bridge.api.cx.metamask.io", + "unified_swap_bridge", + "13.7.0", + ], + ] + `); expect(quotes).toStrictEqual(mockBridgeQuotesSolErc20V1); expect(bridgeController.state).toStrictEqual(expectedControllerState); }, @@ -4172,7 +4176,7 @@ describe('BridgeController', function () { ], }; - const mockQuote = mockBridgeQuotesNativeErc20EthV1[0]; + const mockQuote = getMockBridgeQuotesNativeErc20EthV2()[0]; beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 0d7ad1cd185..ce3dcbb4be4 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -10,6 +10,7 @@ import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionController } from '@metamask/transaction-controller'; import type { CaipAssetType, Hex } from '@metamask/utils'; +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; import type { BridgeClientId } from './constants/bridge.js'; import { BRIDGE_CONTROLLER_NAME, @@ -91,6 +92,7 @@ import { isValidBatchSellQuoteRequest, } from './validators/quote-request.js'; import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; const metadata: StateMetadata = { quoteRequest: { @@ -455,7 +457,7 @@ export class BridgeController extends StaticIntervalPollingController => { this.#batchSellTradesAbortController?.abort( @@ -877,7 +879,7 @@ export class BridgeController extends StaticIntervalPollingController this.#trackQuoteValidationFailures(validationFailures, featureId), - onValidQuoteReceived: async (quote: QuoteResponseV1) => { + onValidQuoteReceived: async (quote: QuoteResponse) => { const feeAppendPromise = (async () => { const quotesWithFees = await appendFeesToQuotes( - formatChainIdToCaip(quote.quote.srcChainId), + quote.chainId, [quote], this.messenger, this.#getLayer1GasFee, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 7e044138197..41dc433bc28 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -74,6 +74,7 @@ export { export { validateQuoteResponseV1, QuoteResponseSchemaV1, + type QuoteResponseV1, } from './validators/quote-response-v1.js'; export { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; @@ -113,11 +114,10 @@ export { isEvmTxData, isStellarTrade, } from './validators/trade.js'; -export type { - QuoteResponseV1 as QuoteResponse, - QuoteResponseV1, -} from './validators/quote-response-v1.js'; -export { validateQuoteResponse } from './validators/quote-response.js'; +export { + validateQuoteResponse, + type QuoteResponse, +} from './validators/quote-response.js'; export type { Quote } from './validators/quote.js'; export { FeeType, DiscountType } from './validators/quote.js'; export { ActionTypes } from './validators/step.js'; @@ -134,6 +134,7 @@ export { QuoteStreamCompleteReason, } from './validators/quote-stream-complete.js'; export { BatchSellTransactionType } from './validators/batch-sell.js'; +export { type AmountsAndAssetSchema } from './validators/amount-and-asset.js'; export { TokenFeatureType } from './validators/token-feature.js'; export type { BridgeAsset, @@ -149,6 +150,7 @@ export { BridgeAssetSecurityDataType, } from './validators/bridge-asset.js'; export { FeatureId } from './validators/feature-flags.js'; +export { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2.js'; export { ALLOWED_BRIDGE_CHAIN_IDS, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index b2e4f2c5ed2..b75e34b3b7d 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -12,9 +12,11 @@ import { merge } from 'lodash'; import { mockBridgeQuotesErc20Erc20V1 } from '../tests/mock-quotes-erc20-erc20.js'; import { - getMockBridgeQuotesNativeErc20V1, mockBridgeQuotesNativeErc20V1, + getMockBridgeQuotesNativeErc20V2, } from '../tests/mock-quotes-native-erc20.js'; +import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; +import { toBridgeAssetV2 } from './coercers/quote-response-v1-to-v2.js'; import { DEFAULT_CHAIN_RANKING, ETH_USDT_ADDRESS } from './constants/bridge.js'; import type { BridgeAppState } from './selectors.js'; import { @@ -43,9 +45,8 @@ import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; import { toQuoteMetadataV1 } from './utils/quote-metadata/to-quote-metadata-v1.js'; import { BatchSellTransactionType } from './validators/batch-sell.js'; -import type { BridgeAsset } from './validators/bridge-asset.js'; +import type { BridgeAssetV2 } from './validators/bridge-asset.js'; import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; -import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; import type { QuoteResponse } from './validators/quote-response.js'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; @@ -612,16 +613,18 @@ describe('Bridge Selectors', () => { destTokenAmount: '2100000000000000000', }, }, - ].map((quote) => ({ - ...merge({}, quote, quoteOverrides), - quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), - })); + ] + .map(toQuoteResponseV2) + .map((quote) => ({ + ...merge({}, quote, quoteOverrides), + quote: merge({}, quote.quote, quoteOverrides?.quote ?? {}), + })); const srcChainId = parseCaipAssetType( - mockQuoteV2[0].quote.srcAsset.assetId, + mockQuoteV2[0].quote.src.asset.assetId, ).chainId; const destChainId = parseCaipAssetType( - mockQuoteV2[0].quote.destAsset.assetId, + mockQuoteV2[0].quote.dest.asset.assetId, ).chainId; return { @@ -630,12 +633,8 @@ describe('Bridge Selectors', () => { { srcChainId: srcChainId ?? decChainId, destChainId: destChainId ?? 137, - srcTokenAddress: - mockQuoteV2[0].quote.srcAsset.assetId ?? - '0x0000000000000000000000000000000000000000', - destTokenAddress: - mockQuoteV2[0].quote.destAsset.assetId ?? - '0x0000000000000000000000000000000000000000', + srcTokenAddress: mockQuoteV2[0].quote.src.asset.assetId, + destTokenAddress: mockQuoteV2[0].quote.dest.asset.assetId, insufficientBal: false, }, ], @@ -700,12 +699,36 @@ describe('Bridge Selectors', () => { selectBridgeQuotes( { ...mockState, + quotes: mockState.quotes.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + feeData: { + ...quote.quote.feeData, + network: [ + { + amount: '7500000000000', + usd: '0.01514', + asset: toBridgeAssetV2(getNativeAssetForChainId(1)), + }, + ], + }, + priceData: { + ...quote.quote.priceData, + ...(quote.quote.requestId === '456' && { + priceImpact: { + usd: '7.9', + }, + }), + }, + }, + })), assetExchangeRates: { - [mockQuote.quote.srcAsset.assetId]: { + [mockQuote.quote.src.asset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [mockQuote.quote.destAsset.assetId]: { + [mockQuote.quote.dest.asset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -724,12 +747,29 @@ describe('Bridge Selectors', () => { nativeExchangeRate: { exchangeRate: '1980', usdExchangeRate: '10' }, }); const expectedQuoteV2 = mergeQuoteMetadata(quote, expectedQuoteMetadata); + expect( + expectedQuoteV2?.quote?.priceData?.priceImpact?.usd, + ).toMatchInlineSnapshot(`"8.9"`); expect(result.sortedQuotes[0]).toStrictEqual(expectedQuoteV2); expect(result.recommendedQuote?.priceImpact?.valueInCurrency).toBe( expectedQuoteV2.priceImpact?.valueInCurrency, ); + expect(result.recommendedQuote?.quote.priceData?.priceImpact) + .toMatchInlineSnapshot(` + { + "usd": "8.9", + "valueInCurrency": "1758", + } + `); + expect(result.recommendedQuote?.priceImpact).toMatchInlineSnapshot(` + { + "usd": "8.9", + "valueInCurrency": "1758", + } + `); + expect(toQuoteMetadataV1(result.recommendedQuote)).toStrictEqual( expectedQuoteMetadata, ); @@ -747,11 +787,11 @@ describe('Bridge Selectors', () => { ...mockState, quotes: [], assetExchangeRates: { - [mockQuote.quote.srcAsset.assetId]: { + [mockQuote.quote.src.asset.assetId]: { exchangeRate: '1980', usdExchangeRate: '10', }, - [mockQuote.quote.destAsset.assetId]: { + [mockQuote.quote.dest.asset.assetId]: { exchangeRate: '200', usdExchangeRate: '1', }, @@ -820,10 +860,10 @@ describe('Bridge Selectors', () => { expect( result.sortedQuotes[0].quote.priceData?.priceImpact, ).toBeUndefined(); - expect(result.recommendedQuote?.quote.destTokenAmount).toBe( + expect(result.recommendedQuote?.quote.dest.amount).toBe( '2100000000000000000', ); - expect(result.recommendedQuote?.toTokenAmount?.amount).toBe('2.1'); + expect(result.recommendedQuote?.quote.dest.normalizedAmount).toBe('2.1'); }); it('should use priceImpact to sort quotes if exchange rate is not available', () => { @@ -833,14 +873,14 @@ describe('Bridge Selectors', () => { ...mockState.quotes[0], quote: { ...mockState.quotes[0].quote, - priceData: { priceImpact: '0.01' }, + priceData: { priceImpact: { amount: '0.01' } }, }, }, { ...mockState.quotes[1], quote: { ...mockState.quotes[1].quote, - priceData: { priceImpact: '-0.02' }, + priceData: { priceImpact: { amount: '-0.02' } }, }, }, ]; @@ -888,22 +928,24 @@ describe('Bridge Selectors', () => { const expectedQuoteV2 = quotesWithPriceImpact[1]; - expect(result.sortedQuotes[0].cost?.valueInCurrency).toBeUndefined(); + expect( + result.sortedQuotes[0].quote.priceData?.priceImpact?.valueInCurrency, + ).toBeUndefined(); expect(result.recommendedQuote).toStrictEqual( mergeQuoteMetadata(expectedQuoteV2, expectedQuoteMetadata), ); - expect(result.recommendedQuote?.quote.priceData?.priceImpact).toBe( - '-0.02', - ); + expect( + result.recommendedQuote?.quote.priceData?.priceImpact?.amount, + ).toBe('-0.02'); }); describe('returns swap metadata', () => { const getMockSwapState = ( - srcAsset: Omit, - destAsset: Omit, + srcAsset: Omit, + destAsset: Omit, txFee?: { amount: string; - asset: Omit; + asset: Omit; }, gasIncluded7702?: boolean, gasEstimatesChainId?: number, @@ -916,7 +958,7 @@ describe('Bridge Selectors', () => { const { chainId: caipChainId } = parseCaipAssetType(srcAsset.assetId); const chainId = formatChainIdToDec(caipChainId); const hexChainId = formatChainIdToHex(chainId); - const nativeAsset = getNativeAssetForChainId(chainId); + const nativeAsset = toBridgeAssetV2(getNativeAssetForChainId(chainId)); const currencyRates = { [nativeAsset.symbol]: { conversionRate: 551.98, @@ -1050,7 +1092,7 @@ describe('Bridge Selectors', () => { return { ...mockState, - quotes: [quoteResponse as QuoteResponseV1], + quotes: [toQuoteResponseV2(quoteResponse)], currencyRates, marketData, quoteRequest: [ @@ -1390,14 +1432,23 @@ describe('Bridge Selectors', () => { ...quote, quote: { ...quote.quote, + priceData: { + priceImpact: { + usd: '1935.36', + }, + swapRate: '1', + }, feeData: { ...quote.quote.feeData, - txFee: { - amount: `${(3 + index) * 1000000}`, - asset: quote.quote.srcAsset, - maxFeePerGas: '1000000000000000000', - maxPriorityFeePerGas: '1000000000000000000', - }, + txFee: [ + { + amount: `${(3 + index) * 1000000}`, + asset: quote.quote.src.asset, + usd: '1935.36', + maxFeePerGas: '1000000000000000000', + maxPriorityFeePerGas: '1000000000000000000', + }, + ], }, }, })), @@ -1423,20 +1474,25 @@ describe('Bridge Selectors', () => { }, }); - expect(sortedQuotes[0].quote.feeData.txFee).toStrictEqual({ + expect(sortedQuotes[0].quote.feeData.txFee?.[0]).toStrictEqual({ amount: '3000000', asset: { - address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', assetId: 'eip155:1/erc20:0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', - chainId: 1, decimals: 6, name: 'USD Coin', symbol: 'USDC', }, maxFeePerGas: '1000000000000000000', maxPriorityFeePerGas: '1000000000000000000', + normalizedAmount: '3', + usd: '1935.36', + valueInCurrency: '1655.94', }); + expect(sortedQuotes[0].quote.src?.normalizedAmount).toBe('3.018117'); + expect(sortedQuotes[0].quote.feeData.txFee?.[0].normalizedAmount).toBe( + '3', + ); expect(sortedQuotes[0].sentAmount?.amount).toBe('3.018117'); const expectedQuoteV2 = mergeQuoteMetadata( newState.quotes[0], @@ -1711,28 +1767,56 @@ describe('Bridge Selectors', () => { const solanaState = getMockState( ChainId.SOLANA, { + namespace: KnownCaipNamespace.Solana, + chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', nonEvmFeesInNative: '5000', trade: 'SOLANATRADE', quote: { - srcChainId: 1151111081099710, - srcAsset: { - address: '0x0000000000000000000000000000000000000000', - decimals: 9, - assetId: getNativeAssetForChainId(ChainId.SOLANA).assetId, - chainId: 1151111081099710, - symbol: 'SOL', - name: 'SOL', + src: { + asset: { + decimals: 9, + assetId: getNativeAssetForChainId(ChainId.SOLANA).assetId, + symbol: 'SOL', + name: 'SOL', + }, }, - destAsset: { - address: 'gjslkdfjsljflds', - decimals: 18, - assetId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:gjslkdfjsljflds', - chainId: 1151111081099710, - symbol: 'USDC', - name: 'USD Coin', + dest: { + asset: { + decimals: 18, + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:gjslkdfjsljflds', + symbol: 'USDC', + name: 'USD Coin', + }, + }, + feeData: { + metabridge: [ + { + amount: '3000', + asset: toBridgeAssetV2( + getNativeAssetForChainId(ChainId.SOLANA), + ), + usd: '999', + }, + ], + network: [ + { + amount: '3000', + asset: toBridgeAssetV2( + getNativeAssetForChainId(ChainId.SOLANA), + ), + usd: '999', + }, + ], }, - } as never, + priceData: { + priceImpact: { + usd: '999', + valueInCurrency: '999', + }, + swapRate: '0.9', + }, + }, }, { assetExchangeRates: { @@ -1756,7 +1840,7 @@ describe('Bridge Selectors', () => { ); const solanaQuote = solanaState.quotes[1]; - expect(solanaQuote.quote.destTokenAmount).toBe('2100000000000000000'); + expect(solanaQuote.quote.dest.amount).toBe('2100000000000000000'); const expectedQuoteMetadata = calcQuoteMetadata(solanaQuote, { srcTokenExchangeRate: { exchangeRate: '0.5', usdExchangeRate: '10' }, @@ -1774,7 +1858,7 @@ describe('Bridge Selectors', () => { solanaQuote, expectedQuoteMetadata, ); - expect(expectedQuoteV2?.toTokenAmount?.amount).toBe('2.1'); + expect(expectedQuoteV2?.quote.dest.amount).toBe('2100000000000000000'); const result = selectBridgeQuotes(solanaState, mockClientParams); expect(result.sortedQuotes).toHaveLength(2); @@ -1794,7 +1878,7 @@ describe('Bridge Selectors', () => { ...quote, quoteRequestIndex: 0, })), - ], + ].map(toQuoteResponseV2), quoteRequest: [ { srcChainId: '10', @@ -1882,14 +1966,34 @@ describe('Bridge Selectors', () => { expect(totalReceived).toMatchInlineSnapshot(` { - "amount": "38.240503", + "amount": "38240503", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "minAmount": "37460000", + "minAmountNormalized": "37.46", + "minAmountUsd": "37.46", + "minAmountValueInCurrency": "7492", + "normalizedAmount": "38.240503", "usd": "38.240503", "valueInCurrency": "7648.1006", } `); expect(minimumReceived).toMatchInlineSnapshot(` { - "amount": "37.46", + "amount": "37460000", + "asset": { + "assetId": "eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "Native USD Coin (POS)", + "symbol": "USDC", + }, + "normalizedAmount": "37.46", "usd": "37.46", "valueInCurrency": "7492", } @@ -1909,16 +2013,32 @@ describe('Bridge Selectors', () => { "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37", ] `); - expect(recommendedQuotes.map((quote) => quote?.sentAmount)) + expect(recommendedQuotes.map((quote) => quote?.quote.src)) .toMatchInlineSnapshot(` [ { - "amount": "0.01", + "amount": "10000000000000000", + "asset": { + "assetId": "eip155:10/slip44:60", + "decimals": 18, + "iconUrl": "https://media.socket.tech/tokens/all/ETH", + "name": "Ethereum", + "symbol": "ETH", + }, + "normalizedAmount": "0.01", "usd": "0.1", "valueInCurrency": "18", }, { - "amount": "14", + "amount": "14000000", + "asset": { + "assetId": "eip155:10/erc20:0x0b2c639c533813f4aa9d7837caf62653d097ff85", + "decimals": 6, + "iconUrl": "https://media.socket.tech/tokens/all/USDC", + "name": "USD Coin", + "symbol": "USDC", + }, + "normalizedAmount": "14", "usd": "140", "valueInCurrency": "27720", }, @@ -1967,7 +2087,7 @@ describe('Bridge Selectors', () => { const { recommendedQuotes } = selectBatchSellQuotes( { ...mockState, - quotes: getMockBridgeQuotesNativeErc20V1().map((quote) => ({ + quotes: getMockBridgeQuotesNativeErc20V2().map((quote) => ({ ...quote, quoteRequestIndex: undefined, })), diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index a9c63ee255d..55f4a07f5f4 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -19,7 +19,11 @@ import { } from 'reselect'; import { BRIDGE_PREFERRED_GAS_ESTIMATE } from './constants/bridge.js'; -import type { BridgeControllerState, ExchangeRate } from './types.js'; +import type { + BridgeControllerState, + DeepPartial, + ExchangeRate, +} from './types.js'; import { RequestStatus, SortOrder } from './types.js'; import { getNativeAssetForChainId, @@ -39,7 +43,7 @@ import { calcQuoteMetadata } from './utils/quote-metadata/calculators.js'; import { mergeQuoteMetadata } from './utils/quote-metadata/merge.js'; import type { QuoteMetadata } from './utils/quote-metadata/types.js'; import { getDefaultSlippagePercentage } from './utils/slippage.js'; -import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; /** * The controller states that provide exchange rates @@ -93,7 +97,7 @@ const createBridgeSelector = createSelector_.withTypes(); */ type BridgeQuotesClientParams = { sortOrder: SortOrder; - selectedQuote: (QuoteResponseV1 & QuoteMetadata) | null; + selectedQuote: (QuoteResponse & QuoteMetadata) | null; }; type EvmTokenExchangeRate = { price?: number; currency?: string }; @@ -290,7 +294,7 @@ export const selectIsAssetExchangeRateInState = ( const selectBridgeFeesPerGas = createBridgeSelector( [ (state) => state.gasFeeEstimatesByChainId, - (state) => state.quotes?.[0]?.quote.srcChainId, + (state) => state.quotes?.[0]?.chainId, ], (gasFeeEstimatesByChainId, srcChainId) => { if (!srcChainId) { @@ -338,21 +342,20 @@ const selectMetadata = createBridgeSelector( calcQuoteMetadata(quote, { srcTokenExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - quote.quote.srcAsset.assetId, + quote.quote.src.asset.assetId, ), bridgeFeesPerGas, destTokenExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - quote.quote.destAsset.assetId ?? + quote.quote.dest.asset.assetId ?? formatAddressToAssetId( - destTokenAddress ?? quote.quote.destAsset.assetId, + destTokenAddress ?? quote.quote.dest.asset.assetId, destChainId, ), ), nativeExchangeRate: selectExchangeRateByAssetId( exchangeRateSources, - getNativeAssetForChainId(srcChainId ?? quote.quote.srcChainId) - ?.assetId, + getNativeAssetForChainId(srcChainId ?? quote.chainId)?.assetId, ), }), ); @@ -373,7 +376,7 @@ const selectSortedBridgeQuotes = createBridgeSelector( selectBridgeQuotesWithMetadata, (_, { sortOrder }: BridgeQuotesClientParams) => sortOrder, ], - (quotesWithMetadata, sortOrder): (QuoteResponseV1 & QuoteMetadata)[] => { + (quotesWithMetadata, sortOrder): (QuoteResponse & QuoteMetadata)[] => { switch (sortOrder) { case SortOrder.ETA_ASC: return orderBy( @@ -384,31 +387,30 @@ const selectSortedBridgeQuotes = createBridgeSelector( default: if ( quotesWithMetadata.every( - (quote) => - quote.priceImpact?.valueInCurrency && - quote.priceImpact?.valueInCurrency !== '0', + (quote) => quote.quote.priceData?.priceImpact?.amount, ) ) { return orderBy( quotesWithMetadata, - ({ priceImpact }) => Number(priceImpact?.valueInCurrency), + ({ quote: { priceData } }) => + Number(priceData?.priceImpact?.amount), 'asc', ); - } - if ( + } else if ( quotesWithMetadata.every( - (quote) => quote.quote.priceData?.priceImpact, + (quote) => quote.quote.priceData?.priceImpact?.valueInCurrency, ) ) { return orderBy( quotesWithMetadata, - ({ quote }) => Number(quote.priceData?.priceImpact), + ({ quote: { priceData } }) => + Number(priceData?.priceImpact?.valueInCurrency), 'asc', ); } return orderBy( quotesWithMetadata, - ({ quote }) => Number(quote.destTokenAmount), + ({ quote }) => Number(quote.dest.amount), 'desc', ); } @@ -509,21 +511,38 @@ const selectRecommendedQuotes = createBridgeSelector( const requestIndex = quote.quoteRequestIndex ?? 0; acc[requestIndex] ??= quote; return acc; - }, Array<(QuoteResponseV1 & QuoteMetadata) | null>(requestCount).fill(null)), + }, Array(requestCount).fill(null)), ); + const selectDestAmountSum = createBridgeSelector( [selectRecommendedQuotes], (recommendedQuotes) => { - return sumAmounts(recommendedQuotes.map((quote) => quote?.toTokenAmount)); + return sumAmounts(recommendedQuotes.map((quote) => quote?.quote.dest)); }, ); const selectMinDestAmountSum = createBridgeSelector( - [selectRecommendedQuotes], - (recommendedQuotes) => { - return sumAmounts( - recommendedQuotes.map((quote) => quote?.minToTokenAmount), - ); + [selectDestAmountSum], + (destAmountSum): DeepPartial | undefined => { + if (!destAmountSum) { + return undefined; + } + + const { + minAmount, + minAmountNormalized, + minAmountValueInCurrency, + minAmountUsd, + asset, + } = destAmountSum; + + return { + amount: minAmount, + normalizedAmount: minAmountNormalized, + valueInCurrency: minAmountValueInCurrency, + usd: minAmountUsd, + asset, + }; }, ); diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 23e1421c4b8..cb44f9615b0 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -35,6 +35,7 @@ import type { } from './validators/feature-flags.js'; import type { IntentSchema } from './validators/intent.js'; import type { QuoteResponseV1 } from './validators/quote-response-v1.js'; +import type { QuoteResponse } from './validators/quote-response.js'; import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete.js'; import type { TxFeeGasLimitsSchema } from './validators/quote.js'; import type { FeeDataSchema } from './validators/quote.js'; @@ -260,7 +261,7 @@ export enum RequestStatus { export type BridgeControllerState = { quoteRequest: Partial[]; - quotes: (QuoteResponseV1 & L1GasFees & NonEvmFees)[]; + quotes: QuoteResponse[]; /** * The time elapsed between the initial quote fetch and when the first valid quote was received */ diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 0de0ac25c38..c0f087dc8be 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -1,8 +1,12 @@ import { AddressZero } from '@ethersproject/constants'; import type { CaipAssetType } from '@metamask/utils'; -import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20.js'; +import { + getMockBridgeQuotesErc20Erc20V2, + mockBridgeQuotesErc20Erc20V1, +} from '../../tests/mock-quotes-erc20-erc20.js'; import { mockBridgeQuotesNativeErc20V1 } from '../../tests/mock-quotes-native-erc20.js'; +import { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2.js'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL, @@ -758,7 +762,10 @@ describe('fetch', () => { signal, method: 'POST', body: JSON.stringify( - formatBatchSellTradesRequest(mockBridgeQuotesErc20Erc20V1, stxEnabled), + formatBatchSellTradesRequest( + mockBridgeQuotesErc20Erc20V1.map(toQuoteResponseV2), + stxEnabled, + ), ), }); @@ -782,7 +789,7 @@ describe('fetch', () => { const { signal } = new AbortController(); const result = await fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -809,7 +816,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -875,7 +882,7 @@ describe('fetch', () => { await expect( fetchBatchSellTrades( - [...mockBridgeQuotesErc20Erc20V1, null], + [...getMockBridgeQuotesErc20Erc20V2(), null], false, signal, BridgeClientId.EXTENSION, @@ -889,7 +896,7 @@ describe('fetch', () => { const result = await Promise.allSettled( Array.from({ length: 3 }, () => fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), false, signal, BridgeClientId.EXTENSION, @@ -960,7 +967,7 @@ describe('fetch', () => { const { signal } = new AbortController(); await fetchBatchSellTrades( - mockBridgeQuotesErc20Erc20V1, + getMockBridgeQuotesErc20Erc20V2(), true, signal, BridgeClientId.EXTENSION, diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index a263ee9d2f7..bf26dacc33c 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StructError } from '@metamask/superstruct'; +import { KnownCaipNamespace } from '@metamask/utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { toQuoteResponseV2 } from '../coercers/quote-response-v1-to-v2.js'; +import { toQuoteResponseV1 } from '../coercers/quote-response-v2-to-v1.js'; import type { FetchFunction, GenericQuoteRequest, @@ -17,6 +20,7 @@ import { validateBridgeAsset } from '../validators/bridge-asset.js'; import type { FeatureId } from '../validators/feature-flags.js'; import type { QuoteResponseV1 } from '../validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../validators/quote-response-v1.js'; +import type { QuoteResponse } from '../validators/quote-response.js'; import { validateQuoteStreamComplete } from '../validators/quote-stream-complete.js'; import { validateTokenFeature } from '../validators/token-feature.js'; import { isEvmTxData } from '../validators/trade.js'; @@ -27,6 +31,7 @@ import { formatChainIdToDec, } from './caip-formatters.js'; import { fetchServerEvents } from './fetch-server-events.js'; +import type { QuoteMetadata } from './quote-metadata/types.js'; import { formatStructErrors } from './struct-error.js'; export const getClientHeaders = ({ @@ -313,10 +318,10 @@ const getQuoteRequestId = ({ `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); const getQuoteResponseId = ({ - srcAsset: { address: srcTokenAddress, chainId: srcChainId }, - destAsset: { address: destTokenAddress, chainId: destChainId }, -}: QuoteResponseV1['quote']): string => - `${formatAddressToAssetId(srcTokenAddress, srcChainId)}-${formatAddressToAssetId(destTokenAddress, destChainId)}`.toLowerCase(); + src: { asset: srcAsset }, + dest: { asset: destAsset }, +}: QuoteResponse['quote']): string => + `${srcAsset.assetId}-${destAsset.assetId}`.toLowerCase(); /** * Fetches quotes from the bridge-api @@ -349,7 +354,7 @@ export async function fetchBridgeQuoteStream( onClose: () => void | Promise; onQuoteValidationFailure: (validationFailures: string[]) => void; onValidQuoteReceived: ( - quotes: QuoteResponseV1 & { resetApproval?: TxData }, + quotes: QuoteResponse & { resetApproval?: TxData }, ) => Promise; onTokenWarning: (warning: TokenFeature) => void; onComplete: (data: QuoteStreamCompleteData) => void; @@ -370,45 +375,47 @@ export async function fetchBridgeQuoteStream( const uniqueValidationFailures: Set = new Set([]); try { - if (validateQuoteResponseV1(quoteResponse)) { - // Fallback to 0 if the quote doesn't match any requests - const matchedQuoteRequestIdx = Math.max( - quoteRequestIds?.findIndex((id) => { - return id === getQuoteResponseId(quoteResponse.quote); - }) ?? 0, - 0, - ); - const matchingQuoteRequest = - normalizedQuoteRequests[matchedQuoteRequestIdx]; - - return await serverEventHandlers.onValidQuoteReceived({ - ...quoteResponse, - featureId, - // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval - resetApproval: - matchingQuoteRequest.resetApproval && - quoteResponse.approval && - isEvmTxData(quoteResponse.approval) - ? { - ...quoteResponse.approval, - data: getEthUsdtResetData(matchingQuoteRequest.destChainId), - } - : undefined, - ...(isBatchSellRequest && { - quoteRequestIndex: matchedQuoteRequestIdx, - }), - }); - } + // Always coerce to QuoteResponseV2 + const quoteResponseV2 = toQuoteResponseV2(quoteResponse); + // Fallback to 0 if the quote doesn't match any requests + const matchedQuoteRequestIdx = Math.max( + quoteRequestIds?.findIndex((id) => { + return id === getQuoteResponseId(quoteResponseV2.quote); + }) ?? 0, + 0, + ); + const matchingQuoteRequest = + normalizedQuoteRequests[matchedQuoteRequestIdx]; + + return await serverEventHandlers.onValidQuoteReceived({ + ...quoteResponseV2, + featureId, + // Append the reset approval data to the quote response if the request has resetApproval set to true and the quote has an approval + resetApproval: + quoteResponseV2.namespace === KnownCaipNamespace.Eip155 && + matchingQuoteRequest.resetApproval && + quoteResponseV2.approval + ? { + ...quoteResponseV2.approval, + data: getEthUsdtResetData(matchingQuoteRequest.destChainId), + } + : undefined, + ...(isBatchSellRequest && { + quoteRequestIndex: matchedQuoteRequestIdx, + }), + }); } catch (error) { if (error instanceof StructError) { console.warn('Quote validation failed', formatStructErrors(error)); error.failures().forEach(({ branch, path }) => { const aggregatorId = - branch?.[0]?.quote?.bridgeId ?? - branch?.[0]?.quote?.bridges?.[0] ?? + branch?.[0]?.quote?.aggregator ?? + branch?.[0]?.quote?.protocols?.[0] ?? + (quoteResponse as QuoteResponseV1)?.quote?.protocols?.[0] ?? (quoteResponse as QuoteResponseV1)?.quote?.bridgeId ?? - ((quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] || - ('unknown' as string)); + (quoteResponse as QuoteResponseV1)?.quote?.bridges?.[0] ?? + (quoteResponse as QuoteResponse)?.quote?.aggregator ?? + 'unknown'; const pathString = path?.join('.') || 'unknown'; uniqueValidationFailures.add([aggregatorId, pathString].join('|')); }); @@ -420,7 +427,6 @@ export async function fetchBridgeQuoteStream( // Rethrow any unexpected errors throw error; } - return undefined; }; const onTokenWarningReceived = (data: unknown): void => { @@ -498,26 +504,15 @@ export async function fetchBridgeQuoteStream( } export const formatBatchSellTradesRequest = ( - quotes: (QuoteResponseV1 | null)[], + quotes: (QuoteResponse | (QuoteResponseV1 & QuoteMetadata) | null)[], stxEnabled: boolean, ): BatchSellTradesRequest => ({ quotes: quotes - .filter((quote): quote is QuoteResponseV1 => quote !== null) - .map( - ({ - trade, - approval, - quote, - estimatedProcessingTimeInSeconds, - quoteId, - }) => ({ - trade, - approval, - quote, - estimatedProcessingTimeInSeconds, - quoteId, - }), - ), + .filter( + (quote): quote is QuoteResponse | (QuoteResponseV1 & QuoteMetadata) => + quote !== null && Boolean(quote), + ) + .map(toQuoteResponseV1), stxEnabled, }); @@ -535,7 +530,7 @@ export const formatBatchSellTradesRequest = ( * @returns The batch sell trades and the total network fee */ export async function fetchBatchSellTrades( - quotes: (QuoteResponseV1 | null)[], + quotes: (QuoteResponse | null)[], stxEnabled: boolean, signal: AbortSignal | null, clientId: string, diff --git a/packages/bridge-controller/src/utils/metrics/properties.test.ts b/packages/bridge-controller/src/utils/metrics/properties.test.ts index 917f51c7ec2..077b3ba96b7 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.test.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.test.ts @@ -1,7 +1,9 @@ import { SolScope } from '@metamask/keyring-api'; import type { CaipChainId } from '@metamask/utils'; +import { toQuoteResponseV2 } from '../../coercers/quote-response-v1-to-v2.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; +import { validateQuoteResponseV1 } from '../../validators/quote-response-v1.js'; import { getNativeAssetForChainId } from '../bridge.js'; import { formatChainIdToCaip } from '../caip-formatters.js'; import type { QuoteMetadata } from '../quote-metadata/types.js'; @@ -170,43 +172,8 @@ describe('properties', () => { describe('formatProviderLabel', () => { it('should format provider label correctly', () => { - const mockQuoteResponse: QuoteResponseV1 = { + const mockQuoteResponse = { quote: { - requestId: 'request1', - srcChainId: 1, - srcAsset: { - chainId: 1, - address: '0x123', - symbol: 'ETH', - name: 'Ethereum', - decimals: 18, - assetId: 'eip155:1/slip44:60', - }, - srcTokenAmount: '1000000000000000000', - destChainId: 1, - destAsset: { - chainId: 1, - address: '0x456', - symbol: 'USDC', - name: 'USD Coin', - decimals: 6, - assetId: 'eip155:1/erc20:0x456', - }, - destTokenAmount: '1000000', - minDestTokenAmount: '950000', - feeData: { - metabridge: { - amount: '10000000000000000', - asset: { - chainId: 1, - address: '0x123', - symbol: 'ETH', - name: 'Ethereum', - decimals: 18, - assetId: 'eip155:1/slip44:60', - }, - }, - }, bridgeId: 'bridge1', bridges: ['bridge1'], steps: [], @@ -219,7 +186,19 @@ describe('properties', () => { data: '0x', gasLimit: 100000, }, - estimatedProcessingTimeInSeconds: 60, + }; + + const result = formatProviderLabel(mockQuoteResponse.quote); + + expect(result).toBe('bridge1_bridge1'); + }); + + it('should format provider label correctly (V2)', () => { + const mockQuoteResponse = { + quote: { + aggregator: 'bridge1', + protocols: ['bridge1'], + }, }; const result = formatProviderLabel(mockQuoteResponse.quote); @@ -421,23 +400,31 @@ describe('properties', () => { }, trade: { chainId: 1, - to: '0x789', - from: '0xabc', + to: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', + from: '0x141d32a89a1e0a5ef360034a2f60a4b917c18838', value: '0x0', - data: '0x', + data: '0x0', gasLimit: 100000, + effectiveGas: 100000, }, estimatedProcessingTimeInSeconds: 60, }; + validateQuoteResponseV1(mockQuoteResponse); + const mockQuoteResponseV2 = toQuoteResponseV2(mockQuoteResponse); - const result = getQuotesReceivedProperties(mockQuoteResponse, [], false, { - ...mockQuoteResponse, - quote: { - ...mockQuoteResponse.quote, - bridges: ['bridge2'], - bridgeId: 'bridge2', + const result = getQuotesReceivedProperties( + mockQuoteResponseV2, + [], + false, + { + ...mockQuoteResponseV2, + quote: { + ...mockQuoteResponseV2.quote, + aggregator: 'bridge2', + protocols: ['bridge2'], + }, }, - }); + ); expect(result).toMatchInlineSnapshot(` { @@ -520,7 +507,9 @@ describe('properties', () => { estimatedProcessingTimeInSeconds: 60, }; - const result = getQuotesReceivedProperties(mockQuoteResponse); + const result = getQuotesReceivedProperties( + toQuoteResponseV2(mockQuoteResponse), + ); expect(result.token_symbol_source).toBe('WETH'); expect(result.token_symbol_destination).toBe('DAI'); diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 0fd51f24baa..1854fccb51d 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -5,14 +5,12 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE } from '../../constants/bridge.js'; import { ChainId } from '../../types.js'; import type { GenericQuoteRequest, QuoteRequest } from '../../types.js'; import { FeatureId } from '../../validators/feature-flags.js'; -import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; -import type { TxData } from '../../validators/trade.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; import { getNativeAssetForChainId, isCrossChain } from '../bridge.js'; import { formatAddressToAssetId, formatChainIdToCaip, } from '../caip-formatters.js'; -import type { QuoteMetadata } from '../quote-metadata/types.js'; import { MetricsSwapType } from './constants.js'; import type { AccountHardwareType, @@ -72,17 +70,24 @@ export const getSwapTypeFromQuote = ( }; export const formatProviderLabel = ({ - bridgeId, + aggregator, + protocols, bridges, -}: QuoteResponseV1['quote']): `${string}_${string}` => - `${bridgeId}_${bridges[0]}`; + bridgeId, +}: { + aggregator?: string; + protocols?: string[]; + bridges?: string[]; + bridgeId?: string; +}): `${string}_${string}` => + `${aggregator ?? bridgeId}_${protocols?.[0] ?? bridges?.[0]}`; /** * @param quoteRequest - The current quote request used to derive chain and token identity fields. * @param quoteRequest.srcChainId - Source chain id of the quote request. * @param quoteRequest.destChainId - Destination chain id of the quote request. * @param quoteRequest.srcTokenAddress - Source token address of the quote request. - * @param quoteRequest.destTokenAddress - Destination token address of the quote request. + * @param quoteRequest.destTokenAddress - Destination token address of the quote request. * @param tokenSecurityTypeDestination - The security classification of the destination token, * supplied by the client (e.g. from token security/scanning data). Pass `null` when no * security data is available for the selected destination token. @@ -155,10 +160,10 @@ export const isCustomSlippage = (slippage: GenericQuoteRequest['slippage']) => { }; export const getQuotesReceivedProperties = ( - activeQuote: null | (QuoteResponseV1 & QuoteMetadata), + activeQuote: null | QuoteResponse, warnings: QuoteWarning[] = [], isSubmittable: boolean = true, - recommendedQuote?: null | (QuoteResponseV1 & QuoteMetadata), + recommendedQuote?: null | QuoteResponse, usdBalanceSource?: number, hasSufficientGasForQuote?: boolean | null, ) => { @@ -170,17 +175,19 @@ export const getQuotesReceivedProperties = ( quoted_time_minutes: activeQuote?.estimatedProcessingTimeInSeconds ? activeQuote.estimatedProcessingTimeInSeconds / 60 : 0, - usd_quoted_gas: Number(activeQuote?.gasFee?.total?.usd ?? 0), - usd_quoted_return: Number(activeQuote?.toTokenAmount?.usd ?? 0), + usd_quoted_gas: Number(activeQuote?.quote?.feeData?.network?.[0]?.usd ?? 0), + usd_quoted_return: Number(activeQuote?.quote?.dest?.usd ?? 0), usd_balance_source: usdBalanceSource ?? 0, best_quote_provider: recommendedQuote ? formatProviderLabel(recommendedQuote.quote) : provider, provider, - token_symbol_source: activeQuote?.quote.srcAsset.symbol ?? '', - token_symbol_destination: activeQuote?.quote.destAsset.symbol ?? null, + token_symbol_source: activeQuote?.quote.src.asset.symbol ?? '', + token_symbol_destination: activeQuote?.quote.dest.asset.symbol ?? null, warnings, - price_impact: Number(activeQuote?.quote.priceData?.priceImpact ?? 0), + price_impact: Number( + activeQuote?.quote.priceData?.priceImpact?.amount ?? 0, + ), ...(hasSufficientGasForQuote !== undefined && { has_sufficient_gas_for_quote: hasSufficientGasForQuote, }), diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index cb0db21ce21..a9bda927090 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -310,7 +310,7 @@ type RequiredEventContextFromClientBase = { // Emitted by clients [UnifiedSwapBridgeEventName.AllQuotesOpened]: Pick< TradeData, - 'gas_included' + 'gas_included' | 'gas_included_7702' > & Pick & Pick & { diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 40a4c91cbd0..20f85a39857 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -6,6 +6,7 @@ import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; import { toQuoteMetadataV2 } from './to-quote-metadata-v2.js'; import type { QuoteMetadata } from './types.js'; +import { toNormalizedAmounts } from './to-normalized-amounts.js'; /** * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} @@ -28,11 +29,13 @@ export function mergeQuoteMetadata< legacyQuoteMetadata, quoteResponse, ); + const normalizedAmountsV2 = toNormalizedAmounts(quoteResponse); // Phase 1 of migration uses calcQuoteMetadata's results return merge( {}, quoteResponse, + normalizedAmountsV2, legacyQuoteMetadataV2, legacyQuoteMetadata, // return for client testing ); diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts index df366dc73cd..05f6c011530 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -1,8 +1,10 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ @@ -980,10 +982,10 @@ export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ export const getMockBridgeQuotesErc20NativeV2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesErc20NativeV1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts index 24c6e0db380..6ef89d6770e 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts @@ -1,8 +1,10 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ @@ -212,10 +214,10 @@ export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ export const getMockBridgeQuotesNativeErc20EthV2 = ( quoteOverrides?: DeepPartial, -): QuoteResponseV1[] => { +): QuoteResponse[] => { return mockBridgeQuotesNativeErc20EthV1.map((quote) => { const mergedQuote = merge({}, quote, quoteOverrides); validateQuoteResponseV1(mergedQuote); - return mergedQuote; + return toQuoteResponseV2(mergedQuote); }); }; diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts index 11cbd1575f2..52214e3c433 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -1,8 +1,11 @@ +import { KnownCaipNamespace } from '@metamask/utils'; import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; import type { TxData } from '../src/validators/trade.js'; @@ -270,3 +273,11 @@ export const getMockBridgeQuotesNativeErc20V1 = ( return mergedQuote as QuoteResponseV1; }); }; + +export const getMockBridgeQuotesNativeErc20V2 = ( + quoteOverrides?: DeepPartial>, +): (QuoteResponse & { namespace: KnownCaipNamespace.Eip155 })[] => { + return getMockBridgeQuotesNativeErc20V1(quoteOverrides).map( + toQuoteResponseV2, + ) as (QuoteResponse & { namespace: KnownCaipNamespace.Eip155 })[]; +}; diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts index 8aff2cea101..92e90049ea9 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -1,8 +1,10 @@ import { merge } from 'lodash'; +import { toQuoteResponseV2 } from '../src/coercers/quote-response-v1-to-v2.js'; import type { DeepPartial } from '../src/types.js'; import type { QuoteResponseV1 } from '../src/validators/quote-response-v1.js'; import { validateQuoteResponseV1 } from '../src/validators/quote-response-v1.js'; +import type { QuoteResponse } from '../src/validators/quote-response.js'; import { ActionTypes } from '../src/validators/step.js'; export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ @@ -189,3 +191,9 @@ export const getMockBridgeQuotesSolErc20V1 = ( return mergedQuote; }); }; + +export const getMockBridgeQuotesSolErc20V2 = ( + quoteOverrides?: DeepPartial, +): QuoteResponse[] => { + return getMockBridgeQuotesSolErc20V1(quoteOverrides).map(toQuoteResponseV2); +}; From e9fc1d75171a8d7ef5719926e6722a30bdadd41d Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 21:32:56 -0700 Subject: [PATCH 09/45] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index e7f027d35d3..6129014db6e 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,9 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Define `QuoteResponse` v2 ([#9085](https://github.com/MetaMask/core/pull/9085)) - - `validateQuoteResponse` validates data -- Export `BridgeAsset` and `validateBridgeAsset`, used by QuoteResponse v2, and token endpoints ([#9085](https://github.com/MetaMask/core/pull/9085)) +- Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) +- Export `BridgeAsset` and `validateBridgeAsset` used by QuoteResponse v2, and token endpoints ([#9724](https://github.com/MetaMask/core/pull/9724)) ### Changed From 4b51f915a16de080ccda6a282fd865fcd968c40a Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:35:32 -0700 Subject: [PATCH 10/45] fix: tests --- .../src/coercers/quote-response-v1-to-v2.test.ts | 1 + packages/bridge-controller/src/utils/fetch.ts | 1 + packages/bridge-controller/src/utils/metrics/types.ts | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts index f077653ad4f..b38b530f5b9 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -135,6 +135,7 @@ describe('quote-response-v2 migration', () => { "valueInCurrency": undefined, }, ], + "txFee": undefined, } `); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index bf26dacc33c..bde74d3d691 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -24,6 +24,7 @@ import type { QuoteResponse } from '../validators/quote-response.js'; import { validateQuoteStreamComplete } from '../validators/quote-stream-complete.js'; import { validateTokenFeature } from '../validators/token-feature.js'; import { isEvmTxData } from '../validators/trade.js'; +import type { TxData } from '../validators/trade.js'; import { getEthUsdtResetData } from './bridge.js'; import { formatAddressToAssetId, diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index a9bda927090..95fd628cbf4 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -272,7 +272,7 @@ type RequiredEventContextFromClientBase = { } & InputPrimaryDenominationData; [UnifiedSwapBridgeEventName.Failed]: ( | // Tx failed before confirmation - (Pick< + (Pick< RequestMetadata, | 'stx_enabled' | 'usd_amount_source' @@ -319,7 +319,7 @@ type RequiredEventContextFromClientBase = { }; [UnifiedSwapBridgeEventName.AllQuotesSorted]: Pick< TradeData, - 'gas_included' + 'gas_included' | 'gas_included_7702' > & Pick & Pick & { From 3118a0512bb1297048311c4c39dde3cb40b7201a Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:36:07 -0700 Subject: [PATCH 11/45] coercers --- .../quote-metadata/to-quote-metadata-v2.ts | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) 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 index 2ce31647cf6..df6db4ca51c 100644 --- 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 @@ -44,6 +44,12 @@ export const toQuoteMetadataV2 = ( : 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: { @@ -70,12 +76,12 @@ export const toQuoteMetadataV2 = ( network: [ { amount: calcTokenValue( - gasFee?.total?.amount, + networkFeeToUse?.amount, nativeAsset?.decimals, ), - normalizedAmount: gasFee?.total?.amount, - valueInCurrency: gasFee?.total?.valueInCurrency, - usd: gasFee?.total?.usd, + normalizedAmount: networkFeeToUse?.amount, + valueInCurrency: networkFeeToUse?.valueInCurrency, + usd: networkFeeToUse?.usd, asset: nativeAsset, }, ], @@ -87,9 +93,9 @@ export const toQuoteMetadataV2 = ( relayerFee?.amount, nativeAsset?.decimals, ), - normalizedAmount: relayerFee?.amount, - valueInCurrency: relayerFee?.valueInCurrency, - usd: relayerFee?.usd, + normalizedAmount: relayerFee.amount, + valueInCurrency: relayerFee.valueInCurrency, + usd: relayerFee.usd, asset: nativeAsset, }, ], @@ -111,24 +117,17 @@ export const toQuoteMetadataV2 = ( }), }, priceData: { - ...((priceImpact && Object.values(priceImpact).some(Boolean)) || - (cost && Object.values(cost).some(Boolean)) - ? { - priceImpact: { - valueInCurrency: - priceImpact?.valueInCurrency ?? cost?.valueInCurrency, - usd: priceImpact?.usd ?? cost?.usd, - }, - } - : {}), - ...(adjustedReturn && Object.values(adjustedReturn).some(Boolean) - ? { - adjustedReturn: { - valueInCurrency: adjustedReturn?.valueInCurrency, - usd: adjustedReturn?.usd, - }, - } - : {}), + ...(priceImpactToUse && + Object.values(priceImpactToUse).some(Boolean) && { + priceImpact: priceImpactToUse, + }), + ...(adjustedReturn && + Object.values(adjustedReturn).some(Boolean) && { + adjustedReturn: { + valueInCurrency: adjustedReturn?.valueInCurrency, + usd: adjustedReturn?.usd, + }, + }), swapRate, }, }, From 34641077e32364fd083afc25192eabb790c57610 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:36:19 -0700 Subject: [PATCH 12/45] v2 --- packages/bridge-controller/src/index.ts | 1 + .../src/validators/amount-and-asset.ts | 15 +++------ .../bridge-controller/src/validators/quote.ts | 33 ++++++++++--------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 41dc433bc28..191ae41575c 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -232,6 +232,7 @@ export { formatChainIdToHex, formatAddressToCaipReference, formatAddressToAssetId, + formatChainIdToDec, } from './utils/caip-formatters.js'; export { extractTradeData } from './utils/trade-utils.js'; diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts index 8a084919112..9bd55654bcd 100644 --- a/packages/bridge-controller/src/validators/amount-and-asset.ts +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -1,11 +1,4 @@ -import { - type, - optional, - nullable, - union, - string, - Infer, -} from '@metamask/superstruct'; +import { type, optional, union, string, Infer } from '@metamask/superstruct'; import { BridgeAssetV2Schema } from './bridge-asset.js'; import { NumberStringSchema, FloatStringSchema } from './number.js'; @@ -16,18 +9,18 @@ export const AmountsAndAssetSchema = type({ * @example "1000000000000000000" */ amount: NumberStringSchema, + asset: BridgeAssetV2Schema, /* * The normalized amount of the asset * @example "1.5" */ normalizedAmount: optional(FloatStringSchema), - asset: BridgeAssetV2Schema, // TODO remove string and fix usd in backend - usd: optional(nullable(union([FloatStringSchema, string()]))), + usd: optional(union([FloatStringSchema, string()])), /* * The value of the asset in the currency, calculated based on usd value * @example "0.15" */ - valueInCurrency: optional(nullable(FloatStringSchema)), + valueInCurrency: optional(FloatStringSchema), }); export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index 5b07a7e8581..5411030de5f 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -7,6 +7,8 @@ import { number, array, nullable, + partial, + pick, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; @@ -141,22 +143,21 @@ export const QuoteSchemaV2 = intersection([ }), ]), priceData: optional( - type({ - swapRate: optional(FloatStringSchema), - priceImpact: optional( - type({ - usd: optional(nullable(FloatStringSchema)), - amount: optional(nullable(FloatStringSchema)), - valueInCurrency: optional(FloatStringSchema), - }), - ), - adjustedReturn: optional( - type({ - usd: nullable(optional(FloatStringSchema)), - valueInCurrency: nullable(optional(FloatStringSchema)), - }), - ), - }), + partial( + type({ + swapRate: FloatStringSchema, + priceImpact: intersection([ + type({ + amount: optional(FloatStringSchema), + }), + pick(AmountsAndAssetSchema, ['usd', 'valueInCurrency']), + ]), + adjustedReturn: pick(AmountsAndAssetSchema, [ + 'usd', + 'valueInCurrency', + ]), + }), + ), ), feeData: type({ [FeeType.METABRIDGE]: array( From 66750d126167e4fb1a552ae6c0757f1d728c9027 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:36:19 -0700 Subject: [PATCH 13/45] v2 --- packages/bridge-controller/src/index.ts | 1 + .../src/validators/amount-and-asset.ts | 15 +++------ .../bridge-controller/src/validators/quote.ts | 33 ++++++++++--------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index a8555ced3c8..c41675b24a5 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -222,6 +222,7 @@ export { formatChainIdToHex, formatAddressToCaipReference, formatAddressToAssetId, + formatChainIdToDec, } from './utils/caip-formatters.js'; export { extractTradeData } from './utils/trade-utils.js'; diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts index 8a084919112..9bd55654bcd 100644 --- a/packages/bridge-controller/src/validators/amount-and-asset.ts +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -1,11 +1,4 @@ -import { - type, - optional, - nullable, - union, - string, - Infer, -} from '@metamask/superstruct'; +import { type, optional, union, string, Infer } from '@metamask/superstruct'; import { BridgeAssetV2Schema } from './bridge-asset.js'; import { NumberStringSchema, FloatStringSchema } from './number.js'; @@ -16,18 +9,18 @@ export const AmountsAndAssetSchema = type({ * @example "1000000000000000000" */ amount: NumberStringSchema, + asset: BridgeAssetV2Schema, /* * The normalized amount of the asset * @example "1.5" */ normalizedAmount: optional(FloatStringSchema), - asset: BridgeAssetV2Schema, // TODO remove string and fix usd in backend - usd: optional(nullable(union([FloatStringSchema, string()]))), + usd: optional(union([FloatStringSchema, string()])), /* * The value of the asset in the currency, calculated based on usd value * @example "0.15" */ - valueInCurrency: optional(nullable(FloatStringSchema)), + valueInCurrency: optional(FloatStringSchema), }); export type AmountsAndAsset = Infer; diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index 5b07a7e8581..5411030de5f 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -7,6 +7,8 @@ import { number, array, nullable, + partial, + pick, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; @@ -141,22 +143,21 @@ export const QuoteSchemaV2 = intersection([ }), ]), priceData: optional( - type({ - swapRate: optional(FloatStringSchema), - priceImpact: optional( - type({ - usd: optional(nullable(FloatStringSchema)), - amount: optional(nullable(FloatStringSchema)), - valueInCurrency: optional(FloatStringSchema), - }), - ), - adjustedReturn: optional( - type({ - usd: nullable(optional(FloatStringSchema)), - valueInCurrency: nullable(optional(FloatStringSchema)), - }), - ), - }), + partial( + type({ + swapRate: FloatStringSchema, + priceImpact: intersection([ + type({ + amount: optional(FloatStringSchema), + }), + pick(AmountsAndAssetSchema, ['usd', 'valueInCurrency']), + ]), + adjustedReturn: pick(AmountsAndAssetSchema, [ + 'usd', + 'valueInCurrency', + ]), + }), + ), ), feeData: type({ [FeeType.METABRIDGE]: array( From c0166df095ba21b9e5cf28581fd5292689f0e9ef Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:36:07 -0700 Subject: [PATCH 14/45] coercers --- .../quote-metadata/to-quote-metadata-v2.ts | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) 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 index 2ce31647cf6..df6db4ca51c 100644 --- 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 @@ -44,6 +44,12 @@ export const toQuoteMetadataV2 = ( : 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: { @@ -70,12 +76,12 @@ export const toQuoteMetadataV2 = ( network: [ { amount: calcTokenValue( - gasFee?.total?.amount, + networkFeeToUse?.amount, nativeAsset?.decimals, ), - normalizedAmount: gasFee?.total?.amount, - valueInCurrency: gasFee?.total?.valueInCurrency, - usd: gasFee?.total?.usd, + normalizedAmount: networkFeeToUse?.amount, + valueInCurrency: networkFeeToUse?.valueInCurrency, + usd: networkFeeToUse?.usd, asset: nativeAsset, }, ], @@ -87,9 +93,9 @@ export const toQuoteMetadataV2 = ( relayerFee?.amount, nativeAsset?.decimals, ), - normalizedAmount: relayerFee?.amount, - valueInCurrency: relayerFee?.valueInCurrency, - usd: relayerFee?.usd, + normalizedAmount: relayerFee.amount, + valueInCurrency: relayerFee.valueInCurrency, + usd: relayerFee.usd, asset: nativeAsset, }, ], @@ -111,24 +117,17 @@ export const toQuoteMetadataV2 = ( }), }, priceData: { - ...((priceImpact && Object.values(priceImpact).some(Boolean)) || - (cost && Object.values(cost).some(Boolean)) - ? { - priceImpact: { - valueInCurrency: - priceImpact?.valueInCurrency ?? cost?.valueInCurrency, - usd: priceImpact?.usd ?? cost?.usd, - }, - } - : {}), - ...(adjustedReturn && Object.values(adjustedReturn).some(Boolean) - ? { - adjustedReturn: { - valueInCurrency: adjustedReturn?.valueInCurrency, - usd: adjustedReturn?.usd, - }, - } - : {}), + ...(priceImpactToUse && + Object.values(priceImpactToUse).some(Boolean) && { + priceImpact: priceImpactToUse, + }), + ...(adjustedReturn && + Object.values(adjustedReturn).some(Boolean) && { + adjustedReturn: { + valueInCurrency: adjustedReturn?.valueInCurrency, + usd: adjustedReturn?.usd, + }, + }), swapRate, }, }, From f229640640603111ffb85b9b4d40d438bf4886c9 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:50:00 -0700 Subject: [PATCH 15/45] fix: lint --- .../src/coercers/quote-response-v1-to-v2.ts | 2 +- .../bridge-controller/src/utils/quote-metadata/merge.ts | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) 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 index fbccfb049b3..7eb005e5f91 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -2,8 +2,8 @@ import { create, coerce, Infer, is, intersection } from '@metamask/superstruct'; import { parseCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { sumAmounts } from '../utils/number-formatters.js'; import { formatAddressToAssetId } from '../utils/caip-formatters.js'; +import { sumAmounts } from '../utils/number-formatters.js'; import { BridgeAssetSchema, BridgeAssetV2Schema, diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 7c7c8e2e5f6..40a4c91cbd0 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -1,13 +1,9 @@ import { is } from '@metamask/superstruct'; import { merge } from 'lodash'; -import { - QuoteResponseSchemaV1, - validateQuoteResponseV1, -} from '../../validators/quote-response-v1.js'; +import { QuoteResponseSchemaV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponseV1 } from '../../validators/quote-response-v1.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; -import { toNormalizedAmounts } from './to-normalized-amounts.js'; import { toQuoteMetadataV2 } from './to-quote-metadata-v2.js'; import type { QuoteMetadata } from './types.js'; @@ -32,13 +28,11 @@ export function mergeQuoteMetadata< legacyQuoteMetadata, quoteResponse, ); - // const normalizedAmountsV2 = toNormalizedAmounts(quoteResponse); // Phase 1 of migration uses calcQuoteMetadata's results return merge( {}, quoteResponse, - // normalizedAmountsV2, legacyQuoteMetadataV2, legacyQuoteMetadata, // return for client testing ); From f4fb464b9dc4234927eabbef3b766fdd86743e97 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:02:19 -0700 Subject: [PATCH 16/45] fix: test coverage --- .../coercers/quote-response-v1-to-v2.test.ts | 1 + .../src/coercers/quote-response-v1-to-v2.ts | 2 +- .../src/utils/quote-metadata/merge.test.ts | 19 ++++++++++++++++++- .../src/utils/quote-metadata/merge.ts | 8 ++++++-- .../quote-metadata/to-normalized-amounts.ts | 2 +- .../quote-metadata/to-quote-metadata-v1.ts | 1 + 6 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts index f077653ad4f..b38b530f5b9 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.test.ts @@ -135,6 +135,7 @@ describe('quote-response-v2 migration', () => { "valueInCurrency": undefined, }, ], + "txFee": undefined, } `); diff --git a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts index 7eb005e5f91..548e902b8d3 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -127,7 +127,7 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { }), }, ], - ...(feeData[FeeType.TX_FEE] && { + ...(feeData[FeeType.TX_FEE] && /* istanbul ignore next */ { [FeeType.TX_FEE]: [ { ...feeData[FeeType.TX_FEE], diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts index eaa8fab9c33..73220afee0f 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.test.ts @@ -13,7 +13,7 @@ describe('mergeQuoteMetadata', () => { mergedQuote: getMockBridgeQuotesErc20Erc20V2()[0], }, { - title: 'with relayer fee', + title: 'with relayer fee, included tx fees, and total network fee', quoteResponse: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { quote: { feeData: { @@ -23,6 +23,18 @@ describe('mergeQuoteMetadata', () => { usd: '100', }, ], + txFee: [ + { + amount: '100', + usd: '100', + }, + ], + network: [ + { + amount: '100', + usd: '100', + }, + ], }, }, }), @@ -32,6 +44,11 @@ describe('mergeQuoteMetadata', () => { valueInCurrency: '100', usd: '10', }, + includedTxFees: { + amount: '.000000000000000105', + valueInCurrency: '100', + usd: '10', + }, }, mergedQuote: merge({}, getMockBridgeQuotesErc20Erc20V2()[0], { quote: { diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 40a4c91cbd0..438f55b495b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -4,6 +4,7 @@ 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'; @@ -29,11 +30,14 @@ export function mergeQuoteMetadata< quoteResponse, ); + const normalizedAmounts = toNormalizedAmounts(quoteResponse); + // Phase 1 of migration uses calcQuoteMetadata's results return merge( {}, quoteResponse, - legacyQuoteMetadataV2, - legacyQuoteMetadata, // return for client testing + 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 index d044777f390..5229a75a101 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts @@ -1,5 +1,5 @@ import type { DeepPartial } from '../../types.js'; -import { QuoteResponse } from '../../validators/quote-response.js'; +import type { QuoteResponse } from '../../validators/quote-response.js'; import { FeeType } from '../../validators/quote.js'; import { calcTokenAmount } from '../number-formatters.js'; diff --git a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts index dc27284fb51..bf5bd21798d 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/to-quote-metadata-v1.ts @@ -17,6 +17,7 @@ export const toQuoteMetadataV1 = ( | (DeepPartial & QuoteMetadata) | null, ): QuoteMetadata => { + /* istanbul ignore if */ if (!quoteResponse) { return {}; } From 48b9858ba6a8bdb3bdd4e05a5a809439203626bd Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:04:35 -0700 Subject: [PATCH 17/45] fix: build --- packages/bridge-controller/src/utils/quote-metadata/merge.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 4f65020c3c8..438f55b495b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -4,6 +4,7 @@ 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'; From eeb0a7386d84af6e0111d6cc95c7260526755bcb Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:09:01 -0700 Subject: [PATCH 18/45] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index b363edf43b6..aaeabd6134f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 human-readable values + - `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)) - Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) - Export `BridgeAsset` and `validateBridgeAsset` used by QuoteResponse v2, and token endpoints ([#9724](https://github.com/MetaMask/core/pull/9724)) From 77ff8877dcf01b41b17dbbeebb4ec8a3220ccb9b Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:15:25 -0700 Subject: [PATCH 19/45] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 37d7b06b6c9..3528f28f634 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,21 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) -- Export `BridgeAsset` and `validateBridgeAsset` used by QuoteResponse v2, and token endpoints ([#9724](https://github.com/MetaMask/core/pull/9724)) - 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 human-readable values -- Selector compatibility ([#9727](https://github.com/MetaMask/core/pull/9727)) - - deprecate cost and use priceImpact - -### Removed - -- Clean up unused unsupporteed `XlmScope.Testnet` chainId mapping ([#9085](https://github.com/MetaMask/core/pull/9085)) +- Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) +- Export `BridgeAsset` and `validateBridgeAsset` used by QuoteResponse v2, and token endpoints ([#9724](https://github.com/MetaMask/core/pull/9724)) ### Changed +- Update `calcQuoteMetadata` util to handle both V1 and V2 quotes. Legacy metadata calculators continue to use the V1 schema ([#9727](https://github.com/MetaMask/core/pull/9727)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/assets-controller` from `^13.0.0` to `^13.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) - Bump `@metamask/assets-controllers` from `^110.0.3` to `^110.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) From 17102efaac8840f6a07caace04ba1e9a989c019d Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:19:11 -0700 Subject: [PATCH 20/45] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 22 ++++++++++++++++++- .../bridge-status-controller/CHANGELOG.md | 19 +++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 42ef49b9fcd..d4c7d8eebfd 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -18,18 +18,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes - **BREAKING:** Use QuoteResponse V2 within the BridgeController; this affects the batch-sell, unified swap/bridge and quickBuy experiences ([#9085](https://github.com/MetaMask/core/pull/9085)) - convert quotes to QuoteResponse v2 in `fetchBridgeQuoteStream` - store quotes as QuoteResponse v2 in the BridgeController - `QuoteResponse` export now means v2; v1 is still exported as `QuoteResponseV1` - `fetchBridgeQuoteStream` and `fetchBatchSellTrades` now return `QuoteResponse` v2 - `fetchBatchSellTrades` expects V2 quotes, then transforms them to V1 for backend compatibility +- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes - Update `calcQuoteMetadata` util to handle both V1 and V2 quotes. Legacy metadata calculators continue to use the V1 schema ([#9727](https://github.com/MetaMask/core/pull/9727)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/assets-controller` from `^13.0.0` to `^13.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) - Bump `@metamask/assets-controllers` from `^110.0.3` to `^110.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) +## [78.0.3] + +### Changed + +- Bump `@metamask/assets-controller` from `^12.0.0` to `^13.0.0` ([#9740](https://github.com/MetaMask/core/pull/9740)) + +## [78.0.2] + +### Changed + +- Bump `@metamask/assets-controllers` from `^110.0.0` to `^110.0.3` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.4.0` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/assets-controller` from `^11.2.1` to `^12.0.0` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9706](https://github.com/MetaMask/core/pull/9706), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/gas-fee-controller` from `^26.3.0` to `^26.3.1` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/multichain-network-controller` from `^3.2.1` to `^3.2.2` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/polling-controller` from `^16.0.8` to `^16.0.9` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + ## [78.0.1] ### Changed diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 560ed46a1b3..2d9aa460de7 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,9 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9085](https://github.com/MetaMask/core/pull/9085)) -- Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx`. Controller logic and utils still require V1, but clients can submit quotes in both formats ([#9085](https://github.com/MetaMask/core/pull/9085)) -- Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.3.0` ([#9693](https://github.com/MetaMask/core/pull/9693)) +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9726](https://github.com/MetaMask/core/pull/9726)) + - Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx` + - Controller logic and utils still require V1, but clients can submit quotes in both formats +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) +- Bump `@metamask/bridge-controller` from `^78.0.2` to `^78.0.3` ([#9740](https://github.com/MetaMask/core/pull/9740)) + +## [74.6.1] + +### Changed + +- Bump `@metamask/transaction-controller` from `^69.2.1` to `^69.4.0` ([#9693](https://github.com/MetaMask/core/pull/9693), [#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/accounts-controller` from `^39.0.5` to `^39.0.6` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/bridge-controller` from `^78.0.1` to `^78.0.2` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/gas-fee-controller` from `^26.3.0` to `^26.3.1` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) +- Bump `@metamask/polling-controller` from `^16.0.8` to `^16.0.9` ([#9735](https://github.com/MetaMask/core/pull/9735)) ## [74.6.0] From 83e453f418508e90555024113a33230fe94cbde9 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:22:49 -0700 Subject: [PATCH 21/45] fix: lint --- packages/bridge-controller/src/utils/metrics/types.ts | 2 +- packages/bridge-controller/src/utils/quote-metadata/merge.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index 95fd628cbf4..6c3d263110b 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -272,7 +272,7 @@ type RequiredEventContextFromClientBase = { } & InputPrimaryDenominationData; [UnifiedSwapBridgeEventName.Failed]: ( | // Tx failed before confirmation - (Pick< + (Pick< RequestMetadata, | 'stx_enabled' | 'usd_amount_source' diff --git a/packages/bridge-controller/src/utils/quote-metadata/merge.ts b/packages/bridge-controller/src/utils/quote-metadata/merge.ts index 0b60ebab50a..438f55b495b 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/merge.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/merge.ts @@ -7,7 +7,6 @@ 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'; -import { toNormalizedAmounts } from './to-normalized-amounts.js'; /** * Merges legacy {@link QuoteMetadata} values into the {@link QuoteResponse} From 60e0d4cd390666c3dfb8b1d9e671dd5d0b723f67 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 12:28:46 -0700 Subject: [PATCH 22/45] fix: changelog --- packages/bridge-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index d4c7d8eebfd..3055db8326e 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -18,13 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Use QuoteResponse V2 within the BridgeController; this affects the batch-sell, unified swap/bridge and quickBuy experiences ([#9085](https://github.com/MetaMask/core/pull/9085)) +- **BREAKING:** Use QuoteResponse V2 within the BridgeController; this affects the batch-sell, unified swap/bridge and quickBuy experiences ([#9726](https://github.com/MetaMask/core/pull/9726)) - convert quotes to QuoteResponse v2 in `fetchBridgeQuoteStream` - store quotes as QuoteResponse v2 in the BridgeController - `QuoteResponse` export now means v2; v1 is still exported as `QuoteResponseV1` - `fetchBridgeQuoteStream` and `fetchBatchSellTrades` now return `QuoteResponse` v2 - `fetchBatchSellTrades` expects V2 quotes, then transforms them to V1 for backend compatibility -- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes +- **BREAKING:** `appendFeesToQuotes` interface now requires a chainId parameter, but still accepts both V1 and V2 quotes ([#9726](https://github.com/MetaMask/core/pull/9726)) - Update `calcQuoteMetadata` util to handle both V1 and V2 quotes. Legacy metadata calculators continue to use the V1 schema ([#9727](https://github.com/MetaMask/core/pull/9727)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/assets-controller` from `^13.0.0` to `^13.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) From 7321ae0dd3a8384f5b062140a3694a815f440171 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 14:32:25 -0700 Subject: [PATCH 23/45] chore: undo Stellar testnet removal --- packages/bridge-controller/src/utils/caip-formatters.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bridge-controller/src/utils/caip-formatters.ts b/packages/bridge-controller/src/utils/caip-formatters.ts index 377b731540d..c3626eb79c2 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -54,6 +54,9 @@ export const formatChainIdToCaip = ( return TrxScope.Mainnet; } if (isStellarChainId(chainId)) { + if (chainId === XlmScope.Testnet) { + return XlmScope.Testnet; + } return XlmScope.Pubnet; } return toEvmCaipChainId(numberToHex(Number(chainId))); From 8bd9439d0e5c087bd97bfb40f2edf13443f805b4 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 14:34:16 -0700 Subject: [PATCH 24/45] chore: update comment --- packages/bridge-controller/src/validators/bridge-asset.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/validators/bridge-asset.ts b/packages/bridge-controller/src/validators/bridge-asset.ts index 686b23dc9f3..94834857ced 100644 --- a/packages/bridge-controller/src/validators/bridge-asset.ts +++ b/packages/bridge-controller/src/validators/bridge-asset.ts @@ -21,11 +21,11 @@ export const MinimalAssetSchema = type({ */ assetId: CaipAssetTypeStruct, /** - * The symbol of token object + * The symbol of the asset */ symbol: string(), /** - * The name for the network + * The name of the asset */ name: string(), decimals: number(), From e8b7fa772d8727a70c1eb585214cdf9ebfda34b4 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 15:06:03 -0700 Subject: [PATCH 25/45] chore: rename NumberStringSchema -> PositiveNumberStringSchema --- .../bridge-controller/src/validators/amount-and-asset.ts | 4 ++-- packages/bridge-controller/src/validators/batch-sell.ts | 4 ++-- packages/bridge-controller/src/validators/number.ts | 2 +- packages/bridge-controller/src/validators/quote.ts | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/bridge-controller/src/validators/amount-and-asset.ts b/packages/bridge-controller/src/validators/amount-and-asset.ts index 9bd55654bcd..90b465d4250 100644 --- a/packages/bridge-controller/src/validators/amount-and-asset.ts +++ b/packages/bridge-controller/src/validators/amount-and-asset.ts @@ -1,14 +1,14 @@ import { type, optional, union, string, Infer } from '@metamask/superstruct'; import { BridgeAssetV2Schema } from './bridge-asset.js'; -import { NumberStringSchema, FloatStringSchema } from './number.js'; +import { PositiveNumberStringSchema, FloatStringSchema } from './number.js'; export const AmountsAndAssetSchema = type({ /* * The atomic amount of the asset * @example "1000000000000000000" */ - amount: NumberStringSchema, + amount: PositiveNumberStringSchema, asset: BridgeAssetV2Schema, /* * The normalized amount of the asset diff --git a/packages/bridge-controller/src/validators/batch-sell.ts b/packages/bridge-controller/src/validators/batch-sell.ts index 6aa59b3aab5..6aa45c9b296 100644 --- a/packages/bridge-controller/src/validators/batch-sell.ts +++ b/packages/bridge-controller/src/validators/batch-sell.ts @@ -10,7 +10,7 @@ import type { Infer } from '@metamask/superstruct'; import { StrictHexStruct } from '@metamask/utils'; import { BridgeAssetSchema } from './bridge-asset.js'; -import { NumberStringSchema } from './number.js'; +import { PositiveNumberStringSchema } from './number.js'; import { GaslessPropertiesSchema } from './quote.js'; import { TxDataSchema } from './trade.js'; @@ -37,7 +37,7 @@ export const BatchSellTradesResponseSchema = intersection([ fee: optional( type({ asset: BridgeAssetSchema, - amount: NumberStringSchema, + amount: PositiveNumberStringSchema, }), ), }), diff --git a/packages/bridge-controller/src/validators/number.ts b/packages/bridge-controller/src/validators/number.ts index db9f45b0595..7d6ebc1c1fc 100644 --- a/packages/bridge-controller/src/validators/number.ts +++ b/packages/bridge-controller/src/validators/number.ts @@ -1,6 +1,6 @@ import { define, pattern, string } from '@metamask/superstruct'; -export const NumberStringSchema = define( +export const PositiveNumberStringSchema = define( 'NumberString', (value: unknown) => typeof value === 'string' && /^\d+$/u.test(value), ); diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index 5411030de5f..affc2dca62a 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -17,7 +17,7 @@ import { ChainIdSchema, BridgeAssetSchema } from './bridge-asset.js'; import { IntentSchema } from './intent.js'; import { TruthyDigitStringSchema, - NumberStringSchema, + PositiveNumberStringSchema, FloatStringSchema, } from './number.js'; import { RefuelDataSchema, StepSchema, StepSchemaV2 } from './step.js'; @@ -44,8 +44,8 @@ export const FeeDataSchema = type({ }); export const TxFeeGasLimitsSchema = type({ - maxFeePerGas: NumberStringSchema, - maxPriorityFeePerGas: NumberStringSchema, + maxFeePerGas: PositiveNumberStringSchema, + maxPriorityFeePerGas: PositiveNumberStringSchema, }); export const GaslessPropertiesSchema = type({ From 81307ecaa00f66724d7c6e50ce199cca3c7d1fda Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 16:24:19 -0700 Subject: [PATCH 26/45] fix: intent --- .../bridge-controller/src/coercers/quote-response-v1-to-v2.ts | 1 + .../bridge-controller/src/coercers/quote-response-v2-to-v1.ts | 1 + 2 files changed, 2 insertions(+) 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 index 548e902b8d3..2f3f9df10a6 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -138,6 +138,7 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { }, steps: steps?.map(toStepV2), ...restQuote, + ...(intent && { intent }), protocols: bridges, aggregator: bridgeId, }; 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 index 9ccdbefc9cf..e0f8a1f0688 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -103,6 +103,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { srcTokenAmount: src.amount, destTokenAmount: dest.amount, minDestTokenAmount: dest.minAmount, + ...(intent && { intent }), feeData: { [FeeType.METABRIDGE]: { ...metabridgeFeeData, From c9d2666e8be4536763160cfccb6bff5ea0e09440 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 16:35:42 -0700 Subject: [PATCH 27/45] fix: priceImpact --- .../bridge-controller/src/coercers/quote-response-v2-to-v1.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 9ccdbefc9cf..e4740f47664 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -121,9 +121,9 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { ...(src.walletAddress && /* istanbul ignore next */ { walletAddress: src.walletAddress, }), - ...(value.priceData?.priceImpact?.amount && /* istanbul ignore next */ { + ...(priceData?.priceImpact?.amount && /* istanbul ignore next */ { priceData: { - priceImpact: value.priceData.priceImpact.amount, + priceImpact: priceData.priceImpact.amount, }, }), /** From 3d460fc7b500c238ca57801205d9b4ae741ffd71 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 16:41:13 -0700 Subject: [PATCH 28/45] fix: coverage --- .../src/coercers/quote-response-v1-to-v2.ts | 6 +++--- .../src/coercers/quote-response-v2-to-v1.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index 2f3f9df10a6..40d37d05cb3 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -111,9 +111,9 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { minAmount: minDestTokenAmount, }, priceData: { - ...(value.priceData?.priceImpact && { + ...(priceData?.priceImpact && { priceImpact: { - amount: value.priceData.priceImpact, + amount: priceData.priceImpact, }, }), }, @@ -138,7 +138,7 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { }, steps: steps?.map(toStepV2), ...restQuote, - ...(intent && { intent }), + ...(intent && /* istanbul ignore next */ { intent }), protocols: bridges, aggregator: bridgeId, }; 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 index e0f8a1f0688..862cc47bcc1 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -80,6 +80,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { aggregator, src, dest, + intent, ...restQuote } = value; @@ -103,7 +104,6 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { srcTokenAmount: src.amount, destTokenAmount: dest.amount, minDestTokenAmount: dest.minAmount, - ...(intent && { intent }), feeData: { [FeeType.METABRIDGE]: { ...metabridgeFeeData, @@ -127,6 +127,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { priceImpact: value.priceData.priceImpact.amount, }, }), + ...(intent && /* istanbul ignore next */ { intent }), /** * @deprecated This field is deprecated. */ From 03be8a6612859aaa1f8ba87895fcd2e92c7b45e2 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 09:24:43 -0700 Subject: [PATCH 29/45] refactor: rename token amount formatters --- .../src/utils/number-formatters.ts | 4 ++-- .../src/utils/quote-metadata/calculators.ts | 15 +++++++++------ .../quote-metadata/to-normalized-amounts.ts | 14 +++++++------- .../quote-metadata/to-quote-metadata-v2.ts | 17 ++++++++++------- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/bridge-controller/src/utils/number-formatters.ts b/packages/bridge-controller/src/utils/number-formatters.ts index b6b2b6d76f9..931444880bf 100644 --- a/packages/bridge-controller/src/utils/number-formatters.ts +++ b/packages/bridge-controller/src/utils/number-formatters.ts @@ -11,7 +11,7 @@ import type { QuoteResponse } from '../validators/quote-response.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, ) => { @@ -29,7 +29,7 @@ export const calcTokenAmount = ( * @param decimals - The number of decimals to convert to * @returns The token value in string format */ -export const calcTokenValue = ( +export const calcAtomicTokenAmount = ( value: string | BigNumber | undefined, decimals: number | undefined, ) => { 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/to-normalized-amounts.ts b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts index 5229a75a101..789459f4b26 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/to-normalized-amounts.ts @@ -1,7 +1,7 @@ import type { DeepPartial } from '../../types.js'; import type { QuoteResponse } from '../../validators/quote-response.js'; import { FeeType } from '../../validators/quote.js'; -import { calcTokenAmount } from '../number-formatters.js'; +import { calcNormalizedTokenAmount } from '../number-formatters.js'; /** * Builds a partial {@link QuoteResponese} with normalized amounts @@ -17,36 +17,36 @@ export const toNormalizedAmounts = ( return { quote: { src: { - normalizedAmount: calcTokenAmount( + normalizedAmount: calcNormalizedTokenAmount( src?.amount, src?.asset?.decimals, )?.toFixed(), }, dest: { - normalizedAmount: calcTokenAmount( + normalizedAmount: calcNormalizedTokenAmount( dest?.amount, dest?.asset?.decimals, )?.toFixed(), - minAmountNormalized: calcTokenAmount( + minAmountNormalized: calcNormalizedTokenAmount( dest?.minAmount, dest?.asset?.decimals, )?.toFixed(), }, feeData: { network: feeData?.[FeeType.NETWORK]?.map((networkFee) => ({ - normalizedAmount: calcTokenAmount( + normalizedAmount: calcNormalizedTokenAmount( networkFee?.amount, networkFee?.asset?.decimals, )?.toFixed(), })), relayer: feeData?.[FeeType.RELAYER]?.map((relayerFee) => ({ - normalizedAmount: calcTokenAmount( + normalizedAmount: calcNormalizedTokenAmount( relayerFee.amount, relayerFee.asset?.decimals, )?.toFixed(), })), txFee: feeData?.[FeeType.TX_FEE]?.map((txFee) => ({ - normalizedAmount: calcTokenAmount( + normalizedAmount: calcNormalizedTokenAmount( txFee.amount, txFee.asset?.decimals, )?.toFixed(), 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 index df6db4ca51c..8b177a55d5d 100644 --- 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 @@ -4,7 +4,7 @@ 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 { calcTokenValue } from '../number-formatters.js'; +import { calcAtomicTokenAmount } from '../number-formatters.js'; import type { QuoteMetadata } from './types.js'; /** @@ -54,17 +54,20 @@ export const toQuoteMetadataV2 = ( ...rest, quote: { src: { - amount: calcTokenValue(sentAmount?.amount, srcAsset?.decimals), + amount: calcAtomicTokenAmount(sentAmount?.amount, srcAsset?.decimals), normalizedAmount: sentAmount?.amount, valueInCurrency: sentAmount?.valueInCurrency, usd: sentAmount?.usd, }, dest: { - amount: calcTokenValue(toTokenAmount?.amount, destAsset?.decimals), + amount: calcAtomicTokenAmount( + toTokenAmount?.amount, + destAsset?.decimals, + ), normalizedAmount: toTokenAmount?.amount, valueInCurrency: toTokenAmount?.valueInCurrency, usd: toTokenAmount?.usd, - minAmount: calcTokenValue( + minAmount: calcAtomicTokenAmount( minToTokenAmount?.amount, destAsset?.decimals, ), @@ -75,7 +78,7 @@ export const toQuoteMetadataV2 = ( feeData: { network: [ { - amount: calcTokenValue( + amount: calcAtomicTokenAmount( networkFeeToUse?.amount, nativeAsset?.decimals, ), @@ -89,7 +92,7 @@ export const toQuoteMetadataV2 = ( Object.values(relayerFee).some(Boolean) && { relayer: [ { - amount: calcTokenValue( + amount: calcAtomicTokenAmount( relayerFee?.amount, nativeAsset?.decimals, ), @@ -104,7 +107,7 @@ export const toQuoteMetadataV2 = ( Object.values(includedTxFees).some(Boolean) && { txFee: [ { - amount: calcTokenValue( + amount: calcAtomicTokenAmount( includedTxFees?.amount, txFeeAsset?.decimals, ), From bfede7af2986c979363c2a813a3cb096b4b056f2 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 09:24:52 -0700 Subject: [PATCH 30/45] fix: export sumAmounts --- packages/bridge-controller/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 7a03644e6d9..728cb9378d1 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -233,6 +233,8 @@ export { export { extractTradeData } from './utils/trade-utils.js'; +export { sumAmounts } from './utils/number-formatters.js'; + export { selectBridgeQuotes, selectBatchSellQuotes, From 157df42543edb0819ab09146efa48fcec59e1f5c Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 09:27:43 -0700 Subject: [PATCH 31/45] fix: changelog --- packages/bridge-controller/CHANGELOG.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index d65289fa869..a03e9539f46 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,16 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [78.1.0] - ### Added -- Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) - 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)) + +## [78.1.0] + +### Added + +- Define `QuoteResponse` v2 and `validateQuoteResponse` ([#9724](https://github.com/MetaMask/core/pull/9724)) - Export `BridgeAsset` and `validateBridgeAsset` used by QuoteResponse v2, and token endpoints ([#9724](https://github.com/MetaMask/core/pull/9724)) ### Changed From cda6f82ac629235f59dd4919fa25f8fefa382d91 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 09:50:56 -0700 Subject: [PATCH 32/45] chore: remove unused vars --- .../src/coercers/quote-response-v1-to-v2.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) 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 index 40d37d05cb3..85fc3748d8b 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -23,20 +23,9 @@ const BridgeAssetV2FromV1 = coerce( BridgeAssetV2Schema, intersection([BridgeAssetSchema, MinimalAssetSchema]), (value) => { - const { - chainId, - address, - // @ts-expect-error - chainAgnosticId is not in the schema - chainAgnosticId, - // @ts-expect-error - logoURI is not in the schema - logoURI, - iconUrl, - icon, - assetId, - ...rest - } = value; - - const resolvedIconUrl = iconUrl ?? logoURI ?? icon; + const { chainId, address, iconUrl, icon, assetId, ...rest } = value; + + const resolvedIconUrl = iconUrl ?? icon; return { assetId: From 466cc59abcc101692362747401b6b17b929b5ce6 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 09:58:50 -0700 Subject: [PATCH 33/45] chore: rm unknown types --- .../src/coercers/quote-response-v1-to-v2.ts | 12 ++++++++---- .../src/coercers/quote-response-v2-to-v1.ts | 9 ++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) 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 index 85fc3748d8b..6b77be19c40 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -38,7 +38,7 @@ const BridgeAssetV2FromV1 = coerce( ); export const toBridgeAssetV2 = ( - data: unknown, + data: Infer | undefined, ): Infer => { return create(data, BridgeAssetV2FromV1); }; @@ -55,7 +55,7 @@ const StepSchemaV2FromV1 = coerce(StepSchemaV2, StepSchema, (value) => { }, }; }); -const toStepV2 = (step: unknown): Infer => +const toStepV2 = (step: Infer): Infer => create(step, StepSchemaV2FromV1); const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { @@ -133,7 +133,9 @@ const QuoteV2FromV1 = coerce(QuoteSchemaV2, QuoteSchema, (value) => { }; }); -const toQuoteV2 = (quote: unknown): Infer => { +const toQuoteV2 = ( + quote: Infer, +): Infer => { const quoteV2 = create(quote, QuoteV2FromV1); return quoteV2; }; @@ -168,7 +170,9 @@ const QuoteResponseV2FromV1 = coerce( * @param quoteResponse - The {@link QuoteResponseV1} to convert * @returns The {@link QuoteResponse} */ -export function toQuoteResponseV2(quoteResponse: unknown): QuoteResponse { +export function toQuoteResponseV2( + quoteResponse: QuoteResponseV1 | QuoteResponse, +): QuoteResponse { let quoteResponseV2: QuoteResponse | null = null; // V1 quote 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 index 862cc47bcc1..c854ef9cd4f 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -20,6 +20,7 @@ import { 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'; @@ -48,7 +49,9 @@ const BridgeAssetV1FromV2 = coerce( }, ); -const toBridgeAssetV1 = (data: unknown): Infer => { +const toBridgeAssetV1 = ( + data: BridgeAssetV2, +): Infer => { return create(data, BridgeAssetV1FromV2); }; @@ -66,7 +69,7 @@ const StepSchemaV1FromV2 = coerce(StepSchema, StepSchemaV2, (value) => { }; }); -const toStepV1 = (step: unknown): Step => { +const toStepV1 = (step: Infer): Step => { const stepV2 = create(step, StepSchemaV1FromV2); return stepV2; }; @@ -136,7 +139,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { }; }); -const toQuoteV1 = (quote: unknown): Quote => { +const toQuoteV1 = (quote: Infer): Quote => { const quoteV2 = create(quote, QuoteV1FromV2); return quoteV2; }; From f9fff64f723419577ce5a842fed24c1efa4e1127 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:18:25 -0700 Subject: [PATCH 34/45] fix: duplicate export --- packages/bridge-controller/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index e65402b2c8c..191ae41575c 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -237,8 +237,6 @@ export { export { extractTradeData } from './utils/trade-utils.js'; -export { sumAmounts } from './utils/number-formatters.js'; - export { selectBridgeQuotes, selectBatchSellQuotes, From 9e075370ef7b112d3529f4df33df338b6bf82845 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:19:19 -0700 Subject: [PATCH 35/45] chore: restore unknown --- .../src/coercers/quote-response-v1-to-v2.ts | 6 ++---- packages/bridge-controller/src/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) 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 index 6b77be19c40..7c2419a212b 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v1-to-v2.ts @@ -38,7 +38,7 @@ const BridgeAssetV2FromV1 = coerce( ); export const toBridgeAssetV2 = ( - data: Infer | undefined, + data: unknown, ): Infer => { return create(data, BridgeAssetV2FromV1); }; @@ -170,9 +170,7 @@ const QuoteResponseV2FromV1 = coerce( * @param quoteResponse - The {@link QuoteResponseV1} to convert * @returns The {@link QuoteResponse} */ -export function toQuoteResponseV2( - quoteResponse: QuoteResponseV1 | QuoteResponse, -): QuoteResponse { +export function toQuoteResponseV2(quoteResponse: unknown): QuoteResponse { let quoteResponseV2: QuoteResponse | null = null; // V1 quote diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 728cb9378d1..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, @@ -233,8 +235,6 @@ export { export { extractTradeData } from './utils/trade-utils.js'; -export { sumAmounts } from './utils/number-formatters.js'; - export { selectBridgeQuotes, selectBatchSellQuotes, From 977b7a8980f7f92882e5c928799896322e5d61b2 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:27:43 -0700 Subject: [PATCH 36/45] fix: import + lint --- .../src/utils/quote-metadata/calculators.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts index 576982b15fe..d21cdafec30 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/calculators.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/calculators.ts @@ -7,7 +7,7 @@ import { import { is } from '@metamask/superstruct'; import { BigNumber } from 'bignumber.js'; -import { toQuoteResponseV1 } from '../../index.js'; +import { toQuoteResponseV1 } from '../../coercers/quote-response-v2-to-v1.js'; import type { L1GasFees, ExchangeRate, @@ -83,7 +83,10 @@ export const calcSentAmount = ( (acc, { amount }) => acc.plus(amount), new BigNumber(srcTokenAmount), ); - const normalizedSentAmount = calcNormalizedTokenAmount(sentAmount, srcAsset.decimals); + const normalizedSentAmount = calcNormalizedTokenAmount( + sentAmount, + srcAsset.decimals, + ); return { amount: normalizedSentAmount?.toFixed(), valueInCurrency: From 8b8ca168aa091bc990730c624f74e4f2014bce92 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:30:17 -0700 Subject: [PATCH 37/45] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index a82984114d1..46552581626 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Update `calcQuoteMetadata` util to handle both V1 and V2 quotes. Legacy metadata calculators continue to use the V1 schema ([#9727](https://github.com/MetaMask/core/pull/9727)) - Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) ## [78.1.0] @@ -28,7 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Update `calcQuoteMetadata` util to handle both V1 and V2 quotes. Legacy metadata calculators continue to use the V1 schema ([#9727](https://github.com/MetaMask/core/pull/9727)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/assets-controller` from `^13.0.0` to `^13.1.0` ([#9743](https://github.com/MetaMask/core/pull/9743)) - Bump `@metamask/assets-controllers` from `^110.0.3` to `^110.1.1` ([#9743](https://github.com/MetaMask/core/pull/9743), [#9779](https://github.com/MetaMask/core/pull/9779)) From aa5c5d2442472903058890d80544c38d0113906c Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:41:44 -0700 Subject: [PATCH 38/45] chore: minAmount fallback --- .../bridge-controller/src/coercers/quote-response-v2-to-v1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c854ef9cd4f..12b875b4193 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -106,7 +106,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { destAsset: toBridgeAssetV1(dest.asset), srcTokenAmount: src.amount, destTokenAmount: dest.amount, - minDestTokenAmount: dest.minAmount, + minDestTokenAmount: dest.minAmount ?? dest.amount, feeData: { [FeeType.METABRIDGE]: { ...metabridgeFeeData, From 21a8c36a12eccfc9db3279ed068e95d5a1f32528 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:48:19 -0700 Subject: [PATCH 39/45] Revert "chore: minAmount fallback" This reverts commit aa5c5d2442472903058890d80544c38d0113906c. --- .../bridge-controller/src/coercers/quote-response-v2-to-v1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 12b875b4193..c854ef9cd4f 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -106,7 +106,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { destAsset: toBridgeAssetV1(dest.asset), srcTokenAmount: src.amount, destTokenAmount: dest.amount, - minDestTokenAmount: dest.minAmount ?? dest.amount, + minDestTokenAmount: dest.minAmount, feeData: { [FeeType.METABRIDGE]: { ...metabridgeFeeData, From 31b0948f663ca082b3a1231dd35a3088fd3617f4 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:41:44 -0700 Subject: [PATCH 40/45] chore: minAmount fallback --- .../bridge-controller/src/coercers/quote-response-v2-to-v1.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index c0d4508d260..b604fdfc2d0 100644 --- a/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts +++ b/packages/bridge-controller/src/coercers/quote-response-v2-to-v1.ts @@ -106,7 +106,7 @@ const QuoteV1FromV2 = coerce(QuoteSchema, QuoteSchemaV2, (value) => { destAsset: toBridgeAssetV1(dest.asset), srcTokenAmount: src.amount, destTokenAmount: dest.amount, - minDestTokenAmount: dest.minAmount, + minDestTokenAmount: dest.minAmount ?? dest.amount, feeData: { [FeeType.METABRIDGE]: { ...metabridgeFeeData, From d5f4a995d1c908ff331b93895c3df3a3209f084c Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 10:55:05 -0700 Subject: [PATCH 41/45] fix: optional minDestTokenAmount (match backend) --- .../src/coercers/quote-response-v2-to-v1.test.ts | 1 - packages/bridge-controller/src/index.ts | 2 -- .../src/utils/__snapshots__/fetch.test.ts.snap | 2 -- packages/bridge-controller/src/utils/fetch.test.ts | 2 -- packages/bridge-controller/src/validators/quote.ts | 2 +- 5 files changed, 1 insertion(+), 8 deletions(-) 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 index cee15a54f24..be6db9f6029 100644 --- 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 @@ -151,7 +151,6 @@ describe('quote-response-v1 compatibility', () => { "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", diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index cb73d81bd9d..191ae41575c 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -78,8 +78,6 @@ 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, diff --git a/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap b/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap index afba60f1101..3df3fbcc9bb 100644 --- a/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap +++ b/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap @@ -13,7 +13,6 @@ exports[`fetch fetchBridgeQuotes should filter out malformed bridge quotes 2`] = "lifi|quote.destChainId", "lifi|quote.destAsset", "lifi|quote.destTokenAmount", - "lifi|quote.minDestTokenAmount", "lifi|quote.feeData", "lifi|quote.steps", "socket|quote.requestId", @@ -23,7 +22,6 @@ exports[`fetch fetchBridgeQuotes should filter out malformed bridge quotes 2`] = "socket|quote.destChainId", "socket|quote.destAsset.address", "socket|quote.destTokenAmount", - "socket|quote.minDestTokenAmount", "socket|quote.feeData", "socket|quote.steps", ], diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index c0f087dc8be..5c52db8da30 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -378,7 +378,6 @@ describe('fetch', () => { "lifi|quote.destChainId", "lifi|quote.destAsset", "lifi|quote.destTokenAmount", - "lifi|quote.minDestTokenAmount", "lifi|quote.feeData", "lifi|quote.steps", "socket|quote.requestId", @@ -388,7 +387,6 @@ describe('fetch', () => { "socket|quote.destChainId", "socket|quote.destAsset.address", "socket|quote.destTokenAmount", - "socket|quote.minDestTokenAmount", "socket|quote.feeData", "socket|quote.steps", ] diff --git a/packages/bridge-controller/src/validators/quote.ts b/packages/bridge-controller/src/validators/quote.ts index affc2dca62a..ca318670c92 100644 --- a/packages/bridge-controller/src/validators/quote.ts +++ b/packages/bridge-controller/src/validators/quote.ts @@ -80,7 +80,7 @@ export const QuoteSchema = intersection([ /** * The minimum amount that will be received, in atomic amount */ - minDestTokenAmount: string(), + minDestTokenAmount: optional(string()), feeData: type({ [FeeType.METABRIDGE]: intersection([ FeeDataSchema, From a163bb159b1aad0ec327c90643ad32e63b1df84e Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 11:04:31 -0700 Subject: [PATCH 42/45] fix: export AmountsAndAssetSchema --- packages/bridge-controller/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 191ae41575c..943a079c055 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -134,7 +134,7 @@ export { QuoteStreamCompleteReason, } from './validators/quote-stream-complete.js'; export { BatchSellTransactionType } from './validators/batch-sell.js'; -export { type AmountsAndAssetSchema } from './validators/amount-and-asset.js'; +export { AmountsAndAssetSchema } from './validators/amount-and-asset.js'; export { TokenFeatureType } from './validators/token-feature.js'; export type { BridgeAsset, From 25714484558b7afab6f2c7ad26a3bd7050ca0c9f Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 11:05:17 -0700 Subject: [PATCH 43/45] fix: changelog --- packages/bridge-status-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 00dcfa89256..b063f7f3322 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -9,15 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9726](https://github.com/MetaMask/core/pull/9726)) + - Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx` + - Controller logic and utils still require V1, but clients can submit quotes in both formats - Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780)) ## [74.6.2] ### Changed -- **BREAKING**: Change `submitBatchSell` and `submitIntent` quoteResponse parameter from `QuoteResponseV1` to `QuoteResponse` V2 ([#9726](https://github.com/MetaMask/core/pull/9726)) - - Support both `QuoteResponseV1` and `QuoteResponse` quoteResponses in `submitTx` - - Controller logic and utils still require V1, but clients can submit quotes in both formats - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/bridge-controller` from `^78.0.2` to `^78.1.0` ([#9740](https://github.com/MetaMask/core/pull/9740), [#9779](https://github.com/MetaMask/core/pull/9779)) - Bump `@metamask/profile-sync-controller` from `^28.3.0` to `^29.0.0` ([#9779](https://github.com/MetaMask/core/pull/9779)) From 426400357a291b6d236d4cd2b29095bb48e97993 Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 11:30:58 -0700 Subject: [PATCH 44/45] fix: space --- packages/bridge-controller/src/utils/metrics/properties.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 1854fccb51d..f3e59577fe2 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -87,7 +87,7 @@ export const formatProviderLabel = ({ * @param quoteRequest.srcChainId - Source chain id of the quote request. * @param quoteRequest.destChainId - Destination chain id of the quote request. * @param quoteRequest.srcTokenAddress - Source token address of the quote request. - * @param quoteRequest.destTokenAddress - Destination token address of the quote request. + * @param quoteRequest.destTokenAddress - Destination token address of the quote request. * @param tokenSecurityTypeDestination - The security classification of the destination token, * supplied by the client (e.g. from token security/scanning data). Pass `null` when no * security data is available for the selected destination token. From cf1a542b980f3d6a809313c0211b48128ec8190c Mon Sep 17 00:00:00 2001 From: micaelae Date: Tue, 4 Aug 2026 14:24:53 -0700 Subject: [PATCH 45/45] fix: log quoteId --- packages/bridge-controller/src/bridge-controller.test.ts | 5 +++-- packages/bridge-controller/src/utils/quote-fees.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index f573c7dc2f5..ce390808bf7 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2578,6 +2578,7 @@ describe('BridgeController', function () { // Use the actual Solana mock which already has string trade type const btcQuoteResponse = mockBridgeQuotesSolErc20V1.map((quote) => ({ ...quote, + quoteId: quote.quote.requestId, quote: { ...quote.quote, srcChainId: ChainId.BTC, @@ -2671,11 +2672,11 @@ describe('BridgeController', function () { expect(quotes[1].nonEvmFeesInNative).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalledTimes(2); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote in bip122:000000000019d6689c085ae165831e93:', + 'Failed to compute non-EVM fees for quote 5cb5a527-d4e4-4b5e-b753-136afc3986d3', new Error('Failed to compute fees'), ); expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Failed to compute non-EVM fees for quote in bip122:000000000019d6689c085ae165831e93:', + 'Failed to compute non-EVM fees for quote 12c94d29-4b5c-4aee-92de-76eee4172d3d', new Error('Failed to compute fees'), ); }, diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index 186fd6dd382..db8e3001798 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -168,7 +168,7 @@ const appendNonEvmFees = async < // Return quote with undefined fee if snap fails (e.g., insufficient UTXO funds) // Client can render special UI or skip the quote card row for quotes with missing fee data console.error( - `Failed to compute non-EVM fees for quote in ${chainId}:`, + `Failed to compute non-EVM fees for quote ${quoteResponse.quoteId ?? ''}`, error, ); return {