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);
+});