From 93299aa6a04ad845439c7f713da48abee5f0bac4 Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 3 Jun 2026 18:32:42 -0700 Subject: [PATCH 1/2] refactor: split up validators file --- .../src/bridge-controller.sse.batch.test.ts | 3 +- .../src/bridge-controller.sse.test.ts | 9 +- .../src/bridge-controller.test.ts | 9 +- .../src/bridge-controller.ts | 3 +- packages/bridge-controller/src/index.ts | 13 +- .../bridge-controller/src/selectors.test.ts | 4 +- packages/bridge-controller/src/types.ts | 29 +- .../src/utils/feature-flags.ts | 2 +- .../bridge-controller/src/utils/fetch.test.ts | 31 +- packages/bridge-controller/src/utils/fetch.ts | 16 +- .../src/utils/metrics/properties.ts | 2 +- packages/bridge-controller/src/utils/quote.ts | 6 +- .../src/validators/batch-sell.ts | 55 ++++ .../src/validators/bridge-asset.ts | 50 ++++ .../src/validators/feature-flags.ts | 136 +++++++++ .../quote-response.ts} | 275 ++---------------- .../src/validators/quote-stream-complete.ts | 39 +++ .../src/validators/token-feature.ts | 22 ++ .../{utils => validators}/validators.test.ts | 6 +- .../tests/mock-quotes-erc20-erc20.ts | 5 +- .../tests/mock-quotes-erc20-native.ts | 5 +- .../tests/mock-quotes-native-erc20-eth.ts | 5 +- .../tests/mock-quotes-native-erc20.ts | 5 +- .../tests/mock-quotes-sol-erc20.ts | 5 +- 24 files changed, 411 insertions(+), 324 deletions(-) create mode 100644 packages/bridge-controller/src/validators/batch-sell.ts create mode 100644 packages/bridge-controller/src/validators/bridge-asset.ts create mode 100644 packages/bridge-controller/src/validators/feature-flags.ts rename packages/bridge-controller/src/{utils/validators.ts => validators/quote-response.ts} (50%) create mode 100644 packages/bridge-controller/src/validators/quote-stream-complete.ts create mode 100644 packages/bridge-controller/src/validators/token-feature.ts rename packages/bridge-controller/src/{utils => validators}/validators.test.ts (99%) diff --git a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts index 1f42e404c8..23baf3f531 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.batch.test.ts @@ -20,11 +20,12 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, } from './constants/bridge'; import * as selectors from './selectors'; -import { ChainId, RequestStatus, FeatureId } from './types'; +import { ChainId, RequestStatus } from './types'; import type { BridgeControllerMessenger } from './types'; import * as balanceUtils from './utils/balance'; import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; +import { FeatureId } from './validators/feature-flags'; type RootMessenger = Messenger< MockAnyNamespace, diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 95bbfd7858..2209554e81 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -29,16 +29,15 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, ETH_USDT_ADDRESS, } from './constants/bridge'; -import { ChainId, RequestStatus, FeatureId } from './types'; +import { ChainId, RequestStatus } from './types'; import type { BridgeControllerMessenger, TxData } from './types'; import * as balanceUtils from './utils/balance'; import { formatChainIdToDec } from './utils/caip-formatters'; import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; -import { - TokenFeatureType, - QuoteStreamCompleteReason, -} from './utils/validators'; +import { QuoteStreamCompleteReason } from './validators/quote-stream-complete'; +import { TokenFeatureType } from './validators/token-feature'; +import { FeatureId } from './validators/feature-flags'; type RootMessenger = Messenger< MockAnyNamespace, diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 3264079bce..291ee62cce 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -31,13 +31,7 @@ import { } from './constants/bridge'; import { SWAPS_API_V2_BASE_URL } from './constants/swaps'; import * as selectors from './selectors'; -import { - ChainId, - RequestStatus, - SortOrder, - StatusTypes, - FeatureId, -} from './types'; +import { ChainId, RequestStatus, SortOrder, StatusTypes } from './types'; import type { BridgeControllerMessenger, QuoteResponseV1, @@ -58,6 +52,7 @@ import { MetricsSwapType, UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; +import { FeatureId } from './validators/feature-flags'; const EMPTY_INIT_STATE = DEFAULT_BRIDGE_CONTROLLER_STATE; diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 3c9f49378e..6f0ed11df2 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -25,7 +25,7 @@ import { ExchangeRateSourcesForLookup, selectIsAssetExchangeRateInState, } from './selectors'; -import { FeatureId, RequestStatus } from './types'; +import { RequestStatus } from './types'; import type { L1GasFees, GenericQuoteRequest, @@ -88,6 +88,7 @@ import { } from './utils/quote'; import { appendFeesToQuotes } from './utils/quote-fees'; import { getMinimumBalanceForRentExemptionInLamports } from './utils/snaps'; +import type { FeatureId } from './validators/feature-flags'; const metadata: StateMetadata = { quoteRequest: { diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 88a9b994b6..f2c1fd8cde 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -83,22 +83,21 @@ export { SortOrder, ChainId, RequestStatus, - FeatureId, type TokenFeature, type QuoteStreamCompleteData, type BridgeControllerGetStateAction, type BridgeControllerStateChangeEvent, } from './types'; +export { FeeType, ActionTypes } from './validators/quote-response'; export { - FeeType, - ActionTypes, - BridgeAssetSchema, - TokenFeatureType, validateQuoteStreamComplete, QuoteStreamCompleteReason, - BatchSellTransactionType, -} from './utils/validators'; +} from './validators/quote-stream-complete'; +export { BatchSellTransactionType } from './validators/batch-sell'; +export { TokenFeatureType } from './validators/token-feature'; +export { BridgeAssetSchema } from './validators/bridge-asset'; +export { FeatureId } from './validators/feature-flags'; export { ALLOWED_BRIDGE_CHAIN_IDS, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 3c8c9f15ce..069365cf0f 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -38,8 +38,8 @@ import { formatChainIdToDec, formatChainIdToHex, } from './utils/caip-formatters'; -import { validateQuoteResponseV1 } from './utils/validators'; -import { BatchSellTransactionType } from './utils/validators'; +import { validateQuoteResponseV1 } from './validators/quote-response'; +import { BatchSellTransactionType } from './validators/batch-sell'; const MOCK_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; const MOCK_MUSD_ADDRESS = '0x12345A7890123456789012345678901234567890'; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 2d842012ca..162ec588be 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -30,27 +30,30 @@ import type { import type { BridgeController } from './bridge-controller'; import type { BridgeControllerMethodActions } from './bridge-controller-method-action-types'; import type { BRIDGE_CONTROLLER_NAME } from './constants/bridge'; +import type { SimulatedGasFeeLimitsSchema } from './validators/batch-sell'; +import type { BatchSellTradesResponseSchema } from './validators/batch-sell'; +import type { BridgeAssetSchema } from './validators/bridge-asset'; +import type { FeatureId } from './validators/feature-flags'; import type { - BitcoinTradeDataSchema, - BridgeAssetSchema, ChainConfigurationSchema, ChainRankingSchema, + PlatformConfigSchema, +} from './validators/feature-flags'; +import type { + BitcoinTradeDataSchema, FeeDataSchema, IntentSchema, - PlatformConfigSchema, ProtocolSchema, QuoteResponseSchema, QuoteSchema, StepSchema, - TokenFeatureSchema, - QuoteStreamCompleteSchema, TronTradeDataSchema, TxDataSchema, - BatchSellTradesResponseSchema, GaslessPropertiesSchema, - SimulatedGasFeeLimitsSchema, TxFeeGasLimitsSchema, -} from './utils/validators'; +} from './validators/quote-response'; +import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete'; +import type { TokenFeatureSchema } from './validators/token-feature'; export type FetchFunction = ( input: RequestInfo | URL | string, @@ -258,16 +261,6 @@ export enum StatusTypes { COMPLETE = 'COMPLETE', } -export enum FeatureId { - UNKNOWN = 'unknown', - PERPS = 'perps', - QUICK_BUY_FOLLOW_TRADING = 'quick_buy_follow_trading', - QUICK_BUY_TOKEN_DETAILS = 'quick_buy_token_details', - DAPP_SWAP = 'dapp_swap', - BATCH_SELL = 'batch_sell', - UNIFIED_SWAP_BRIDGE = 'unified_swap_bridge', -} - /** * These are types that components pass in. Since data is a mix of types when coming from the redux store, we need to use a generic type that can cover all the types. * Payloads with this type are transformed into QuoteRequest by fetchBridgeQuotes right before fetching quotes diff --git a/packages/bridge-controller/src/utils/feature-flags.ts b/packages/bridge-controller/src/utils/feature-flags.ts index 4060f302b0..b0aedd5197 100644 --- a/packages/bridge-controller/src/utils/feature-flags.ts +++ b/packages/bridge-controller/src/utils/feature-flags.ts @@ -5,8 +5,8 @@ import { DEFAULT_FEATURE_FLAG_CONFIG, } from '../constants/bridge'; import type { FeatureFlagsPlatformConfig, ChainConfiguration } from '../types'; +import { validateFeatureFlagsResponse } from '../validators/feature-flags'; import { formatChainIdToCaip } from './caip-formatters'; -import { validateFeatureFlagsResponse } from './validators'; export const formatFeatureFlags = ( bridgeFeatureFlags: FeatureFlagsPlatformConfig, diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index e0fb5d06c1..82fd3f3f51 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -4,7 +4,8 @@ import type { CaipAssetType } from '@metamask/utils'; import { mockBridgeQuotesErc20Erc20V1 } from '../../tests/mock-quotes-erc20-erc20'; import { mockBridgeQuotesNativeErc20V1 } from '../../tests/mock-quotes-native-erc20'; import { BridgeClientId, BRIDGE_PROD_API_BASE_URL } from '../constants/bridge'; -import { FeatureId } from '../types'; +import { BatchSellTransactionType } from '../validators/batch-sell'; +import { FeatureId } from '../validators/feature-flags'; import { fetchBridgeQuotes, fetchBridgeTokens, @@ -12,7 +13,6 @@ import { fetchBatchSellTrades, formatBatchSellTradesRequest, } from './fetch'; -import { BatchSellTransactionType } from './validators'; const mockFetchFn = jest.fn(); @@ -887,6 +887,27 @@ describe('fetch', () => { ), ), ); + expect( + // @ts-expect-error - reason is not in type + result.map((error) => ({ ...error, reason: error.reason?.message })), + ).toMatchInlineSnapshot(` + [ + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 1000", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string matching \`/^0x[0-9a-f]+$/\` but received "1000"", + "status": "rejected", + }, + { + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 291", + "status": "rejected", + }, + ] + `); + expect(mockConsoleWarn).not.toHaveBeenCalled(); + mockConsoleWarn.mockRestore(); expect(mockFetchFn).toHaveBeenCalledTimes(4); expect(mockFetchFn).toHaveBeenCalledWith( @@ -900,15 +921,15 @@ describe('fetch', () => { ).toMatchInlineSnapshot(` [ { - "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a value of type \`HexString\`, but received: \`1000\`", + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 1000", "status": "rejected", }, { - "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a value of type \`HexString\`, but received: \`"1000"\`", + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string matching \`/^0x[0-9a-f]+$/\` but received "1000"", "status": "rejected", }, { - "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a value of type \`HexString\`, but received: \`291\`", + "reason": "Invalid batch simulation response. StructError: At path: transactions.0.maxFeePerGas -- Expected a string, but received: 291", "status": "rejected", }, ] diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 07f9795afe..3672468ec4 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -12,8 +12,13 @@ import type { QuoteStreamCompleteData, BatchSellTradesRequest, BatchSellTradesResponse, - FeatureId, } from '../types'; +import { validateBatchSellTradesResponse } from '../validators/batch-sell'; +import { validateBridgeAsset } from '../validators/bridge-asset'; +import type { FeatureId } from '../validators/feature-flags'; +import { validateQuoteResponseV1 } from '../validators/quote-response'; +import { validateQuoteStreamComplete } from '../validators/quote-stream-complete'; +import { validateTokenFeature } from '../validators/token-feature'; import { getEthUsdtResetData } from './bridge'; import { formatAddressToAssetId, @@ -22,13 +27,6 @@ import { } from './caip-formatters'; import { fetchServerEvents } from './fetch-server-events'; import { isEvmTxData } from './trade-utils'; -import { - validateQuoteResponseV1, - validateSwapsTokenObject, - validateTokenFeature, - validateQuoteStreamComplete, - validateBatchSellTradesResponse, -} from './validators'; export const getClientHeaders = ({ clientId, @@ -76,7 +74,7 @@ export async function fetchBridgeTokens( const transformedTokens: Record = {}; tokens.forEach((token: unknown) => { - if (validateSwapsTokenObject(token)) { + if (validateBridgeAsset(token)) { transformedTokens[token.address] = token; } }); diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 9dc453dcd6..efde092f58 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -9,7 +9,6 @@ import type { QuoteResponseV1, TxData, } from '../../types'; -import { FeatureId } from '../../types'; import { getNativeAssetForChainId, isCrossChain } from '../bridge'; import { formatAddressToAssetId, @@ -23,6 +22,7 @@ import type { QuoteWarning, RequestParams, } from './types'; +import { FeatureId } from '../../validators/feature-flags'; export const toInputChangedPropertyKey: Partial< Record diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 00acc26507..f6112dce9e 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -17,7 +17,7 @@ import type { NonEvmFees, TxData, } from '../types'; -import { FeatureId } from '../types'; +import { FeatureId } from '../validators/feature-flags'; import { isNativeAddress, isNonEvmChainId } from './bridge'; export const isValidQuoteRequest = ( @@ -152,9 +152,7 @@ export const calcSentAmount = ( const sentAmount = intent ? new BigNumber(srcTokenAmount) : Object.values(feeData) - .filter( - (fee) => fee && fee.amount && fee.asset?.assetId === srcAsset.assetId, - ) + .filter((fee) => fee?.amount && fee.asset?.assetId === srcAsset.assetId) .reduce( (acc, { amount }) => acc.plus(amount), new BigNumber(srcTokenAmount), diff --git a/packages/bridge-controller/src/validators/batch-sell.ts b/packages/bridge-controller/src/validators/batch-sell.ts new file mode 100644 index 0000000000..35acf98d1d --- /dev/null +++ b/packages/bridge-controller/src/validators/batch-sell.ts @@ -0,0 +1,55 @@ +import { + intersection, + type, + array, + enums, + optional, + assert, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { StrictHexStruct } from '@metamask/utils'; + +import { BridgeAssetSchema } from './bridge-asset'; +import { + TxDataSchema, + NumberStringSchema, + GaslessPropertiesSchema, +} from './quote-response'; + +export enum BatchSellTransactionType { + TRADE = 'trade', + APPROVAL = 'approval', + TRANSFER = 'transfer', +} + +export const SimulatedGasFeeLimitsSchema = type({ + maxFeePerGas: StrictHexStruct, + maxPriorityFeePerGas: StrictHexStruct, +}); + +export const BatchSellTradesResponseSchema = intersection([ + type({ + transactions: array( + intersection([ + TxDataSchema, + SimulatedGasFeeLimitsSchema, + type({ type: enums(Object.values(BatchSellTransactionType)) }), + ]), + ), + fee: optional( + type({ + asset: BridgeAssetSchema, + amount: NumberStringSchema, + }), + ), + }), + GaslessPropertiesSchema, +]); + +export const validateBatchSellTradesResponse = ( + data: unknown, +): data is Infer => { + assert(data, BatchSellTradesResponseSchema); + return true; +}; diff --git a/packages/bridge-controller/src/validators/bridge-asset.ts b/packages/bridge-controller/src/validators/bridge-asset.ts new file mode 100644 index 0000000000..0e8092a691 --- /dev/null +++ b/packages/bridge-controller/src/validators/bridge-asset.ts @@ -0,0 +1,50 @@ +import { + number, + type, + string, + optional, + nullable, + is, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; + +export const ChainIdSchema = number(); + +export const BridgeAssetSchema = type({ + /** + * The chainId of the token + */ + chainId: ChainIdSchema, + /** + * An address that the metaswap-api recognizes as the default token + */ + address: string(), + /** + * The assetId of the token + */ + assetId: CaipAssetTypeStruct, + /** + * The symbol of token object + */ + symbol: string(), + /** + * The name for the network + */ + name: string(), + decimals: number(), + /** + * URL for token icon + */ + icon: optional(nullable(string())), + /** + * URL for token icon + */ + iconUrl: optional(nullable(string())), +}); + +export const validateBridgeAsset = ( + data: unknown, +): data is Infer => { + return is(data, BridgeAssetSchema); +}; diff --git a/packages/bridge-controller/src/validators/feature-flags.ts b/packages/bridge-controller/src/validators/feature-flags.ts new file mode 100644 index 0000000000..63250ddeb5 --- /dev/null +++ b/packages/bridge-controller/src/validators/feature-flags.ts @@ -0,0 +1,136 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + type, + record, + string, + optional, + array, + boolean, + number, + enums, + is, + define, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { CaipChainIdStruct, CaipAssetTypeStruct } from '@metamask/utils'; + +export enum FeatureId { + UNKNOWN = 'unknown', + PERPS = 'perps', + QUICK_BUY_FOLLOW_TRADING = 'quick_buy_follow_trading', + QUICK_BUY_TOKEN_DETAILS = 'quick_buy_token_details', + DAPP_SWAP = 'dapp_swap', + BATCH_SELL = 'batch_sell', + UNIFIED_SWAP_BRIDGE = 'unified_swap_bridge', +} + +export const VersionStringSchema = define( + 'VersionString', + (value: unknown) => + typeof value === 'string' && + /^(\d+\.*){2}\d+$/u.test(value) && + value.split('.').length === 3, +); + +const DefaultPairSchema = type({ + /** + * The standard default pairs. Use this if the pair is only set once. + * The key is the CAIP asset type of the src token and the value is the CAIP asset type of the dest token. + */ + standard: record(string(), string()), + /** + * The other default pairs. Use this if the dest token depends on the src token and can be set multiple times. + * The key is the CAIP asset type of the src token and the value is the CAIP asset type of the dest token. + */ + other: record(string(), string()), +}); + +export const ChainRankingItemSchema = type({ + /** + * The CAIP-2 chain identifier (e.g., "eip155:1" for Ethereum mainnet) + */ + chainId: CaipChainIdStruct, + /** + * The display name of the chain (e.g., "Ethereum") + */ + name: string(), +}); + +export const ChainRankingSchema = optional(array(ChainRankingItemSchema)); + +export const ChainConfigurationSchema = type({ + isActiveSrc: boolean(), + isActiveDest: boolean(), + refreshRate: optional(number()), + topAssets: optional(array(string())), + stablecoins: optional(array(string())), + batchSellDestStablecoins: optional(array(CaipAssetTypeStruct)), + isUnifiedUIEnabled: optional(boolean()), + isSingleSwapBridgeButtonEnabled: optional(boolean()), + isGaslessSwapEnabled: optional(boolean()), + noFeeAssets: optional(array(string())), + defaultPairs: optional(DefaultPairSchema), +}); + +export const PriceImpactThresholdSchema = type({ + // TODO: + // We are moving into a unified approach where + // price impact thresholds will be segmented by + // importance rather than transaction type. + // The introduction of warning/danger will first be handled + // by mobile, followed by extension and then removal of gasless/normal + // from LD configs. + // To make the migration easier, we define all fields as optional for now. + // After the migration takes place, gasless/normal will be removed + // and warning/danger will be set as required fields. + gasless: number(), // Percentage value in decimal format (eg 0.02 is 2%) + normal: number(), // Percentage value in decimal format + warning: optional(number()), // Percentage value in decimal format + error: optional(number()), // Percentage value in decimal format +}); +const GenericQuoteRequestSchema = type({ + aggIds: optional(array(string())), + bridgeIds: optional(array(string())), + fee: optional(number()), +}); +const FeatureIdSchema = enums(Object.values(FeatureId)); +/** + * This is the schema for the feature flags response from the RemoteFeatureFlagController + */ + +export const PlatformConfigSchema = type({ + priceImpactThreshold: optional(PriceImpactThresholdSchema), + quoteRequestOverrides: optional( + record(FeatureIdSchema, optional(GenericQuoteRequestSchema)), + ), + minimumVersion: string(), + refreshRate: number(), + maxRefreshCount: number(), + support: boolean(), + chains: record(string(), ChainConfigurationSchema), + /** + * The bip44 default pairs for the chains + * Key is the CAIP chainId namespace + */ + bip44DefaultPairs: optional(record(string(), optional(DefaultPairSchema))), + sse: optional( + type({ + enabled: boolean(), + /** + * The minimum version of the client required to enable SSE, for example 13.8.0 + */ + minimumVersion: VersionStringSchema, + }), + ), + /** + * Array of chain objects ordered by preference/ranking + */ + chainRanking: ChainRankingSchema, + maxPendingHistoryItemAgeMs: optional(number()), +}); + +export const validateFeatureFlagsResponse = ( + data: unknown, +): data is Infer => { + return is(data, PlatformConfigSchema); +}; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/validators/quote-response.ts similarity index 50% rename from packages/bridge-controller/src/utils/validators.ts rename to packages/bridge-controller/src/validators/quote-response.ts index 433726518d..5c258ace46 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { isValidHexAddress } from '@metamask/controller-utils'; import type { Infer } from '@metamask/superstruct'; import { any, @@ -7,7 +6,6 @@ import { boolean, number, type, - is, record, array, nullable, @@ -20,11 +18,13 @@ import { intersection, } from '@metamask/superstruct'; import { - CaipAssetTypeStruct, - CaipChainIdStruct, - isStrictHexString, + HexAddressStruct, + HexChecksumAddressStruct, + StrictHexStruct, } from '@metamask/utils'; +import { BridgeAssetSchema, ChainIdSchema } from './bridge-asset'; + export enum FeeType { METABRIDGE = 'metabridge', REFUEL = 'refuel', @@ -37,171 +37,19 @@ export enum ActionTypes { REFUEL = 'refuel', } -const HexAddressSchema = define<`0x${string}`>('HexAddress', (value: unknown) => - isValidHexAddress(value as string, { allowNonPrefixed: false }), -); - -const HexStringSchema = define<`0x${string}`>('HexString', isStrictHexString); +const HexAddressOrChecksumAddressSchema = union([ + HexAddressStruct, + HexChecksumAddressStruct, +]); -const NumberStringSchema = define( +export const NumberStringSchema = define( 'NumberString', (value: unknown) => typeof value === 'string' && /^\d+$/u.test(value), ); -const VersionStringSchema = define( - 'VersionString', - (value: unknown) => - typeof value === 'string' && - /^(\d+\.*){2}\d+$/u.test(value) && - value.split('.').length === 3, -); - export const truthyString = (value: string): boolean => Boolean(value?.length); const TruthyDigitStringSchema = pattern(string(), /^\d+$/u); -const ChainIdSchema = number(); - -export const BridgeAssetSchema = type({ - /** - * The chainId of the token - */ - chainId: ChainIdSchema, - /** - * An address that the metaswap-api recognizes as the default token - */ - address: string(), - /** - * The assetId of the token - */ - assetId: CaipAssetTypeStruct, - /** - * The symbol of token object - */ - symbol: string(), - /** - * The name for the network - */ - name: string(), - decimals: number(), - /** - * URL for token icon - */ - icon: optional(nullable(string())), - /** - * URL for token icon - */ - iconUrl: optional(nullable(string())), -}); - -const DefaultPairSchema = type({ - /** - * The standard default pairs. Use this if the pair is only set once. - * The key is the CAIP asset type of the src token and the value is the CAIP asset type of the dest token. - */ - standard: record(string(), string()), - /** - * The other default pairs. Use this if the dest token depends on the src token and can be set multiple times. - * The key is the CAIP asset type of the src token and the value is the CAIP asset type of the dest token. - */ - other: record(string(), string()), -}); - -export const ChainRankingItemSchema = type({ - /** - * The CAIP-2 chain identifier (e.g., "eip155:1" for Ethereum mainnet) - */ - chainId: CaipChainIdStruct, - /** - * The display name of the chain (e.g., "Ethereum") - */ - name: string(), -}); - -export const ChainRankingSchema = optional(array(ChainRankingItemSchema)); - -export const ChainConfigurationSchema = type({ - isActiveSrc: boolean(), - isActiveDest: boolean(), - refreshRate: optional(number()), - topAssets: optional(array(string())), - stablecoins: optional(array(string())), - batchSellDestStablecoins: optional(array(CaipAssetTypeStruct)), - isUnifiedUIEnabled: optional(boolean()), - isSingleSwapBridgeButtonEnabled: optional(boolean()), - isGaslessSwapEnabled: optional(boolean()), - noFeeAssets: optional(array(string())), - defaultPairs: optional(DefaultPairSchema), -}); - -export const PriceImpactThresholdSchema = type({ - // TODO: - // We are moving into a unified approach where - // price impact thresholds will be segmented by - // importance rather than transaction type. - // The introduction of warning/danger will first be handled - // by mobile, followed by extension and then removal of gasless/normal - // from LD configs. - // To make the migration easier, we define all fields as optional for now. - // After the migration takes place, gasless/normal will be removed - // and warning/danger will be set as required fields. - gasless: number(), // Percentage value in decimal format (eg 0.02 is 2%) - normal: number(), // Percentage value in decimal format - warning: optional(number()), // Percentage value in decimal format - error: optional(number()), // Percentage value in decimal format -}); - -const GenericQuoteRequestSchema = type({ - aggIds: optional(array(string())), - bridgeIds: optional(array(string())), - fee: optional(number()), -}); - -/** - * This is the schema for the feature flags response from the RemoteFeatureFlagController - */ -export const PlatformConfigSchema = type({ - priceImpactThreshold: optional(PriceImpactThresholdSchema), - quoteRequestOverrides: optional( - record(string(), optional(GenericQuoteRequestSchema)), - ), - minimumVersion: string(), - refreshRate: number(), - maxRefreshCount: number(), - support: boolean(), - chains: record(string(), ChainConfigurationSchema), - /** - * The bip44 default pairs for the chains - * Key is the CAIP chainId namespace - */ - bip44DefaultPairs: optional(record(string(), optional(DefaultPairSchema))), - sse: optional( - type({ - enabled: boolean(), - /** - * The minimum version of the client required to enable SSE, for example 13.8.0 - */ - minimumVersion: VersionStringSchema, - }), - ), - /** - * Array of chain objects ordered by preference/ranking - */ - chainRanking: ChainRankingSchema, - maxPendingHistoryItemAgeMs: optional(number()), -}); - -export const validateFeatureFlagsResponse = ( - data: unknown, -): data is Infer => { - return is(data, PlatformConfigSchema); -}; - -export const validateSwapsTokenObject = ( - data: unknown, -): data is Infer => { - return is(data, BridgeAssetSchema); -}; - export const FeeDataSchema = type({ amount: TruthyDigitStringSchema, asset: BridgeAssetSchema, @@ -248,18 +96,18 @@ export const IntentOrderSchema = type({ /** * Address of the token being sold. */ - sellToken: HexAddressSchema, + sellToken: HexAddressOrChecksumAddressSchema, /** * Address of the token being bought. */ - buyToken: HexAddressSchema, + buyToken: HexAddressOrChecksumAddressSchema, /** * Optional receiver of the bought tokens. * If omitted, defaults to the signer / order owner. */ - receiver: optional(HexAddressSchema), + receiver: optional(HexAddressOrChecksumAddressSchema), /** * Order expiration time. @@ -277,7 +125,7 @@ export const IntentOrderSchema = type({ /** * Hash of the `appData` field, used for EIP-712 signing. */ - appDataHash: HexStringSchema, + appDataHash: StrictHexStruct, /** * Fee amount paid for order execution, expressed as a digit string. @@ -316,7 +164,7 @@ export const IntentOrderSchema = type({ * * Provided for convenience when building the EIP-712 domain and message. */ - from: optional(HexAddressSchema), + from: optional(HexAddressOrChecksumAddressSchema), }); /** @@ -339,7 +187,7 @@ export const IntentSchema = type({ /** * Optional settlement contract address used for execution. */ - settlementContract: optional(HexAddressSchema), + settlementContract: optional(HexAddressOrChecksumAddressSchema), /** * Optional EIP-712 typed data payload for signing. @@ -430,10 +278,10 @@ export const QuoteSchema = intersection([ export const TxDataSchema = type({ chainId: number(), - to: HexAddressSchema, - from: HexAddressSchema, - value: HexStringSchema, - data: HexStringSchema, + to: HexAddressOrChecksumAddressSchema, + from: HexAddressOrChecksumAddressSchema, + value: StrictHexStruct, + data: StrictHexStruct, gasLimit: nullable(number()), effectiveGas: optional(number()), }); @@ -481,86 +329,3 @@ export const validateQuoteResponseV1 = ( assert(data, QuoteResponseSchema); return true; }; - -export enum TokenFeatureType { - MALICIOUS = 'Malicious', - WARNING = 'Warning', - INFO = 'Info', - BENIGN = 'Benign', -} - -export const TokenFeatureSchema = type({ - feature_id: string(), - type: enums(Object.values(TokenFeatureType)), - description: string(), -}); - -export const validateTokenFeature = ( - data: unknown, -): data is Infer => { - assert(data, TokenFeatureSchema); - return true; -}; - -export enum QuoteStreamCompleteReason { - RETRY = 'RETRY', - AMOUNT_TOO_HIGH = 'AMOUNT_TOO_HIGH', - AMOUNT_TOO_LOW = 'AMOUNT_TOO_LOW', - SLIPPAGE_TOO_HIGH = 'SLIPPAGE_TOO_HIGH', - SLIPPAGE_TOO_LOW = 'SLIPPAGE_TOO_LOW', - TOKEN_NOT_SUPPORTED = 'TOKEN_NOT_SUPPORTED', - RWA_GEO_RESTRICTED = 'RWA_GEO_RESTRICTED', - RWA_NATIVE_TOKEN_UNSUPPORTED = 'RWA_NATIVE_TOKEN_UNSUPPORTED', - RWA_MARKET_UNAVAILABLE = 'RWA_MARKET_UNAVAILABLE', -} - -export const QuoteStreamCompleteSchema = type({ - quoteCount: number(), - hasQuotes: boolean(), - reason: optional(enums(Object.values(QuoteStreamCompleteReason))), - context: optional(record(string(), any())), -}); - -export const validateQuoteStreamComplete = ( - data: unknown, -): data is Infer => { - assert(data, QuoteStreamCompleteSchema); - return true; -}; - -export enum BatchSellTransactionType { - TRADE = 'trade', - APPROVAL = 'approval', - TRANSFER = 'transfer', -} - -export const SimulatedGasFeeLimitsSchema = type({ - maxFeePerGas: HexStringSchema, - maxPriorityFeePerGas: HexStringSchema, -}); - -export const BatchSellTradesResponseSchema = intersection([ - type({ - transactions: array( - intersection([ - TxDataSchema, - SimulatedGasFeeLimitsSchema, - type({ type: enums(Object.values(BatchSellTransactionType)) }), - ]), - ), - fee: optional( - type({ - asset: BridgeAssetSchema, - amount: NumberStringSchema, - }), - ), - }), - GaslessPropertiesSchema, -]); - -export const validateBatchSellTradesResponse = ( - data: unknown, -): data is Infer => { - assert(data, BatchSellTradesResponseSchema); - return true; -}; diff --git a/packages/bridge-controller/src/validators/quote-stream-complete.ts b/packages/bridge-controller/src/validators/quote-stream-complete.ts new file mode 100644 index 0000000000..4e4dc790b9 --- /dev/null +++ b/packages/bridge-controller/src/validators/quote-stream-complete.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + type, + number, + boolean, + optional, + enums, + record, + string, + any, + assert, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +export enum QuoteStreamCompleteReason { + RETRY = 'RETRY', + AMOUNT_TOO_HIGH = 'AMOUNT_TOO_HIGH', + AMOUNT_TOO_LOW = 'AMOUNT_TOO_LOW', + SLIPPAGE_TOO_HIGH = 'SLIPPAGE_TOO_HIGH', + SLIPPAGE_TOO_LOW = 'SLIPPAGE_TOO_LOW', + TOKEN_NOT_SUPPORTED = 'TOKEN_NOT_SUPPORTED', + RWA_GEO_RESTRICTED = 'RWA_GEO_RESTRICTED', + RWA_NATIVE_TOKEN_UNSUPPORTED = 'RWA_NATIVE_TOKEN_UNSUPPORTED', + RWA_MARKET_UNAVAILABLE = 'RWA_MARKET_UNAVAILABLE', +} + +export const QuoteStreamCompleteSchema = type({ + quoteCount: number(), + hasQuotes: boolean(), + reason: optional(enums(Object.values(QuoteStreamCompleteReason))), + context: optional(record(string(), any())), +}); + +export const validateQuoteStreamComplete = ( + data: unknown, +): data is Infer => { + assert(data, QuoteStreamCompleteSchema); + return true; +}; diff --git a/packages/bridge-controller/src/validators/token-feature.ts b/packages/bridge-controller/src/validators/token-feature.ts new file mode 100644 index 0000000000..e9e5fcaf84 --- /dev/null +++ b/packages/bridge-controller/src/validators/token-feature.ts @@ -0,0 +1,22 @@ +import { type, string, enums, assert } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; + +export enum TokenFeatureType { + MALICIOUS = 'Malicious', + WARNING = 'Warning', + INFO = 'Info', + BENIGN = 'Benign', +} + +export const TokenFeatureSchema = type({ + feature_id: string(), + type: enums(Object.values(TokenFeatureType)), + description: string(), +}); + +export const validateTokenFeature = ( + data: unknown, +): data is Infer => { + assert(data, TokenFeatureSchema); + return true; +}; diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/validators/validators.test.ts similarity index 99% rename from packages/bridge-controller/src/utils/validators.test.ts rename to packages/bridge-controller/src/validators/validators.test.ts index 2d1a6c09a4..87b29163f2 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/validators/validators.test.ts @@ -1,11 +1,11 @@ import { is } from '@metamask/superstruct'; +import { validateFeatureFlagsResponse } from './feature-flags'; +import { IntentSchema } from './quote-response'; import { - validateFeatureFlagsResponse, validateQuoteStreamComplete, QuoteStreamCompleteReason, - IntentSchema, -} from './validators'; +} from './quote-stream-complete'; describe('validators', () => { describe('validateFeatureFlagsResponse', () => { diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts index 362963434d..1bca02382c 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.ts @@ -1,7 +1,10 @@ import { merge } from 'lodash'; import type { QuoteResponseV1, DeepPartial } from '../src/types'; -import { ActionTypes, validateQuoteResponseV1 } from '../src/utils/validators'; +import { + ActionTypes, + validateQuoteResponseV1, +} from '../src/validators/quote-response'; export const mockBridgeQuotesErc20Erc20V1: QuoteResponseV1[] = [ { diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts index d5c1be1c9d..1ee086ea9a 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.ts +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.ts @@ -1,7 +1,10 @@ import { merge } from 'lodash'; import type { QuoteResponseV1, DeepPartial } from '../src/types'; -import { ActionTypes, validateQuoteResponseV1 } from '../src/utils/validators'; +import { + ActionTypes, + validateQuoteResponseV1, +} from '../src/validators/quote-response'; export const mockBridgeQuotesErc20NativeV1: QuoteResponseV1[] = [ { diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts index 65359833e8..4f4f864349 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.ts @@ -1,7 +1,10 @@ import { merge } from 'lodash'; import type { QuoteResponseV1, DeepPartial } from '../src/types'; -import { ActionTypes, validateQuoteResponseV1 } from '../src/utils/validators'; +import { + ActionTypes, + validateQuoteResponseV1, +} from '../src/validators/quote-response'; export const mockBridgeQuotesNativeErc20EthV1: QuoteResponseV1[] = [ { diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts index 76788fc52d..f1289ec65a 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.ts @@ -1,7 +1,10 @@ import { merge } from 'lodash'; import type { QuoteResponseV1, DeepPartial } from '../src/types'; -import { ActionTypes, validateQuoteResponseV1 } from '../src/utils/validators'; +import { + ActionTypes, + validateQuoteResponseV1, +} from '../src/validators/quote-response'; export const mockBridgeQuotesNativeErc20V1: QuoteResponseV1[] = [ { diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts index 14b925e7e6..6adbb08cae 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.ts @@ -1,7 +1,10 @@ import { merge } from 'lodash'; import type { QuoteResponseV1, DeepPartial } from '../src/types'; -import { ActionTypes, validateQuoteResponseV1 } from '../src/utils/validators'; +import { + ActionTypes, + validateQuoteResponseV1, +} from '../src/validators/quote-response'; export const mockBridgeQuotesSolErc20V1: QuoteResponseV1[] = [ { From 98f7ff10c9003e2a5d3456bf43264d2698240d54 Mon Sep 17 00:00:00 2001 From: micaelae Date: Wed, 3 Jun 2026 18:44:45 -0700 Subject: [PATCH 2/2] refactor: extract trade validators --- .../src/bridge-controller.sse.test.ts | 3 +- packages/bridge-controller/src/index.ts | 18 ++-- packages/bridge-controller/src/types.ts | 13 +-- .../bridge-controller/src/utils/bridge.ts | 2 +- packages/bridge-controller/src/utils/fetch.ts | 2 +- .../src/utils/metrics/properties.ts | 2 +- .../bridge-controller/src/utils/quote-fees.ts | 5 +- .../bridge-controller/src/utils/quote.test.ts | 2 +- packages/bridge-controller/src/utils/quote.ts | 2 +- .../src/utils/trade-utils.test.ts | 16 +-- .../src/utils/trade-utils.ts | 48 ++------- .../src/validators/batch-sell.ts | 8 +- .../src/validators/quote-response.ts | 52 ++-------- .../bridge-controller/src/validators/trade.ts | 99 +++++++++++++++++++ 14 files changed, 145 insertions(+), 127 deletions(-) create mode 100644 packages/bridge-controller/src/validators/trade.ts diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 2209554e81..8b0a5edbc2 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -30,7 +30,7 @@ import { ETH_USDT_ADDRESS, } from './constants/bridge'; import { ChainId, RequestStatus } from './types'; -import type { BridgeControllerMessenger, TxData } from './types'; +import type { BridgeControllerMessenger } from './types'; import * as balanceUtils from './utils/balance'; import { formatChainIdToDec } from './utils/caip-formatters'; import * as featureFlagUtils from './utils/feature-flags'; @@ -38,6 +38,7 @@ import * as fetchUtils from './utils/fetch'; import { QuoteStreamCompleteReason } from './validators/quote-stream-complete'; import { TokenFeatureType } from './validators/token-feature'; import { FeatureId } from './validators/feature-flags'; +import type { TxData } from './validators/trade'; type RootMessenger = Messenger< MockAnyNamespace, diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index f2c1fd8cde..0ecfecd244 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -49,11 +49,8 @@ export type { Quote, QuoteResponseV1 as QuoteResponse, FeeData, - TxData, Intent, IntentOrderLike, - BitcoinTradeData, - TronTradeData, BridgeControllerState, BridgeControllerAction, BridgeControllerActions, @@ -89,6 +86,13 @@ export { type BridgeControllerStateChangeEvent, } from './types'; +export type { + TxData, + BitcoinTradeData, + TronTradeData, + Trade, +} from './validators/trade'; +export { isBitcoinTrade, isTronTrade, isEvmTxData } from './validators/trade'; export { FeeType, ActionTypes } from './validators/quote-response'; export { validateQuoteStreamComplete, @@ -167,13 +171,7 @@ export { formatAddressToAssetId, } from './utils/caip-formatters'; -export { - extractTradeData, - isBitcoinTrade, - isTronTrade, - isEvmTxData, - type Trade, -} from './utils/trade-utils'; +export { extractTradeData } from './utils/trade-utils'; export { selectBridgeQuotes, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 162ec588be..134c629d08 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -40,20 +40,22 @@ import type { PlatformConfigSchema, } from './validators/feature-flags'; import type { - BitcoinTradeDataSchema, FeeDataSchema, IntentSchema, ProtocolSchema, QuoteResponseSchema, QuoteSchema, StepSchema, - TronTradeDataSchema, - TxDataSchema, GaslessPropertiesSchema, TxFeeGasLimitsSchema, } from './validators/quote-response'; import type { QuoteStreamCompleteSchema } from './validators/quote-stream-complete'; import type { TokenFeatureSchema } from './validators/token-feature'; +import type { + BitcoinTradeData, + TronTradeData, + TxData, +} from './validators/trade'; export type FetchFunction = ( input: RequestInfo | URL | string, @@ -281,14 +283,9 @@ export type FeeData = Infer; export type Quote = Infer; -export type TxData = Infer; - export type Intent = Infer; export type IntentOrderLike = Intent['order']; -export type BitcoinTradeData = Infer; - -export type TronTradeData = Infer; /** * This is the type for the quote response from the bridge-api * TxDataType can be overriden to be a string when the quote is non-evm diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index 27671ed8f9..8d7cfa62ef 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -22,9 +22,9 @@ import type { BridgeControllerState, GenericQuoteRequest, QuoteResponseV1, - TxData, } from '../types'; import { ChainId } from '../types'; +import type { TxData } from '../validators/trade'; import { formatChainIdToCaip, formatChainIdToDec, diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 3672468ec4..59a0afee6e 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -19,6 +19,7 @@ import type { FeatureId } from '../validators/feature-flags'; import { validateQuoteResponseV1 } from '../validators/quote-response'; import { validateQuoteStreamComplete } from '../validators/quote-stream-complete'; import { validateTokenFeature } from '../validators/token-feature'; +import { isEvmTxData } from '../validators/trade'; import { getEthUsdtResetData } from './bridge'; import { formatAddressToAssetId, @@ -26,7 +27,6 @@ import { formatChainIdToDec, } from './caip-formatters'; import { fetchServerEvents } from './fetch-server-events'; -import { isEvmTxData } from './trade-utils'; export const getClientHeaders = ({ clientId, diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index efde092f58..97e7e5f5dc 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -7,8 +7,8 @@ import type { QuoteMetadata, QuoteRequest, QuoteResponseV1, - TxData, } from '../../types'; +import type { TxData } from '../../validators/trade'; import { getNativeAssetForChainId, isCrossChain } from '../bridge'; import { formatAddressToAssetId, diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index ccd1cad5b0..0ff8bd4a77 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -7,13 +7,14 @@ import type { QuoteResponseV1, L1GasFees, NonEvmFees, - TxData, BridgeControllerMessenger, } from '../types'; +import { isTronTrade } from '../validators/trade'; +import type { TxData } from '../validators/trade'; import { isNonEvmChainId, sumHexes } from './bridge'; import { formatChainIdToCaip } from './caip-formatters'; import { computeFeeRequest } from './snaps'; -import { extractTradeData, isTronTrade } from './trade-utils'; +import { extractTradeData } from './trade-utils'; /** * Appends transaction fees for EVM chains to quotes diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 5ad6c52afd..f75f838503 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -8,8 +8,8 @@ import type { Quote, NonEvmFees, L1GasFees, - TxData, } from '../types'; +import type { TxData } from '../validators/trade'; import { isValidQuoteRequest, getQuoteIdentifier, diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index f6112dce9e..2328fe0696 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -15,9 +15,9 @@ import type { QuoteMetadata, QuoteResponseV1, NonEvmFees, - TxData, } from '../types'; import { FeatureId } from '../validators/feature-flags'; +import type { TxData } from '../validators/trade'; import { isNativeAddress, isNonEvmChainId } from './bridge'; export const isValidQuoteRequest = ( diff --git a/packages/bridge-controller/src/utils/trade-utils.test.ts b/packages/bridge-controller/src/utils/trade-utils.test.ts index 0d4a4cf74f..ba36f73800 100644 --- a/packages/bridge-controller/src/utils/trade-utils.test.ts +++ b/packages/bridge-controller/src/utils/trade-utils.test.ts @@ -1,11 +1,11 @@ -import type { BitcoinTradeData, TronTradeData, TxData } from '../types'; -import { - extractTradeData, - isEvmTxData, - isBitcoinTrade, - isTronTrade, -} from './trade-utils'; -import type { Trade } from './trade-utils'; +import type { + TxData, + BitcoinTradeData, + TronTradeData, + Trade, +} from '../validators/trade'; +import { isEvmTxData, isBitcoinTrade, isTronTrade } from '../validators/trade'; +import { extractTradeData } from './trade-utils'; describe('Trade utils', () => { describe('isEvmTxData', () => { diff --git a/packages/bridge-controller/src/utils/trade-utils.ts b/packages/bridge-controller/src/utils/trade-utils.ts index 0e78b063da..bed513ce8d 100644 --- a/packages/bridge-controller/src/utils/trade-utils.ts +++ b/packages/bridge-controller/src/utils/trade-utils.ts @@ -1,45 +1,9 @@ -import type { BitcoinTradeData, TronTradeData, TxData } from '../types'; - -// Union type representing all possible trade formats (EVM, Solana, Bitcoin, Tron) -export type Trade = TxData | string | BitcoinTradeData | TronTradeData; - -/** - * Type guard to check if a trade is an EVM TxData object - * - * @param trade - The trade object to check - * @returns True if the trade is a TxData object with data property - */ -export const isEvmTxData = (trade: Trade): trade is TxData => { - return ( - typeof trade === 'object' && - trade !== null && - 'data' in trade && - 'chainId' in trade && - 'to' in trade - ); -}; - -/** - * Type guard to check if a trade is a Bitcoin trade with unsignedPsbtBase64 - * - * @param trade - The trade object to check - * @returns True if the trade is a Bitcoin trade with unsignedPsbtBase64 property - */ -export const isBitcoinTrade = (trade: Trade): trade is BitcoinTradeData => { - return ( - typeof trade === 'object' && trade !== null && 'unsignedPsbtBase64' in trade - ); -}; - -/** - * Type guard to check if a trade is a Tron trade with raw_data_hex - * - * @param trade - The trade object to check - * @returns True if the trade is a Tron trade with raw_data_hex property - */ -export const isTronTrade = (trade: Trade): trade is TronTradeData => { - return typeof trade === 'object' && trade !== null && 'raw_data_hex' in trade; -}; +import { + Trade, + isBitcoinTrade, + isTronTrade, + isEvmTxData, +} from '../validators/trade'; /** * Extracts the transaction data from different trade formats diff --git a/packages/bridge-controller/src/validators/batch-sell.ts b/packages/bridge-controller/src/validators/batch-sell.ts index 35acf98d1d..753078f8a6 100644 --- a/packages/bridge-controller/src/validators/batch-sell.ts +++ b/packages/bridge-controller/src/validators/batch-sell.ts @@ -5,17 +5,13 @@ import { enums, optional, assert, - union, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { StrictHexStruct } from '@metamask/utils'; import { BridgeAssetSchema } from './bridge-asset'; -import { - TxDataSchema, - NumberStringSchema, - GaslessPropertiesSchema, -} from './quote-response'; +import { NumberStringSchema, GaslessPropertiesSchema } from './quote-response'; +import { TxDataSchema } from './trade'; export enum BatchSellTransactionType { TRADE = 'trade', diff --git a/packages/bridge-controller/src/validators/quote-response.ts b/packages/bridge-controller/src/validators/quote-response.ts index 5c258ace46..31a500f8bf 100644 --- a/packages/bridge-controller/src/validators/quote-response.ts +++ b/packages/bridge-controller/src/validators/quote-response.ts @@ -8,7 +8,6 @@ import { type, record, array, - nullable, optional, enums, define, @@ -17,13 +16,15 @@ import { pattern, intersection, } from '@metamask/superstruct'; -import { - HexAddressStruct, - HexChecksumAddressStruct, - StrictHexStruct, -} from '@metamask/utils'; +import { StrictHexStruct } from '@metamask/utils'; import { BridgeAssetSchema, ChainIdSchema } from './bridge-asset'; +import { + TxDataSchema, + TronTradeDataSchema, + BitcoinTradeDataSchema, + HexAddressOrChecksumAddressSchema, +} from './trade'; export enum FeeType { METABRIDGE = 'metabridge', @@ -37,11 +38,6 @@ export enum ActionTypes { REFUEL = 'refuel', } -const HexAddressOrChecksumAddressSchema = union([ - HexAddressStruct, - HexChecksumAddressStruct, -]); - export const NumberStringSchema = define( 'NumberString', (value: unknown) => typeof value === 'string' && /^\d+$/u.test(value), @@ -276,40 +272,6 @@ export const QuoteSchema = intersection([ }), ]); -export const TxDataSchema = type({ - chainId: number(), - to: HexAddressOrChecksumAddressSchema, - from: HexAddressOrChecksumAddressSchema, - value: StrictHexStruct, - data: StrictHexStruct, - gasLimit: nullable(number()), - effectiveGas: optional(number()), -}); - -export const BitcoinTradeDataSchema = type({ - unsignedPsbtBase64: string(), - inputsToSign: nullable(array(type({}))), -}); - -export const TronTradeDataSchema = type({ - raw_data_hex: string(), - visible: optional(boolean()), - raw_data: optional( - nullable( - type({ - contract: optional( - array( - type({ - type: optional(string()), - }), - ), - ), - fee_limit: optional(number()), - }), - ), - ), -}); - export const QuoteResponseSchema = type({ quoteId: optional(string()), quote: QuoteSchema, diff --git a/packages/bridge-controller/src/validators/trade.ts b/packages/bridge-controller/src/validators/trade.ts new file mode 100644 index 0000000000..de9611f610 --- /dev/null +++ b/packages/bridge-controller/src/validators/trade.ts @@ -0,0 +1,99 @@ +import { + type, + number, + nullable, + optional, + string, + array, + boolean, + union, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import { + HexAddressStruct, + HexChecksumAddressStruct, + StrictHexStruct, +} from '@metamask/utils'; + +export const HexAddressOrChecksumAddressSchema = union([ + HexAddressStruct, + HexChecksumAddressStruct, +]); + +export const TxDataSchema = type({ + chainId: number(), + to: HexAddressOrChecksumAddressSchema, + from: HexAddressOrChecksumAddressSchema, + value: StrictHexStruct, + data: StrictHexStruct, + gasLimit: nullable(number()), + effectiveGas: optional(number()), +}); + +export const BitcoinTradeDataSchema = type({ + unsignedPsbtBase64: string(), + inputsToSign: nullable(array(type({}))), +}); + +export const TronTradeDataSchema = type({ + raw_data_hex: string(), + visible: optional(boolean()), + raw_data: optional( + nullable( + type({ + contract: optional( + array( + type({ + type: optional(string()), + }), + ), + ), + fee_limit: optional(number()), + }), + ), + ), +}); // Union type representing all possible trade formats (EVM, Solana, Bitcoin, Tron) + +export type Trade = TxData | string | BitcoinTradeData | TronTradeData; +/** + * Type guard to check if a trade is an EVM TxData object + * + * @param trade - The trade object to check + * @returns True if the trade is a TxData object with data property + */ + +export const isEvmTxData = (trade: Trade): trade is TxData => { + return ( + typeof trade === 'object' && + trade !== null && + 'data' in trade && + 'chainId' in trade && + 'to' in trade + ); +}; +/** + * Type guard to check if a trade is a Bitcoin trade with unsignedPsbtBase64 + * + * @param trade - The trade object to check + * @returns True if the trade is a Bitcoin trade with unsignedPsbtBase64 property + */ + +export const isBitcoinTrade = (trade: Trade): trade is BitcoinTradeData => { + return ( + typeof trade === 'object' && trade !== null && 'unsignedPsbtBase64' in trade + ); +}; +/** + * Type guard to check if a trade is a Tron trade with raw_data_hex + * + * @param trade - The trade object to check + * @returns True if the trade is a Tron trade with raw_data_hex property + */ + +export const isTronTrade = (trade: Trade): trade is TronTradeData => { + return typeof trade === 'object' && trade !== null && 'raw_data_hex' in trade; +}; +export type BitcoinTradeData = Infer; + +export type TronTradeData = Infer; +export type TxData = Infer;