Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions deployments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -115,6 +136,10 @@ npm run record -- ckb --network ckb-testnet --rpc https://testnet.ckbapp.dev \

npm run record -- evm --network horizen-testnet --rpc <rpc> \
--contract WraithSender=<address>@<deploy tx hash>

# a supported contract that is not deployed on this network
npm run record -- evm --network horizen-testnet --rpc <rpc> \
--undeployed WraithNames="Not deployed on horizen-testnet yet."
```

`record` reads heights and artifact hashes from the chain, merges into the existing
Expand Down
14 changes: 14 additions & 0 deletions deployments/evm/horizen-testnet.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
14 changes: 14 additions & 0 deletions deployments/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
},
{
Expand Down
13 changes: 13 additions & 0 deletions deployments/schema/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
7 changes: 7 additions & 0 deletions scripts/deployment-manifest/src/catalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export const EXPECTED_CONTRACTS: Record<Chain, string[]> = {
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<Chain, string> = {
evm: 'EVM',
stellar: 'Stellar',
Expand Down
26 changes: 25 additions & 1 deletion scripts/deployment-manifest/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -54,6 +56,9 @@ const USAGE = `Usage: tsx src/cli.ts <command>
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 <sha|HEAD>
--version <semver> | --version name=<semver> (repeatable)
--public-rpc <https-url> stored as rpcUrl for \`verify\`. --rpc is never
Expand Down Expand Up @@ -232,6 +237,7 @@ async function record(chain: Chain, argv: string[]): Promise<void> {
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 },
Expand Down Expand Up @@ -286,6 +292,16 @@ async function record(chain: Chain, argv: string[]): Promise<void> {
});
}

const recordedNames = new Set(recorded.map((c) => c.name));
const undeployed = new Map<string, Undeployed>(
(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,
Expand All @@ -295,17 +311,25 @@ async function record(chain: Chain, argv: string[]): Promise<void> {
: {}),
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<void> {
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) {
Expand Down
60 changes: 59 additions & 1 deletion scripts/deployment-manifest/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -28,13 +29,19 @@ export interface Contract {
solana?: { programData: string; upgradeAuthority: string | null };
}

export interface Undeployed {
name: string;
reason: string;
}

export interface Deployment {
chain: Chain;
network: string;
networkId: string;
rpcUrl?: string;
recordedBy: 'deploy-script' | 'chain-query';
contracts: Contract[];
undeployed?: Undeployed[];
}

export interface Manifest {
Expand Down Expand Up @@ -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<string>();
(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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion scripts/deployment-manifest/src/subgraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading