Skip to content

Commit 4c91737

Browse files
authored
feat(widgets): make chain search menu metadata-first friendly (#8637)
1 parent 04c7f64 commit 4c91737

7 files changed

Lines changed: 169 additions & 31 deletions

File tree

.changeset/fast-pumpkins-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperlane-xyz/widgets': patch
3+
---
4+
5+
Made `ChainSearchMenu` fit metadata-first consumers better by lazy-loading the chain-details drilldown, and hardened `ChainAddMenu` validation against duplicate `chainId` and effective `domainId` conflicts across merged base and override metadata.

.changeset/neat-birds-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hyperlane-xyz/sdk': patch
3+
---
4+
5+
Added shared chain ID normalization helpers under the existing `metadata/*` subpath so metadata-first consumers can reuse the same chain ID and effective domain ID validation logic as the SDK resolver.

typescript/sdk/src/metadata/ChainMetadataResolver.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { assert, isNullish } from '@hyperlane-xyz/utils';
33
import type { ChainMap, ChainNameOrId } from '../types.js';
44

55
import type { ChainMetadata } from './chainMetadataTypes.js';
6+
import { tryNormalizeNumericChainId } from './chainIdUtils.js';
67

78
export interface ChainMetadataResolver<MetaExt = {}> {
89
metadata: ChainMap<ChainMetadata<MetaExt>>;
@@ -90,17 +91,3 @@ export function createChainMetadataResolver<MetaExt = {}>(
9091
tryGetProtocol: (chain) => tryGetChainMetadata(chain)?.protocol ?? null,
9192
};
9293
}
93-
94-
function tryNormalizeNumericChainId(chainId: string | number) {
95-
if (typeof chainId === 'number') {
96-
return Number.isSafeInteger(chainId) ? chainId : null;
97-
}
98-
99-
if (!/^\d+$/.test(chainId)) return null;
100-
101-
const numericChainId = Number(chainId);
102-
if (!Number.isSafeInteger(numericChainId)) return null;
103-
if (String(numericChainId) !== chainId) return null;
104-
105-
return numericChainId;
106-
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { expect } from 'chai';
2+
3+
import {
4+
areChainIdsEqual,
5+
getEffectiveDomainId,
6+
tryNormalizeNumericChainId,
7+
} from './chainIdUtils.js';
8+
9+
describe(tryNormalizeNumericChainId.name, () => {
10+
it('accepts safe integer numbers', () => {
11+
expect(tryNormalizeNumericChainId(0)).to.equal(0);
12+
expect(tryNormalizeNumericChainId(123)).to.equal(123);
13+
});
14+
15+
it('accepts canonical numeric strings', () => {
16+
expect(tryNormalizeNumericChainId('0')).to.equal(0);
17+
expect(tryNormalizeNumericChainId('123')).to.equal(123);
18+
});
19+
20+
it('rejects non-canonical or invalid inputs', () => {
21+
expect(tryNormalizeNumericChainId('00123')).to.equal(null);
22+
expect(tryNormalizeNumericChainId('cosmoshub-4')).to.equal(null);
23+
expect(tryNormalizeNumericChainId(1.5)).to.equal(null);
24+
expect(tryNormalizeNumericChainId(Number.MAX_SAFE_INTEGER + 1)).to.equal(
25+
null,
26+
);
27+
});
28+
});
29+
30+
describe(areChainIdsEqual.name, () => {
31+
it('matches exact values and canonical numeric aliases', () => {
32+
expect(areChainIdsEqual(123, 123)).to.equal(true);
33+
expect(areChainIdsEqual('123', 123)).to.equal(true);
34+
});
35+
36+
it('rejects nullish and non-matching values', () => {
37+
expect(areChainIdsEqual(undefined, 123)).to.equal(false);
38+
expect(areChainIdsEqual(null, '123')).to.equal(false);
39+
expect(areChainIdsEqual(5, 10)).to.equal(false);
40+
expect(areChainIdsEqual('5', 10)).to.equal(false);
41+
expect(areChainIdsEqual('00123', 123)).to.equal(false);
42+
expect(areChainIdsEqual('cosmoshub-4', 4)).to.equal(false);
43+
});
44+
});
45+
46+
describe(getEffectiveDomainId.name, () => {
47+
it('prefers explicit domain ids', () => {
48+
expect(getEffectiveDomainId({ chainId: 1, domainId: 999 })).to.equal(999);
49+
});
50+
51+
it('falls back to normalized chain ids when domain id is absent', () => {
52+
expect(getEffectiveDomainId({ chainId: 123 })).to.equal(123);
53+
expect(getEffectiveDomainId({ chainId: '123' })).to.equal(123);
54+
});
55+
56+
it('returns null for non-numeric fallback chain ids', () => {
57+
expect(getEffectiveDomainId({ chainId: 'cosmoshub-4' })).to.equal(null);
58+
});
59+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { isNullish } from '@hyperlane-xyz/utils';
2+
3+
import type { ChainMetadata } from './chainMetadataTypes.js';
4+
5+
// Decimal-only canonical chain IDs. Rejects hex, prefixed values, and mixed IDs
6+
// like cosmoshub-4 so those never alias to numeric domain lookups.
7+
const NUMERIC_CHAIN_ID_REGEX = /^\d+$/;
8+
9+
export function tryNormalizeNumericChainId(
10+
chainId: string | number,
11+
): number | null {
12+
if (typeof chainId === 'number') {
13+
return Number.isSafeInteger(chainId) ? chainId : null;
14+
}
15+
16+
if (!NUMERIC_CHAIN_ID_REGEX.test(chainId)) return null;
17+
18+
const numericChainId = Number(chainId);
19+
if (!Number.isSafeInteger(numericChainId)) return null;
20+
if (String(numericChainId) !== chainId) return null;
21+
22+
return numericChainId;
23+
}
24+
25+
export function areChainIdsEqual(
26+
left: ChainMetadata['chainId'] | null | undefined,
27+
right: ChainMetadata['chainId'] | null | undefined,
28+
): boolean {
29+
if (isNullish(left) || isNullish(right)) return false;
30+
31+
if (left === right) return true;
32+
33+
const leftNumeric = tryNormalizeNumericChainId(left);
34+
const rightNumeric = tryNormalizeNumericChainId(right);
35+
return leftNumeric !== null && leftNumeric === rightNumeric;
36+
}
37+
38+
export function getEffectiveDomainId(metadata: {
39+
chainId: ChainMetadata['chainId'];
40+
domainId?: ChainMetadata['domainId'] | null;
41+
}): number | null {
42+
if (!isNullish(metadata.domainId)) {
43+
return metadata.domainId;
44+
}
45+
46+
return tryNormalizeNumericChainId(metadata.chainId);
47+
}

typescript/widgets/src/chains/ChainAddMenu.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { clsx } from 'clsx';
2-
import React, { useState } from 'react';
2+
import React, { useMemo, useState } from 'react';
33

44
import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry';
55
import {
66
ChainMetadata,
77
ChainMetadataSchema,
8+
mergeChainMetadataMap,
89
} from '@hyperlane-xyz/sdk/metadata/chainMetadataTypes';
10+
import {
11+
areChainIdsEqual,
12+
getEffectiveDomainId,
13+
} from '@hyperlane-xyz/sdk/metadata/chainIdUtils';
914
import type { ChainMap } from '@hyperlane-xyz/sdk/types';
1015
import {
1116
Result,
@@ -82,14 +87,18 @@ function Form({
8287
}: ChainAddMenuProps) {
8388
const [textInput, setTextInput] = useState('');
8489
const [error, setError] = useState<any>(null);
90+
const existingChainMetadata = useMemo(
91+
() => mergeChainMetadataMap(chainMetadata, overrideChainMetadata),
92+
[chainMetadata, overrideChainMetadata],
93+
);
8594

8695
const onChangeInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
8796
setTextInput(e.target.value);
8897
setError(null);
8998
};
9099

91100
const onClickAdd = () => {
92-
const result = tryParseMetadataInput(textInput, chainMetadata);
101+
const result = tryParseMetadataInput(textInput, existingChainMetadata);
93102
if (result.success) {
94103
onChangeOverrideMetadata({
95104
...overrideChainMetadata,
@@ -149,14 +158,27 @@ function tryParseMetadataInput(
149158
}
150159

151160
const newMetadata = result.data as ChainMetadata;
161+
const chainId = newMetadata.chainId;
162+
const effectiveDomainId = getEffectiveDomainId(newMetadata);
152163

153164
if (existingChainMetadata[newMetadata.name]) {
154165
return failure('name is already in use by another chain');
155166
}
156167

168+
// The resolver can tolerate ambiguous duplicate chainId aliases, but local
169+
// add-chain UX rejects them to avoid persisting ambiguous metadata entries.
170+
if (
171+
Object.entries(existingChainMetadata).some(([, metadata]) =>
172+
areChainIdsEqual(metadata.chainId, chainId),
173+
)
174+
) {
175+
return failure('chainId is already in use by another chain');
176+
}
177+
157178
if (
158-
Object.values(existingChainMetadata).some(
159-
(metadata) => metadata.domainId === newMetadata.domainId,
179+
effectiveDomainId !== null &&
180+
Object.entries(existingChainMetadata).some(
181+
([, metadata]) => getEffectiveDomainId(metadata) === effectiveDomainId,
160182
)
161183
) {
162184
return failure('domainId is already in use by another chain');

typescript/widgets/src/chains/ChainSearchMenu.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useMemo, useState } from 'react';
1+
import React, { Suspense, lazy, useCallback, useMemo, useState } from 'react';
22

33
import {
44
mergeChainMetadataMap,
@@ -16,9 +16,14 @@ import {
1616
import { SegmentedControl } from '../components/SegmentedControl.js';
1717

1818
import { ChainAddMenu } from './ChainAddMenu.js';
19-
import { ChainDetailsMenu } from './ChainDetailsMenu.js';
2019
import { ChainLogo } from './ChainLogo.js';
2120

21+
const ChainDetailsMenu = lazy(() =>
22+
import('./ChainDetailsMenu.js').then((mod) => ({
23+
default: mod.ChainDetailsMenu,
24+
})),
25+
);
26+
2227
export enum ChainSortByOption {
2328
Name = 'name',
2429
ChainId = 'chain id',
@@ -119,18 +124,26 @@ export function ChainSearchMenu({
119124
};
120125

121126
return (
122-
<ChainDetailsMenu
123-
chainMetadata={chainMetadata[drilldownChain]}
124-
overrideChainMetadata={overrideChainMetadata?.[drilldownChain]}
125-
onChangeOverrideMetadata={(o) =>
126-
onChangeOverrideMetadata({
127-
...overrideChainMetadata,
128-
[drilldownChain]: o,
129-
})
127+
<Suspense
128+
fallback={
129+
<div className="htw-py-8 htw-text-center htw-text-sm htw-text-gray-500">
130+
Loading chain details...
131+
</div>
130132
}
131-
onClickBack={() => setDrilldownChain(undefined)}
132-
onRemoveChain={isLocalOverrideChain ? onRemoveChain : undefined}
133-
/>
133+
>
134+
<ChainDetailsMenu
135+
chainMetadata={chainMetadata[drilldownChain]}
136+
overrideChainMetadata={overrideChainMetadata?.[drilldownChain]}
137+
onChangeOverrideMetadata={(o) =>
138+
onChangeOverrideMetadata({
139+
...overrideChainMetadata,
140+
[drilldownChain]: o,
141+
})
142+
}
143+
onClickBack={() => setDrilldownChain(undefined)}
144+
onRemoveChain={isLocalOverrideChain ? onRemoveChain : undefined}
145+
/>
146+
</Suspense>
134147
);
135148
}
136149

0 commit comments

Comments
 (0)