From 9a4d9ce8c3ce3526d036fa2dd6ebfb5fa49f0f64 Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Tue, 17 Feb 2026 20:23:31 +0000 Subject: [PATCH] feat(ccip-api): Add unified search and expand chain family support - Add unified search endpoint with automatic query type detection: - Selector search (>17 digit numbers) - ChainId search (numeric values) - InternalId search (kebab-case identifiers) - DisplayName fuzzy search (using Fuse.js) - Expand chain support to 9 families: - evm, solana, aptos, sui, tron, canton, ton, stellar, starknet - Remove legacy svm/mvm family groupings - Add new selector YAML files for: - sui, tron, canton, ton, stellar, starknet - Update API to return both supported and unsupported chains in search mode with `supported: boolean` field - Add family filter parameter for search results - Update OpenAPI spec with all 9 chain families - Consolidate ChainType/ChainFamily types to src/config/types.ts BREAKING CHANGE: Chain family values changed from svm/mvm to solana/aptos/sui. Clients must update data.svm to data.solana, data.mvm to data.aptos. --- package-lock.json | 10 + package.json | 1 + public/api/ccip/v1/openapi.json | 184 ++++++++++++--- src/config/chainTypes.ts | 42 ++++ src/config/data/ccip/paths.ts | 18 ++ src/config/data/ccip/selectors.ts | 99 +++++++- src/config/data/ccip/selectors.yml | 4 + src/config/data/ccip/selectors_canton.yml | 17 ++ src/config/data/ccip/selectors_starknet.yml | 9 + src/config/data/ccip/selectors_stellar.yml | 17 ++ src/config/data/ccip/selectors_sui.yml | 13 ++ src/config/data/ccip/selectors_ton.yml | 13 ++ src/config/data/ccip/selectors_tron.yml | 17 ++ src/config/types.ts | 4 +- src/features/utils/index.ts | 22 +- src/lib/ccip/faucet/adapters/index.ts | 2 +- src/lib/ccip/faucet/adapters/svm-drip.ts | 2 +- src/lib/ccip/faucet/adapters/svm.ts | 2 +- src/lib/ccip/services/chain-data.ts | 199 ++++++++++++++-- src/lib/ccip/services/chain-search.ts | 220 ++++++++++++++++++ src/lib/ccip/types/index.ts | 18 +- src/lib/ccip/utils.ts | 154 ++++++++---- src/lib/ccip/utils/display-name.ts | 22 ++ .../domain/services/signature-verification.ts | 14 +- src/pages/api/ccip/v1/chains.ts | 158 ++++++++++--- src/pages/api/ccip/v1/docs.astro | 14 +- 26 files changed, 1134 insertions(+), 141 deletions(-) create mode 100644 src/config/data/ccip/selectors_canton.yml create mode 100644 src/config/data/ccip/selectors_starknet.yml create mode 100644 src/config/data/ccip/selectors_stellar.yml create mode 100644 src/config/data/ccip/selectors_sui.yml create mode 100644 src/config/data/ccip/selectors_ton.yml create mode 100644 src/config/data/ccip/selectors_tron.yml create mode 100644 src/lib/ccip/services/chain-search.ts create mode 100644 src/lib/ccip/utils/display-name.ts diff --git a/package-lock.json b/package-lock.json index 0496a2b41a2..b33972ef6f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "dotenv": "^16.6.1", "ethers": "^6.16.0", "focus-trap-react": "^11.0.6", + "fuse.js": "^7.1.0", "github-slugger": "^2.0.0", "lodash": "^4.17.23", "marked": "^15.0.12", @@ -21649,6 +21650,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", diff --git a/package.json b/package.json index 460cc63928a..b6f5c0a8d73 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "dotenv": "^16.6.1", "ethers": "^6.16.0", "focus-trap-react": "^11.0.6", + "fuse.js": "^7.1.0", "github-slugger": "^2.0.0", "lodash": "^4.17.23", "marked": "^15.0.12", diff --git a/public/api/ccip/v1/openapi.json b/public/api/ccip/v1/openapi.json index 8cf38841257..4d3ec401d27 100644 --- a/public/api/ccip/v1/openapi.json +++ b/public/api/ccip/v1/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "CCIP Docs config API", "description": "API for retrieving CCIP chain, token, and lane information.\n\nTo get started quickly, you can download our [Postman Collection](/api/ccip/v1/postman-collection.json) which includes all endpoints and example requests.", - "version": "1.5.0", + "version": "1.6.0", "contact": { "name": "File issues", "url": "https://github.com/smartcontractkit/documentation/issues/new/choose" @@ -101,6 +101,25 @@ "default": "false" }, "description": "When set to 'true', returns detailed fee token information including addresses, names, and decimals instead of just symbol strings" + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "maxLength": 100 + }, + "description": "Unified search query. Automatically detects query type: selector (>17 digits), chainId (≤17 digits or Solana base58 hash), internalId (kebab-case), or displayName (fuzzy text search). Cannot be combined with chainId, selector, or internalId filters.", + "example": "ethereum" + }, + { + "name": "family", + "in": "query", + "schema": { + "type": "string", + "enum": ["evm", "solana", "aptos", "sui", "tron", "canton", "ton", "stellar", "starknet"] + }, + "description": "Filter results by chain family. Only effective when using the search parameter." } ], "responses": { @@ -136,10 +155,11 @@ "tokenAdminRegistry": "0xb22764f98dD05c789929716D677382Df22C05Cb6", "tokenPoolFactory": "0x17D8a409fE2ceF2d3808bcB61F14aBEFfc28876e", "chainType": "evm", - "chainFamily": "evm" + "chainFamily": "evm", + "supported": true } }, - "svm": { + "solana": { "solana-devnet": { "chainId": "solana-devnet", "displayName": "Solana Devnet", @@ -150,7 +170,52 @@ "rmn": "CRmNVnB7S6SqEPFG6m9dVp9fJJCjr3TC2TiAWB3RqNod", "feeQuoter": "FqbCVbS7a4ndxs9xZ8UmfL6LQsUhAJNkWxW3duJRrCWD", "chainType": "solana", - "chainFamily": "svm" + "chainFamily": "solana", + "supported": true + } + } + }, + "ignored": [] + } + }, + "search": { + "summary": "Search response (includes both supported and unsupported chains)", + "value": { + "metadata": { + "environment": "testnet", + "timestamp": "2024-03-14T12:00:00Z", + "requestId": "123e4567-e89b-12d3-a456-426614174000", + "ignoredChainCount": 0, + "validChainCount": 2, + "searchQuery": "solana", + "searchType": "displayName" + }, + "data": { + "solana": { + "solana-devnet": { + "chainId": "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", + "displayName": "Solana Devnet", + "selector": "16015286601757825753", + "internalId": "solana-devnet", + "feeTokens": ["LINK", "SOL"], + "router": "CCiPv7hcmEqNdMdJgmHDJmEJyCkBgLqxmcf87R1Gho6H", + "rmn": "CRmNVnB7S6SqEPFG6m9dVp9fJJCjr3TC2TiAWB3RqNod", + "feeQuoter": "FqbCVbS7a4ndxs9xZ8UmfL6LQsUhAJNkWxW3duJRrCWD", + "chainType": "solana", + "chainFamily": "solana", + "supported": true + }, + "solana-testnet": { + "chainId": "4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", + "displayName": "Solana Testnet", + "selector": "6302590918974934319", + "internalId": "solana-testnet", + "feeTokens": [], + "router": "", + "rmn": "", + "chainType": "solana", + "chainFamily": "solana", + "supported": false } } }, @@ -200,7 +265,8 @@ "tokenAdminRegistry": "0xb22764f98dD05c789929716D677382Df22C05Cb6", "tokenPoolFactory": "0x17D8a409fE2ceF2d3808bcB61F14aBEFfc28876e", "chainType": "evm", - "chainFamily": "evm" + "chainFamily": "evm", + "supported": true } } }, @@ -576,9 +642,22 @@ "type": "integer", "minimum": 0, "description": "Number of valid chains in the response" + }, + "searchQuery": { + "type": "string", + "description": "The search query that was used (only present when search parameter was provided)" + }, + "searchType": { + "$ref": "#/components/schemas/SearchType", + "description": "The detected type of search query (only present when search parameter was provided)" } } }, + "SearchType": { + "type": "string", + "enum": ["selector", "chainId", "internalId", "displayName"], + "description": "The detected type of search query. 'selector' for CCIP selectors (>17 digits), 'chainId' for chain IDs (≤17 digits or Solana base58), 'internalId' for kebab-case identifiers, 'displayName' for fuzzy text search." + }, "FeeTokenEnriched": { "type": "object", "required": ["symbol", "name", "address", "decimals"], @@ -614,7 +693,8 @@ "router", "rmn", "chainType", - "chainFamily" + "chainFamily", + "supported" ], "properties": { "chainId": { @@ -623,7 +703,7 @@ }, "displayName": { "type": "string", - "description": "Human-readable name of the chain" + "description": "Human-readable name of the chain. For supported chains, this comes from configuration. For unsupported chains, it is derived from the internalId." }, "selector": { "type": "string", @@ -650,25 +730,29 @@ "description": "Detailed fee token information (when enrichFeeTokens=true)" } ], - "description": "Fee tokens - either as string symbols or enriched objects with addresses" + "description": "Fee tokens - either as string symbols or enriched objects with addresses. Empty array for unsupported chains." }, "router": { "type": "string", - "description": "CCIP Router contract address" + "description": "CCIP Router contract address. Empty string for unsupported chains." }, "rmn": { "type": "string", - "description": "Risk Management Network contract address" + "description": "Risk Management Network contract address. Empty string for unsupported chains." }, "chainType": { "type": "string", - "enum": ["evm", "solana", "aptos"], + "enum": ["evm", "solana", "aptos", "sui", "canton", "ton", "tron", "stellar", "starknet"], "description": "Type of blockchain" }, "chainFamily": { "type": "string", - "enum": ["evm", "svm", "mvm"], - "description": "Blockchain family (EVM, Solana VM, Move VM)" + "enum": ["evm", "solana", "aptos", "sui", "tron", "canton", "ton", "stellar", "starknet"], + "description": "Blockchain family grouping" + }, + "supported": { + "type": "boolean", + "description": "Whether this chain is fully supported with complete configuration. Unsupported chains have minimal details (empty router, rmn, feeTokens)." }, "registryModule": { "type": "string", @@ -676,7 +760,7 @@ }, "tokenAdminRegistry": { "type": "string", - "description": "Token Admin Registry contract address (EVM chains only)" + "description": "Token Admin Registry contract address (EVM and Aptos chains)" }, "tokenPoolFactory": { "type": "string", @@ -685,6 +769,10 @@ "feeQuoter": { "type": "string", "description": "Fee Quoter address (Solana chains only)" + }, + "mcms": { + "type": "string", + "description": "MCMS (Multi-Chain Management Service) address (Aptos chains only)" } } }, @@ -730,19 +818,61 @@ }, "description": "EVM chain details keyed by the specified output key" }, - "svm": { + "solana": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ChainDetails" + }, + "description": "Solana chain details keyed by the specified output key" + }, + "aptos": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ChainDetails" + }, + "description": "Aptos chain details keyed by the specified output key" + }, + "sui": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ChainDetails" + }, + "description": "Sui chain details keyed by the specified output key" + }, + "tron": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ChainDetails" + }, + "description": "Tron chain details keyed by the specified output key" + }, + "canton": { "type": "object", "additionalProperties": { "$ref": "#/components/schemas/ChainDetails" }, - "description": "Solana VM chain details keyed by the specified output key" + "description": "Canton chain details keyed by the specified output key" }, - "mvm": { + "ton": { "type": "object", "additionalProperties": { "$ref": "#/components/schemas/ChainDetails" }, - "description": "Move VM (Aptos) chain details keyed by the specified output key" + "description": "TON chain details keyed by the specified output key" + }, + "stellar": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ChainDetails" + }, + "description": "Stellar chain details keyed by the specified output key" + }, + "starknet": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ChainDetails" + }, + "description": "Starknet chain details keyed by the specified output key" } }, "description": "Chain details grouped by chain family" @@ -789,7 +919,8 @@ }, "ChainInfo": { "type": "object", - "required": ["chainId", "displayName", "selector", "internalId", "chainType", "chainFamily"], + "description": "Chain information used in lane endpoints. Note: chainType and chainFamily are intentionally excluded from this schema for API responses.", + "required": ["chainId", "displayName", "selector", "internalId"], "properties": { "chainId": { "oneOf": [{ "type": "integer" }, { "type": "string" }], @@ -806,16 +937,6 @@ "internalId": { "type": "string", "description": "Internal identifier used in configuration" - }, - "chainType": { - "type": "string", - "enum": ["evm", "solana", "aptos", "sui"], - "description": "Type of blockchain" - }, - "chainFamily": { - "type": "string", - "enum": ["evm", "mvm", "svm"], - "description": "Family of blockchain virtual machine" } } }, @@ -931,6 +1052,11 @@ "message": { "type": "string", "description": "Human-readable error message" + }, + "requestId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for the request, useful for debugging and support" } } }, diff --git a/src/config/chainTypes.ts b/src/config/chainTypes.ts index 0cf27273143..02f4ce4ddfc 100644 --- a/src/config/chainTypes.ts +++ b/src/config/chainTypes.ts @@ -38,6 +38,48 @@ export const CHAIN_TYPE_CONFIGS: Record = { color: "#00D4AA", // Aptos teal description: "Aptos Move blockchain", }, + sui: { + id: "sui", + displayName: "Sui", + icon: "/assets/chains/sui.svg", + color: "#6FBCF0", // Sui blue + description: "Sui Move blockchain", + }, + canton: { + id: "canton", + displayName: "Canton", + icon: "/assets/chains/canton.svg", + color: "#1E1E1E", // Canton dark + description: "Canton Network blockchain", + }, + ton: { + id: "ton", + displayName: "TON", + icon: "/assets/chains/ton.svg", + color: "#0088CC", // TON blue + description: "The Open Network blockchain", + }, + tron: { + id: "tron", + displayName: "TRON", + icon: "/assets/chains/tron.svg", + color: "#FF0013", // TRON red + description: "TRON blockchain", + }, + stellar: { + id: "stellar", + displayName: "Stellar", + icon: "/assets/chains/stellar.svg", + color: "#000000", // Stellar black + description: "Stellar blockchain", + }, + starknet: { + id: "starknet", + displayName: "Starknet", + icon: "/assets/chains/starknet.svg", + color: "#EC796B", // Starknet coral + description: "Starknet blockchain", + }, } /** diff --git a/src/config/data/ccip/paths.ts b/src/config/data/ccip/paths.ts index 75acb970693..85200066b80 100644 --- a/src/config/data/ccip/paths.ts +++ b/src/config/data/ccip/paths.ts @@ -17,6 +17,12 @@ export const SELECTOR_FILES = { evm: "selectors.yml", solana: "selectors_solana.yml", aptos: "selectors_aptos.yml", + sui: "selectors_sui.yml", + canton: "selectors_canton.yml", + ton: "selectors_ton.yml", + tron: "selectors_tron.yml", + stellar: "selectors_stellar.yml", + starknet: "selectors_starknet.yml", } /** Destination paths for selector files */ @@ -24,6 +30,12 @@ export const SELECTOR_CONFIG_PATHS = { evm: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.evm), solana: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.solana), aptos: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.aptos), + sui: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.sui), + canton: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.canton), + ton: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.ton), + tron: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.tron), + stellar: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.stellar), + starknet: path.join(CCIP_CONFIG_DIR, SELECTOR_FILES.starknet), } /** Backup paths for selector files */ @@ -31,6 +43,12 @@ export const SELECTOR_BACKUP_PATHS = { evm: `${SELECTOR_CONFIG_PATHS.evm}.backup`, solana: `${SELECTOR_CONFIG_PATHS.solana}.backup`, aptos: `${SELECTOR_CONFIG_PATHS.aptos}.backup`, + sui: `${SELECTOR_CONFIG_PATHS.sui}.backup`, + canton: `${SELECTOR_CONFIG_PATHS.canton}.backup`, + ton: `${SELECTOR_CONFIG_PATHS.ton}.backup`, + tron: `${SELECTOR_CONFIG_PATHS.tron}.backup`, + stellar: `${SELECTOR_CONFIG_PATHS.stellar}.backup`, + starknet: `${SELECTOR_CONFIG_PATHS.starknet}.backup`, } // Legacy paths for backward compatibility diff --git a/src/config/data/ccip/selectors.ts b/src/config/data/ccip/selectors.ts index 5f102d0b013..fa5946e8419 100644 --- a/src/config/data/ccip/selectors.ts +++ b/src/config/data/ccip/selectors.ts @@ -1,31 +1,58 @@ import evmConfig from "./selectors.yml" import solanaConfig from "./selectors_solana.yml" import aptosConfig from "./selectors_aptos.yml" +import suiConfig from "./selectors_sui.yml" +import cantonConfig from "./selectors_canton.yml" +import tonConfig from "./selectors_ton.yml" +import tronConfig from "./selectors_tron.yml" +import stellarConfig from "./selectors_stellar.yml" +import starknetConfig from "./selectors_starknet.yml" import { ChainType } from "@config/types.ts" export interface Selector { selector: string name: string + network_type?: "mainnet" | "testnet" } export interface SelectorsConfig { selectors: Record } +export interface SelectorWithMeta { + chainId: string + selector: string + name: string + networkType: "mainnet" | "testnet" + chainType: ChainType +} + // Chain-specific configs export type EvmSelectorsConfig = SelectorsConfig export type SolanaSelectorsConfig = SelectorsConfig export type AptosSelectorsConfig = SelectorsConfig +export type SuiSelectorsConfig = SelectorsConfig +export type CantonSelectorsConfig = SelectorsConfig +export type TonSelectorsConfig = SelectorsConfig +export type TronSelectorsConfig = SelectorsConfig +export type StellarSelectorsConfig = SelectorsConfig +export type StarknetSelectorsConfig = SelectorsConfig // Cast configs to appropriate types const evmSelectorsConfig = evmConfig as unknown as EvmSelectorsConfig const solanaSelectorsConfig = solanaConfig as unknown as SolanaSelectorsConfig const aptosSelectorsConfig = aptosConfig as unknown as AptosSelectorsConfig +const suiSelectorsConfig = suiConfig as unknown as SuiSelectorsConfig +const cantonSelectorsConfig = cantonConfig as unknown as CantonSelectorsConfig +const tonSelectorsConfig = tonConfig as unknown as TonSelectorsConfig +const tronSelectorsConfig = tronConfig as unknown as TronSelectorsConfig +const stellarSelectorsConfig = stellarConfig as unknown as StellarSelectorsConfig +const starknetSelectorsConfig = starknetConfig as unknown as StarknetSelectorsConfig /** * Retrieves the selector configuration for the given chainId and chain type. * @param chainId The chain ID (string or number) for which to retrieve the selector. - * @param chainType The chain type (evm, solana, aptos) to look up in. + * @param chainType The chain type to look up in. * @returns The selector entry { selector: string; name: string } if found, otherwise undefined. */ export function getSelectorEntry( @@ -48,8 +75,78 @@ export function getSelectorEntry( case "aptos": result = aptosSelectorsConfig.selectors?.[chainIdStr] || null break + case "sui": + result = suiSelectorsConfig.selectors?.[chainIdStr] || null + break + case "canton": + result = cantonSelectorsConfig.selectors?.[chainIdStr] || null + break + case "ton": + result = tonSelectorsConfig.selectors?.[chainIdStr] || null + break + case "tron": + result = tronSelectorsConfig.selectors?.[chainIdStr] || null + break + case "stellar": + result = stellarSelectorsConfig.selectors?.[chainIdStr] || null + break + case "starknet": + result = starknetSelectorsConfig.selectors?.[chainIdStr] || null + break } // If found, return as { selector, name } format return result ? { selector: result.selector, name: result.name } : undefined } + +/** + * Retrieves all selectors for a given chain type and network type + * @param chainType The chain type to get selectors for + * @param networkType The network type (mainnet/testnet) to filter by + * @returns Array of selector entries with metadata + */ +export function getAllSelectors(chainType: ChainType, networkType: "mainnet" | "testnet"): SelectorWithMeta[] { + let config: SelectorsConfig + + switch (chainType) { + case "evm": + config = evmSelectorsConfig + break + case "solana": + config = solanaSelectorsConfig + break + case "aptos": + config = aptosSelectorsConfig + break + case "sui": + config = suiSelectorsConfig + break + case "canton": + config = cantonSelectorsConfig + break + case "ton": + config = tonSelectorsConfig + break + case "tron": + config = tronSelectorsConfig + break + case "stellar": + config = stellarSelectorsConfig + break + case "starknet": + config = starknetSelectorsConfig + break + default: + return [] + } + + return Object.entries(config.selectors) + .filter(([_, data]) => data.network_type === networkType) + .map(([chainId, data]) => ({ + chainId, + selector: data.selector, + name: data.name, + networkType: data.network_type as "mainnet" | "testnet", + chainType, + })) +} diff --git a/src/config/data/ccip/selectors.yml b/src/config/data/ccip/selectors.yml index e08c8f51610..4d397eb9760 100644 --- a/src/config/data/ccip/selectors.yml +++ b/src/config/data/ccip/selectors.yml @@ -617,6 +617,10 @@ selectors: selector: "7254999290874773717" name: dogeos-testnet-chikyu network_type: testnet + 46630: + selector: "2032988798112970440" + name: "robinhood-testnet" + network_type: testnet # Mainnets 1: diff --git a/src/config/data/ccip/selectors_canton.yml b/src/config/data/ccip/selectors_canton.yml new file mode 100644 index 00000000000..ff7d840370e --- /dev/null +++ b/src/config/data/ccip/selectors_canton.yml @@ -0,0 +1,17 @@ +selectors: + LocalNet: + selector: "8706591216959472610" + name: canton-localnet + network_type: testnet + DevNet: + selector: "10109143320554840099" + name: canton-devnet + network_type: testnet + TestNet: + selector: "9268731218649498074" + name: canton-testnet + network_type: testnet + MainNet: + selector: "2308837218439511688" + name: canton-mainnet + network_type: mainnet diff --git a/src/config/data/ccip/selectors_starknet.yml b/src/config/data/ccip/selectors_starknet.yml new file mode 100644 index 00000000000..340b03cd6f5 --- /dev/null +++ b/src/config/data/ccip/selectors_starknet.yml @@ -0,0 +1,9 @@ +selectors: + "SN_MAIN": + name: ethereum-mainnet-starknet-1 + selector: "511843109281680063" + network_type: mainnet + "SN_SEPOLIA": + name: ethereum-testnet-sepolia-starknet-1 + selector: "4115550741429562104" + network_type: testnet diff --git a/src/config/data/ccip/selectors_stellar.yml b/src/config/data/ccip/selectors_stellar.yml new file mode 100644 index 00000000000..12edabe1716 --- /dev/null +++ b/src/config/data/ccip/selectors_stellar.yml @@ -0,0 +1,17 @@ +# Network IDs are SHA-256 hashes of the network passphrase (https://developers.stellar.org/docs/networks#network-ids) +selectors: + # Localnet - sha256("Standalone Network ; February 2017") + baefd734b8d3e48472cff83912375fedbc7573701912fe308af730180f97d74a: + selector: "17301180955411967724" + name: stellar-localnet + network_type: testnet + # Testnet - sha256("Test SDF Network ; September 2015") + cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472: + selector: "4894814558906953166" + name: stellar-testnet + network_type: testnet + # Mainnet - sha256("Public Global Stellar Network ; September 2015") + 7ac33997544e3175d266bd022439b22cdb16508c01163f26e5cb2a3e1045a979: + selector: "17783245649066640917" + name: stellar-mainnet + network_type: mainnet diff --git a/src/config/data/ccip/selectors_sui.yml b/src/config/data/ccip/selectors_sui.yml new file mode 100644 index 00000000000..7a0cbb301a2 --- /dev/null +++ b/src/config/data/ccip/selectors_sui.yml @@ -0,0 +1,13 @@ +selectors: + 1: + name: sui-mainnet + selector: "17529533435026248318" + network_type: mainnet + 2: + name: sui-testnet + selector: "9762610643973837292" + network_type: testnet + 4: + name: sui-localnet + selector: "18395503381733958356" + network_type: testnet diff --git a/src/config/data/ccip/selectors_ton.yml b/src/config/data/ccip/selectors_ton.yml new file mode 100644 index 00000000000..cf59ad71f59 --- /dev/null +++ b/src/config/data/ccip/selectors_ton.yml @@ -0,0 +1,13 @@ +selectors: + -239: + name: ton-mainnet + selector: "16448340667252469081" + network_type: mainnet + -3: + name: ton-testnet + selector: "1399300952838017768" + network_type: testnet + -217: + name: ton-localnet + selector: "13879075125137744094" + network_type: testnet diff --git a/src/config/data/ccip/selectors_tron.yml b/src/config/data/ccip/selectors_tron.yml new file mode 100644 index 00000000000..a160f3f153b --- /dev/null +++ b/src/config/data/ccip/selectors_tron.yml @@ -0,0 +1,17 @@ +selectors: + 3448148188: + selector: "2052925811360307740" + name: "tron-testnet-nile" + network_type: testnet + 2494104990: + selector: "13231703482326770597" + name: "tron-testnet-shasta" + network_type: testnet + 728126428: + selector: "1546563616611573945" + name: "tron-mainnet" + network_type: mainnet + 3360022319: + selector: "13231703482326770599" + name: "tron-devnet" + network_type: testnet diff --git a/src/config/types.ts b/src/config/types.ts index 9196be69c2b..57004cb485e 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -83,9 +83,9 @@ export type SupportedTechnology = | "ARC_NETWORK" | "DOGE_OS" -export type ChainType = "evm" | "solana" | "aptos" +export type ChainType = "evm" | "solana" | "aptos" | "sui" | "canton" | "ton" | "tron" | "stellar" | "starknet" -export type ChainFamily = "evm" | "mvm" | "svm" +export type ChainFamily = "evm" | "aptos" | "sui" | "solana" | "tron" | "canton" | "ton" | "stellar" | "starknet" export type SupportedChain = | "ETHEREUM_MAINNET" diff --git a/src/features/utils/index.ts b/src/features/utils/index.ts index ae733428137..76246e491b0 100644 --- a/src/features/utils/index.ts +++ b/src/features/utils/index.ts @@ -117,10 +117,28 @@ export const getChainTypeAndFamily = (supportedChain: SupportedChain): ChainType chainFamily = "evm" break case "aptos": - chainFamily = "mvm" + chainFamily = "aptos" + break + case "sui": + chainFamily = "sui" break case "solana": - chainFamily = "svm" + chainFamily = "solana" + break + case "tron": + chainFamily = "tron" + break + case "canton": + chainFamily = "canton" + break + case "ton": + chainFamily = "ton" + break + case "stellar": + chainFamily = "stellar" + break + case "starknet": + chainFamily = "starknet" break default: throw new Error(`Unknown chain type: ${chainType}`) diff --git a/src/lib/ccip/faucet/adapters/index.ts b/src/lib/ccip/faucet/adapters/index.ts index ae1962dad49..ac5723acc41 100644 --- a/src/lib/ccip/faucet/adapters/index.ts +++ b/src/lib/ccip/faucet/adapters/index.ts @@ -11,7 +11,7 @@ export const prerender = false export class FaucetAdapterFactory { static getAdapter(family: ChainFamily): FamilyAdapter { switch (family) { - case "svm": + case "solana": return new SvmAdapter() default: throw new Error(`Faucet not available for chain family: ${family}`) diff --git a/src/lib/ccip/faucet/adapters/svm-drip.ts b/src/lib/ccip/faucet/adapters/svm-drip.ts index 190bd4f6c83..eb0fb9ae979 100644 --- a/src/lib/ccip/faucet/adapters/svm-drip.ts +++ b/src/lib/ccip/faucet/adapters/svm-drip.ts @@ -103,7 +103,7 @@ export class SvmDripAdapter { * Check if drip is available for the given chain */ isDripAvailable(chainConfig: FaucetChainConfig): boolean { - return !!(chainConfig.enabled && chainConfig.family === "svm" && chainConfig.faucetAddress) + return !!(chainConfig.enabled && chainConfig.family === "solana" && chainConfig.faucetAddress) } /** diff --git a/src/lib/ccip/faucet/adapters/svm.ts b/src/lib/ccip/faucet/adapters/svm.ts index 4b0fecd4848..1096e5de69d 100644 --- a/src/lib/ccip/faucet/adapters/svm.ts +++ b/src/lib/ccip/faucet/adapters/svm.ts @@ -133,7 +133,7 @@ export class SvmAdapter implements FamilyAdapter { logger.error({ message: "Error during signature verification", requestId: args.requestId, - family: "svm", + family: "solana", error: error instanceof Error ? error.message : "Unknown error", step: "verification_error", }) diff --git a/src/lib/ccip/services/chain-data.ts b/src/lib/ccip/services/chain-data.ts index c268c3ea39b..2e92dba94b9 100644 --- a/src/lib/ccip/services/chain-data.ts +++ b/src/lib/ccip/services/chain-data.ts @@ -1,12 +1,20 @@ -import { Environment, ChainDetails, FilterType, ChainConfigError, FeeTokenEnriched } from "~/lib/ccip/types/index.ts" +import { + Environment, + ChainDetails, + FilterType, + ChainConfigError, + FeeTokenEnriched, + ChainFamily, +} from "~/lib/ccip/types/index.ts" import { ChainsConfig } from "@config/data/ccip/index.ts" -import { getSelectorEntry } from "@config/data/ccip/selectors.ts" +import { getSelectorEntry, getAllSelectors } from "@config/data/ccip/selectors.ts" import { resolveChainOrThrow } from "~/lib/ccip/utils.ts" import { logger } from "@lib/logging/index.js" -import { getChainId, getNativeCurrency, getTitle, getChainTypeAndFamily } from "../../../features/utils/index.ts" -import { SupportedChain, ChainType, ChainFamily } from "~/config/index.ts" +import { getChainId, getNativeCurrency, getTitle, getChainTypeAndFamily } from "@features/utils/index.ts" +import { SupportedChain, ChainType } from "~/config/index.ts" import { getTokenData } from "@config/data/ccip/data.ts" import { Version } from "@config/data/ccip/types.ts" +import { deriveDisplayName } from "~/lib/ccip/utils/display-name.ts" export const prerender = false @@ -65,13 +73,13 @@ abstract class BaseChainStrategy implements IChainProcessingStrategy { } } - // Validate chainId and selectorEntry - if (!chainId || !selectorEntry) { + // Validate chainId and selectorEntry (use explicit null/undefined check to allow chainId 0) + if (chainId === undefined || chainId === null || !selectorEntry) { logger.warn({ message: "Missing chain ID or selector entry", requestId: this.requestId, networkId, - hasChainId: !!chainId, + hasChainId: chainId !== undefined && chainId !== null, hasSelectorEntry: !!selectorEntry, }) @@ -228,9 +236,19 @@ class EvmChainStrategy extends BaseChainStrategy { } } - // Construct the complete EVM chain details + // Construct the complete EVM chain details with explicit field assignment + const { baseData } = baseValidation const validatedData: ChainDetails = { - ...(baseValidation.baseData as unknown as ChainDetails), + chainId: baseData.chainId!, + displayName: baseData.displayName!, + selector: baseData.selector!, + internalId: baseData.internalId!, + feeTokens: baseData.feeTokens!, + router: baseData.router!, + rmn: baseData.rmn!, + chainType: baseData.chainType!, + chainFamily: baseData.chainFamily!, + supported: true, registryModule: chainConfig.registryModule?.address, tokenAdminRegistry: chainConfig.tokenAdminRegistry?.address, tokenPoolFactory: chainConfig.tokenPoolFactory?.address, @@ -292,9 +310,19 @@ class SolanaChainStrategy extends BaseChainStrategy { } } - // Construct the complete Solana chain details + // Construct the complete Solana chain details with explicit field assignment + const { baseData } = baseValidation const validatedData: ChainDetails = { - ...(baseValidation.baseData as unknown as ChainDetails), + chainId: baseData.chainId!, + displayName: baseData.displayName!, + selector: baseData.selector!, + internalId: baseData.internalId!, + feeTokens: baseData.feeTokens!, + router: baseData.router!, + rmn: baseData.rmn!, + chainType: baseData.chainType!, + chainFamily: baseData.chainFamily!, + supported: true, feeQuoter: chainConfig.feeQuoter, } @@ -348,8 +376,19 @@ class AptosChainStrategy extends BaseChainStrategy { } } + // Construct the complete Aptos chain details with explicit field assignment + const { baseData } = baseValidation const validatedData: ChainDetails = { - ...(baseValidation.baseData as ChainDetails), + chainId: baseData.chainId!, + displayName: baseData.displayName!, + selector: baseData.selector!, + internalId: baseData.internalId!, + feeTokens: baseData.feeTokens!, + router: baseData.router!, + rmn: baseData.rmn!, + chainType: baseData.chainType!, + chainFamily: baseData.chainFamily!, + supported: true, tokenAdminRegistry: chainConfig.tokenAdminRegistry?.address ?? "", mcms: chainConfig.mcms?.address ?? "", } @@ -373,6 +412,13 @@ class ChainStrategyFactory { ["evm", EvmChainStrategy], ["solana", SolanaChainStrategy], ["aptos", AptosChainStrategy], + ["sui", AptosChainStrategy], // Sui uses Move VM like Aptos + // New chain types use EVM strategy as fallback until specific strategies are implemented + ["tron", EvmChainStrategy], + ["canton", EvmChainStrategy], + ["ton", EvmChainStrategy], + ["stellar", EvmChainStrategy], + ["starknet", EvmChainStrategy], ]) static getStrategy(chainType: ChainType, requestId: string): IChainProcessingStrategy { @@ -399,10 +445,11 @@ export class ChainDataService { /** * Creates a new instance of ChainDataService * @param chainConfig - Configuration for supported chains + * @param requestId - Optional request ID for log correlation (generates new UUID if not provided) */ - constructor(chainConfig: ChainsConfig) { + constructor(chainConfig: ChainsConfig, requestId?: string) { this.chainConfig = chainConfig - this.requestId = crypto.randomUUID() + this.requestId = requestId ?? crypto.randomUUID() logger.debug({ message: "ChainDataService initialized", @@ -625,7 +672,7 @@ export class ChainDataService { if (filters.chainId) { const chainIds = filters.chainId.split(",").map((id) => id.trim()) - filteredChains = chains.filter((chain) => chainIds.includes(String(chain.chainId))) + filteredChains = filteredChains.filter((chain) => chainIds.includes(String(chain.chainId))) } if (filters.selector) { @@ -654,8 +701,14 @@ export class ChainDataService { // Group by chain family const groupedChains: Record = { evm: [], - mvm: [], - svm: [], + aptos: [], + sui: [], + solana: [], + tron: [], + canton: [], + ton: [], + stellar: [], + starknet: [], } for (const chain of filteredChains) { @@ -681,3 +734,115 @@ export class ChainDataService { } } } + +/** + * Maps a chain type to its corresponding chain family. + * + * Each chain type maps directly to its own family: + * - evm: Ethereum Virtual Machine chains + * - solana: Solana chains + * - aptos: Aptos chains + * - sui: Sui chains + * - tron, canton, ton, stellar, starknet: Each has its own family + * + * @param chainType - The specific chain type + * @returns The chain family (same as chain type) + * @example + * getChainFamilyFromType('solana') // returns 'solana' + * getChainFamilyFromType('aptos') // returns 'aptos' + */ +function getChainFamilyFromType(chainType: ChainType): ChainFamily { + switch (chainType) { + case "evm": + return "evm" + case "solana": + return "solana" + case "aptos": + return "aptos" + case "sui": + return "sui" + case "tron": + return "tron" + case "canton": + return "canton" + case "ton": + return "ton" + case "stellar": + return "stellar" + case "starknet": + return "starknet" + default: + return "evm" + } +} + +/** + * Gets all chains for search including both supported and unsupported chains. + * + * Supported chains have full details from the chain configuration, while + * unsupported chains have minimal details derived from selector YAML files. + * The displayName for unsupported chains is derived from their internalId. + * + * @param environment - Network environment (mainnet/testnet) + * @param supportedChains - Array of fully supported chain details with complete configuration + * @returns Array of all chain details with supported flag indicating if chain is fully configured + * + * @example + * const allChains = getAllChainsForSearch(Environment.Mainnet, supportedChains) + * // Returns both supported chains (with full details) and unsupported chains (minimal details) + */ +export function getAllChainsForSearch(environment: Environment, supportedChains: ChainDetails[]): ChainDetails[] { + // Use Map for O(1) lookup instead of find() which is O(n) + const supportedChainsBySelector = new Map( + supportedChains.map((chain) => [chain.selector, chain]) + ) + const networkType = environment === Environment.Mainnet ? "mainnet" : "testnet" + + // Get all selectors from YAML files for all chain types + const allSelectors = [ + ...getAllSelectors("evm", networkType), + ...getAllSelectors("solana", networkType), + ...getAllSelectors("aptos", networkType), + ...getAllSelectors("sui", networkType), + ...getAllSelectors("canton", networkType), + ...getAllSelectors("ton", networkType), + ...getAllSelectors("tron", networkType), + ...getAllSelectors("stellar", networkType), + ...getAllSelectors("starknet", networkType), + ] + + const result: ChainDetails[] = [] + + for (const entry of allSelectors) { + // Skip entries with missing required data + if (!entry.selector || !entry.chainType) { + continue + } + + const supportedChain = supportedChainsBySelector.get(entry.selector) + + if (supportedChain) { + // Supported chain - use full details with supported: true + result.push({ ...supportedChain, supported: true }) + } else { + // Unsupported chain - minimal details from selector data + // Use explicit chainId check to allow 0 as valid value + const chainFamily = getChainFamilyFromType(entry.chainType) + const internalId = entry.name || `chain-${entry.chainId ?? "unknown"}` + result.push({ + chainId: entry.chainId ?? "", + selector: entry.selector, + internalId, + displayName: deriveDisplayName(internalId), + chainType: entry.chainType, + chainFamily, + supported: false, + feeTokens: [], + router: "", + rmn: "", + }) + } + } + + return result +} diff --git a/src/lib/ccip/services/chain-search.ts b/src/lib/ccip/services/chain-search.ts new file mode 100644 index 00000000000..f2e419d4521 --- /dev/null +++ b/src/lib/ccip/services/chain-search.ts @@ -0,0 +1,220 @@ +import Fuse, { type IFuseOptions } from "fuse.js" +import type { ChainDetails, ChainFamily, SearchType } from "~/lib/ccip/types/index.ts" +import { CCIPError } from "~/lib/ccip/utils.ts" +import { logger } from "@lib/logging/index.js" + +export const prerender = false + +// Fuse.js configuration for fuzzy search +const FUSE_OPTIONS: IFuseOptions = { + keys: [ + { name: "displayName", weight: 0.4 }, + { name: "internalId", weight: 0.25 }, + { name: "chainFamily", weight: 0.2 }, + { name: "chainId", weight: 0.15 }, + ], + threshold: 0.4, + ignoreLocation: true, + minMatchCharLength: 2, +} + +// Cache for search indexes to avoid O(n) operations on every search +// Includes Fuse.js instance and Map-based lookups for O(1) exact matches +interface SearchCache { + key: string + fuse: Fuse + selectorMap: Map + internalIdMap: Map + chainIdMap: Map // chainId can have duplicates across chains +} + +let searchCache: SearchCache | null = null + +/** + * Generates a cache key based on chain data. + * Uses chain count, first/middle/last selectors, and a checksum to detect when data changes. + */ +function generateCacheKey(chains: ChainDetails[]): string { + if (chains.length === 0) return "empty" + const firstSelector = chains[0]?.selector ?? "" + const lastSelector = chains[chains.length - 1]?.selector ?? "" + const midIndex = Math.floor(chains.length / 2) + const midSelector = chains[midIndex]?.selector ?? "" + + // Create a simple checksum of all selectors to detect middle changes + let checksum = 0 + for (const chain of chains) { + const selector = chain.selector || "" + for (let i = 0; i < selector.length; i++) { + checksum = (checksum + selector.charCodeAt(i)) % 1000000 + } + } + + return `${chains.length}:${firstSelector}:${midSelector}:${lastSelector}:${checksum}` +} + +/** + * Builds search indexes for the given chains. + * Creates Fuse.js instance and Map-based lookups. + */ +function buildSearchIndexes(chains: ChainDetails[]): SearchCache { + const selectorMap = new Map() + const internalIdMap = new Map() + const chainIdMap = new Map() + + for (const chain of chains) { + if (chain.selector) { + selectorMap.set(chain.selector, chain) + } + if (chain.internalId) { + internalIdMap.set(chain.internalId.toLowerCase(), chain) + } + // chainId can have duplicates, so store as array + const chainIdStr = String(chain.chainId) + const existing = chainIdMap.get(chainIdStr) || [] + existing.push(chain) + chainIdMap.set(chainIdStr, existing) + } + + return { + key: generateCacheKey(chains), + fuse: new Fuse(chains, FUSE_OPTIONS), + selectorMap, + internalIdMap, + chainIdMap, + } +} + +/** + * Gets or creates cached search indexes for the given chains. + * Reuses existing cache if the chain data hasn't changed. + */ +function getSearchIndexes(chains: ChainDetails[]): SearchCache { + const cacheKey = generateCacheKey(chains) + + if (searchCache && searchCache.key === cacheKey) { + logger.debug({ + message: "Using cached search indexes", + cacheKey, + }) + return searchCache + } + + logger.debug({ + message: "Building new search indexes", + cacheKey, + chainCount: chains.length, + }) + + searchCache = buildSearchIndexes(chains) + return searchCache +} + +/** + * Checks if a query looks like a selector (18-20 digit number). + * Selectors are always large numeric strings. + */ +function isLikelySelector(query: string): boolean { + const candidate = query.replace(/n$/, "") // Remove trailing 'n' for BigInt literals + return /^\d+$/.test(candidate) && candidate.length > 17 +} + +/** + * Executes search on chains with automatic query type detection. + * + * Detection strategy (in order): + * 1. Check if query matches a selector in the map + * 2. Check if query matches a chainId in the map (handles all formats: numeric, negative, hex, strings) + * 3. Check if query matches an internalId in the map + * 4. Fall back to fuzzy search on displayName + * + * This approach is simpler and automatically handles all chainId formats across chain families. + * + * @param query - Search query string + * @param chains - Array of chain details to search + * @param familyFilter - Optional chain family filter + * @returns Search results and detected search type + * @throws CCIPError if query is too short for fuzzy search + */ +export function searchChains( + query: string, + chains: ChainDetails[], + familyFilter?: ChainFamily | null +): { results: ChainDetails[]; searchType: SearchType } { + const trimmed = query.trim() + + if (trimmed.length < 2) { + throw new CCIPError(400, "Search query must be at least 2 characters.") + } + + const indexes = getSearchIndexes(chains) + let results: ChainDetails[] + let searchType: SearchType + + // 1. Check if it's a selector (large numeric string > 17 digits) + const selectorCandidate = trimmed.replace(/n$/, "") + if (isLikelySelector(trimmed) && indexes.selectorMap.has(selectorCandidate)) { + const chain = indexes.selectorMap.get(selectorCandidate)! + results = [chain] + searchType = "selector" + logger.debug({ + message: "Search matched selector", + query: trimmed, + type: "selector", + }) + } + // 2. Check if query exists as a chainId (handles all formats automatically) + else if (indexes.chainIdMap.has(trimmed)) { + results = indexes.chainIdMap.get(trimmed) || [] + searchType = "chainId" + logger.debug({ + message: "Search matched chainId", + query: trimmed, + type: "chainId", + }) + } + // 3. Check if query exists as an internalId (case-insensitive) + else if (indexes.internalIdMap.has(trimmed.toLowerCase())) { + const chain = indexes.internalIdMap.get(trimmed.toLowerCase())! + results = [chain] + searchType = "internalId" + logger.debug({ + message: "Search matched internalId", + query: trimmed, + type: "internalId", + }) + } + // 4. Fall back to fuzzy search + else { + results = indexes.fuse.search(trimmed).map((r) => r.item) + searchType = "displayName" + logger.debug({ + message: "Search using fuzzy match", + query: trimmed, + type: "displayName", + resultCount: results.length, + }) + } + + // Apply family filter if specified + if (familyFilter) { + results = results.filter((c) => c.chainFamily === familyFilter) + logger.debug({ + message: "Applied family filter", + family: familyFilter, + filteredCount: results.length, + }) + } + + return { results, searchType } +} + +/** + * Clears the search cache. Call this if chain data changes. + */ +export function clearSearchCache(): void { + searchCache = null + logger.debug({ + message: "Search cache cleared", + }) +} diff --git a/src/lib/ccip/types/index.ts b/src/lib/ccip/types/index.ts index f2a74d933ff..18c463d01c8 100644 --- a/src/lib/ccip/types/index.ts +++ b/src/lib/ccip/types/index.ts @@ -1,15 +1,21 @@ // Chain Data API Types import { Environment } from "@config/data/ccip/types.ts" +import type { ChainType, ChainFamily } from "@config/types.ts" export { Environment } - -// Chain type and family declarations -export type ChainType = "evm" | "solana" | "aptos" | "sui" -export type ChainFamily = "evm" | "mvm" | "svm" +export type { ChainType, ChainFamily } export const prerender = false +// Search types +export type SearchType = "selector" | "chainId" | "internalId" | "displayName" + +export interface SearchDetectionResult { + type: SearchType + normalizedQuery: string +} + /** * Enriched fee token information with address and metadata * Used when enrichFeeTokens=true query parameter is set @@ -33,6 +39,9 @@ export type ChainMetadata = { timestamp: string requestId: string ignoredChainCount: number + validChainCount: number + searchQuery?: string + searchType?: SearchType } export interface ChainDetails { @@ -45,6 +54,7 @@ export interface ChainDetails { rmn: string chainType: ChainType chainFamily: ChainFamily + supported: boolean registryModule?: string tokenAdminRegistry?: string tokenPoolFactory?: string diff --git a/src/lib/ccip/utils.ts b/src/lib/ccip/utils.ts index 4496319a028..04d04457276 100644 --- a/src/lib/ccip/utils.ts +++ b/src/lib/ccip/utils.ts @@ -3,9 +3,18 @@ import { JsonRpcProvider, Contract } from "ethers" import { ChainsConfig, Environment, loadReferenceData, Version } from "@config/data/ccip/index.ts" import { SupportedChain } from "@config/index.ts" import { directoryToSupportedChain } from "@features/utils/index.ts" -import { v4 as uuidv4 } from "uuid" -import type { TokenMetadata, ChainType, OutputKeyType } from "./types/index.ts" +import type { + TokenMetadata, + ChainType, + OutputKeyType, + ChainFamily, + SearchType, + ChainMetadata, + ChainConfigError, + FilterType, +} from "./types/index.ts" import { jsonHeaders, commonHeaders as sharedCommonHeaders } from "@lib/api/cacheHeaders.js" +import { logger } from "@lib/logging/index.js" export const prerender = false @@ -39,35 +48,8 @@ export class CCIPError extends Error { } } -/** - * Metadata structure for chain API responses - */ -export type ChainMetadata = { - environment: Environment - timestamp: string - requestId: string - ignoredChainCount: number - validChainCount: number -} - -/** - * Error structure for chain configuration issues - */ -export type ChainConfigError = { - chainId: number - networkId: string - reason: string - missingFields: string[] -} - -/** - * Filter parameters for chain queries - */ -export type FilterType = { - chainId?: string - selector?: string - internalId?: string -} +// Re-export types from types/index.ts for backwards compatibility +export type { ChainMetadata, ChainConfigError, FilterType } /** * Arguments required for ARM proxy contract interactions @@ -175,7 +157,11 @@ export const checkIfChainIsCursed = async ( try { return await getArmIsCursed({ provider, routerAddress }) } catch (error) { - console.error(`Error checking if chain ${chain} is cursed: ${error}`) + logger.error({ + message: "Error checking if chain is cursed", + chain, + error: error instanceof Error ? error.message : "Unknown error", + }) return false } } @@ -195,12 +181,13 @@ export const withTimeout = (promise: Promise, ms: number, timeoutErrorMess /** * Creates metadata object for chain API responses * @param environment - Current environment (mainnet/testnet) + * @param requestId - Optional request ID for correlation (generates new UUID if not provided) * @returns Metadata object with timestamp and request tracking */ -export const createMetadata = (environment: Environment): ChainMetadata => ({ +export const createMetadata = (environment: Environment, requestId?: string): ChainMetadata => ({ environment, timestamp: new Date().toISOString(), - requestId: uuidv4(), + requestId: requestId ?? crypto.randomUUID(), ignoredChainCount: 0, validChainCount: 0, }) @@ -255,7 +242,7 @@ export const validateFilters = (filters: FilterType): void => { export const validateOutputKey = (outputKey?: string): "chainId" | "selector" | "internalId" => { if (!outputKey) return "chainId" if (!["chainId", "selector", "internalId"].includes(outputKey)) { - throw new CCIPError(400, "outputKey must be one of: chainId, selector, or internalId") + throw new CCIPError(400, "outputKey must be one of: chainId, selector, or internalId.") } return outputKey as "chainId" | "selector" | "internalId" } @@ -270,11 +257,84 @@ export const validateEnrichFeeTokens = (enrichFeeTokens?: string): boolean => { if (!enrichFeeTokens) return false const normalizedValue = enrichFeeTokens.toLowerCase() if (!["true", "false"].includes(normalizedValue)) { - throw new CCIPError(400, 'enrichFeeTokens must be "true" or "false"') + throw new CCIPError(400, 'enrichFeeTokens must be "true" or "false".') } return normalizedValue === "true" } +/** + * Validates search parameter + * @param search - Search query string to validate + * @returns Trimmed search string or null if empty + * @throws CCIPError if search query is invalid + */ +export const validateSearch = (search: string | null): string | null => { + if (!search) return null + + const trimmed = search.trim() + + if (trimmed.length === 0) return null + + if (trimmed.length > 100) { + throw new CCIPError(400, "Search query must not exceed 100 characters.") + } + + // Allow alphanumeric, spaces, hyphens, and underscores (explicit ASCII to prevent Unicode issues) + if (!/^[a-zA-Z0-9_\s-]+$/.test(trimmed)) { + throw new CCIPError(400, "Search query contains invalid characters.") + } + + return trimmed +} + +/** + * Validates family parameter + * @param family - Family string to validate + * @returns Normalized ChainFamily or null if empty + * @throws CCIPError if family value is invalid + */ +export const validateFamily = (family: string | null): ChainFamily | null => { + if (!family) return null + + const trimmed = family.trim() + if (trimmed.length === 0) return null + + const normalized = trimmed.toLowerCase() + + const familyMap: Record = { + evm: "evm", + solana: "solana", + aptos: "aptos", + sui: "sui", + tron: "tron", + canton: "canton", + ton: "ton", + stellar: "stellar", + starknet: "starknet", + } + + const mapped = familyMap[normalized] + if (!mapped) { + throw new CCIPError(400, "family must be one of: evm, solana, aptos, sui, tron, canton, ton, stellar, starknet.") + } + + return mapped +} + +/** + * Validates that search and legacy filters are not combined + * @param search - Search query string + * @param filters - Legacy filter parameters + * @throws CCIPError if search is combined with legacy filters + */ +export const validateSearchParams = (search: string | null, filters: FilterType): void => { + const hasLegacyFilter = filters.chainId || filters.selector || filters.internalId + + if (search && hasLegacyFilter) { + throw new CCIPError(400, "Cannot combine search with chainId, selector, or internalId filters.") + } +} + export const generateChainKey = (chainId: number | string, chainType: ChainType, outputKey: OutputKeyType): string => { const chainIdStr = chainId.toString() @@ -309,7 +369,11 @@ export const normalizeVersion = (version: string): string => { } // Fallback for unknown formats - console.warn(`Unknown version format: ${version}`) + logger.warn({ + message: "Unknown version format, using default", + version, + defaultVersion: "1.0.0", + }) return "1.0.0" } @@ -394,7 +458,10 @@ export const loadChainConfiguration = async ( sourceRouterAddress, } } catch (error) { - console.error("Error loading chain configuration:", error) + logger.error({ + message: "Error loading chain configuration", + error: error instanceof Error ? error.message : "Unknown error", + }) throw new CCIPError(500, "Failed to load chain configuration") } } @@ -415,6 +482,7 @@ export enum APIErrorType { export interface APIError { error: APIErrorType message: string + requestId?: string details?: unknown } @@ -424,12 +492,20 @@ export interface APIError { * @param message - Error message * @param status - HTTP status code * @param details - Additional error details + * @param requestId - Optional request ID for correlation * @returns Response object with error details */ -export function createErrorResponse(error: APIErrorType, message: string, status: number, details?: unknown): Response { +export function createErrorResponse( + error: APIErrorType, + message: string, + status: number, + details?: unknown, + requestId?: string +): Response { const errorResponse: APIError = { error, message, + ...(requestId ? { requestId } : {}), ...(details ? { details } : {}), } diff --git a/src/lib/ccip/utils/display-name.ts b/src/lib/ccip/utils/display-name.ts new file mode 100644 index 00000000000..adca735e25b --- /dev/null +++ b/src/lib/ccip/utils/display-name.ts @@ -0,0 +1,22 @@ +/** + * Display name utilities for CCIP chains + */ + +/** + * Derives display name from internalId by converting kebab-case or snake_case to Title Case. + * @param internalId - The internal chain identifier (e.g., "ethereum-mainnet", "binance_smart_chain-testnet") + * @returns Human-readable display name (e.g., "Ethereum Mainnet", "Binance Smart Chain Testnet") + * @example + * deriveDisplayName("ethereum-mainnet") // "Ethereum Mainnet" + * deriveDisplayName("binance_smart_chain-testnet") // "Binance Smart Chain Testnet" + */ +export function deriveDisplayName(internalId: string): string { + if (!internalId) { + return "Unknown" + } + return internalId + .split(/[-_]/) + .filter((word) => word.length > 0) // Filter empty strings to prevent double spaces + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} diff --git a/src/lib/solana/domain/services/signature-verification.ts b/src/lib/solana/domain/services/signature-verification.ts index 5d9f0d03cd1..9b34b273f0c 100644 --- a/src/lib/solana/domain/services/signature-verification.ts +++ b/src/lib/solana/domain/services/signature-verification.ts @@ -308,7 +308,7 @@ export class SolanaSignatureService { logger.warn({ message: "Timing-safe HMAC verification failed for SIWS challenge", requestId: params.requestId, - family: "svm", + family: "solana", receiverAddress: params.receiver, step: "hmac_verification", }) @@ -327,7 +327,7 @@ export class SolanaSignatureService { logger.debug({ message: "SIWS challenge metadata parsed", requestId: params.requestId, - family: "svm", + family: "solana", expiresAt: metadata.expiresAt, issuedAt: metadata.issuedAt, nonce: metadata.nonce, @@ -351,7 +351,7 @@ export class SolanaSignatureService { logger.warn({ message: "SIWS challenge expired", requestId: params.requestId, - family: "svm", + family: "solana", expiresAt: metadata.expiresAt, currentTime: now, timeRemaining: metadata.expiresAt - now, @@ -366,7 +366,7 @@ export class SolanaSignatureService { logger.warn({ message: "SIWS challenge issued in the future", requestId: params.requestId, - family: "svm", + family: "solana", issuedAt: metadata.issuedAt, currentTime: now, step: "future_check", @@ -388,7 +388,7 @@ export class SolanaSignatureService { logger.info({ message: "Ed25519 signature verification successful", requestId: params.requestId, - family: "svm", + family: "solana", receiverAddress: params.receiver, verificationTimeMs: verificationTime, step: "signature_verification", @@ -398,7 +398,7 @@ export class SolanaSignatureService { logger.warn({ message: "Ed25519 signature verification failed", requestId: params.requestId, - family: "svm", + family: "solana", receiverAddress: params.receiver, verificationTimeMs: verificationTime, step: "signature_verification", @@ -418,7 +418,7 @@ export class SolanaSignatureService { logger.error({ message: "Error during SIWS signature verification", requestId: params.requestId, - family: "svm", + family: "solana", error: error instanceof Error ? error.message : "Unknown error", step: "verification_error", }) diff --git a/src/pages/api/ccip/v1/chains.ts b/src/pages/api/ccip/v1/chains.ts index 5f733985065..94c68f3179c 100644 --- a/src/pages/api/ccip/v1/chains.ts +++ b/src/pages/api/ccip/v1/chains.ts @@ -4,9 +4,11 @@ import { validateFilters, validateOutputKey, validateEnrichFeeTokens, + validateSearch, + validateFamily, + validateSearchParams, generateChainKey, createMetadata, - handleApiError, successHeaders, commonHeaders, loadChainConfiguration, @@ -17,8 +19,9 @@ import { } from "~/lib/ccip/utils.ts" import { logger } from "@lib/logging/index.js" -import type { ChainDetails, ChainApiResponse } from "~/lib/ccip/types/index.ts" -import { ChainDataService } from "~/lib/ccip/services/chain-data.ts" +import type { ChainDetails, ChainApiResponse, ChainFamily } from "~/lib/ccip/types/index.ts" +import { ChainDataService, getAllChainsForSearch } from "~/lib/ccip/services/chain-data.ts" +import { searchChains } from "~/lib/ccip/services/chain-search.ts" export const prerender = false @@ -43,13 +46,38 @@ export const GET: APIRoute = async ({ request }) => { environment, }) - // Validate filters + // Validate search and family (new unified search params) + const search = validateSearch(params.get("search")) + const family = validateFamily(params.get("family")) + logger.debug({ + message: "Search params validated", + requestId, + search, + family, + }) + + // Validate legacy filters const filters: FilterType = { chainId: params.get("chainId") || undefined, selector: params.get("selector") || undefined, internalId: params.get("internalId") || undefined, } validateFilters(filters) + + // Validate mutual exclusion of search and legacy filters + validateSearchParams(search, filters) + + // Warn if family is used with legacy filters (family has no effect in legacy mode) + const hasLegacyFilter = filters.chainId || filters.selector || filters.internalId + if (family && hasLegacyFilter) { + logger.warn({ + message: "family parameter is ignored when using legacy filters (chainId, selector, internalId)", + requestId, + family, + filters, + }) + } + logger.debug({ message: "Filters validated", requestId, @@ -80,29 +108,97 @@ export const GET: APIRoute = async ({ request }) => { chainCount: Object.keys(config.chainsConfig).length, }) - const chainDataService = new ChainDataService(config.chainsConfig) - const { - data, - errors, - metadata: serviceMetadata, - } = await chainDataService.getFilteredChains(environment, filters, enrichFeeTokens) + const chainDataService = new ChainDataService(config.chainsConfig, requestId) - logger.info({ - message: "Chain data retrieved successfully", - requestId, - validChainCount: serviceMetadata.validChainCount, - errorCount: errors.length, - filters, - }) + let data: Record + let errors: { chainId: number; networkId: string; reason: string; missingFields: string[] }[] + const metadata = createMetadata(environment, requestId) + + if (search) { + // SEARCH MODE: Get all chains (supported + unsupported) and search + const { + data: supportedData, + errors: supportedErrors, + metadata: serviceMetadata, + } = await chainDataService.getFilteredChains(environment, {}, enrichFeeTokens) - const metadata = createMetadata(environment) - metadata.ignoredChainCount = serviceMetadata.ignoredChainCount - metadata.validChainCount = serviceMetadata.validChainCount + // Flatten supported chains from all families + const supportedChains = [ + ...supportedData.evm, + ...supportedData.solana, + ...supportedData.aptos, + ...supportedData.sui, + ...supportedData.tron, + ...supportedData.canton, + ...supportedData.ton, + ...supportedData.stellar, + ...supportedData.starknet, + ] + + // Get all chains including unsupported + const allChains = getAllChainsForSearch(environment, supportedChains) + + // Execute search with optional family filter + const { results, searchType } = searchChains(search, allChains, family) + + // Group results by family + data = { evm: [], solana: [], aptos: [], sui: [], tron: [], canton: [], ton: [], stellar: [], starknet: [] } + for (const chain of results) { + data[chain.chainFamily].push(chain) + } + + errors = supportedErrors + metadata.validChainCount = results.length + metadata.ignoredChainCount = supportedErrors.length + metadata.searchQuery = search + metadata.searchType = searchType + + logger.info({ + message: "Search completed successfully", + requestId, + searchQuery: search, + searchType, + resultCount: results.length, + family, + }) + } else { + // LEGACY MODE: Existing filter behavior (supported chains only) + const { + data: serviceData, + errors: serviceErrors, + metadata: serviceMetadata, + } = await chainDataService.getFilteredChains(environment, filters, enrichFeeTokens) + + // Add supported: true to all chains in legacy mode + data = { + evm: serviceData.evm.map((c) => ({ ...c, supported: true })), + solana: serviceData.solana.map((c) => ({ ...c, supported: true })), + aptos: serviceData.aptos.map((c) => ({ ...c, supported: true })), + sui: serviceData.sui.map((c) => ({ ...c, supported: true })), + tron: serviceData.tron.map((c) => ({ ...c, supported: true })), + canton: serviceData.canton.map((c) => ({ ...c, supported: true })), + ton: serviceData.ton.map((c) => ({ ...c, supported: true })), + stellar: serviceData.stellar.map((c) => ({ ...c, supported: true })), + starknet: serviceData.starknet.map((c) => ({ ...c, supported: true })), + } + + errors = serviceErrors + metadata.validChainCount = serviceMetadata.validChainCount + metadata.ignoredChainCount = serviceMetadata.ignoredChainCount + + logger.info({ + message: "Chain data retrieved successfully", + requestId, + validChainCount: serviceMetadata.validChainCount, + errorCount: errors.length, + filters, + }) + } // Convert each chain family's array to a keyed object structure as required by the API const formattedData = Object.entries(data).reduce( - (acc, [family, chainList]) => { - acc[family] = chainList.reduce( + (acc, [familyKey, chainList]) => { + acc[familyKey] = chainList.reduce( (familyAcc, chain) => { const key = outputKey === "chainId" @@ -149,16 +245,18 @@ export const GET: APIRoute = async ({ request }) => { error.statusCode === 400 ? APIErrorType.VALIDATION_ERROR : APIErrorType.SERVER_ERROR, error.message, error.statusCode, - {} + undefined, + requestId ) } - // Handle other errors - if (error instanceof Error) { - return createErrorResponse(APIErrorType.SERVER_ERROR, "Failed to process chain request", 500, { - message: error.message, - }) - } - return handleApiError(error) + // Handle all other errors generically without exposing internal details + return createErrorResponse( + APIErrorType.SERVER_ERROR, + "An unexpected error occurred while processing the request.", + 500, + undefined, + requestId + ) } } diff --git a/src/pages/api/ccip/v1/docs.astro b/src/pages/api/ccip/v1/docs.astro index 6e1ad90eb26..4ca6e697ae2 100644 --- a/src/pages/api/ccip/v1/docs.astro +++ b/src/pages/api/ccip/v1/docs.astro @@ -13,13 +13,13 @@ const fetchCcipChains = async () => { const evmChains = data.data.evm; console.log('EVM chains:', evmChains); - // Access Solana VM chains (if available) - const solanaChains = data.data.svm || {}; + // Access Solana chains (if available) + const solanaChains = data.data.solana || {}; console.log('Solana chains:', solanaChains); - - // Access Move VM chains (if available) - const mvmChains = data.data.mvm || {}; - console.log('Aptos chains:', mvmChains); + + // Access Aptos chains (if available) + const aptosChains = data.data.aptos || {}; + console.log('Aptos chains:', aptosChains); return data; } catch (err) { @@ -50,7 +50,7 @@ const fetchSolanaChains = async () => { const data = await response.json(); // Extract just the Solana chains - const solanaChains = data.data.svm || {}; + const solanaChains = data.data.solana || {}; console.log('Solana chains:', solanaChains); return solanaChains; } catch (err) {