From 455ae00f9de1d1adeb1c85a7795ec54edce1cf45 Mon Sep 17 00:00:00 2001 From: ied <11755605+izzetemredemir@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:11:20 +0300 Subject: [PATCH 1/2] fix(registry): fail on missing governance entries and fix splitter/batch sender rows The contract registry check only warned when a mainnet contract had no governance entry in the pinned contracts manifest. That is how stealth-splitter and stealth-batch-sender stayed unverified while the registry listed both as upgradeable, although neither contract has an upgrade entrypoint. Registry and page: - Record both contracts as non-upgradeable in the testnet and mainnet rows and in the governance table on the registry page. - Re-pin pinnedContractsRepoCommit to the contracts commit that adds their governance entries. Check script: - A missing governance entry is a failure. This applies to a contract listed on any network, not only mainnet. - A pinned manifest that returns 404 is a failure. Network errors are still warnings. - Parse the manifest's new "Non-Upgradeable Contracts" section. It holds contracts with immutable code and a pause admin. - `upgradeable` must be a boolean, and the same contract version must have the same flag on every network. - main() runs when the script is invoked through a symlinked path. Tests: - Export the pure checks and add node:test coverage. - check:contract-registry runs the tests before the network checks, so CI runs them without workflow changes. Closes #182 --- package.json | 3 +- reference/contract-registry.mdx | 2 + scripts/check-contract-registry.mjs | 142 +++++++++++++++++------ scripts/check-contract-registry.test.mjs | 119 +++++++++++++++++++ scripts/contract-registry.json | 12 +- 5 files changed, 235 insertions(+), 43 deletions(-) create mode 100644 scripts/check-contract-registry.test.mjs diff --git a/package.json b/package.json index 6e19237..6694561 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "check:api-examples": "tsx scripts/check-api-examples.ts", "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:contract-registry": "node --test scripts/check-contract-registry.test.mjs && 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:contract-registry": "node --test scripts/check-contract-registry.test.mjs", "test:preview-redirect": "node scripts/check-preview-redirect.mjs", "check:stellar-testnet": "tsx scripts/check-stellar-testnet-snippets.ts", "generate:stellar-reference": "tsx scripts/generate-stellar-reference.ts", diff --git a/reference/contract-registry.mdx b/reference/contract-registry.mdx index 962bfa4..5ca3368 100644 --- a/reference/contract-registry.mdx +++ b/reference/contract-registry.mdx @@ -87,6 +87,8 @@ Mainnet deployment is gated on the security audit and remediation, per the contr | `stealth-registry` | None | Immutable — no upgrade mechanism | | `stealth-sender` | 3-of-5 multisig | Upgradeable with a 7-day timelock (120,960 ledgers), emergency pause | | `wraith-names` | 3-of-5 multisig | Upgradeable with a 7-day timelock; no pause | +| `stealth-splitter` | None | Immutable — no upgrade mechanism, no pause | +| `stealth-batch-sender` | Pause admin, set once at `init` | No upgrade mechanism. The admin can only pause and unpause `batch_send`; a separate signer multisig can only rotate its own signers, behind a 7-day timelock | ## Verify a deployed contract diff --git a/scripts/check-contract-registry.mjs b/scripts/check-contract-registry.mjs index 970ea19..55b97d8 100644 --- a/scripts/check-contract-registry.mjs +++ b/scripts/check-contract-registry.mjs @@ -11,14 +11,16 @@ * A registry that only validates its own shape is not canonical, so this check * cross-verifies every recorded fact against pinned authoritative sources: * - * 1. Registry shape and address validity. + * 1. Registry shape and address validity. A contract version has the same + * `upgradeable` flag on every network. * 2. Addresses must match the @wraith-protocol/sdk `getDeployment()` manifest * resolved from the lockfile (the same package version CI installs). * 3. Artifact hashes and deployment timestamps must match on-chain state * reported by the Stellar Expert API and Horizon. * 4. Upgradeability claims and mainnet deployment status must match the * contracts-repo deployment manifest, fetched at the commit pinned in the - * registry. + * registry. Every contract listed on any network needs a governance + * entry there, and a pin without the manifest (HTTP 404) fails. * 5. A shipped .mdx page still carries a stale placeholder contract id. * 6. A deployed registry address is not documented anywhere. * 7. The network guide, SDK, quickstart (demo), or contracts page stopped @@ -31,9 +33,10 @@ * .github/workflows/snippets.yml. */ import { readFile, readdir } from "node:fs/promises"; -import { readFileSync } from "node:fs"; +import { readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; const repoRoot = process.cwd(); const registryPath = path.join(repoRoot, "scripts", "contract-registry.json"); @@ -187,7 +190,7 @@ async function main() { // ---------------------------------------------------------------------- /** Validate the registry shape and return the set of deployed contract ids. */ -function validateRegistry(registry, failures) { +export function validateRegistry(registry, failures) { const deployed = new Set(); const networks = registry.networks; @@ -216,6 +219,10 @@ function validateRegistry(registry, failures) { for (const [contractKey, contract] of Object.entries(network.contracts ?? {})) { const where = `${networkKey}/${contractKey}`; + if (typeof contract.upgradeable !== "boolean") { + failures.push(`scripts/contract-registry.json: ${where} needs a boolean "upgradeable".`); + } + // sdkKey: name of the contract in the SDK manifest, or null when the SDK // does not publish it. A non-null sdkKey with a null address (or vice // versa) is a registry bug, so both directions are validated. @@ -253,9 +260,40 @@ function validateRegistry(registry, failures) { } } + failures.push(...checkUpgradeableAcrossNetworks(networks)); + return deployed; } +/** + * Upgradeability is a property of the contract code, so the same contract + * version must carry the same `upgradeable` flag on every network. Only the + * mainnet rows are cross-checked against the pinned contracts manifest; this + * keeps the testnet and futurenet rows from drifting away from them. + */ +export function checkUpgradeableAcrossNetworks(networks) { + const failures = []; + const seen = new Map(); + for (const [networkKey, network] of Object.entries(networks)) { + for (const [contractKey, contract] of Object.entries(network.contracts ?? {})) { + if (contract.version == null) { + continue; + } + const key = `${contractKey}@${contract.version}`; + const first = seen.get(key); + if (!first) { + seen.set(key, { networkKey, upgradeable: contract.upgradeable }); + } else if (first.upgradeable !== contract.upgradeable) { + failures.push( + `scripts/contract-registry.json: ${contractKey} ${contract.version} is upgradeable=${first.upgradeable} ` + + `on ${first.networkKey} but upgradeable=${contract.upgradeable} on ${networkKey}.`, + ); + } + } + } + return failures; +} + // ---------------------------------------------------------------------- // Cross-check 1: pinned SDK manifest (addresses + which contracts exist) // ---------------------------------------------------------------------- @@ -472,7 +510,15 @@ async function verifyAgainstContractsManifest(registry, failures, warnings) { const pin = registry.pinnedContractsRepoCommit; const manifestUrl = `https://raw.githubusercontent.com/wraith-protocol/contracts/${pin}/audit-prep/DEPLOYMENT_MANIFEST.md`; - const manifest = await fetchText(manifestUrl); + const manifest = await fetchWithRetry(manifestUrl, (text) => text, MANIFEST_NOT_FOUND); + if (manifest === MANIFEST_NOT_FOUND) { + failures.push( + `Contracts manifest not found at pinned commit ${pin.slice(0, 8)} (HTTP 404). ` + + "pinnedContractsRepoCommit must name a wraith-protocol/contracts commit that has " + + "audit-prep/DEPLOYMENT_MANIFEST.md.", + ); + return; + } if (!manifest) { warnings.push( `Contracts manifest at pinned commit ${pin.slice(0, 8)} unreachable; upgradeability cross-check skipped this run.`, @@ -514,41 +560,63 @@ async function verifyAgainstContractsManifest(registry, failures, warnings) { ); } - // Upgradeability claims must match the manifest's governance section. - const governance = parseGovernanceSection(manifest); - for (const [contractKey, contract] of Object.entries(mainnetContracts)) { - const expected = governance.get(contractKey); - if (!expected) { - warnings.push(`${contractKey}: no governance entry found in the pinned manifest.`); - continue; + failures.push(...checkGovernance(registry.networks, parseGovernanceSection(manifest))); +} + +/** + * Every contract listed on any network needs an entry in the manifest's + * governance section: without one its `upgradeable` flag is unverified. The + * mainnet rows' flags must match that entry; the other networks follow from + * checkUpgradeableAcrossNetworks. + */ +export function checkGovernance(networks, governance) { + const failures = []; + const listed = new Set(Object.values(networks).flatMap((network) => Object.keys(network.contracts ?? {}))); + for (const contractKey of listed) { + if (!governance.has(contractKey)) { + failures.push( + `${contractKey}: no governance entry in the pinned contracts manifest. Add it to the ` + + "Governance Configuration section of audit-prep/DEPLOYMENT_MANIFEST.md, then re-pin " + + "pinnedContractsRepoCommit.", + ); } - if (Boolean(contract.upgradeable) !== expected.upgradeable) { + } + for (const [contractKey, contract] of Object.entries(networks["stellar-mainnet"]?.contracts ?? {})) { + const expected = governance.get(contractKey); + if (expected && contract.upgradeable !== expected.upgradeable) { failures.push( `${contractKey}: registry upgradeable=${contract.upgradeable} but the pinned manifest ` + `classifies it as ${expected.label}.`, ); } } + return failures; } +/** Governance subsections of the contracts manifest and what each one means. */ +const governanceSections = [ + { heading: "Frozen Contracts", upgradeable: false, label: "frozen (no upgrade mechanism)" }, + { + heading: "Non-Upgradeable Contracts", + upgradeable: false, + label: "non-upgradeable (pause admin, no upgrade mechanism)", + }, + { heading: "Upgradeable Contracts", upgradeable: true, label: "upgradeable (multisig + timelock)" }, +]; + /** - * Pull the "Frozen Contracts" and "Upgradeable Contracts" blocks out of the - * manifest markdown and return a map of contract key -> {upgradeable, label}. + * Pull the governance subsections out of the manifest markdown and return a + * map of contract key -> {upgradeable, label}. */ -function parseGovernanceSection(manifest) { +export function parseGovernanceSection(manifest) { const governance = new Map(); - const frozen = manifest.match(/### Frozen Contracts[^\n]*\n([\s\S]*?)(?=\n### |\n## )/); - if (frozen) { - for (const match of frozen[1].matchAll(/^\*\*([a-z0-9-]+):\*\*/gm)) { - governance.set(match[1], { upgradeable: false, label: "frozen (no upgrade mechanism)" }); - } - } - - const upgradeable = manifest.match(/### Upgradeable Contracts[^\n]*\n([\s\S]*?)(?=\n### |\n## )/); - if (upgradeable) { - for (const match of upgradeable[1].matchAll(/^\*\*([a-z0-9-]+):\*\*/gm)) { - governance.set(match[1], { upgradeable: true, label: "upgradeable (multisig + timelock)" }); + for (const { heading, upgradeable, label } of governanceSections) { + const section = manifest.match(new RegExp(`### ${heading}[^\\n]*\\n([\\s\\S]*?)(?=\\n### |\\n## )`)); + if (section) { + for (const match of section[1].matchAll(/^\*\*([a-z0-9-]+):\*\*/gm)) { + governance.set(match[1], { upgradeable, label }); + } } } @@ -563,11 +631,10 @@ async function fetchJson(url) { return fetchWithRetry(url, (text) => JSON.parse(text)); } -async function fetchText(url) { - return fetchWithRetry(url, (text) => text); -} +/** Returned by fetchWithRetry for a 404 when the caller asks to tell it apart. */ +const MANIFEST_NOT_FOUND = Symbol("manifest not found"); -async function fetchWithRetry(url, parse) { +async function fetchWithRetry(url, parse, notFound = null) { for (let attempt = 1; attempt <= FETCH_RETRIES; attempt++) { try { const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); @@ -575,7 +642,7 @@ async function fetchWithRetry(url, parse) { return parse(await response.text()); } if (response.status === 404) { - return null; // Definitive: the resource does not exist. + return notFound; // Definitive: the resource does not exist. } // Other statuses (429/5xx) are worth retrying. } catch { @@ -628,7 +695,10 @@ function lineNumberAt(text, index) { return text.slice(0, index).split("\n").length; } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +// Compare real paths so a run through a symlinked checkout still starts main(). +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check-contract-registry.test.mjs b/scripts/check-contract-registry.test.mjs new file mode 100644 index 0000000..1f84139 --- /dev/null +++ b/scripts/check-contract-registry.test.mjs @@ -0,0 +1,119 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + checkGovernance, + checkUpgradeableAcrossNetworks, + parseGovernanceSection, + validateRegistry, +} from "./check-contract-registry.mjs"; + +const manifest = `## Governance Configuration + +### Frozen Contracts (No Admin) + +**stealth-announcer:** +- No admin role +- **stealth-registry:** a nested label, not an entry + +### Non-Upgradeable Contracts (Pause Admin) + +**stealth-batch-sender:** +- No upgrade mechanism + +### Upgradeable Contracts (Multisig + Timelock) + +**stealth-sender:** +- **Admin:** 3-of-5 Multisig + +### Multisig Guardians + +**wraith-names:** +`; + +const row = (upgradeable, version = "v1") => ({ sdkKey: null, address: null, version, artifactHash: null, deploymentLedger: null, upgradeable }); +const networks = (testnet, mainnet) => ({ + "stellar-testnet": { contracts: testnet }, + "stellar-mainnet": { contracts: mainnet }, +}); + +test("parses every governance subsection and only line-start entries inside them", () => { + const governance = parseGovernanceSection(manifest); + assert.deepEqual([...governance.keys()].sort(), ["stealth-announcer", "stealth-batch-sender", "stealth-sender"]); + assert.equal(governance.get("stealth-announcer").upgradeable, false); + assert.equal(governance.get("stealth-batch-sender").upgradeable, false); + assert.match(governance.get("stealth-batch-sender").label, /^non-upgradeable/); + assert.equal(governance.get("stealth-sender").upgradeable, true); +}); + +test("a mainnet contract without a governance entry fails", () => { + const failures = checkGovernance( + networks({}, { "stealth-announcer": row(false), "stealth-splitter": row(false) }), + parseGovernanceSection(manifest), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /^stealth-splitter: no governance entry in the pinned contracts manifest/); +}); + +test("a contract listed only on testnet still needs a governance entry", () => { + const failures = checkGovernance( + networks({ "stealth-vault": row(true) }, { "stealth-announcer": row(false) }), + parseGovernanceSection(manifest), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /^stealth-vault: no governance entry/); +}); + +test("a mainnet upgradeable flag that disagrees with the manifest fails", () => { + const failures = checkGovernance( + networks({}, { "stealth-batch-sender": row(true), "stealth-sender": row(true) }), + parseGovernanceSection(manifest), + ); + assert.deepEqual(failures, [ + "stealth-batch-sender: registry upgradeable=true but the pinned manifest classifies it as " + + "non-upgradeable (pause admin, no upgrade mechanism).", + ]); +}); + +test("matching entries pass", () => { + const rows = { "stealth-announcer": row(false), "stealth-batch-sender": row(false), "stealth-sender": row(true) }; + assert.deepEqual(checkGovernance(networks(rows, rows), parseGovernanceSection(manifest)), []); +}); + +test("the same contract version must have the same upgradeable flag on every network", () => { + const failures = checkUpgradeableAcrossNetworks({ + "stellar-testnet": { contracts: { "stealth-splitter": row(true), "wraith-names": row(true) } }, + "stellar-futurenet": { contracts: { "stealth-splitter": row(false, null) } }, + "stellar-mainnet": { contracts: { "stealth-splitter": row(false), "wraith-names": row(true) } }, + }); + assert.deepEqual(failures, [ + "scripts/contract-registry.json: stealth-splitter v1 is upgradeable=true on stellar-testnet " + + "but upgradeable=false on stellar-mainnet.", + ]); +}); + +test("different contract versions may differ", () => { + assert.deepEqual( + checkUpgradeableAcrossNetworks(networks({ "stealth-announcer": row(true, "v1") }, { "stealth-announcer": row(false, "v2") })), + [], + ); +}); + +test("upgradeable must be a boolean", () => { + const registry = { + sdkVersion: "1.0.0", + pinnedContractsRepoCommit: "0".repeat(40), + networks: { + "stellar-mainnet": { + label: "Stellar Mainnet", + network: "mainnet", + passphrase: "p", + status: "pending", + explorerBase: "e", + contracts: { "stealth-splitter": { ...row("false"), upgradable: false } }, + }, + }, + }; + const failures = []; + validateRegistry(registry, failures); + assert.deepEqual(failures, ['scripts/contract-registry.json: stellar-mainnet/stealth-splitter needs a boolean "upgradeable".']); +}); diff --git a/scripts/contract-registry.json b/scripts/contract-registry.json index b188ce4..0c5e674 100644 --- a/scripts/contract-registry.json +++ b/scripts/contract-registry.json @@ -1,7 +1,7 @@ { "$comment": "Canonical Wraith contract registry. Source of truth: @wraith-protocol/sdk getDeployment() plus the contracts repo audit-prep/DEPLOYMENT_MANIFEST.md. Validated offline by scripts/check-contract-registry.mjs. Do not hand-edit addresses without a matching SDK release.", "sdkVersion": "1.4.5", - "verifiedAt": "2026-09-26", + "verifiedAt": "2026-10-17", "sources": { "sdkManifest": "@wraith-protocol/sdk → getDeployment() (resolved from the lockfile, so CI always checks the same version)", "contractsManifest": "https://raw.githubusercontent.com/wraith-protocol/contracts//audit-prep/DEPLOYMENT_MANIFEST.md", @@ -9,7 +9,7 @@ "onChainExplorerApi": "https://api.stellar.expert/explorer/{network}/contract/{contract-id} → wasm hash + created timestamp", "onChainLedgerApi": "https://horizon-testnet.stellar.org/ledgers/{sequence} → closed_at timestamp for the deployment ledger" }, - "pinnedContractsRepoCommit": "7bc1c6c11e755aac9f4ddcfbe12d774a918f5724", + "pinnedContractsRepoCommit": "fde37d6e5d3353ceeae005bf27eae54b860322fa", "networks": { "stellar-testnet": { "label": "Stellar Testnet", @@ -61,7 +61,7 @@ "artifactHash": null, "deploymentLedger": null, "deployedAt": null, - "upgradeable": true + "upgradeable": false }, "stealth-batch-sender": { "sdkKey": null, @@ -70,7 +70,7 @@ "artifactHash": null, "deploymentLedger": null, "deployedAt": null, - "upgradeable": true + "upgradeable": false } } }, @@ -98,8 +98,8 @@ "stealth-registry": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": false }, "stealth-sender": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": true }, "wraith-names": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": true }, - "stealth-splitter": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": true }, - "stealth-batch-sender": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": true } + "stealth-splitter": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": false }, + "stealth-batch-sender": { "sdkKey": null, "address": null, "version": "v1", "artifactHash": null, "deploymentLedger": null, "deployedAt": null, "upgradeable": false } } } } From 6d2de9fbc3bafb2c92a80235d522af9753be3b44 Mon Sep 17 00:00:00 2001 From: ied <11755605+izzetemredemir@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:40:36 +0300 Subject: [PATCH 2/2] chore(registry): pin the merged contracts governance commit wraith-protocol/contracts#255 merged as affc51c. Pin it instead of the PR head so the registry is checked against the manifest on develop. --- scripts/contract-registry.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/contract-registry.json b/scripts/contract-registry.json index 0c5e674..705a685 100644 --- a/scripts/contract-registry.json +++ b/scripts/contract-registry.json @@ -9,7 +9,7 @@ "onChainExplorerApi": "https://api.stellar.expert/explorer/{network}/contract/{contract-id} → wasm hash + created timestamp", "onChainLedgerApi": "https://horizon-testnet.stellar.org/ledgers/{sequence} → closed_at timestamp for the deployment ledger" }, - "pinnedContractsRepoCommit": "fde37d6e5d3353ceeae005bf27eae54b860322fa", + "pinnedContractsRepoCommit": "affc51c679856cfe67076b20826da00288e266e8", "networks": { "stellar-testnet": { "label": "Stellar Testnet",