diff --git a/docs.json b/docs.json index 7bf261a..b367b72 100644 --- a/docs.json +++ b/docs.json @@ -102,6 +102,7 @@ "reference/audits", "reference/auditor-guide", "reference/contract-registry", + "reference/compatibility-matrix", "reference/error-codes", "reference/security-disclosure", "reference/sep-compatibility", diff --git a/package.json b/package.json index fd9100c..12daa6c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "check:snippets-extended": "tsx scripts/check-snippets-extended.ts", "check:nav-coverage": "node scripts/check-nav-coverage.mjs", "check:contract-registry": "node scripts/check-contract-registry.mjs", + "check:compatibility-matrix": "node scripts/check-compatibility-matrix.mjs", "check:redirects-and-anchors": "node scripts/check-redirects-and-anchors.mjs", "test:redirects-and-anchors": "node --test scripts/check-redirects-and-anchors.test.mjs", "test:preview-redirect": "node scripts/check-preview-redirect.mjs", @@ -19,7 +20,7 @@ "test:playground": "playwright test --config scripts/playground/tests/playwright.config.ts", "mint:validate": "mint validate", "mint:broken-links": "mint broken-links", - "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-registry && npm run test:redirects-and-anchors" + "test": "npm run check:snippets && npm run check:nav-coverage && npm run check:contract-registry && npm run check:compatibility-matrix && npm run test:redirects-and-anchors" }, "dependencies": { "@solana/web3.js": "^1.95.0", diff --git a/reference/compatibility-matrix.mdx b/reference/compatibility-matrix.mdx new file mode 100644 index 0000000..e95d5d5 --- /dev/null +++ b/reference/compatibility-matrix.mdx @@ -0,0 +1,145 @@ +--- +title: "Chain and Package Compatibility Matrix" +description: "Which Wraith SDK entry point, framework packages, contract versions, and deployment IDs belong together on EVM, Stellar, Solana, and CKB" +keywords: "compatibility, matrix, EVM, Stellar, Soroban, Solana, Anchor, CKB, Nervos, viem, stellar-sdk, web3.js, deployments, versions, migration, multi-chain" +--- + +This page answers one question: **which SDK entry point, framework packages, and deployed contracts belong together on each chain?** Use it before starting a multi-chain integration or bumping a dependency, so a version or deployment mismatch surfaces here rather than at runtime. + +Every value below is transcribed from the page that owns it — the generated Stellar reference, the per-chain contracts pages, and the dependency pins in `package.json`. The [sync check](#keeping-this-matrix-in-sync) in CI keeps this page from drifting from those owners. + +## SDK core + +All four chains are served by the same npm package. Only the chain entry point and its optional peer dependency change. + +| Package | Version (pinned by these docs) | Used by | Purpose | +|---|---|---|---| +| `@wraith-protocol/sdk` | `^1.4.5` | All chains | Agent client (root import) plus the four chain entry points | +| `@noble/curves` | direct dependency | EVM, Stellar, Solana, CKB | secp256k1 (EVM, CKB) and ed25519 (Stellar, Solana) curve arithmetic | +| `@noble/hashes` | direct dependency | EVM, Stellar, Solana, CKB | SHA-256, SHA-512, keccak256, blake2b | +| `viem` | direct dependency | EVM, agent client | EVM utilities and address encoding | + +Versions come from this repository's [`package.json`](https://github.com/wraith-protocol/docs/blob/main/package.json); the dependency roles come from [SDK Overview](/sdk/overview#dependencies). + +## Chain and package matrix + +| Chain | `Chain` enum | SDK entry point | Curve | Framework / peer packages | Deployment IDs | Migration notes | +|---|---|---|---|---|---|---| +| EVM (Horizen, Ethereum, Polygon, Base) | `Chain.Horizen`, `Chain.Ethereum`, `Chain.Polygon`, `Chain.Base` | `@wraith-protocol/sdk/chains/evm` | secp256k1 | `viem` (bundled; any signer library works) | [Horizen Testnet table](#evm--horizen-testnet) | [Deployment order and per-chain redeploys](#evm-migration-notes) | +| Stellar | `Chain.Stellar` | `@wraith-protocol/sdk/chains/stellar` | ed25519 | `@stellar/stellar-sdk` `^13.1.0` (optional peer) | [Stellar Testnet table](#stellar--testnet) | [Testnet resets and pending contracts](#stellar-migration-notes) | +| Solana | `Chain.Solana` | `@wraith-protocol/sdk/chains/solana` | ed25519 | `@solana/web3.js` `^1.95.0` (optional peer), Anchor event logs | [Solana table](#solana--devnet) | [PDA and rent model](#solana-migration-notes) | +| CKB | `Chain.CKB` | `@wraith-protocol/sdk/chains/ckb` | secp256k1 | none — native `fetch` RPC plus `@noble/hashes` blake2b | [CKB Testnet table](#ckb--testnet) | [Cell model, no peer dependency](#ckb-migration-notes) | + +The `@stellar/stellar-sdk` and `@solana/web3.js` peer dependencies are only needed when you import their chain module; the CKB module adds none. See [SDK Overview](/sdk/overview#entry-points) for the full export list per entry point. + +--- + +## Deployment IDs + +### EVM — Horizen Testnet + +| Contract | Address | Version | +|---|---|---| +| `ERC5564Announcer` | `0x8AE65c05E7eb48B9bA652781Bc0a3DBA09A484F3` | — | +| `ERC6538Registry` | `0x953E6cEdcdfAe321796e7637d33653F6Ce05c527` | — | +| `WraithSender` | `0x226C5eb4e139D9fa01cc09eA318638b090b12095` | — | +| `WraithNames` | `0x3d46f709a99A3910f52bD292211Eb5D557F882D6` | — | + +`WraithWithdrawer` (EIP-7702 gas sponsorship) is optional and is deployed only on chains that support EIP-7702. These are the addresses currently published on [EVM Contracts](/contracts/evm#deployed-addresses-horizen-testnet); no other EVM network has a published deployment yet. + + + +#### EVM migration notes + +- Deploy order is fixed: announcer → registry → sender (constructor arg: announcer) → names → optional withdrawer. See [Deployment Order](/contracts/evm#deployment-order). +- Adding a new EVM chain means re-running the deploy script on that chain and publishing a new row here; the SDK's `Chain` enum gains a value in the same release. +- `WraithSender` is the only contract with a constructor argument, so a redeploy of the announcer requires redeploying the sender too. + +### Stellar — Testnet + +| Contract | Testnet address | Version | +|---|---|---| +| `stealth-announcer` | `CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL` | v2 | +| `stealth-registry` | Pending deployment | v1 | +| `stealth-sender` | Pending deployment | v1 | +| `wraith-names` | `CDEMB3MAE62ZOCCKZPTYSXR5CS5WVENPOU5MDVK4PNKTZXFVDC74AFBV` | v1 | + +Source of truth: `getDeployment("stellar")` via `@wraith-protocol/sdk/chains/stellar`, as published in [Stellar Networks](/reference/stellar-networks#wraith-contract-ids) and the generated section of [Stellar Contracts](/contracts/stellar). Mainnet addresses are still placeholders. + + + +#### Stellar migration notes + +- **Testnet resets roughly quarterly** and wipes all contract state. Re-read the current announcer and names IDs after a reset instead of pinning them in config. See [Stellar Networks → Testnet](/reference/stellar-networks#testnet). +- `stealth-registry` and `stealth-sender` addresses are pending; integrations that need meta-address registration should track `contracts/stellar/MAINNET_READINESS.md` in the contracts repo. +- `stealth-announcer` is on event version **v2**; the SDK's announcement parsing assumes the v2 indexed topics. See [Stellar Event Schemas (v2)](/reference/stellar-event-schemas). +- The decoder-facing differences from EVM (caller auth instead of ECDSA recovery, `getEvents` instead of a subgraph) are tabulated in [Stellar Contracts → Differences from EVM Contracts](/contracts/stellar#differences-from-evm-contracts). + +### Solana — Devnet + +| Program | Program ID | Version | +|---|---|---| +| `wraith-announcer` | Not published in these docs | — | +| `wraith-sender` | Not published in these docs | — | +| `wraith-names` | Not published in these docs | — | + +Program IDs are not yet published in the documentation. Until they are, deploy with `anchor deploy --provider.cluster devnet` and read the IDs from the deploy output; see [Solana Contracts → Deployment](/contracts/solana#deployment). + + + +#### Solana migration notes + +- Names are stored in PDAs rather than contract storage, and accounts need rent-exempt balances; see [Solana Contracts → Differences](/contracts/solana#differences-from-evm-and-stellar-contracts). +- Announcements are parsed from Anchor event logs, so the `@solana/web3.js` peer version must be able to read the deployed program's logs. +- Program upgrades change the program ID's executable data in place; PIN the program ID, not the deployment transaction, in config. + +### CKB — Testnet + +| Script | Identifier | Kind | +|---|---|---| +| `wraith-stealth-lock` | Not published in these docs | Code hash (`data2`) | +| `wraith-names-type` | `0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d` | Code hash (`data2`) | +| `wraith-names-type` cell dep | `0x9acd640d35eadd893b358dddd415f4061fe81cb249e8ace51a866fee314141b8` | Transaction hash (index 0, `code`) | + +CKB has no separate announcer: the stealth Cell's lock args carry the announcement. The `wraith-names-type` code hash and its deployment cell dep above are the testnet values published in [CKB Contracts → Deployed Code Hash](/contracts/ckb#deployed-code-hash); the `wraith-stealth-lock` code hash is not yet recorded in the deployment reference, so this table does not publish one. Confirm every value against `getDeployment("ckb")` before mainnet use. + + + +#### CKB migration notes + +- The CKB module has **no optional peer dependency** — RPC calls use native `fetch` and hashing uses the already-bundled `@noble/hashes`. See [CKB Primitives](/sdk/chains/ckb). +- A redeploy produces a **new code hash**, which changes every stealth Cell's `lock.code_hash`. Record the new hash in the SDK's `deployments.ts` as described in [CKB Contracts → Record Deployment Info](/contracts/ckb#record-deployment-info). +- CKB's Cell model has no account layer, so there is nothing analogous to EVM's `createAccount` prerequisite on Stellar. + +--- + +## Known gaps + +| Gap | Chains affected | Tracking | +|---|---|---| +| Mainnet contract addresses not yet published | Stellar | [Stellar Networks → Mainnet](/reference/stellar-networks#mainnet) | +| `stealth-registry` / `stealth-sender` not deployed | Stellar Testnet | [Stellar Networks → Testnet](/reference/stellar-networks#wraith-contract-ids) | +| Program IDs not published in docs | Solana | [Solana Contracts → Deployment](/contracts/solana#deployment) | +| Single published EVM deployment (Horizen Testnet) | EVM | [EVM Contracts → Deployed Addresses](/contracts/evm#deployed-addresses-horizen-testnet) | + +## Keeping this matrix in sync + +The deployment identifiers on this page are checked in CI **row by row**, against the entry that owns each one: + +```bash +npm run check:compatibility-matrix +``` + +For each chain section the check resolves every row's contract to its owning entry — `contracts/evm.mdx` for the Horizen addresses, `scripts/contract-registry.json` for the Stellar addresses and versions, and `contracts/ckb.mdx` for the CKB code hashes and cell deps — and fails when the value is not that contract's value, when it belongs to a different chain or contract, or when its version does not match the registry. A Solana row may carry no identifier until program IDs are published. After a redeploy: + +1. Update the owning page (or re-run `npm run generate:stellar-reference` for Stellar). +2. Update the matching row here. +3. Run `npm run check:compatibility-matrix` and the full `npm test`. + +## Related + +- [SDK Overview](/sdk/overview) — entry points, `Chain` enum, dependency roles +- [EVM Contracts](/contracts/evm) · [Stellar Contracts](/contracts/stellar) · [Solana Contracts](/contracts/solana) · [CKB Contracts](/contracts/ckb) +- [Stellar Networks](/reference/stellar-networks) — live contract IDs per network +- [Stellar SEP Compatibility](/reference/sep-compatibility) — SEP-level support for Stellar integrations +- [Multichain Agent guide](/guides/multichain-agent) — driving one agent across several chains diff --git a/scripts/check-compatibility-matrix.mjs b/scripts/check-compatibility-matrix.mjs new file mode 100644 index 0000000..eddf119 --- /dev/null +++ b/scripts/check-compatibility-matrix.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +/** + * check-compatibility-matrix.mjs + * + * Validates reference/compatibility-matrix.mdx row by row against the entry + * that OWNS each value, instead of checking that an identifier merely appears + * somewhere in the docs. + * + * Each chain section of the matrix is owned by a specific source: + * + * - EVM -> contracts/evm.mdx (the "Deployed Addresses (Horizen Testnet)" table) + * - Stellar-> scripts/contract-registry.json (networks["stellar-testnet"], address + version) + * - Solana -> contracts/solana.mdx (no program IDs are published yet) + * - CKB -> contracts/ckb.mdx (each script's "Deployed Code Hash" / "Cell Dep") + * + * Every row is resolved to its contract's entry in its owner, and the check + * fails when: + * - the row's contract has no entry in the owner, + * - the row's identifier is not the owner's identifier for that contract, + * - the identifier belongs to a different chain or contract, + * - the row's version does not match the owner's version, + * - the owner publishes a value but the matrix marks the row pending, or + * - a Solana/no-identifier row is used for a contract the owner has deployed. + * + * Run after changing a deployment: + * + * node scripts/check-compatibility-matrix.mjs + * + * Wired into `npm test`, so a matrix row that no longer matches its owning + * entry fails the docs build. + */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const matrixRel = "reference/compatibility-matrix.mdx"; + +const EVM_ADDRESS = /\b0x[0-9a-fA-F]{40}\b/g; +const CKB_HASH = /\b0x[0-9a-fA-F]{64}\b/g; +const STELLAR_STRKEY = /\bC[A-Z2-7]{55}\b/g; + +/** Cells that stand in for "no value published yet". */ +const PENDING = /^(pending( deployment)?|not deployed|not published( in these docs)?|tbd|—+|-+)$/i; + +/** Chain sections of the matrix, in the order they appear under "## Deployment IDs". */ +const CHAINS = [ + { key: "evm", heading: /^###\s+EVM\b/, label: "EVM" }, + { key: "stellar", heading: /^###\s+Stellar\b/, label: "Stellar" }, + { key: "solana", heading: /^###\s+Solana\b/, label: "Solana" }, + { key: "ckb", heading: /^###\s+CKB\b/, label: "CKB" }, +]; + +async function main() { + const failures = []; + + const owners = { + evm: await buildEvmOwner(), + stellar: await buildStellarOwner(), + ckb: await buildCkbOwner(), + solana: await buildSolanaOwner(), + }; + + // Global index so a value placed under the wrong chain or contract is caught + // even when it happens to be *some* valid identifier. + const globalIndex = new Map(); + for (const owner of Object.values(owners)) { + for (const entry of owner.entries.values()) { + for (const id of entry.addresses) { + const previous = globalIndex.get(id); + if (previous && (previous.chain !== owner.chain || normalizeContract(previous.contract) !== normalizeContract(entry.contract))) { + failures.push( + `Identifier ${id} is owned by both ${previous.chain}/${previous.contract} and ` + + `${owner.chain}/${entry.contract}; owners must be unambiguous.`, + ); + } + globalIndex.set(id, { chain: owner.chain, contract: entry.contract }); + } + } + } + + const matrix = await readFile(path.join(repoRoot, matrixRel), "utf8"); + const matrixLines = splitLines(matrix); + + let checkedRows = 0; + let checkedIdentifiers = 0; + + for (const chain of CHAINS) { + const start = headingIndex(matrixLines, chain.heading); + if (start < 0) { + failures.push(`${matrixRel}: missing the "${chain.label}" deployment section.`); + continue; + } + + const table = tableAfter(matrixLines, start); + if (!table || table.rows.length === 0) { + failures.push(`${matrixRel}: the "${chain.label}" section has no deployment table.`); + continue; + } + + const owner = owners[chain.key]; + for (const row of table.rows) { + checkedRows++; + const contractCell = row[0] ?? ""; + const valueCell = row[1] ?? ""; + const versionCell = row[2] ?? ""; + const contractKey = normalizeContract(contractCell); + const ids = identifiersIn(valueCell); + const entry = owner.entries.get(contractKey); + + if (chain.key === "solana") { + if (ids.length > 0) { + failures.push( + `${matrixRel}: Solana row "${stripCode(contractCell)}" publishes ${ids.join(", ")}, ` + + `but ${owner.rel} does not own any Solana identifier yet.`, + ); + } else if (!PENDING.test(stripCode(valueCell))) { + failures.push( + `${matrixRel}: Solana row "${stripCode(contractCell)}" should be marked pending ` + + `(no program IDs are published in ${owner.rel}).`, + ); + } + continue; + } + + if (!entry) { + failures.push( + `${matrixRel}: row "${stripCode(contractCell)}" has no owning entry for ` + + `"${contractKey}" in ${owner.rel}.`, + ); + continue; + } + + if (ids.length === 0) { + if (!PENDING.test(stripCode(valueCell))) { + failures.push( + `${matrixRel}: row "${stripCode(contractCell)}" carries no recognizable identifier ` + + `and is not marked pending.`, + ); + } else if (entry.addresses.length > 0) { + failures.push( + `${matrixRel}: row "${stripCode(contractCell)}" is marked pending, but ${owner.rel} ` + + `publishes ${entry.addresses.join(", ")} for it.`, + ); + } + } else { + for (const id of ids) { + checkedIdentifiers++; + if (entry.addresses.includes(id)) continue; + + const ownerOfId = globalIndex.get(id); + if (ownerOfId && ownerOfId.chain !== chain.key) { + failures.push( + `${matrixRel}: "${stripCode(contractCell)}" lists ${id}, which belongs to the ` + + `${ownerOfId.chain} chain (${ownerOfId.contract}), not ${chain.label}.`, + ); + } else if (ownerOfId && normalizeContract(ownerOfId.contract) !== contractKey) { + failures.push( + `${matrixRel}: "${stripCode(contractCell)}" lists ${id}, which ${owner.rel} owns ` + + `under "${ownerOfId.contract}", not "${contractKey}".`, + ); + } else { + failures.push( + `${matrixRel}: "${stripCode(contractCell)}" lists ${id}, but ${owner.rel} does not ` + + `own that value for "${contractKey}".`, + ); + } + } + } + + if (entry.version != null) { + const version = stripCode(versionCell); + if (version.toLowerCase() !== String(entry.version).toLowerCase()) { + failures.push( + `${matrixRel}: "${stripCode(contractCell)}" version is "${version}", but ` + + `${owner.rel} records "${entry.version}".`, + ); + } + } + } + } + + if (checkedIdentifiers === 0) { + failures.push( + `${matrixRel}: no deployment identifiers found. Expected at least one EVM address, ` + + `Stellar contract strkey, or CKB code/transaction hash.`, + ); + } + + if (failures.length > 0) { + fail(failures); + } + + console.log( + `Compatibility matrix passed: ${checkedRows} rows / ${checkedIdentifiers} deployment ` + + `identifiers validated against their owning entries (${Object.values(owners) + .map((owner) => owner.rel) + .join(", ")}).`, + ); +} + +// ---------------------------------------------------------------------- +// Owners — one per chain, keyed by contract name +// ---------------------------------------------------------------------- + +async function buildEvmOwner() { + const rel = "contracts/evm.mdx"; + const text = await readFile(path.join(repoRoot, rel), "utf8"); + const ls = splitLines(text); + const start = headingIndex(ls, /^###\s+Deployed Addresses \(Horizen Testnet\)/); + if (start < 0) { + throw new Error(`${rel}: could not find "### Deployed Addresses (Horizen Testnet)".`); + } + const table = tableAfter(ls, start); + const entries = new Map(); + for (const row of table?.rows ?? []) { + const [contract, address] = row; + if (!contract) continue; + entries.set(normalizeContract(contract), { + contract: stripCode(contract), + addresses: identifiersIn(address ?? ""), + version: null, + }); + } + return { chain: "evm", rel, entries }; +} + +async function buildStellarOwner() { + const rel = "scripts/contract-registry.json"; + const registry = JSON.parse(await readFile(path.join(repoRoot, rel), "utf8")); + const network = registry.networks?.["stellar-testnet"]; + if (!network) { + throw new Error(`${rel}: networks["stellar-testnet"] is missing.`); + } + const entries = new Map(); + for (const [contract, entry] of Object.entries(network.contracts ?? {})) { + entries.set(normalizeContract(contract), { + contract, + addresses: entry.address ? [entry.address.toLowerCase()] : [], + version: entry.version ?? null, + }); + } + return { chain: "stellar", rel, entries }; +} + +async function buildCkbOwner() { + const rel = "contracts/ckb.mdx"; + const text = await readFile(path.join(repoRoot, rel), "utf8"); + const ls = splitLines(text); + const entries = new Map(); + + for (let i = 0; i < ls.length; i++) { + const match = ls[i].match(/^##\s+(wraith-[a-z0-9-]+)\s*$/); + if (!match) continue; + let end = i + 1; + while (end < ls.length && !/^##\s/.test(ls[end])) end++; + const section = ls.slice(i, end); + const codeHash = fencedAfter(section, "### Deployed Code Hash"); + const cellDep = fencedAfter(section, "### Cell Dep"); + const ids = new Set(identifiersIn(codeHash ?? "")); + if (cellDep) { + const tx = cellDep.match(/tx:\s*(0x[0-9a-fA-F]{64})/); + if (tx) ids.add(tx[1].toLowerCase()); + for (const id of identifiersIn(cellDep)) ids.add(id); + } + entries.set(normalizeContract(match[1]), { + contract: match[1], + addresses: [...ids], + version: null, + }); + } + + if (entries.size === 0) { + throw new Error(`${rel}: found no "## wraith-…" script sections.`); + } + return { chain: "ckb", rel, entries }; +} + +async function buildSolanaOwner() { + const rel = "contracts/solana.mdx"; + let text = ""; + try { + text = await readFile(path.join(repoRoot, rel), "utf8"); + } catch { + return { chain: "solana", rel, entries: new Map() }; + } + const published = identifiersIn(text); + if (published.length > 0) { + throw new Error( + `${rel}: now publishes deployment identifiers (${published.join(", ")}); ` + + `add them to the matrix and validate them here.`, + ); + } + return { chain: "solana", rel, entries: new Map() }; +} + +// ---------------------------------------------------------------------- +// Markdown helpers +// ---------------------------------------------------------------------- + +function splitLines(text) { + return text.split("\n"); +} + +function headingIndex(lines, pattern) { + for (let i = 0; i < lines.length; i++) { + if (pattern.test(lines[i])) return i; + } + return -1; +} + +/** Parse the first GitHub-flavoured table at or after `fromIndex`. */ +function tableAfter(lines, fromIndex) { + let i = fromIndex; + while (i < lines.length && !lines[i].trim().startsWith("|")) i++; + if (i >= lines.length) return null; + + const raw = []; + while (i < lines.length && lines[i].trim().startsWith("|")) { + raw.push(lines[i].trim()); + i++; + } + const cells = raw.map((line) => + line + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()), + ); + if (cells.length < 2) return null; // header + separator + return { header: cells[0], rows: cells.slice(2) }; +} + +/** Value inside the first fenced block that follows `heading` within `lines`. */ +function fencedAfter(lines, heading) { + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() !== heading) continue; + let j = i + 1; + while (j < lines.length && !lines[j].trim().startsWith("```")) j++; + if (j >= lines.length) return null; + const buffer = []; + j++; + while (j < lines.length && !lines[j].trim().startsWith("```")) { + buffer.push(lines[j]); + j++; + } + return buffer.join("\n").trim(); + } + return null; +} + +// ---------------------------------------------------------------------- +// Value helpers +// ---------------------------------------------------------------------- + +function stripCode(value) { + return String(value ?? "").replace(/`/g, "").trim(); +} + +/** Collapse a row label to a contract key ("`WraithNames`" -> "wraithnames"). */ +function normalizeContract(value) { + return stripCode(value) + .toLowerCase() + .replace(/\s*\((cell[- ]?dep|deployment cell dep)\)\s*$/, "") + .replace(/\s+(deployment\s+)?cell[- ]?dep$/, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** Every deployment identifier present in a cell, lower-cased. */ +function identifiersIn(value) { + const found = new Set(); + const text = String(value ?? ""); + for (const pattern of [CKB_HASH, EVM_ADDRESS, STELLAR_STRKEY]) { + for (const match of text.matchAll(pattern)) found.add(match[0].toLowerCase()); + } + return [...found]; +} + +function fail(lines) { + console.error(["Compatibility matrix check failed.", "", ...lines].join("\n")); + process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});