From 217229818f86fd369934ab6af1c6991791e20fd2 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Sat, 22 Aug 2026 22:03:13 +0200 Subject: [PATCH 1/2] fix(registry): refresh reviewed asset data Ship current non-IBC metadata from an exact reviewed gitlink and make schema, artifact, submodule, and image verification reproducible. Co-authored-by: Cursor --- .changeset/refresh-reviewed-registry-data.md | 7 + packages/registry/README.md | 27 ++- packages/registry/RUNBOOK.md | 128 ++++++++++++- packages/registry/community-assetlist | 2 +- packages/registry/package.json | 6 +- .../src/tokens/__tests__/index.spec.ts | 162 ++++++++++++---- packages/registry/src/tokens/filter.ts | 171 +++++++++++++++++ packages/registry/src/tokens/index.ts | 88 ++------- packages/registry/src/tokens/types.ts | 73 +++++++ scripts/build-registry.ts | 29 +-- scripts/check-registry-artifact.ts | 34 ++++ scripts/check-registry-images.ts | 80 ++++++++ scripts/check-registry-submodules.ts | 102 ++++++++++ scripts/registry-release.test.ts | 178 ++++++++++++++++++ 14 files changed, 950 insertions(+), 137 deletions(-) create mode 100644 .changeset/refresh-reviewed-registry-data.md create mode 100644 packages/registry/src/tokens/filter.ts create mode 100644 packages/registry/src/tokens/types.ts create mode 100644 scripts/check-registry-artifact.ts create mode 100644 scripts/check-registry-images.ts create mode 100644 scripts/check-registry-submodules.ts create mode 100644 scripts/registry-release.test.ts diff --git a/.changeset/refresh-reviewed-registry-data.md b/.changeset/refresh-reviewed-registry-data.md new file mode 100644 index 000000000..1280cd2b0 --- /dev/null +++ b/.changeset/refresh-reviewed-registry-data.md @@ -0,0 +1,7 @@ +--- +'@sei-js/registry': minor +--- + +Refresh the community asset metadata from a reviewed upstream pin, including current non-IBC mainnet assets, retained image metadata, and typed native/EVM `pointer_contract` details. + +Keep source and bundle filtering in sync, and add deterministic release checks for schema fidelity, generated data, submodule cleanliness, and retained image links. diff --git a/packages/registry/README.md b/packages/registry/README.md index 7a0966378..ac4b03524 100644 --- a/packages/registry/README.md +++ b/packages/registry/README.md @@ -1,5 +1,5 @@ # @sei-js/registry -This package contains TypeScript typed exports for the Sei registry repository as well as the community asset-list repository. +Typed Sei network, wallet, chain, and asset metadata. ## Installation ```bash @@ -11,5 +11,30 @@ bun add @sei-js/registry import { TOKEN_LIST, NETWORKS, WALLETS } from '@sei-js/registry' const sei = TOKEN_LIST['pacific-1'].find(asset => asset.base === 'usei') +const usdt = TOKEN_LIST['pacific-1'].find(asset => asset.symbol === 'USDT') +const nativePointer = usdt?.pointer_contract const keplr = WALLETS.find(wallet => wallet.identifier === 'keplr') ``` + +`Token.pointer_contract` is optional. When present, it contains a typed `address` and a `type_asset` of `cw20` or `erc20`, describing the contract that points to the asset in Sei's other runtime. `TOKEN_LIST` entries use the `RegistryToken` subtype, whose `type_asset` is required and restricted to values from the reviewed upstream schema. + +## Asset data policy +The community asset list is pinned to reviewed commit `964ca87f7cff8d8791ad1e994628fa410faae61e` from `Seitrace/sei-assetlist`, committed on 2026-02-01. The package does not follow the upstream `main` branch at build or release time. + +Only `pacific-1` and `atlantic-2` are exported. Assets are excluded when their base denomination or any denomination unit starts with `ibc/`, or when `type_asset` is `ics20`. The source module and package build use the same validator and filter implementation. + +At this pin: +- The upstream supported-network arrays contain 53 mainnet and 9 testnet entries. +- The package retains 46 mainnet and 7 testnet entries after filtering 7 mainnet and 2 testnet IBC/ICS-20 entries. +- The retained image metadata contains 48 unique HTTPS URLs. + +This is a community-maintained list. Asset inclusion is not an endorsement; verify contract addresses and metadata for your application. + +## Migration notes +The previous asset-list pin was `831406ba8cbe41f3f620c4e7a8ddca67405b5512`. The reviewed refresh is 186 commits ahead and increases the retained mainnet list from 19 to 46 assets while leaving the retained testnet count at 7. + +Newly available mainnet metadata includes WSEI, native USDC, USDT0, Stargate WETH, fastUSD/sfastUSD, Frax assets, Fiamma BTC, and USDY. Existing non-IBC native and EVM assets remain available. IBC assets such as USDC.n, USDC.axl, and kavaUSDT remain intentionally absent from `TOKEN_LIST`. + +Pointer metadata is now exposed for assets that provide `pointer_contract` upstream. Consumers should treat it as optional and continue identifying an asset by its network and base denomination. Consumers that model exported list entries can use `RegistryToken` for schema-validated `type_asset` values while the general `Token` wrapper remains compatible. Non-fungible assets can have an empty `denom_units` array; do not assume every retained entry has two fungible denomination units. + +The reviewed upstream pin also restores the USDY SVG metadata and other retained image links. See [RUNBOOK.md](./RUNBOOK.md) for repinning, schema, artifact, package, and live image-link verification. diff --git a/packages/registry/RUNBOOK.md b/packages/registry/RUNBOOK.md index 62c2779ca..91ae02b2a 100644 --- a/packages/registry/RUNBOOK.md +++ b/packages/registry/RUNBOOK.md @@ -1,2 +1,126 @@ -### Init submodules -`git submodule update --init --recursive` +# Registry release runbook + +Run commands from the repository root unless a step says otherwise. + +## Initialize and inspect submodules +```bash +git submodule update --init --recursive +bun run --cwd packages/registry check:submodules +``` + +The current reviewed community asset-list values are: +- Remote: `https://github.com/Seitrace/sei-assetlist.git` +- Commit: `964ca87f7cff8d8791ad1e994628fa410faae61e` +- Author and commit date: VuTran1902, 2026-02-01T10:49:02+07:00 +- Relationship to the previous `831406ba8cbe41f3f620c4e7a8ddca67405b5512` pin: descendant, 186 commits ahead + +`check:submodules` parses each stage-0 gitlink OID from the index and requires the initialized checkout to be at that exact OID with a clean worktree and the expected configured/origin remote. The community gitlink and checkout must also equal the reviewed revision above. A release must not bundle an unstaged gitlink update or local submodule edits. + +The registry package `test` script runs this live repository check before unit tests. The root `test` script invokes every package test, so both the Checks and Release workflows enforce it; both workflows must continue using `actions/checkout` with `submodules: recursive`. + +## Review and repin the community asset list +Never update the gitlink by blindly checking out a moving branch. Review a candidate commit and then check out its full SHA. + +```bash +assetlist=packages/registry/community-assetlist +previous=$(git -C "$assetlist" rev-parse HEAD) + +git config --file .gitmodules --get submodule.packages/registry/community-assetlist.url +git -C "$assetlist" remote -v +git ls-remote --symref https://github.com/Seitrace/sei-assetlist.git HEAD refs/heads/main +git -C "$assetlist" fetch origin main + +candidate= +git -C "$assetlist" show -s --format='%H%n%aI%n%cI%n%an <%ae>%n%s' "$candidate" +git -C "$assetlist" merge-base --is-ancestor "$previous" "$candidate" +git -C "$assetlist" rev-list --count "$previous..$candidate" +git -C "$assetlist" log --reverse --format='%h %cs %s' "$previous..$candidate" +git -C "$assetlist" diff --stat "$previous..$candidate" +git -C "$assetlist" diff "$previous..$candidate" -- assetlist.json schema/assetlist.json +``` + +Review all supported-network additions, removals, contract addresses, `type_asset` values, `pointer_contract` objects, denomination units, and image URL changes. Record the candidate's exact SHA and commit date in the release change. Only after review: + +```bash +git -C "$assetlist" checkout --detach "$candidate" +``` + +At this point the checkout has moved but the index gitlink has not. `check:submodules` must fail with both OIDs until the reviewed gitlink is staged. For the current remediation, the expected pre-stage mismatch is recorded `831406ba8cbe41f3f620c4e7a8ddca67405b5512` versus checkout/reviewed `964ca87f7cff8d8791ad1e994628fa410faae61e`. + +After review, stage only the intended gitlink and verify the exact recorded value before running release checks: + +```bash +git add packages/registry/community-assetlist +git ls-files --stage -- packages/registry/community-assetlist packages/registry/chain-registry +git diff --cached --submodule=short -- packages/registry/community-assetlist +bun run --cwd packages/registry check:submodules +``` + +The community index entry must be mode `160000`, stage `0`, and OID `964ca87f7cff8d8791ad1e994628fa410faae61e`. The chain-registry index OID and checkout must remain `855440d90df49246498d0870c6be5de5af56dada`. Update `REVIEWED_ASSETLIST_REVISION`, deterministic counts and canonical fixtures, this runbook, the README migration notes, and the changeset in the same change. + +## Validate schema and filtering +The package validator checks every upstream network entry for the public token shape, allowed asset types, denomination field types, image metadata, and pointer metadata. The deterministic tests then verify supported networks, source and retained counts, canonical assets, IBC/ICS-20 removal, and source/runtime parity. + +```bash +bun run --cwd packages/registry test +bun run typecheck +``` + +Also review the upstream Draft-07 schema itself. At the current pin, that schema requires exactly two denomination units for every token, but upstream's `ForU AI Genesis` ERC-721 intentionally has an empty `denom_units` array. The package retains this non-IBC asset and validates its fields while allowing the non-fungible exception. Do not rewrite pinned upstream metadata during packaging, and do not generalize this exception to malformed fungible assets without a separate review. + +The source and esbuild plugin share `filterTokenList`; both reject malformed fields and remove an asset if: +- `base` starts with `ibc/`, case-insensitively; +- any denomination-unit `denom` starts with `ibc/`; or +- `type_asset` is `ics20`, case-insensitively. + +The current reviewed source has 53 `pacific-1` and 9 `atlantic-2` entries. The package must retain 46 and 7 respectively, filtering 7 and 2 IBC/ICS-20 entries. No other network key may appear at runtime. + +## Build and compare the generated artifact +```bash +bun run --cwd packages/registry build +bun run --cwd packages/registry check:artifact +bun test --isolate scripts/registry-release.test.ts +``` + +`check:artifact` imports the generated ESM bundle and deep-compares `TOKEN_LIST` with a fresh validation and filtering pass over the pinned submodule source. + +The release-script tests use simulated git command results for exact gitlinks, wrong recorded OIDs, mismatched checkouts, dirty worktrees, missing/uninitialized worktrees, and remote mismatches. They do not require the current worktree gitlink to be staged: + +```bash +bun test --isolate scripts/registry-release.test.ts +``` + +## Verify retained image links +Ordinary unit tests validate URL shape and deterministic URL collection without contacting external hosts. Run the live check explicitly for every release: + +```bash +bun run --cwd packages/registry check:images +``` + +The checker probes every unique retained PNG/SVG URL with bounded concurrency and a timeout. It reports each HTTP status or network failure and exits nonzero if any URL fails. Record the number of URLs checked and the result in the release evidence. The current reviewed pin has 48 unique retained image URLs. + +Remediation verification on 2026-08-22: all 48 retained image URLs returned successful HTTP responses. + +## Verify package quality +```bash +bun run --cwd packages/registry build +(cd packages/registry && bun run publint --pack npm --strict --level warning) +(cd packages/registry && bun run attw --pack . --profile esm-only) +node --input-type=module -e "import('./packages/registry/dist/index.js').then(({ TOKEN_LIST }) => console.log(Object.fromEntries(Object.entries(TOKEN_LIST).map(([network, assets]) => [network, assets.length]))))" +(cd packages/registry && npm pack --dry-run --json) +``` + +The dry run should contain only package metadata and `dist`; it must not contain either submodule, source data, tests, or operational scripts. `package.json` must not add `prepare`, `prepack`, `postinstall`, or other install/submodule lifecycle hooks. + +## Full repository verification +```bash +bun run check +bun run build +bun run test +bun run --cwd packages/registry check:submodules +git -C packages/registry/community-assetlist status --short +git -C packages/registry/chain-registry status --short +git status --short +``` + +Before release, confirm the only registry changes are the reviewed gitlink, intended source/tests/docs/scripts, and changeset. Re-run the live image check close to publication because external URL health can change independently of the pinned JSON. diff --git a/packages/registry/community-assetlist b/packages/registry/community-assetlist index 831406ba8..964ca87f7 160000 --- a/packages/registry/community-assetlist +++ b/packages/registry/community-assetlist @@ -1 +1 @@ -Subproject commit 831406ba8cbe41f3f620c4e7a8ddca67405b5512 +Subproject commit 964ca87f7cff8d8791ad1e994628fa410faae61e diff --git a/packages/registry/package.json b/packages/registry/package.json index 4df0b310d..17c09e884 100644 --- a/packages/registry/package.json +++ b/packages/registry/package.json @@ -14,7 +14,11 @@ "build": "rm -rf dist && bun run build:types && bun run build:esm", "build:types": "tsc --project ./tsconfig.json && printf \"export * from './src/index.js';\\n\" > dist/index.d.ts && bun ../../scripts/fix-dts-extensions.ts dist", "build:esm": "bun ../../scripts/build-registry.ts", - "test": "bun test --isolate src" + "check:artifact": "bun ../../scripts/check-registry-artifact.ts", + "check:images": "bun ../../scripts/check-registry-images.ts", + "check:submodules": "bun ../../scripts/check-registry-submodules.ts", + "test": "bun run check:submodules && bun test --isolate src", + "verify:release-data": "bun run check:submodules && bun run build && bun run test && bun run check:artifact && bun run check:images" }, "exports": { ".": { diff --git a/packages/registry/src/tokens/__tests__/index.spec.ts b/packages/registry/src/tokens/__tests__/index.spec.ts index 2e1f9bafe..5632d7036 100644 --- a/packages/registry/src/tokens/__tests__/index.spec.ts +++ b/packages/registry/src/tokens/__tests__/index.spec.ts @@ -1,58 +1,144 @@ -import type { Network } from '../../index'; -import { type DenomUnit, TOKEN_LIST } from '../index'; - -describe('AssetList Tests', () => { - it('should have the correct structure for each network', () => { - const networks: Network[] = ['pacific-1', 'atlantic-2']; - expect(Object.keys(TOKEN_LIST).sort()).toEqual([...networks].sort()); - - for (const network of networks) { - expect(Array.isArray(TOKEN_LIST[network])).toBeTruthy(); - for (const asset of TOKEN_LIST[network]) { - expect(asset).toHaveProperty('name'); - expect(asset).toHaveProperty('description'); - expect(asset).toHaveProperty('symbol'); - expect(asset).toHaveProperty('base'); - expect(asset).toHaveProperty('display'); - expect(asset).toHaveProperty('denom_units'); - expect(Array.isArray(asset.denom_units)).toBeTruthy(); +import AssetListJSON from '../../../community-assetlist/assetlist.json'; +import { CHAIN_IDS, type Network } from '../../supported-networks'; +import { filterTokenList, isIbcAsset, parseTokenList } from '../filter'; +import { ASSET_TYPES, type DenomUnit, POINTER_CONTRACT_TYPES, TOKEN_LIST } from '../index'; + +const EXPECTED_SOURCE_COUNTS: Record = { + [CHAIN_IDS.mainnet]: 53, + [CHAIN_IDS.testnet]: 9 +}; + +const EXPECTED_RETAINED_COUNTS: Record = { + [CHAIN_IDS.mainnet]: 46, + [CHAIN_IDS.testnet]: 7 +}; + +const EXPECTED_CANONICAL_MAINNET_ASSETS = [ + { label: 'WSEI', symbol: 'WSEI', base: '0xE30feDd158A2e3b13e9badaeABaFc5516e95e8C7', type_asset: 'erc20' }, + { label: 'native USDC', symbol: 'USDC', base: '0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392', type_asset: 'erc20' }, + { label: 'USDT0', symbol: 'USDT', base: '0x9151434b16b9763660705744891fA906F660EcC5', type_asset: 'erc20' }, + { label: 'Stargate WETH', symbol: 'WETH', base: '0x160345fC359604fC6e70E3c5fAcbdE5F7A9342d8', type_asset: 'erc20' }, + { label: 'fastUSD', symbol: 'fastUSD', base: '0x37a4dD9CED2b19Cfe8FAC251cd727b5787E45269', type_asset: 'erc20' } +] as const; + +describe('community asset list', () => { + it('validates the reviewed upstream shape and pointer metadata', () => { + const parsed = parseTokenList(AssetListJSON); + let pointerContracts = 0; + + for (const [network, assets] of Object.entries(parsed)) { + for (const [index, asset] of assets.entries()) { + const path = `${network}[${index}]`; + expect(typeof asset.name, path).toBe('string'); + expect(typeof asset.description, path).toBe('string'); + expect(typeof asset.symbol, path).toBe('string'); + expect(typeof asset.base, path).toBe('string'); + expect(typeof asset.display, path).toBe('string'); + expect(Array.isArray(asset.denom_units), path).toBeTrue(); + expect(ASSET_TYPES).toContain(asset.type_asset); + for (const denomUnit of asset.denom_units) { - expect(denomUnit).toHaveProperty('denom'); - expect(denomUnit).toHaveProperty('exponent'); - expect(typeof denomUnit.denom).toBe('string'); - expect(typeof denomUnit.exponent).toBe('number'); + expect(typeof denomUnit.denom, path).toBe('string'); + expect(Number.isInteger(denomUnit.exponent), path).toBeTrue(); } - if (asset.images) { - if (asset.images.png) expect(typeof asset.images.png).toBe('string'); - if (asset.images.svg) expect(typeof asset.images.svg).toBe('string'); + + if (asset.images.png !== undefined) expect(typeof asset.images.png, path).toBe('string'); + if (asset.images.svg !== undefined) expect(typeof asset.images.svg, path).toBe('string'); + if (asset.coingecko_id !== undefined) expect(typeof asset.coingecko_id, path).toBe('string'); + + if (asset.pointer_contract !== undefined) { + pointerContracts += 1; + expect(typeof asset.pointer_contract.address, path).toBe('string'); + expect(POINTER_CONTRACT_TYPES).toContain(asset.pointer_contract.type_asset); } - if (asset.coingecko_id) expect(typeof asset.coingecko_id).toBe('string'); - if (asset.type_asset) expect(typeof asset.type_asset).toBe('string'); } } + + expect(pointerContracts).toBe(25); + expect( + Object.values(TOKEN_LIST) + .flat() + .filter(({ pointer_contract }) => pointer_contract !== undefined) + ).toHaveLength(18); }); - it('excludes IBC assets', () => { + it('does not apply the reviewed non-fungible denomination exception to fungible assets', () => { + const malformed = { + [CHAIN_IDS.mainnet]: [ + { + name: 'Malformed fungible token', + description: 'Fixture', + symbol: 'BAD', + base: 'ubad', + display: 'bad', + denom_units: [], + images: {}, + type_asset: 'sdk.coin' + } + ] + }; + + expect(() => parseTokenList(malformed)).toThrow('denom_units must contain two units'); + }); + + it('retains only supported networks with reviewed source and runtime counts', () => { + const supportedNetworks: Network[] = [CHAIN_IDS.mainnet, CHAIN_IDS.testnet]; + expect(Object.keys(TOKEN_LIST)).toEqual(supportedNetworks); + + for (const network of supportedNetworks) { + expect(AssetListJSON[network]).toHaveLength(EXPECTED_SOURCE_COUNTS[network]); + expect(TOKEN_LIST[network]).toHaveLength(EXPECTED_RETAINED_COUNTS[network]); + } + }); + + it('uses the same deterministic filter for source and runtime data', () => { + expect(TOKEN_LIST).toEqual(filterTokenList(AssetListJSON)); + }); + + it('excludes every IBC denomination and ICS-20 asset', () => { for (const assets of Object.values(TOKEN_LIST)) { for (const asset of assets) { - expect(asset.base.toLowerCase().startsWith('ibc/')).toBeFalse(); - expect(asset.denom_units.some(({ denom }) => denom.toLowerCase().startsWith('ibc/'))).toBeFalse(); - expect(asset.type_asset?.toLowerCase()).not.toBe('ics20'); + expect(isIbcAsset(asset), `${asset.symbol}: ${asset.base}`).toBeFalse(); } } }); + + it('retains reviewed canonical mainnet assets', () => { + for (const expected of EXPECTED_CANONICAL_MAINNET_ASSETS) { + const asset = TOKEN_LIST[CHAIN_IDS.mainnet].find(({ base }) => base === expected.base); + expect(asset, expected.label).toBeDefined(); + expect(asset?.symbol, expected.label).toBe(expected.symbol); + expect(asset?.type_asset, expected.label).toBe(expected.type_asset); + } + }); + + it('retains non-IBC NFT metadata even when denomination units do not apply', () => { + const nft = TOKEN_LIST[CHAIN_IDS.mainnet].find(({ base }) => base === '0x1F963C268e711d09f7A9173532665d9c4491120A'); + expect(nft?.symbol).toBe('foruaigenesis'); + expect(nft?.type_asset).toBe('erc721'); + expect(nft?.denom_units).toEqual([]); + }); + + it('retains the reviewed image metadata without malformed URLs', () => { + const urls = new Set( + Object.values(TOKEN_LIST).flatMap((assets) => assets.flatMap(({ images }) => [images.png, images.svg].filter((url): url is string => url !== undefined))) + ); + + expect(urls.size).toBe(48); + for (const url of urls) { + expect(url).toMatch(/^https:\/\/.+/); + } + }); }); -it('should contain the "sei" asset with correct properties in each network', () => { - for (const network of Object.keys(TOKEN_LIST)) { - const seiAsset = TOKEN_LIST[network as Network].find((asset) => asset.symbol === 'SEI'); +it('contains the native SEI asset on each supported network', () => { + for (const network of Object.keys(TOKEN_LIST) as Network[]) { + const seiAsset = TOKEN_LIST[network].find((asset) => asset.symbol === 'SEI'); expect(seiAsset).toBeDefined(); expect(seiAsset?.name).toBe('Sei'); expect(seiAsset?.description).toBe('The native token of Sei'); expect(seiAsset?.base).toBe('usei'); - expect(seiAsset?.denom_units.some((unit: DenomUnit) => unit.denom === 'sei' && unit.exponent === 6)).toBeTruthy(); - if (seiAsset?.images) { - expect(seiAsset.images.png).toMatch(/^https?:\/\/.+/); - } + expect(seiAsset?.denom_units.some((unit: DenomUnit) => unit.denom === 'sei' && unit.exponent === 6)).toBeTrue(); + expect(seiAsset?.images.png).toMatch(/^https?:\/\/.+/); } }); diff --git a/packages/registry/src/tokens/filter.ts b/packages/registry/src/tokens/filter.ts new file mode 100644 index 000000000..d6e0e272a --- /dev/null +++ b/packages/registry/src/tokens/filter.ts @@ -0,0 +1,171 @@ +import { CHAIN_IDS, type Network } from '../supported-networks'; +import { + ASSET_TYPES, + type AssetType, + POINTER_CONTRACT_TYPES, + type PointerContract, + type PointerContractType, + type RegistryToken, + type SeiTokenList, + type TokenImages +} from './types'; + +const ASSET_TYPE_SET: ReadonlySet = new Set(ASSET_TYPES); +const POINTER_CONTRACT_TYPE_SET: ReadonlySet = new Set(POINTER_CONTRACT_TYPES); +const NON_FUNGIBLE_ASSET_TYPES: ReadonlySet = new Set(['cw721', 'erc721', 'erc1155']); +const TOKEN_KEYS = new Set(['name', 'description', 'symbol', 'base', 'display', 'denom_units', 'images', 'coingecko_id', 'type_asset', 'pointer_contract']); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new TypeError(message); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertOnlyKeys(value: Record, allowedKeys: ReadonlySet, path: string): void { + for (const key of Object.keys(value)) { + assert(allowedKeys.has(key), `${path} contains unsupported property "${key}"`); + } +} + +function requiredString(value: Record, key: string, path: string): string { + const field = value[key]; + assert(typeof field === 'string', `${path}.${key} must be a string`); + return field; +} + +function isAssetType(value: string): value is AssetType { + return ASSET_TYPE_SET.has(value); +} + +function isPointerContractType(value: string): value is PointerContractType { + return POINTER_CONTRACT_TYPE_SET.has(value); +} + +function parseImages(value: unknown, path: string): TokenImages { + assert(isRecord(value), `${path} must be an object`); + assertOnlyKeys(value, new Set(['png', 'svg']), path); + const images: TokenImages = {}; + + if (value.png !== undefined) { + assert(typeof value.png === 'string', `${path}.png must be a string`); + images.png = value.png; + } + if (value.svg !== undefined) { + assert(typeof value.svg === 'string', `${path}.svg must be a string`); + images.svg = value.svg; + } + + return images; +} + +function parsePointerContract(value: unknown, path: string): PointerContract { + assert(isRecord(value), `${path} must be an object`); + assertOnlyKeys(value, new Set(['address', 'type_asset']), path); + assert(typeof value.address === 'string', `${path}.address must be a string`); + assert(typeof value.type_asset === 'string' && isPointerContractType(value.type_asset), `${path}.type_asset is unsupported`); + + return { + address: value.address, + type_asset: value.type_asset + }; +} + +function parseToken(value: unknown, path: string): RegistryToken { + assert(isRecord(value), `${path} must be an object`); + assertOnlyKeys(value, TOKEN_KEYS, path); + + const name = requiredString(value, 'name', path); + const description = requiredString(value, 'description', path); + const symbol = requiredString(value, 'symbol', path); + const base = requiredString(value, 'base', path); + const display = requiredString(value, 'display', path); + const typeAsset = value.type_asset; + assert(typeof typeAsset === 'string' && isAssetType(typeAsset), `${path}.type_asset is unsupported`); + + assert(Array.isArray(value.denom_units), `${path}.denom_units must be an array`); + assert( + value.denom_units.length === 2 || (value.denom_units.length === 0 && NON_FUNGIBLE_ASSET_TYPES.has(typeAsset)), + `${path}.denom_units must contain two units, or be empty for a non-fungible asset` + ); + const denomUnits = value.denom_units.map((unit, index) => { + const unitPath = `${path}.denom_units[${index}]`; + assert(isRecord(unit), `${unitPath} must be an object`); + assertOnlyKeys(unit, new Set(['denom', 'exponent']), unitPath); + assert(typeof unit.denom === 'string', `${unitPath}.denom must be a string`); + assert(Number.isInteger(unit.exponent) && Number(unit.exponent) >= 0, `${unitPath}.exponent must be a non-negative integer`); + if (index === 0) { + assert(unit.exponent === 0, `${unitPath}.exponent must be 0 for the base denomination`); + } + + return { + denom: unit.denom, + exponent: Number(unit.exponent) + }; + }); + + const images = parseImages(value.images, `${path}.images`); + + if (value.coingecko_id !== undefined) { + assert(typeof value.coingecko_id === 'string', `${path}.coingecko_id must be a string`); + } + + return { + name, + description, + symbol, + base, + display, + denom_units: denomUnits, + images, + type_asset: typeAsset, + ...(value.coingecko_id === undefined ? {} : { coingecko_id: value.coingecko_id }), + ...(value.pointer_contract === undefined ? {} : { pointer_contract: parsePointerContract(value.pointer_contract, `${path}.pointer_contract`) }) + }; +} + +/** + * Validate the checked-in community asset list against the public token shape. + */ +export function parseTokenList(source: unknown, sourceName = 'asset list'): Record { + assert(isRecord(source), `${sourceName} must be an object`); + + return Object.fromEntries( + Object.entries(source).map(([network, assets]) => { + assert(Array.isArray(assets), `${sourceName}.${network} must be an array`); + return [network, assets.map((asset, index) => parseToken(asset, `${sourceName}.${network}[${index}]`))]; + }) + ); +} + +export function isIbcDenomination(denomination: string): boolean { + return denomination.toLowerCase().startsWith('ibc/'); +} + +export function isIbcAsset(asset: Pick): boolean { + return isIbcDenomination(asset.base) || asset.denom_units.some(({ denom }) => isIbcDenomination(denom)) || asset.type_asset.toLowerCase() === 'ics20'; +} + +function retainedAssets(source: Record, network: Network, sourceName: string): RegistryToken[] { + const assets = source[network]; + assert(assets !== undefined, `${sourceName} is missing supported network ${network}`); + return assets.filter((asset) => !isIbcAsset(asset)); +} + +/** + * Validate, select supported networks, and remove IBC/ICS-20 assets. + * + * This function is shared by the source module and the package bundler so the + * runtime and generated-artifact policies cannot drift. + */ +export function filterTokenList(source: unknown, sourceName = 'asset list'): SeiTokenList { + const parsed = parseTokenList(source, sourceName); + + return { + [CHAIN_IDS.mainnet]: retainedAssets(parsed, CHAIN_IDS.mainnet, sourceName), + [CHAIN_IDS.testnet]: retainedAssets(parsed, CHAIN_IDS.testnet, sourceName) + }; +} diff --git a/packages/registry/src/tokens/index.ts b/packages/registry/src/tokens/index.ts index a5d324574..5e1eb4d20 100644 --- a/packages/registry/src/tokens/index.ts +++ b/packages/registry/src/tokens/index.ts @@ -1,72 +1,18 @@ import TokenListJSON from '../../community-assetlist/assetlist.json'; -import { type Network, pickSupportedNetworks } from '../supported-networks'; - -interface AssetMetadata { - base: string; - denom_units: readonly { - denom: string; - }[]; - type_asset?: string; -} - -const isIbcDenomination = (denomination: string): boolean => denomination.toLowerCase().startsWith('ibc/'); - -const isIbcAsset = (asset: AssetMetadata): boolean => - isIbcDenomination(asset.base) || asset.denom_units.some(({ denom }) => isIbcDenomination(denom)) || asset.type_asset?.toLowerCase() === 'ics20'; - -/** - * DenomUnit represents a struct that describes a given - * denomination unit of the basic token. - */ -export interface DenomUnit { - /** denom represents the string name of the given denom unit (e.g. usei). */ - denom: string; - /** - * exponent represents power of 10 exponent that one must - * raise the base_denom to in order to equal the given DenomUnit's denom - * 1 denom = 10^exponent base_denom - * (e.g. with a base_denom of usei, one can create a DenomUnit of 'sei' with - * exponent = 6, thus: 1 sei = 10^6 usei). - */ - exponent: number; - /** aliases is an optional list of string aliases for the given denom */ - aliases?: string[]; -} - -/** - * Defines the structure for a Sei token. - */ -export interface Token { - /** The name of the token. */ - name: string; - /** A description of the token. */ - description: string; - /** The symbol representing the token. */ - symbol: string; - /** The base denomination of the token. */ - base: string; - /** The display denomination of the token for user interfaces. */ - display: string; - /** An array of denomination units for the token. */ - denom_units: DenomUnit[]; - /** URLs to images representing the token, in PNG and SVG formats (optional). */ - images: { - png?: string; - svg?: string; - }; - /** An optional identifier for the token on the CoinGecko platform. */ - coingecko_id?: string; - /** The type of the token, if applicable (e.g., "cw20" for CosmWasm tokens). */ - type_asset?: string; -} - -/** - * A mapping of all supported Sei network names to their respective arrays of `Token` objects. - */ -type SeiTokens = { - /** Each network name is associated with an array of `Token` objects. */ - [network in Network]: Token[]; -}; +import { filterTokenList } from './filter'; +import type { SeiTokenList } from './types'; + +export type { + AssetType, + DenomUnit, + PointerContract, + PointerContractType, + RegistryToken, + SeiTokenList, + Token, + TokenImages +} from './types'; +export { ASSET_TYPES, POINTER_CONTRACT_TYPES } from './types'; /** * A constant that maps each Sei network to its respective tokens, imported from the community-run [asset list](https://github.com/Seitrace/sei-assetlist). @@ -82,8 +28,4 @@ type SeiTokens = { * const uSei = TOKEN_LIST['pacific-1'].find((asset) => asset.symbol === 'SEI'); * ``` */ -const supportedTokenList = pickSupportedNetworks(TokenListJSON); - -export const TOKEN_LIST: SeiTokens = Object.fromEntries( - Object.entries(supportedTokenList).map(([network, assets]) => [network, assets.filter((asset) => !isIbcAsset(asset))]) -) as unknown as SeiTokens; +export const TOKEN_LIST: SeiTokenList = filterTokenList(TokenListJSON, 'community-assetlist/assetlist.json'); diff --git a/packages/registry/src/tokens/types.ts b/packages/registry/src/tokens/types.ts new file mode 100644 index 000000000..479a6edd3 --- /dev/null +++ b/packages/registry/src/tokens/types.ts @@ -0,0 +1,73 @@ +import type { Network } from '../supported-networks'; + +/** + * Asset kinds supported by the reviewed community asset-list schema. + */ +export const ASSET_TYPES = ['sdk.coin', 'ics20', 'cw20', 'cw721', 'erc20', 'erc721', 'erc1155', 'erc404'] as const; + +export type AssetType = (typeof ASSET_TYPES)[number]; + +/** + * Contract kinds supported for pointers between Sei's native and EVM runtimes. + */ +export const POINTER_CONTRACT_TYPES = ['cw20', 'erc20'] as const; + +export type PointerContractType = (typeof POINTER_CONTRACT_TYPES)[number]; + +/** + * DenomUnit describes one denomination of an asset. + */ +export interface DenomUnit { + /** The string name of the denomination (for example, `usei`). */ + denom: string; + /** + * The power-of-ten relationship to the base denomination. For example, + * `sei` has exponent 6 because 1 SEI equals 10^6 usei. + */ + exponent: number; + /** Optional aliases retained for compatibility with registry consumers. */ + aliases?: string[]; +} + +export interface TokenImages { + png?: string; + svg?: string; +} + +export interface PointerContract { + /** Address of the contract pointing to this asset in the other runtime. */ + address: string; + /** Runtime contract standard used by the pointer. */ + type_asset: PointerContractType; +} + +/** + * Metadata for one asset in the reviewed Sei community asset list. + */ +export interface Token { + name: string; + description: string; + symbol: string; + base: string; + display: string; + denom_units: DenomUnit[]; + images: TokenImages; + coingecko_id?: string; + type_asset?: string; + /** Optional native/EVM pointer metadata supplied by the upstream registry. */ + pointer_contract?: PointerContract; +} + +/** + * A token validated from the current upstream schema. + */ +export interface RegistryToken extends Token { + type_asset: AssetType; +} + +/** + * Assets retained for each network supported by this package. + */ +export type SeiTokenList = { + [network in Network]: RegistryToken[]; +}; diff --git a/scripts/build-registry.ts b/scripts/build-registry.ts index 21117200b..c6f5f013c 100644 --- a/scripts/build-registry.ts +++ b/scripts/build-registry.ts @@ -3,19 +3,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { build, type Plugin } from 'esbuild'; import { CHAIN_IDS } from '../packages/registry/src/supported-networks'; - -interface RegistryAssetMetadata { - base: string; - denom_units: readonly { - denom: string; - }[]; - type_asset?: string; -} - -const isIbcDenomination = (denomination: string): boolean => denomination.toLowerCase().startsWith('ibc/'); - -const isIbcAsset = (asset: RegistryAssetMetadata): boolean => - isIbcDenomination(asset.base) || asset.denom_units.some(({ denom }) => isIbcDenomination(denom)) || asset.type_asset?.toLowerCase() === 'ics20'; +import { filterTokenList } from '../packages/registry/src/tokens/filter'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const packageDir = join(root, 'packages/registry'); @@ -32,6 +20,13 @@ const filterNetworkData: Plugin = { } const source = JSON.parse(await readFile(path, 'utf8')) as Record; + if (path === assetListFile) { + return { + contents: JSON.stringify(filterTokenList(source, path)), + loader: 'json' + }; + } + const filtered = Object.fromEntries( supportedNetworks.map((network) => { const value = source[network]; @@ -39,14 +34,6 @@ const filterNetworkData: Plugin = { throw new Error(`${path} is missing supported network ${network}`); } - if (path === assetListFile) { - if (!Array.isArray(value)) { - throw new Error(`${path} must contain an asset array for supported network ${network}`); - } - - return [network, value.filter((asset) => !isIbcAsset(asset as RegistryAssetMetadata))]; - } - return [network, value]; }) ); diff --git a/scripts/check-registry-artifact.ts b/scripts/check-registry-artifact.ts new file mode 100644 index 000000000..97cdd6287 --- /dev/null +++ b/scripts/check-registry-artifact.ts @@ -0,0 +1,34 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; +import AssetListJSON from '../packages/registry/community-assetlist/assetlist.json'; +import { filterTokenList } from '../packages/registry/src/tokens/filter'; +import type { SeiTokenList } from '../packages/registry/src/tokens/types'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const artifactPath = join(root, 'packages/registry/dist/index.js'); + +export async function checkRegistryArtifact(): Promise { + if (!(await Bun.file(artifactPath).exists())) { + throw new Error(`Registry artifact is missing: ${artifactPath}. Run the registry build first.`); + } + + const artifact = (await import(`${pathToFileURL(artifactPath).href}?registry-artifact-check=${Date.now()}`)) as { + TOKEN_LIST?: unknown; + }; + const expected = filterTokenList(AssetListJSON, 'community-assetlist/assetlist.json'); + + if (!isDeepStrictEqual(artifact.TOKEN_LIST, expected)) { + throw new Error('Generated registry TOKEN_LIST does not match the filtered community asset-list source.'); + } + + return expected; +} + +if (import.meta.main) { + const tokenList = await checkRegistryArtifact(); + const counts = Object.entries(tokenList) + .map(([network, assets]) => `${network}=${assets.length}`) + .join(', '); + console.log(`Verified generated registry artifact parity (${counts}).`); +} diff --git a/scripts/check-registry-images.ts b/scripts/check-registry-images.ts new file mode 100644 index 000000000..cdd80136f --- /dev/null +++ b/scripts/check-registry-images.ts @@ -0,0 +1,80 @@ +import AssetListJSON from '../packages/registry/community-assetlist/assetlist.json'; +import { filterTokenList } from '../packages/registry/src/tokens/filter'; + +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_CONCURRENCY = 8; + +export function retainedRegistryImageUrls(): string[] { + const tokenList = filterTokenList(AssetListJSON, 'community-assetlist/assetlist.json'); + + return [ + ...new Set( + Object.values(tokenList).flatMap((assets) => assets.flatMap(({ images }) => [images.png, images.svg].filter((url): url is string => url !== undefined))) + ) + ].sort(); +} + +async function requestImage(url: string, method: 'HEAD' | 'GET', timeoutMs: number): Promise { + return fetch(url, { + method, + redirect: 'follow', + headers: { + 'user-agent': 'sei-js-registry-image-check', + ...(method === 'GET' ? { range: 'bytes=0-0' } : {}) + }, + signal: AbortSignal.timeout(timeoutMs) + }); +} + +async function checkImage(url: string, timeoutMs: number): Promise { + try { + let response = await requestImage(url, 'HEAD', timeoutMs); + if (response.status === 405 || response.status === 501) { + response = await requestImage(url, 'GET', timeoutMs); + await response.body?.cancel(); + } + + if (!response.ok) { + return `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ${url}`; + } + } catch (error) { + return `${error instanceof Error ? error.message : String(error)}: ${url}`; + } + + return undefined; +} + +export async function checkRegistryImages(options: { timeoutMs?: number; concurrency?: number } = {}): Promise { + const urls = retainedRegistryImageUrls(); + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, urls.length)); + const failures: string[] = []; + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < urls.length) { + const url = urls[nextIndex]; + nextIndex += 1; + const failure = await checkImage(url, timeoutMs); + if (failure !== undefined) failures.push(failure); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + if (failures.length > 0) { + throw new Error( + `Registry image link check failed for ${failures.length} of ${urls.length} URLs:\n${failures + .sort() + .map((failure) => `- ${failure}`) + .join('\n')}` + ); + } + + return urls.length; +} + +if (import.meta.main) { + const count = await checkRegistryImages(); + console.log(`Verified ${count} retained registry image URLs.`); +} diff --git a/scripts/check-registry-submodules.ts b/scripts/check-registry-submodules.ts new file mode 100644 index 000000000..c69defb15 --- /dev/null +++ b/scripts/check-registry-submodules.ts @@ -0,0 +1,102 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const REVIEWED_ASSETLIST_REVISION = '964ca87f7cff8d8791ad1e994628fa410faae61e'; +export const ASSETLIST_REMOTE = 'https://github.com/Seitrace/sei-assetlist.git'; +export const CHAIN_REGISTRY_REMOTE = 'https://github.com/sei-protocol/chain-registry.git'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const decoder = new TextDecoder(); + +const registrySubmodules = [ + { + path: 'packages/registry/community-assetlist', + configName: 'packages/registry/community-assetlist', + expectedRemote: ASSETLIST_REMOTE, + expectedRevision: REVIEWED_ASSETLIST_REVISION + }, + { + path: 'packages/registry/chain-registry', + configName: 'packages/registry/chain-registry', + expectedRemote: CHAIN_REGISTRY_REMOTE + } +] as const; + +export interface RegistrySubmoduleEnvironment { + git(args: readonly string[]): string; + isInitialized(path: string): boolean; +} + +function git(args: readonly string[]): string { + const result = Bun.spawnSync(['git', ...args], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe' + }); + const stdout = decoder.decode(result.stdout).trim(); + const stderr = decoder.decode(result.stderr).trim(); + + if (result.exitCode !== 0) { + throw new Error(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`); + } + + return stdout; +} + +const defaultEnvironment: RegistrySubmoduleEnvironment = { + git, + isInitialized: (path) => existsSync(join(root, path, '.git')) +}; + +export function parseRecordedGitlink(indexEntry: string, path: string): string { + const lines = indexEntry.split('\n').filter((line) => line !== ''); + if (lines.length !== 1) { + throw new Error(`${path} must have exactly one index entry`); + } + + const match = /^160000 ([0-9a-f]+) 0\t(.+)$/.exec(lines[0]); + if (match === null || match[2] !== path) { + throw new Error(`${path} is not recorded as a stage-0 gitlink`); + } + + return match[1]; +} + +export function checkRegistrySubmodules(environment: RegistrySubmoduleEnvironment = defaultEnvironment): void { + for (const submodule of registrySubmodules) { + const configuredRemote = environment.git(['config', '--file', '.gitmodules', '--get', `submodule.${submodule.configName}.url`]); + if (configuredRemote !== submodule.expectedRemote) { + throw new Error(`${submodule.path} has unexpected configured remote: ${configuredRemote || '(missing)'}`); + } + + const recordedRevision = parseRecordedGitlink(environment.git(['ls-files', '--stage', '--', submodule.path]), submodule.path); + if ('expectedRevision' in submodule && recordedRevision !== submodule.expectedRevision) { + throw new Error(`${submodule.path} recorded gitlink ${recordedRevision} does not match reviewed revision ${submodule.expectedRevision}`); + } + + if (!environment.isInitialized(submodule.path)) { + throw new Error(`${submodule.path} is missing or uninitialized; run git submodule update --init --recursive`); + } + + const checkoutRevision = environment.git(['-C', submodule.path, 'rev-parse', 'HEAD']); + if (checkoutRevision !== recordedRevision) { + throw new Error(`${submodule.path} checkout ${checkoutRevision} does not match recorded gitlink ${recordedRevision}`); + } + + const status = environment.git(['-C', submodule.path, 'status', '--porcelain', '--untracked-files=all']); + if (status !== '') { + throw new Error(`${submodule.path} has local changes:\n${status}`); + } + + const checkoutRemote = environment.git(['-C', submodule.path, 'remote', 'get-url', 'origin']); + if (checkoutRemote !== submodule.expectedRemote) { + throw new Error(`${submodule.path} checkout has unexpected origin: ${checkoutRemote}`); + } + } +} + +if (import.meta.main) { + checkRegistrySubmodules(); + console.log(`Verified clean registry submodules and reviewed asset-list ${REVIEWED_ASSETLIST_REVISION}.`); +} diff --git a/scripts/registry-release.test.ts b/scripts/registry-release.test.ts new file mode 100644 index 000000000..54271946a --- /dev/null +++ b/scripts/registry-release.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from 'bun:test'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkRegistryArtifact } from './check-registry-artifact'; +import { retainedRegistryImageUrls } from './check-registry-images'; +import { + ASSETLIST_REMOTE, + CHAIN_REGISTRY_REMOTE, + checkRegistrySubmodules, + parseRecordedGitlink, + REVIEWED_ASSETLIST_REVISION, + type RegistrySubmoduleEnvironment +} from './check-registry-submodules'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const decoder = new TextDecoder(); +const COMMUNITY_PATH = 'packages/registry/community-assetlist'; +const CHAIN_PATH = 'packages/registry/chain-registry'; +const CHAIN_REVISION = '855440d90df49246498d0870c6be5de5af56dada'; + +interface FixtureState { + recorded: Record; + checkout: Record; + initialized: Record; + status: Record; + configuredRemote: Record; + checkoutRemote: Record; + indexEntry: Record; +} + +function createSubmoduleFixture(overrides: { [key in keyof FixtureState]?: Partial } = {}): { + environment: RegistrySubmoduleEnvironment; + nestedGitPaths: string[]; +} { + const recorded = { + [COMMUNITY_PATH]: REVIEWED_ASSETLIST_REVISION, + [CHAIN_PATH]: CHAIN_REVISION, + ...overrides.recorded + }; + const state: FixtureState = { + recorded, + checkout: { + [COMMUNITY_PATH]: recorded[COMMUNITY_PATH], + [CHAIN_PATH]: recorded[CHAIN_PATH], + ...overrides.checkout + }, + initialized: { + [COMMUNITY_PATH]: true, + [CHAIN_PATH]: true, + ...overrides.initialized + }, + status: { + [COMMUNITY_PATH]: '', + [CHAIN_PATH]: '', + ...overrides.status + }, + configuredRemote: { + [COMMUNITY_PATH]: ASSETLIST_REMOTE, + [CHAIN_PATH]: CHAIN_REGISTRY_REMOTE, + ...overrides.configuredRemote + }, + checkoutRemote: { + [COMMUNITY_PATH]: ASSETLIST_REMOTE, + [CHAIN_PATH]: CHAIN_REGISTRY_REMOTE, + ...overrides.checkoutRemote + }, + indexEntry: { + [COMMUNITY_PATH]: `160000 ${recorded[COMMUNITY_PATH]} 0\t${COMMUNITY_PATH}`, + [CHAIN_PATH]: `160000 ${recorded[CHAIN_PATH]} 0\t${CHAIN_PATH}`, + ...overrides.indexEntry + } + }; + const nestedGitPaths: string[] = []; + + return { + nestedGitPaths, + environment: { + isInitialized: (path) => state.initialized[path] ?? false, + git: (args) => { + if (args[0] === 'config') { + const key = args[4] ?? ''; + const path = key.slice('submodule.'.length, -'.url'.length); + return state.configuredRemote[path] ?? ''; + } + if (args[0] === 'ls-files') { + const path = args[3] ?? ''; + return state.indexEntry[path] ?? ''; + } + if (args[0] === '-C') { + const path = args[1] ?? ''; + nestedGitPaths.push(path); + if (args[2] === 'rev-parse') return state.checkout[path] ?? ''; + if (args[2] === 'status') return state.status[path] ?? ''; + if (args[2] === 'remote') return state.checkoutRemote[path] ?? ''; + } + + throw new Error(`Unexpected fixture git command: ${args.join(' ')}`); + } + } + }; +} + +describe('registry release data', () => { + test('accepts exact reviewed gitlinks, clean checkouts, and configured remotes', () => { + const { environment } = createSubmoduleFixture(); + expect(REVIEWED_ASSETLIST_REVISION).toBe('964ca87f7cff8d8791ad1e994628fa410faae61e'); + expect(() => checkRegistrySubmodules(environment)).not.toThrow(); + }); + + test('requires a stage-0 gitlink index entry', () => { + expect(() => parseRecordedGitlink(`100644 ${REVIEWED_ASSETLIST_REVISION} 0\t${COMMUNITY_PATH}`, COMMUNITY_PATH)).toThrow( + 'is not recorded as a stage-0 gitlink' + ); + }); + + test('rejects a community gitlink that is not the reviewed revision', () => { + const { environment } = createSubmoduleFixture({ + recorded: { [COMMUNITY_PATH]: '831406ba8cbe41f3f620c4e7a8ddca67405b5512' } + }); + expect(() => checkRegistrySubmodules(environment)).toThrow('does not match reviewed revision'); + }); + + test('rejects a checkout that differs from its recorded gitlink', () => { + const { environment } = createSubmoduleFixture({ + checkout: { [CHAIN_PATH]: '1111111111111111111111111111111111111111' } + }); + expect(() => checkRegistrySubmodules(environment)).toThrow(`${CHAIN_PATH} checkout`); + }); + + test('rejects dirty submodule worktrees', () => { + const { environment } = createSubmoduleFixture({ + status: { [CHAIN_PATH]: ' M chains.json' } + }); + expect(() => checkRegistrySubmodules(environment)).toThrow(`${CHAIN_PATH} has local changes`); + }); + + test('rejects missing or uninitialized worktrees before nested git commands', () => { + const { environment, nestedGitPaths } = createSubmoduleFixture({ + initialized: { [COMMUNITY_PATH]: false } + }); + expect(() => checkRegistrySubmodules(environment)).toThrow('is missing or uninitialized'); + expect(nestedGitPaths).not.toContain(COMMUNITY_PATH); + }); + + test('rejects unexpected configured and checkout remotes', () => { + const wrongConfigured = createSubmoduleFixture({ + configuredRemote: { [CHAIN_PATH]: 'https://example.com/wrong.git' } + }); + expect(() => checkRegistrySubmodules(wrongConfigured.environment)).toThrow('unexpected configured remote'); + + const wrongCheckout = createSubmoduleFixture({ + checkoutRemote: { [CHAIN_PATH]: 'https://example.com/wrong.git' } + }); + expect(() => checkRegistrySubmodules(wrongCheckout.environment)).toThrow('checkout has unexpected origin'); + }); + + test('collects only deterministic retained image URLs without making network requests', () => { + const urls = retainedRegistryImageUrls(); + expect(urls).toHaveLength(48); + expect(urls).toEqual([...urls].sort()); + expect(urls.every((url) => url.startsWith('https://'))).toBeTrue(); + }); + + test('generates a bundle identical to the filtered submodule source', async () => { + const build = Bun.spawnSync(['bun', 'run', '--cwd', 'packages/registry', 'build'], { + cwd: root, + stdout: 'pipe', + stderr: 'pipe' + }); + if (build.exitCode !== 0) { + throw new Error(`Registry build failed:\n${decoder.decode(build.stderr)}\n${decoder.decode(build.stdout)}`); + } + + const tokenList = await checkRegistryArtifact(); + expect(tokenList['pacific-1']).toHaveLength(46); + expect(tokenList['atlantic-2']).toHaveLength(7); + }); +}); From 96fe0f2e4f0db7a5b00e42a94b8a973ac128f6dc Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Sat, 22 Aug 2026 22:16:13 +0200 Subject: [PATCH 2/2] test(registry): gate data identity in release CI Keep unit tests hermetic while making exact gitlinks, artifact parity, aliases, and transient image failures explicit release checks. Co-authored-by: Cursor --- .changeset/refresh-reviewed-registry-data.md | 6 +- .github/workflows/checks.yml | 6 ++ .github/workflows/release.yml | 6 ++ packages/registry/RUNBOOK.md | 21 ++++-- packages/registry/package.json | 2 +- .../src/tokens/__tests__/index.spec.ts | 26 ++++++++ packages/registry/src/tokens/filter.ts | 13 +++- scripts/check-registry-images.ts | 65 +++++++++++++++---- scripts/registry-release.test.ts | 58 ++++++++++++----- 9 files changed, 163 insertions(+), 40 deletions(-) diff --git a/.changeset/refresh-reviewed-registry-data.md b/.changeset/refresh-reviewed-registry-data.md index 1280cd2b0..22f2e7d3f 100644 --- a/.changeset/refresh-reviewed-registry-data.md +++ b/.changeset/refresh-reviewed-registry-data.md @@ -2,6 +2,8 @@ '@sei-js/registry': minor --- -Refresh the community asset metadata from a reviewed upstream pin, including current non-IBC mainnet assets, retained image metadata, and typed native/EVM `pointer_contract` details. +Refresh the community asset metadata from a reviewed upstream pin, including current non-IBC mainnet assets and retained image metadata. -Keep source and bundle filtering in sync, and add deterministic release checks for schema fidelity, generated data, submodule cleanliness, and retained image links. +`TOKEN_LIST` entries now use `RegistryToken`, which narrows `type_asset` from the general optional string to a required upstream-schema asset type. `Token.pointer_contract` exposes optional, typed native/EVM pointer metadata. + +Keep source and bundle validation/filtering in sync, and add deterministic release checks for schema fidelity, generated data, submodule cleanliness, and retained image links. diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8d129d892..6deeb61e8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -37,9 +37,15 @@ jobs: - name: Lint, Format & Type Check run: bun run check + - name: Verify Registry Submodules + run: bun run --cwd packages/registry check:submodules + - name: Build All Packages run: bun run build + - name: Verify Registry Artifact + run: bun run --cwd packages/registry check:artifact + - name: Test run: bun run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dcf7d4f2..1b3ea6b79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,9 +42,15 @@ jobs: - name: Lint, Format & Type Check run: bun run check + - name: Verify Registry Submodules + run: bun run --cwd packages/registry check:submodules + - name: Build Packages run: bun run build + - name: Verify Registry Artifact + run: bun run --cwd packages/registry check:artifact + - name: Test run: bun run test diff --git a/packages/registry/RUNBOOK.md b/packages/registry/RUNBOOK.md index 91ae02b2a..79e1f77bc 100644 --- a/packages/registry/RUNBOOK.md +++ b/packages/registry/RUNBOOK.md @@ -16,7 +16,7 @@ The current reviewed community asset-list values are: `check:submodules` parses each stage-0 gitlink OID from the index and requires the initialized checkout to be at that exact OID with a clean worktree and the expected configured/origin remote. The community gitlink and checkout must also equal the reviewed revision above. A release must not bundle an unstaged gitlink update or local submodule edits. -The registry package `test` script runs this live repository check before unit tests. The root `test` script invokes every package test, so both the Checks and Release workflows enforce it; both workflows must continue using `actions/checkout` with `submodules: recursive`. +Registry package and root unit tests are hermetic and do not inspect live git state. The Checks and Release workflows enforce this repository check explicitly before building; both workflows must continue using `actions/checkout` with `submodules: recursive`. They also run `check:artifact` immediately after the build. ## Review and repin the community asset list Never update the gitlink by blindly checking out a moving branch. Review a candidate commit and then check out its full SHA. @@ -56,7 +56,7 @@ git diff --cached --submodule=short -- packages/registry/community-assetlist bun run --cwd packages/registry check:submodules ``` -The community index entry must be mode `160000`, stage `0`, and OID `964ca87f7cff8d8791ad1e994628fa410faae61e`. The chain-registry index OID and checkout must remain `855440d90df49246498d0870c6be5de5af56dada`. Update `REVIEWED_ASSETLIST_REVISION`, deterministic counts and canonical fixtures, this runbook, the README migration notes, and the changeset in the same change. +The community index entry must be mode `160000`, stage `0`, and OID `964ca87f7cff8d8791ad1e994628fa410faae61e`. The chain-registry index OID and checkout must remain `855440d90df49246498d0870c6be5de5af56dada`. Update `REVIEWED_ASSETLIST_REVISION`, deterministic counts and canonical fixtures, every documented source/retained/filtered/pointer/image count in the README and runbook, and the changeset in the same change. ## Validate schema and filtering The package validator checks every upstream network entry for the public token shape, allowed asset types, denomination field types, image metadata, and pointer metadata. The deterministic tests then verify supported networks, source and retained counts, canonical assets, IBC/ICS-20 removal, and source/runtime parity. @@ -68,6 +68,8 @@ bun run typecheck Also review the upstream Draft-07 schema itself. At the current pin, that schema requires exactly two denomination units for every token, but upstream's `ForU AI Genesis` ERC-721 intentionally has an empty `denom_units` array. The package retains this non-IBC asset and validates its fields while allowing the non-fungible exception. Do not rewrite pinned upstream metadata during packaging, and do not generalize this exception to malformed fungible assets without a separate review. +Unknown fields, unknown enum values, and missing current required fields intentionally fail at import and build time. A reviewed pin should never silently discard or broaden newly introduced metadata: update the public types, validator, tests, and documentation as one reviewed change. Runtime validation is deliberately retained so source execution and the generated bundle enforce the same schema. + The source and esbuild plugin share `filterTokenList`; both reject malformed fields and remove an asset if: - `base` starts with `ibc/`, case-insensitively; - any denomination-unit `denom` starts with `ibc/`; or @@ -84,7 +86,7 @@ bun test --isolate scripts/registry-release.test.ts `check:artifact` imports the generated ESM bundle and deep-compares `TOKEN_LIST` with a fresh validation and filtering pass over the pinned submodule source. -The release-script tests use simulated git command results for exact gitlinks, wrong recorded OIDs, mismatched checkouts, dirty worktrees, missing/uninitialized worktrees, and remote mismatches. They do not require the current worktree gitlink to be staged: +The release-script tests use simulated git command results for exact gitlinks, wrong recorded OIDs, mismatched checkouts, dirty worktrees, missing/uninitialized worktrees, and remote mismatches. They also test image retry behavior with injected responses. They do not inspect current git state, build packages, mutate `dist`, or make network requests: ```bash bun test --isolate scripts/registry-release.test.ts @@ -97,7 +99,7 @@ Ordinary unit tests validate URL shape and deterministic URL collection without bun run --cwd packages/registry check:images ``` -The checker probes every unique retained PNG/SVG URL with bounded concurrency and a timeout. It reports each HTTP status or network failure and exits nonzero if any URL fails. Record the number of URLs checked and the result in the release evidence. The current reviewed pin has 48 unique retained image URLs. +The checker probes every unique retained PNG/SVG URL with bounded concurrency and a timeout. A 429 or 5xx response receives one bounded retry after a short backoff; a second failure remains a hard error. It reports each final HTTP status or network failure and exits nonzero if any URL fails. Record the number of URLs checked and the result in the release evidence. The current reviewed pin has 48 unique retained image URLs. Remediation verification on 2026-08-22: all 48 retained image URLs returned successful HTTP responses. @@ -115,12 +117,19 @@ The dry run should contain only package metadata and `dist`; it must not contain ## Full repository verification ```bash bun run check +bun run --cwd packages/registry check:submodules bun run build +bun run --cwd packages/registry check:artifact bun run test -bun run --cwd packages/registry check:submodules git -C packages/registry/community-assetlist status --short git -C packages/registry/chain-registry status --short git status --short ``` -Before release, confirm the only registry changes are the reviewed gitlink, intended source/tests/docs/scripts, and changeset. Re-run the live image check close to publication because external URL health can change independently of the pinned JSON. +The full manual release-data gate runs the submodule check once, then build, hermetic unit tests, artifact comparison, and live image verification: + +```bash +bun run --cwd packages/registry verify:release-data +``` + +Before release, confirm the only registry changes are the reviewed gitlink, intended source/tests/docs/scripts, workflows, and changeset. Re-run the live image check close to publication because external URL health can change independently of the pinned JSON. diff --git a/packages/registry/package.json b/packages/registry/package.json index 17c09e884..8db441642 100644 --- a/packages/registry/package.json +++ b/packages/registry/package.json @@ -17,7 +17,7 @@ "check:artifact": "bun ../../scripts/check-registry-artifact.ts", "check:images": "bun ../../scripts/check-registry-images.ts", "check:submodules": "bun ../../scripts/check-registry-submodules.ts", - "test": "bun run check:submodules && bun test --isolate src", + "test": "bun test --isolate src", "verify:release-data": "bun run check:submodules && bun run build && bun run test && bun run check:artifact && bun run check:images" }, "exports": { diff --git a/packages/registry/src/tokens/__tests__/index.spec.ts b/packages/registry/src/tokens/__tests__/index.spec.ts index 5632d7036..4f4d66ab1 100644 --- a/packages/registry/src/tokens/__tests__/index.spec.ts +++ b/packages/registry/src/tokens/__tests__/index.spec.ts @@ -21,6 +21,22 @@ const EXPECTED_CANONICAL_MAINNET_ASSETS = [ { label: 'fastUSD', symbol: 'fastUSD', base: '0x37a4dD9CED2b19Cfe8FAC251cd727b5787E45269', type_asset: 'erc20' } ] as const; +function tokenWithAliases(aliases: unknown): Record { + return { + name: 'Alias fixture', + description: 'Fixture for denomination aliases', + symbol: 'ALIAS', + base: 'ualias', + display: 'alias', + denom_units: [ + { denom: 'ualias', exponent: 0, aliases }, + { denom: 'alias', exponent: 6 } + ], + images: {}, + type_asset: 'sdk.coin' + }; +} + describe('community asset list', () => { it('validates the reviewed upstream shape and pointer metadata', () => { const parsed = parseTokenList(AssetListJSON); @@ -81,6 +97,16 @@ describe('community asset list', () => { expect(() => parseTokenList(malformed)).toThrow('denom_units must contain two units'); }); + it('validates and preserves optional denomination aliases', () => { + const parsed = parseTokenList({ fixture: [tokenWithAliases(['microalias', 'uALIAS'])] }); + expect(parsed.fixture[0]?.denom_units[0]?.aliases).toEqual(['microalias', 'uALIAS']); + }); + + it('rejects malformed denomination aliases', () => { + expect(() => parseTokenList({ fixture: [tokenWithAliases('microalias')] })).toThrow('aliases must be an array'); + expect(() => parseTokenList({ fixture: [tokenWithAliases(['microalias', 7])] })).toThrow('aliases[1] must be a string'); + }); + it('retains only supported networks with reviewed source and runtime counts', () => { const supportedNetworks: Network[] = [CHAIN_IDS.mainnet, CHAIN_IDS.testnet]; expect(Object.keys(TOKEN_LIST)).toEqual(supportedNetworks); diff --git a/packages/registry/src/tokens/filter.ts b/packages/registry/src/tokens/filter.ts index d6e0e272a..240d3d18b 100644 --- a/packages/registry/src/tokens/filter.ts +++ b/packages/registry/src/tokens/filter.ts @@ -94,16 +94,25 @@ function parseToken(value: unknown, path: string): RegistryToken { const denomUnits = value.denom_units.map((unit, index) => { const unitPath = `${path}.denom_units[${index}]`; assert(isRecord(unit), `${unitPath} must be an object`); - assertOnlyKeys(unit, new Set(['denom', 'exponent']), unitPath); + assertOnlyKeys(unit, new Set(['denom', 'exponent', 'aliases']), unitPath); assert(typeof unit.denom === 'string', `${unitPath}.denom must be a string`); assert(Number.isInteger(unit.exponent) && Number(unit.exponent) >= 0, `${unitPath}.exponent must be a non-negative integer`); if (index === 0) { assert(unit.exponent === 0, `${unitPath}.exponent must be 0 for the base denomination`); } + let aliases: string[] | undefined; + if (unit.aliases !== undefined) { + assert(Array.isArray(unit.aliases), `${unitPath}.aliases must be an array`); + aliases = unit.aliases.map((alias, aliasIndex) => { + assert(typeof alias === 'string', `${unitPath}.aliases[${aliasIndex}] must be a string`); + return alias; + }); + } return { denom: unit.denom, - exponent: Number(unit.exponent) + exponent: Number(unit.exponent), + ...(aliases === undefined ? {} : { aliases }) }; }); diff --git a/scripts/check-registry-images.ts b/scripts/check-registry-images.ts index cdd80136f..a84a28edd 100644 --- a/scripts/check-registry-images.ts +++ b/scripts/check-registry-images.ts @@ -3,6 +3,17 @@ import { filterTokenList } from '../packages/registry/src/tokens/filter'; const DEFAULT_TIMEOUT_MS = 15_000; const DEFAULT_CONCURRENCY = 8; +const DEFAULT_RETRY_DELAY_MS = 500; + +export interface ImageCheckDependencies { + request(url: string, init: RequestInit): Promise; + sleep(milliseconds: number): Promise; +} + +const defaultDependencies: ImageCheckDependencies = { + request: (url, init) => fetch(url, init), + sleep: (milliseconds) => Bun.sleep(milliseconds) +}; export function retainedRegistryImageUrls(): string[] { const tokenList = filterTokenList(AssetListJSON, 'community-assetlist/assetlist.json'); @@ -14,8 +25,8 @@ export function retainedRegistryImageUrls(): string[] { ].sort(); } -async function requestImage(url: string, method: 'HEAD' | 'GET', timeoutMs: number): Promise { - return fetch(url, { +async function requestImage(url: string, method: 'HEAD' | 'GET', timeoutMs: number, dependencies: ImageCheckDependencies): Promise { + return dependencies.request(url, { method, redirect: 'follow', headers: { @@ -26,25 +37,51 @@ async function requestImage(url: string, method: 'HEAD' | 'GET', timeoutMs: numb }); } -async function checkImage(url: string, timeoutMs: number): Promise { +async function probeImage(url: string, timeoutMs: number, dependencies: ImageCheckDependencies): Promise { + let response = await requestImage(url, 'HEAD', timeoutMs, dependencies); + if (response.status === 405 || response.status === 501) { + response = await requestImage(url, 'GET', timeoutMs, dependencies); + await response.body?.cancel(); + } + + return response; +} + +function isTransientHttpStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +export async function checkRegistryImageUrl( + url: string, + options: { timeoutMs?: number; retryDelayMs?: number; dependencies?: ImageCheckDependencies } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + const dependencies = options.dependencies ?? defaultDependencies; + try { - let response = await requestImage(url, 'HEAD', timeoutMs); - if (response.status === 405 || response.status === 501) { - response = await requestImage(url, 'GET', timeoutMs); - await response.body?.cancel(); - } + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await probeImage(url, timeoutMs, dependencies); + if (response.ok) { + return undefined; + } + if (attempt === 0 && isTransientHttpStatus(response.status)) { + await dependencies.sleep(retryDelayMs); + continue; + } - if (!response.ok) { return `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ${url}`; } } catch (error) { return `${error instanceof Error ? error.message : String(error)}: ${url}`; } - return undefined; + return `Image check exhausted retries without a response: ${url}`; } -export async function checkRegistryImages(options: { timeoutMs?: number; concurrency?: number } = {}): Promise { +export async function checkRegistryImages( + options: { timeoutMs?: number; concurrency?: number; retryDelayMs?: number; dependencies?: ImageCheckDependencies } = {} +): Promise { const urls = retainedRegistryImageUrls(); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, urls.length)); @@ -55,7 +92,11 @@ export async function checkRegistryImages(options: { timeoutMs?: number; concurr while (nextIndex < urls.length) { const url = urls[nextIndex]; nextIndex += 1; - const failure = await checkImage(url, timeoutMs); + const failure = await checkRegistryImageUrl(url, { + timeoutMs, + retryDelayMs: options.retryDelayMs, + dependencies: options.dependencies + }); if (failure !== undefined) failures.push(failure); } } diff --git a/scripts/registry-release.test.ts b/scripts/registry-release.test.ts index 54271946a..2ac4fe97d 100644 --- a/scripts/registry-release.test.ts +++ b/scripts/registry-release.test.ts @@ -1,8 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { checkRegistryArtifact } from './check-registry-artifact'; -import { retainedRegistryImageUrls } from './check-registry-images'; +import { checkRegistryImageUrl, retainedRegistryImageUrls } from './check-registry-images'; import { ASSETLIST_REMOTE, CHAIN_REGISTRY_REMOTE, @@ -12,8 +9,6 @@ import { type RegistrySubmoduleEnvironment } from './check-registry-submodules'; -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const decoder = new TextDecoder(); const COMMUNITY_PATH = 'packages/registry/community-assetlist'; const CHAIN_PATH = 'packages/registry/chain-registry'; const CHAIN_REVISION = '855440d90df49246498d0870c6be5de5af56dada'; @@ -161,18 +156,47 @@ describe('registry release data', () => { expect(urls.every((url) => url.startsWith('https://'))).toBeTrue(); }); - test('generates a bundle identical to the filtered submodule source', async () => { - const build = Bun.spawnSync(['bun', 'run', '--cwd', 'packages/registry', 'build'], { - cwd: root, - stdout: 'pipe', - stderr: 'pipe' + test('retries a transient image response once with backoff', async () => { + const statuses = [429, 200]; + const delays: number[] = []; + let requests = 0; + const failure = await checkRegistryImageUrl('https://example.com/image.png', { + retryDelayMs: 25, + dependencies: { + request: async () => { + requests += 1; + return new Response(null, { status: statuses.shift() }); + }, + sleep: async (milliseconds) => { + delays.push(milliseconds); + } + } + }); + + expect(failure).toBeUndefined(); + expect(requests).toBe(2); + expect(delays).toEqual([25]); + }); + + test('fails after one retry for persistent transient image responses', async () => { + const statuses = [503, 500]; + const delays: number[] = []; + let requests = 0; + const failure = await checkRegistryImageUrl('https://example.com/image.png', { + retryDelayMs: 25, + dependencies: { + request: async () => { + requests += 1; + return new Response(null, { status: statuses.shift() }); + }, + sleep: async (milliseconds) => { + delays.push(milliseconds); + } + } }); - if (build.exitCode !== 0) { - throw new Error(`Registry build failed:\n${decoder.decode(build.stderr)}\n${decoder.decode(build.stdout)}`); - } - const tokenList = await checkRegistryArtifact(); - expect(tokenList['pacific-1']).toHaveLength(46); - expect(tokenList['atlantic-2']).toHaveLength(7); + expect(failure).toContain('HTTP 500'); + expect(requests).toBe(2); + expect(delays).toEqual([25]); }); });