diff --git a/deployments/README.md b/deployments/README.md index 4d54dc5..01baa69 100644 --- a/deployments/README.md +++ b/deployments/README.md @@ -68,6 +68,27 @@ so formatting does not matter). Solana programs carry `solana.programData` and `solana.upgradeAuthority`. CKB scripts carry `ckb.hashType` and `ckb.cellDep`, and `external: true` marks third-party scripts Wraith depends on (ckb-auth). +### Every supported contract needs an explicit record + +For EVM, a network's record must account for every contract the repository supports +(`ERC5564Announcer`, `ERC6538Registry`, `WraithSender`, `WraithNames`, +`WraithWithdrawer`; the list is `EXPECTED_CONTRACTS` in +[`catalogue.ts`](../scripts/deployment-manifest/src/catalogue.ts)). Each one is either: + +- in `contracts`, with its deployment transaction (`deployment.txHash`), block + (`deployment.height`), `artifactHash` and `abi` (path and canonical hash), or +- in `undeployed`, with a reason: + +```jsonc +"undeployed": [ + { "name": "WraithSender", "reason": "Not deployed on horizen-testnet yet." } +] +``` + +A name in both lists, a name that is not a supported contract, or a supported contract +in neither list fails `npm run check`, `npm run build` and `npm run verify`. Recording a +contract removes it from `undeployed`. + `recordedBy: "chain-query"` means the record was reconstructed from chain state after the fact, so `sourceCommit` and `version` may be `null`. The current EVM, Solana and CKB records are of this kind: they predate the manifest. Records written by a deploy script @@ -115,6 +136,10 @@ npm run record -- ckb --network ckb-testnet --rpc https://testnet.ckbapp.dev \ npm run record -- evm --network horizen-testnet --rpc \ --contract WraithSender=
@ + +# a supported contract that is not deployed on this network +npm run record -- evm --network horizen-testnet --rpc \ + --undeployed WraithNames="Not deployed on horizen-testnet yet." ``` `record` reads heights and artifact hashes from the chain, merges into the existing diff --git a/deployments/evm/horizen-testnet.json b/deployments/evm/horizen-testnet.json index f5e2dbe..fde9929 100644 --- a/deployments/evm/horizen-testnet.json +++ b/deployments/evm/horizen-testnet.json @@ -45,5 +45,19 @@ "timestamp": "2026-04-10T03:34:55Z" } } + ], + "undeployed": [ + { + "name": "WraithSender", + "reason": "Not deployed on horizen-testnet yet; the README lists its address as TBD." + }, + { + "name": "WraithNames", + "reason": "Not deployed on horizen-testnet yet; the README lists its address as TBD." + }, + { + "name": "WraithWithdrawer", + "reason": "Not deployed on horizen-testnet yet; the README lists its address as TBD." + } ] } diff --git a/deployments/manifest.json b/deployments/manifest.json index 3afbde3..ca1f441 100644 --- a/deployments/manifest.json +++ b/deployments/manifest.json @@ -49,6 +49,20 @@ "timestamp": "2026-04-10T03:34:55Z" } } + ], + "undeployed": [ + { + "name": "WraithSender", + "reason": "Not deployed on horizen-testnet yet; the README lists its address as TBD." + }, + { + "name": "WraithNames", + "reason": "Not deployed on horizen-testnet yet; the README lists its address as TBD." + }, + { + "name": "WraithWithdrawer", + "reason": "Not deployed on horizen-testnet yet; the README lists its address as TBD." + } ] }, { diff --git a/deployments/schema/v1.json b/deployments/schema/v1.json index cf8e894..9f05262 100644 --- a/deployments/schema/v1.json +++ b/deployments/schema/v1.json @@ -45,6 +45,19 @@ "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/contract" } + }, + "undeployed": { + "description": "Supported contracts this network deliberately has no deployment for. A network that records any contract must account for every contract its chain supports: each one is either in contracts or listed here with a reason. A name must not appear in both.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "reason"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "reason": { "type": "string", "minLength": 1 } + } + } } }, "allOf": [ diff --git a/scripts/deployment-manifest/src/catalogue.ts b/scripts/deployment-manifest/src/catalogue.ts index 04c658d..ccb8f55 100644 --- a/scripts/deployment-manifest/src/catalogue.ts +++ b/scripts/deployment-manifest/src/catalogue.ts @@ -11,6 +11,13 @@ export const EXPECTED_CONTRACTS: Record = { ckb: ['wraith-stealth-lock', 'wraith-names-type'], }; +/** + * Chains whose records must account for every contract in EXPECTED_CONTRACTS: + * each one is either recorded under `contracts` or listed under `undeployed`. + * EVM is the first; add a chain here once its records are complete. + */ +export const EXPLICIT_COVERAGE_CHAINS: Chain[] = ['evm']; + export const CHAIN_LABEL: Record = { evm: 'EVM', stellar: 'Stellar', diff --git a/scripts/deployment-manifest/src/cli.ts b/scripts/deployment-manifest/src/cli.ts index 05429b1..3537854 100644 --- a/scripts/deployment-manifest/src/cli.ts +++ b/scripts/deployment-manifest/src/cli.ts @@ -8,11 +8,13 @@ import { loadRecords, readManifest, serialize, + coverageErrors, validateDeployment, validateManifest, type Chain, type Contract, type Deployment, + type Undeployed, } from './manifest.js'; import { MANIFEST_PATH, REPO_ROOT, recordPath, repoPath } from './paths.js'; import { ckbNetworkId, recordCkbScript } from './record/ckb.js'; @@ -54,6 +56,9 @@ const USAGE = `Usage: tsx src/cli.ts stellar --contract name=CContractId --wasm name=path/to.wasm [--deployer G...] solana --program name=ProgramId ckb --script name=0xTxHash:index:hashType[:external] + evm --undeployed Name=reason mark a supported contract as not deployed on + this network (repeatable). Recording a contract + removes it from the undeployed list. common --recorded-by deploy-script|chain-query --source-commit --version | --version name= (repeatable) --public-rpc stored as rpcUrl for \`verify\`. --rpc is never @@ -232,6 +237,7 @@ async function record(chain: Chain, argv: string[]): Promise { wasm: { type: 'string', multiple: true }, abi: { type: 'string', multiple: true }, deployer: { type: 'string', multiple: true }, + undeployed: { type: 'string', multiple: true }, 'recorded-by': { type: 'string', default: 'chain-query' }, 'source-commit': { type: 'string' }, version: { type: 'string', multiple: true }, @@ -286,6 +292,16 @@ async function record(chain: Chain, argv: string[]): Promise { }); } + const recordedNames = new Set(recorded.map((c) => c.name)); + const undeployed = new Map( + (existing?.undeployed ?? []).filter((u) => !recordedNames.has(u.name)).map((u) => [u.name, u]), + ); + for (const [name, reason] of pairs(values.undeployed, '--undeployed')) { + if (recordedNames.has(name)) + throw new Error(`${name} cannot be both recorded and --undeployed`); + undeployed.set(name, { name, reason }); + } + const deployment: Deployment = { chain, network: values.network, @@ -295,17 +311,25 @@ async function record(chain: Chain, argv: string[]): Promise { : {}), recordedBy: values['recorded-by'] as Deployment['recordedBy'], contracts: [...contracts.values()], + ...(undeployed.size ? { undeployed: [...undeployed.values()] } : {}), }; const errors = validateDeployment(deployment); + if (!errors.length) errors.push(...coverageErrors(deployment)); if (errors.length) fail(errors); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, serialize(deployment)); - console.log(`✓ ${path.relative(REPO_ROOT, file)}: ${recorded.map((c) => c.name).join(', ')}`); + console.log( + `✓ ${path.relative(REPO_ROOT, file)}: ${recorded.map((c) => c.name).join(', ') || 'no new contracts'}` + + (undeployed.size ? ` (undeployed: ${[...undeployed.keys()].join(', ')})` : ''), + ); build(); } async function verify(): Promise { const manifest = readManifest(); + // Every supported contract must have an explicit record before anything is read from a chain. + const invalid = validateManifest(manifest); + if (invalid.length) fail(invalid); const problems: string[] = []; for (const d of manifest.deployments) { if (!d.rpcUrl) { diff --git a/scripts/deployment-manifest/src/manifest.ts b/scripts/deployment-manifest/src/manifest.ts index cfe7fe7..a215fa7 100644 --- a/scripts/deployment-manifest/src/manifest.ts +++ b/scripts/deployment-manifest/src/manifest.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Ajv2020, type ValidateFunction } from 'ajv/dist/2020.js'; +import { EXPECTED_CONTRACTS, EXPLICIT_COVERAGE_CHAINS } from './catalogue.js'; import { ID_CHECKS } from './ids.js'; import { DEPLOYMENTS_DIR, MANIFEST_PATH, REPO_ROOT, SCHEMA_PATH } from './paths.js'; @@ -28,6 +29,11 @@ export interface Contract { solana?: { programData: string; upgradeAuthority: string | null }; } +export interface Undeployed { + name: string; + reason: string; +} + export interface Deployment { chain: Chain; network: string; @@ -35,6 +41,7 @@ export interface Deployment { rpcUrl?: string; recordedBy: 'deploy-script' | 'chain-query'; contracts: Contract[]; + undeployed?: Undeployed[]; } export interface Manifest { @@ -79,6 +86,54 @@ export function validateDeployment(deployment: Deployment): string[] { errors.push(`${where} records written by a deploy script must carry sourceCommit`); } }); + + const supported = EXPECTED_CONTRACTS[deployment.chain]; + const undeployed = new Set(); + (deployment.undeployed ?? []).forEach((entry, i) => { + const where = `/undeployed/${i} (${entry.name})`; + if (undeployed.has(entry.name)) errors.push(`${where} duplicate undeployed name`); + undeployed.add(entry.name); + if (!supported.includes(entry.name)) { + errors.push( + `${where} is not a supported ${deployment.chain} contract (${supported.join(', ')})`, + ); + } + if (seen.has(entry.name)) { + errors.push(`${where} is also recorded under contracts; it is either deployed or not`); + } + }); + return errors; +} + +/** + * What a record must say beyond the schema, for chains in EXPLICIT_COVERAGE_CHAINS: + * every supported contract is recorded or explicitly marked undeployed, and an + * EVM contract that is recorded carries its deployment transaction and ABI. + * Run it after validateDeployment passes. + */ +export function coverageErrors(deployment: Deployment): string[] { + if (!EXPLICIT_COVERAGE_CHAINS.includes(deployment.chain)) return []; + const errors: string[] = []; + const accounted = new Set([ + ...deployment.contracts.map((c) => c.name), + ...(deployment.undeployed ?? []).map((u) => u.name), + ]); + for (const name of EXPECTED_CONTRACTS[deployment.chain]) { + if (!accounted.has(name)) { + errors.push( + `/contracts ${name} has no record: record its deployment, or list it under "undeployed" with a reason`, + ); + } + } + if (deployment.chain === 'evm') { + deployment.contracts.forEach((contract, i) => { + const where = `/contracts/${i} (${contract.name})`; + if (!contract.deployment.txHash) { + errors.push(`${where} deployment.txHash is required for a deployed EVM contract`); + } + if (!contract.abi) errors.push(`${where} abi is required for a deployed EVM contract`); + }); + } return errors; } @@ -90,7 +145,9 @@ export function validateManifest(manifest: Manifest): string[] { const key = `${d.chain}/${d.network}`; if (keys.has(key)) errors.push(`duplicate deployment ${key}`); keys.add(key); - errors.push(...validateDeployment(d).map((e) => `${key}${e}`)); + const problems = validateDeployment(d); + if (!problems.length) problems.push(...coverageErrors(d)); + errors.push(...problems.map((e) => `${key}${e}`)); } return errors; } @@ -120,6 +177,7 @@ export function readRecord( return { errors: ['not a JSON object'] }; } const errors = validateDeployment(deployment); + if (!errors.length) errors.push(...coverageErrors(deployment)); if (deployment.chain !== chain) errors.push(`chain "${deployment.chain}" but stored under ${chain}/`); if (`${deployment.network}.json` !== name) { diff --git a/scripts/deployment-manifest/src/subgraph.ts b/scripts/deployment-manifest/src/subgraph.ts index d16915c..7ece7bf 100644 --- a/scripts/deployment-manifest/src/subgraph.ts +++ b/scripts/deployment-manifest/src/subgraph.ts @@ -91,7 +91,12 @@ export function renderSubgraph( const name = ds.getIn(['source', 'abi']) as string; const contract = contracts.find((c) => c?.name === name); if (!contract) { - errors.push(`${name}: not recorded in ${recordFile}`); + const reason = Array.isArray(deployment.undeployed) + ? deployment.undeployed.find((u) => u?.name === name)?.reason + : undefined; + errors.push( + `${name}: not recorded in ${recordFile}${reason ? ` (marked undeployed: ${reason})` : ''}`, + ); continue; } const problems: string[] = []; diff --git a/scripts/deployment-manifest/test/check.e2e.test.ts b/scripts/deployment-manifest/test/check.e2e.test.ts index 48de853..592939e 100644 --- a/scripts/deployment-manifest/test/check.e2e.test.ts +++ b/scripts/deployment-manifest/test/check.e2e.test.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { parse as parseYaml } from 'yaml'; +import { canonicalAbiSha256 } from '../src/hash.js'; const run = promisify(execFile); const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -28,6 +29,11 @@ const FIXTURE_PATHS = [ 'stellar/contract-ids.json', ]; +type Record_Contract = { + deployment: { txHash: string | null }; + abi?: unknown; +}; + let root: string; async function cli(...args: string[]) { @@ -138,6 +144,74 @@ describe('check', () => { expect(code).toBe(1); }); + describe('EVM coverage', () => { + const RECORD = 'deployments/evm/horizen-testnet.json'; + type Record = { contracts: unknown[]; undeployed?: Array<{ name: string; reason: string }> }; + const change = (fn: (record: Record) => void) => { + const file = path.join(root, RECORD); + const record: Record = JSON.parse(fs.readFileSync(file, 'utf8')); + fn(record); + fs.writeFileSync(file, JSON.stringify(record, null, 2)); + }; + + it('records the two deployed contracts and marks the other three undeployed', () => { + const record: Record = JSON.parse(fs.readFileSync(path.join(root, RECORD), 'utf8')); + expect(record.contracts).toHaveLength(2); + expect(record.undeployed?.map((u) => u.name).sort()).toEqual([ + 'WraithNames', + 'WraithSender', + 'WraithWithdrawer', + ]); + }); + + it('fails when a supported contract is neither recorded nor marked undeployed', async () => { + change((record) => { + record.undeployed = record.undeployed!.filter((u) => u.name !== 'WraithNames'); + }); + const { code, output } = await cli('check'); + expect(output).toMatch(/horizen-testnet\.json: \/contracts WraithNames has no record/); + expect(output).not.toMatch(/WraithSender has no record|WraithWithdrawer has no record/); + expect(code).toBe(1); + }); + + it('fails when no contract is marked undeployed and three are missing', async () => { + change((record) => delete record.undeployed); + const { code, output } = await cli('check'); + for (const name of ['WraithSender', 'WraithNames', 'WraithWithdrawer']) { + expect(output).toContain(`${name} has no record`); + } + expect(code).toBe(1); + }); + + it('fails when a contract is marked undeployed that is also recorded', async () => { + change((record) => + record.undeployed!.push({ name: 'ERC5564Announcer', reason: 'wrongly listed' }), + ); + const { code, output } = await cli('check'); + expect(output).toMatch(/\(ERC5564Announcer\) is also recorded under contracts/); + expect(code).toBe(1); + }); + + it('fails when a recorded EVM contract has no transaction hash or no ABI', async () => { + change((record) => { + const [first, second] = record.contracts as Array; + first.deployment.txHash = null; + delete second.abi; + }); + const { code, output } = await cli('check'); + expect(output).toMatch(/ERC5564Announcer\) deployment\.txHash is required/); + expect(output).toMatch(/ERC6538Registry\) abi is required/); + expect(code).toBe(1); + }); + + it('refuses to build while a supported contract has no record', async () => { + change((record) => delete record.undeployed); + const { code, output } = await cli('build'); + expect(output).toContain('WraithNames has no record'); + expect(code).toBe(1); + }); + }); + it('fails when a record changes without rebuilding the manifest', async () => { edit('deployments/solana/solana-devnet.json', '"height": 456215445', '"height": 456215446'); const { code, output } = await cli('check'); @@ -225,6 +299,7 @@ describe('subgraph', () => { type EvmRecord = { network: string; contracts: Array<{ name: string; id: string; deployment: { height?: number } }>; + undeployed?: unknown; }; const readJson = (rel: string) => JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8')); const readYaml = (rel: string) => parseYaml(fs.readFileSync(path.join(root, rel), 'utf8')); @@ -233,15 +308,24 @@ describe('subgraph', () => { function recordAll(change?: (record: EvmRecord) => void) { const record: EvmRecord = readJson(RECORD); for (const { name, id, height } of missing) { + const abiPath = `evm/subgraph/abis/${name}.json`; record.contracts.push({ name, id, version: null, sourceCommit: null, artifactHash: { algorithm: 'keccak256', value: `0x${'00'.repeat(32)}` }, - deployment: { height }, + abi: { + path: abiPath, + sha256: canonicalAbiSha256( + JSON.parse(fs.readFileSync(path.join(repoRoot, abiPath), 'utf8')), + ), + }, + deployment: { height, txHash: `0x${'44'.repeat(32)}` }, } as EvmRecord['contracts'][number]); } + // Recorded now, so no longer undeployed. + delete record.undeployed; change?.(record); fs.writeFileSync(path.join(root, RECORD), JSON.stringify(record, null, 2)); return record; diff --git a/scripts/deployment-manifest/test/units.test.ts b/scripts/deployment-manifest/test/units.test.ts index e5b1f2d..1b9f073 100644 --- a/scripts/deployment-manifest/test/units.test.ts +++ b/scripts/deployment-manifest/test/units.test.ts @@ -2,7 +2,12 @@ import { base64 } from '@scure/base'; import { describe, expect, it } from 'vitest'; import { canonicalAbiSha256, ckbHashHex, keccak256Hex } from '../src/hash.js'; import { ID_CHECKS, decodeStellarContractId } from '../src/ids.js'; -import { buildManifest, validateDeployment, type Deployment } from '../src/manifest.js'; +import { + buildManifest, + coverageErrors, + validateDeployment, + type Deployment, +} from '../src/manifest.js'; import { serializeScript } from '../src/record/ckb.js'; import { contractInstanceKey, wasmHashFromInstanceEntry } from '../src/record/stellar.js'; @@ -136,6 +141,26 @@ describe('validation', () => { expect(validateDeployment(d).length).toBeGreaterThan(0); }); + it('accepts undeployed entries and rejects unknown, duplicate, empty or contradictory ones', () => { + const entry = (name: string, reason = 'not deployed yet') => ({ name, reason }); + const withUndeployed = (...undeployed: Array<{ name: string; reason: string }>) => ({ + ...evmDeployment(), + undeployed, + }); + + expect(validateDeployment(withUndeployed(entry('WraithSender')))).toEqual([]); + expect(validateDeployment(withUndeployed(entry('WraithRouter'))).join()).toMatch( + /not a supported evm contract/, + ); + expect( + validateDeployment(withUndeployed(entry('WraithSender'), entry('WraithSender'))).join(), + ).toMatch(/duplicate undeployed/); + expect(validateDeployment(withUndeployed(entry('ERC5564Announcer'))).join()).toMatch( + /also recorded under contracts/, + ); + expect(validateDeployment(withUndeployed(entry('WraithSender', ''))).length).toBeGreaterThan(0); + }); + it('orders the manifest by chain then network', () => { const ckb = { ...evmDeployment(), chain: 'ckb' as const, network: 'a' }; const evmB = { ...evmDeployment(), network: 'b' }; @@ -146,3 +171,54 @@ describe('validation', () => { expect(order).toEqual(['evm/a', 'evm/b', 'ckb/a']); }); }); + +describe('coverage', () => { + const EVM = [ + 'ERC5564Announcer', + 'ERC6538Registry', + 'WraithSender', + 'WraithNames', + 'WraithWithdrawer', + ]; + const deployed = (name: string) => ({ + ...evmDeployment().contracts[0], + name, + abi: { path: `evm/subgraph/abis/${name}.json`, sha256: `0x${'00'.repeat(32)}` }, + deployment: { height: 1, txHash: `0x${'11'.repeat(32)}` }, + }); + const record = (deployedNames: string[], undeployedNames: string[] = []): Deployment => ({ + ...evmDeployment(), + contracts: deployedNames.map(deployed), + ...(undeployedNames.length + ? { undeployed: undeployedNames.map((name) => ({ name, reason: 'not deployed yet' })) } + : {}), + }); + + it('passes when every supported EVM contract is recorded', () => { + expect(coverageErrors(record(EVM))).toEqual([]); + }); + + it('passes when the contracts that are not recorded are marked undeployed', () => { + expect(coverageErrors(record(EVM.slice(0, 2), EVM.slice(2)))).toEqual([]); + }); + + it('names every supported contract that is neither recorded nor marked undeployed', () => { + const errors = coverageErrors(record(EVM.slice(0, 2), ['WraithSender'])).join('\n'); + expect(errors).toMatch(/WraithNames has no record/); + expect(errors).toMatch(/WraithWithdrawer has no record/); + expect(errors).not.toMatch(/WraithSender|ERC5564Announcer|ERC6538Registry/); + }); + + it('requires a deployment transaction and an ABI on every recorded EVM contract', () => { + const d = record(EVM); + d.contracts[0].deployment.txHash = null; + delete d.contracts[1].abi; + const errors = coverageErrors(d).join('\n'); + expect(errors).toMatch(/ERC5564Announcer\) deployment\.txHash is required/); + expect(errors).toMatch(/ERC6538Registry\) abi is required/); + }); + + it('only applies to chains that require explicit coverage', () => { + expect(coverageErrors({ ...evmDeployment(), chain: 'stellar' })).toEqual([]); + }); +});