diff --git a/.changeset/refresh-reviewed-registry-data.md b/.changeset/refresh-reviewed-registry-data.md new file mode 100644 index 00000000..22f2e7d3 --- /dev/null +++ b/.changeset/refresh-reviewed-registry-data.md @@ -0,0 +1,9 @@ +--- +'@sei-js/registry': minor +--- + +Refresh the community asset metadata from a reviewed upstream pin, including current non-IBC mainnet assets and retained image metadata. + +`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 8d129d89..6deeb61e 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 8dcf7d4f..1b3ea6b7 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/README.md b/packages/registry/README.md index 7a096637..ac4b0352 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 62c2779c..79e1f77b 100644 --- a/packages/registry/RUNBOOK.md +++ b/packages/registry/RUNBOOK.md @@ -1,2 +1,135 @@ -### 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. + +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. + +```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, 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. + +```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. + +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 +- `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 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 +``` + +## 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. 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. + +## 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 --cwd packages/registry check:submodules +bun run build +bun run --cwd packages/registry check:artifact +bun run test +git -C packages/registry/community-assetlist status --short +git -C packages/registry/chain-registry status --short +git status --short +``` + +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/community-assetlist b/packages/registry/community-assetlist index 831406ba..964ca87f 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 4df0b310..8db44164 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 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 2e1f9baf..4f4d66ab 100644 --- a/packages/registry/src/tokens/__tests__/index.spec.ts +++ b/packages/registry/src/tokens/__tests__/index.spec.ts @@ -1,58 +1,170 @@ -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; + +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); + 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('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('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); + + for (const network of supportedNetworks) { + expect(AssetListJSON[network]).toHaveLength(EXPECTED_SOURCE_COUNTS[network]); + expect(TOKEN_LIST[network]).toHaveLength(EXPECTED_RETAINED_COUNTS[network]); + } }); - it('excludes IBC assets', () => { + 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 00000000..240d3d18 --- /dev/null +++ b/packages/registry/src/tokens/filter.ts @@ -0,0 +1,180 @@ +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', '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), + ...(aliases === undefined ? {} : { aliases }) + }; + }); + + 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 a5d32457..5e1eb4d2 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 00000000..479a6edd --- /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 21117200..c6f5f013 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 00000000..97cdd628 --- /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 00000000..a84a28ed --- /dev/null +++ b/scripts/check-registry-images.ts @@ -0,0 +1,121 @@ +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; +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'); + + 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, dependencies: ImageCheckDependencies): Promise { + return dependencies.request(url, { + method, + redirect: 'follow', + headers: { + 'user-agent': 'sei-js-registry-image-check', + ...(method === 'GET' ? { range: 'bytes=0-0' } : {}) + }, + signal: AbortSignal.timeout(timeoutMs) + }); +} + +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 { + 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; + } + + return `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}: ${url}`; + } + } catch (error) { + return `${error instanceof Error ? error.message : String(error)}: ${url}`; + } + + return `Image check exhausted retries without a response: ${url}`; +} + +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)); + const failures: string[] = []; + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < urls.length) { + const url = urls[nextIndex]; + nextIndex += 1; + const failure = await checkRegistryImageUrl(url, { + timeoutMs, + retryDelayMs: options.retryDelayMs, + dependencies: options.dependencies + }); + 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 00000000..c69defb1 --- /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 00000000..2ac4fe97 --- /dev/null +++ b/scripts/registry-release.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from 'bun:test'; +import { checkRegistryImageUrl, retainedRegistryImageUrls } from './check-registry-images'; +import { + ASSETLIST_REMOTE, + CHAIN_REGISTRY_REMOTE, + checkRegistrySubmodules, + parseRecordedGitlink, + REVIEWED_ASSETLIST_REVISION, + type RegistrySubmoduleEnvironment +} from './check-registry-submodules'; + +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('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); + } + } + }); + + expect(failure).toContain('HTTP 500'); + expect(requests).toBe(2); + expect(delays).toEqual([25]); + }); +});