From 158b79febfcc79cbf325adb7a690479a9ae3c8ee Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 10:55:09 -0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 e9fc1d75171a8d7ef5719926e6722a30bdadd41d Mon Sep 17 00:00:00 2001 From: micaelae Date: Thu, 30 Jul 2026 21:32:56 -0700 Subject: [PATCH 4/7] 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 66750d126167e4fb1a552ae6c0757f1d728c9027 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 11:36:19 -0700 Subject: [PATCH 5/7] 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 7321ae0dd3a8384f5b062140a3694a815f440171 Mon Sep 17 00:00:00 2001 From: micaelae Date: Mon, 3 Aug 2026 14:32:25 -0700 Subject: [PATCH 6/7] 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 7/7] 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(),