Skip to content

Commit 430b3f3

Browse files
committed
fix: prioritize EVM chains when resolving chainId collisions
ChainId can have collisions across chain families (e.g., both Ethereum and Aptos have chainId=1). Updated resolveToInternalId to collect all matches and prioritize EVM chains, since by-chain-id endpoints are typically used for EVM chain IDs. This fixes the issue where /lanes/by-chain-id/1/43114 was incorrectly resolving to Aptos instead of Ethereum.
1 parent 739c343 commit 430b3f3

19 files changed

Lines changed: 1122 additions & 47 deletions

File tree

jest.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@ module.exports = {
3535
"\\.ya?ml$": "<rootDir>/src/__mocks__/yamlMock.ts",
3636
},
3737
transformIgnorePatterns: ["/node_modules/(?!.*\\.mjs$)"],
38-
testPathIgnorePatterns: ["/node_modules/", "src/tests/chain-api.test.ts"],
38+
testPathIgnorePatterns: ["/node_modules/", "src/tests/chain-api.test.ts", "src/tests/chain-identifier-service.test.ts"],
3939
}

public/api/ccip/v1/openapi.json

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"info": {
44
"title": "CCIP Docs config API",
55
"description": "API for retrieving CCIP chain, token, lane, and rate limits 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.",
6-
"version": "1.12.0",
6+
"version": "1.13.0",
77
"contact": {
88
"name": "File issues",
99
"url": "https://github.com/smartcontractkit/documentation/issues/new/choose"
@@ -120,6 +120,16 @@
120120
"enum": ["evm", "solana", "aptos", "sui", "tron", "canton", "ton", "stellar", "starknet"]
121121
},
122122
"description": "Filter results by chain family. Only effective when using the search parameter."
123+
},
124+
{
125+
"name": "internalIdFormat",
126+
"in": "query",
127+
"schema": {
128+
"type": "string",
129+
"enum": ["directory", "selector"],
130+
"default": "selector"
131+
},
132+
"description": "Format for internal IDs in the response. 'selector' uses canonical selector names (e.g., 'ethereum-mainnet'), 'directory' uses chains.json keys (e.g., 'mainnet'). Only applies when outputKey=internal_id."
123133
}
124134
],
125135
"responses": {
@@ -344,6 +354,16 @@
344354
"default": "chain_id"
345355
},
346356
"description": "Key to use for organizing the response data"
357+
},
358+
{
359+
"name": "internalIdFormat",
360+
"in": "query",
361+
"schema": {
362+
"type": "string",
363+
"enum": ["directory", "selector"],
364+
"default": "selector"
365+
},
366+
"description": "Format for internal IDs in the response. 'selector' uses canonical selector names (e.g., 'ethereum-mainnet'), 'directory' uses chains.json keys (e.g., 'mainnet'). Only applies when output_key=internal_id."
347367
}
348368
],
349369
"responses": {
@@ -463,6 +483,16 @@
463483
"default": "chain_id"
464484
},
465485
"description": "Key to use for organizing the response data by chain"
486+
},
487+
{
488+
"name": "internalIdFormat",
489+
"in": "query",
490+
"schema": {
491+
"type": "string",
492+
"enum": ["directory", "selector"],
493+
"default": "selector"
494+
},
495+
"description": "Format for internal IDs in the response. 'selector' uses canonical selector names (e.g., 'ethereum-mainnet'), 'directory' uses chains.json keys (e.g., 'mainnet'). Only applies when output_key=internal_id."
466496
}
467497
],
468498
"responses": {
@@ -618,6 +648,16 @@
618648
"default": "chainId"
619649
},
620650
"description": "Key to use for organizing the response data by chain"
651+
},
652+
{
653+
"name": "internalIdFormat",
654+
"in": "query",
655+
"schema": {
656+
"type": "string",
657+
"enum": ["directory", "selector"],
658+
"default": "selector"
659+
},
660+
"description": "Format for internal IDs in the response. 'selector' uses canonical selector names (e.g., 'ethereum-mainnet'), 'directory' uses chains.json keys (e.g., 'mainnet'). Only applies when output_key=internalId."
621661
}
622662
],
623663
"responses": {
@@ -785,6 +825,16 @@
785825
"default": "chain_id"
786826
},
787827
"description": "Key format to use for organizing the lane keys in the response"
828+
},
829+
{
830+
"name": "internalIdFormat",
831+
"in": "query",
832+
"schema": {
833+
"type": "string",
834+
"enum": ["directory", "selector"],
835+
"default": "selector"
836+
},
837+
"description": "Format for internal IDs in the response. 'selector' uses canonical selector names (e.g., 'ethereum-mainnet'), 'directory' uses chains.json keys (e.g., 'mainnet'). Only applies when outputKey=internal_id."
788838
}
789839
],
790840
"responses": {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from "@jest/globals"
2+
import { validateInternalIdFormat, CCIPError } from "~/lib/ccip/utils.ts"
3+
4+
describe("validateInternalIdFormat", () => {
5+
describe("valid inputs", () => {
6+
it("should accept 'selector' format", () => {
7+
expect(validateInternalIdFormat("selector")).toBe("selector")
8+
})
9+
10+
it("should accept 'directory' format", () => {
11+
expect(validateInternalIdFormat("directory")).toBe("directory")
12+
})
13+
})
14+
15+
describe("default behavior", () => {
16+
it("should default to 'selector' when undefined", () => {
17+
expect(validateInternalIdFormat(undefined)).toBe("selector")
18+
})
19+
20+
it("should default to 'selector' for empty string", () => {
21+
// Empty string is falsy, should trigger default
22+
expect(validateInternalIdFormat("")).toBe("selector")
23+
})
24+
})
25+
26+
describe("invalid inputs", () => {
27+
it("should throw CCIPError for 'invalid'", () => {
28+
expect(() => validateInternalIdFormat("invalid")).toThrow(CCIPError)
29+
})
30+
31+
it("should throw CCIPError for 'chainId'", () => {
32+
expect(() => validateInternalIdFormat("chainId")).toThrow(CCIPError)
33+
})
34+
35+
it("should throw CCIPError for 'selectorName'", () => {
36+
expect(() => validateInternalIdFormat("selectorName")).toThrow(CCIPError)
37+
})
38+
39+
it("should throw CCIPError for 'internal'", () => {
40+
expect(() => validateInternalIdFormat("internal")).toThrow(CCIPError)
41+
})
42+
43+
it("should throw CCIPError for 'SELECTOR' (case-sensitive)", () => {
44+
expect(() => validateInternalIdFormat("SELECTOR")).toThrow(CCIPError)
45+
})
46+
47+
it("should throw CCIPError for 'DIRECTORY' (case-sensitive)", () => {
48+
expect(() => validateInternalIdFormat("DIRECTORY")).toThrow(CCIPError)
49+
})
50+
})
51+
52+
describe("error details", () => {
53+
it("should throw CCIPError with correct message", () => {
54+
try {
55+
validateInternalIdFormat("invalid")
56+
fail("Expected CCIPError to be thrown")
57+
} catch (error) {
58+
expect(error).toBeInstanceOf(CCIPError)
59+
const ccipError = error as CCIPError
60+
expect(ccipError.message).toBe('internalIdFormat must be "directory" or "selector".')
61+
expect(ccipError.statusCode).toBe(400)
62+
}
63+
})
64+
})
65+
})
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { loadReferenceData, Version } from "@config/data/ccip/index.ts"
2+
import type { ChainConfig } from "@config/data/ccip/types.ts"
3+
import { getSelectorEntry } from "@config/data/ccip/selectors.ts"
4+
import { getChainId, getChainTypeAndFamily, directoryToSupportedChain } from "~/features/utils/index.ts"
5+
import { Environment } from "~/lib/ccip/types/index.ts"
6+
import { logger } from "@lib/logging/index.js"
7+
8+
/**
9+
* Naming convention for chain identifiers
10+
* - 'directory': chains.json keys (e.g., "mainnet", "bsc-mainnet")
11+
* - 'selector': selectors.yml names (e.g., "ethereum-mainnet", "binance_smart_chain-mainnet")
12+
*/
13+
export type NamingConvention = "directory" | "selector"
14+
15+
/**
16+
* Result of resolving a chain identifier
17+
*/
18+
export interface ResolvedChain {
19+
directoryKey: string // Key in chains.json (for internal data lookups)
20+
selectorName: string // Name in selectors.yml (canonical form)
21+
inputConvention: NamingConvention // Which convention was used in the input
22+
}
23+
24+
/**
25+
* Service for handling chain identifier resolution and formatting.
26+
* Supports bidirectional mapping between directory keys and selector names.
27+
*
28+
* This enables the API to:
29+
* 1. Accept both naming conventions as input
30+
* 2. Mirror the user's chosen convention in responses
31+
* 3. Maintain backward compatibility (default to selector names)
32+
*/
33+
export class ChainIdentifierService {
34+
private directoryToSelector: Map<string, string> = new Map()
35+
private selectorToDirectory: Map<string, string> = new Map()
36+
private directoryKeys: Set<string> = new Set()
37+
private readonly requestId: string
38+
39+
constructor(
40+
private readonly environment: Environment,
41+
private readonly defaultConvention: NamingConvention = "selector"
42+
) {
43+
this.requestId = crypto.randomUUID()
44+
this.buildMappings()
45+
}
46+
47+
/**
48+
* Build bidirectional mappings between directory keys and selector names
49+
*/
50+
private buildMappings(): void {
51+
const { chainsReferenceData } = loadReferenceData({
52+
environment: this.environment,
53+
version: Version.V1_2_0,
54+
})
55+
56+
for (const [directoryKey, chainConfig] of Object.entries(chainsReferenceData as Record<string, ChainConfig>)) {
57+
this.directoryKeys.add(directoryKey)
58+
59+
try {
60+
// Get chain ID and type to look up the selector entry
61+
const supportedChain = directoryToSupportedChain(directoryKey)
62+
const chainId = getChainId(supportedChain)
63+
const { chainType } = getChainTypeAndFamily(supportedChain)
64+
65+
if (chainId) {
66+
const selectorEntry = getSelectorEntry(chainId, chainType)
67+
if (selectorEntry?.name) {
68+
const selectorName = selectorEntry.name
69+
70+
// Only add mapping if names are different
71+
if (selectorName !== directoryKey) {
72+
this.directoryToSelector.set(directoryKey, selectorName)
73+
this.selectorToDirectory.set(selectorName, directoryKey)
74+
}
75+
}
76+
}
77+
} catch {
78+
// Skip chains that can't be resolved
79+
logger.debug({
80+
message: "Could not resolve chain for mapping",
81+
requestId: this.requestId,
82+
directoryKey,
83+
})
84+
}
85+
}
86+
87+
logger.debug({
88+
message: "Chain identifier mappings built",
89+
requestId: this.requestId,
90+
mappingCount: this.directoryToSelector.size,
91+
directoryKeyCount: this.directoryKeys.size,
92+
})
93+
}
94+
95+
/**
96+
* Check if an identifier is a directory key (chains.json key)
97+
*/
98+
isDirectoryKey(identifier: string): boolean {
99+
return this.directoryKeys.has(identifier)
100+
}
101+
102+
/**
103+
* Check if an identifier is a selector name (selectors.yml name)
104+
*/
105+
isSelectorName(identifier: string): boolean {
106+
// It's a selector name if:
107+
// 1. It maps to a directory key, OR
108+
// 2. It's a directory key that has no different selector name (they're the same)
109+
return this.selectorToDirectory.has(identifier) || this.directoryKeys.has(identifier)
110+
}
111+
112+
/**
113+
* Resolve a chain identifier to both directory key and selector name.
114+
* Detects which convention was used in the input.
115+
*
116+
* @param identifier - Chain identifier (directory key or selector name)
117+
* @returns Resolved chain info or null if not found
118+
*/
119+
resolve(identifier: string): ResolvedChain | null {
120+
// Check if it's a directory key
121+
if (this.directoryKeys.has(identifier)) {
122+
const selectorName = this.directoryToSelector.get(identifier) ?? identifier
123+
return {
124+
directoryKey: identifier,
125+
selectorName,
126+
inputConvention: "directory",
127+
}
128+
}
129+
130+
// Check if it's a selector name that maps to a directory key
131+
if (this.selectorToDirectory.has(identifier)) {
132+
const directoryKey = this.selectorToDirectory.get(identifier)!
133+
return {
134+
directoryKey,
135+
selectorName: identifier,
136+
inputConvention: "selector",
137+
}
138+
}
139+
140+
// Not found
141+
return null
142+
}
143+
144+
/**
145+
* Format a directory key using the specified naming convention.
146+
*
147+
* @param directoryKey - The chains.json key
148+
* @param convention - Which format to output
149+
* @returns Formatted identifier
150+
*/
151+
format(directoryKey: string, convention: NamingConvention): string {
152+
if (convention === "directory") {
153+
return directoryKey
154+
}
155+
156+
// Return selector name, or directory key if no mapping exists
157+
return this.directoryToSelector.get(directoryKey) ?? directoryKey
158+
}
159+
160+
/**
161+
* Detect the naming convention from a list of identifiers.
162+
* Returns the convention of the first resolvable identifier.
163+
*
164+
* @param identifiers - List of identifiers to check
165+
* @returns Detected convention or default
166+
*/
167+
detectConvention(...identifiers: (string | undefined)[]): NamingConvention {
168+
for (const identifier of identifiers) {
169+
if (!identifier) continue
170+
171+
const resolved = this.resolve(identifier)
172+
if (resolved) {
173+
return resolved.inputConvention
174+
}
175+
}
176+
177+
return this.defaultConvention
178+
}
179+
180+
/**
181+
* Get the default naming convention
182+
*/
183+
getDefaultConvention(): NamingConvention {
184+
return this.defaultConvention
185+
}
186+
187+
/**
188+
* Get the directory key for a given identifier (either format)
189+
* This is useful for internal data lookups
190+
*/
191+
getDirectoryKey(identifier: string): string | null {
192+
const resolved = this.resolve(identifier)
193+
return resolved?.directoryKey ?? null
194+
}
195+
196+
/**
197+
* Get the selector name for a given identifier (either format)
198+
*/
199+
getSelectorName(identifier: string): string | null {
200+
const resolved = this.resolve(identifier)
201+
return resolved?.selectorName ?? null
202+
}
203+
}

0 commit comments

Comments
 (0)