Deployment manifest
The app never hard-codes contract addresses for v0.3. It reads a deployment manifest per chain: a JSON file in the repository, bundled into the build, that lists every contract set deployed on that chain and which one is active. A second manifest records the ZK access gate. The deploy scripts write these files; the browser, the server routes and the scripts read them.
| File | Contents | Parser |
|---|---|---|
src/contracts/v03/deployment.<chainId>.json | The v0.3 contract sets of one chain, the active set, the timelock | src/contracts/v03/manifest.ts |
src/contracts/zk/deployment.<chainId>.json | The access gate, its Semaphore contracts and pinned code hashes | src/contracts/zk/manifest.ts |
src/contracts/v03/deployment.example.json, src/contracts/zk/deployment.example.json | Shape examples with zero addresses | |
blockchain/deployments/<network>.json | The legacy v0.2 deployment record | Read by the v0.2 scripts only |
Tracked in the repository as of 2026-10-24: the v0.3 manifest and the access gate manifest for chain 11155111 (Sepolia), plus the two examples. The local chain files (deployment.31337.json) are written by the local deploy scripts and are gitignored. The addresses are listed in Deployments.
v0.3 manifest, schema 2
Schema 2 lists several sets. It is what Sepolia uses.
| Field | Type | Required | Meaning and validation |
|---|---|---|---|
schemaVersion | number | yes | 2 |
network | string | yes | Name of the network, for people (sepolia) |
chainId | number | yes | Must equal the chain the loader asked for, else "chainId ... does not match ..." |
activeSet | string | yes | Label of the set new credentials are issued in; must be one of the listed labels |
timelock | string | no | Address of the CertichainTimelock recorded for this chain. Once recorded it is never replaced by another address (withTimelock). |
sets | array | yes | At least one set |
sets[].label | string | yes | 1 to 32 letters, digits, dots, dashes or underscores, starting with a letter or digit; unique |
sets[].deployedAt | string | yes | ISO timestamp of the deployment |
sets[].deploymentBlock | number | yes | Block where log scans for this set start |
sets[].abiVersion | string | yes | ABI version of the set (0.3.0, 0.5.0) |
sets[].contracts | object | yes | IssuerRegistry, CredentialRegistry, StatusRegistry, HolderRegistry, RecordTypeRegistry, each a 20-byte hex address. No two sets may share a CredentialRegistry (two sets may share an IssuerRegistry). |
The timelock field means "the timelock that administers the registries of this chain once the handover is recorded". On Sepolia it was written when the timelock was deployed (commit a721863); whether every registry has actually accepted it must be read from the chain (see Governance).
Example: Sepolia
This is the real file src/contracts/v03/deployment.11155111.json as of 2026-10-24.
{
"schemaVersion": 2,
"network": "sepolia",
"chainId": 11155111,
"activeSet": "0.3.0",
"timelock": "0x33a22Cc4D86A62Ae141349B768C054b1DD794f33",
"sets": [
{
"label": "0.3.0",
"deployedAt": "2026-10-13T08:23:03.914Z",
"deploymentBlock": 11904844,
"abiVersion": "0.3.0",
"contracts": {
"IssuerRegistry": "0xf401fDe78884bd2a1809BC479Bcb67e0217578fA",
"CredentialRegistry": "0x53Fb56822E507bAf5c0221D993108495CE1931ea",
"StatusRegistry": "0x59A4298f3aACb6E5EA001C5a94DA7DE731bcB54E",
"HolderRegistry": "0x58659C14c955B31B442f23822ae82d37961423d3",
"RecordTypeRegistry": "0x1ce73AF9DF31935d7F7231217a64660030670b3B"
}
},
{
"label": "0.5.0",
"deployedAt": "2026-10-17T07:58:53.131Z",
"deploymentBlock": 11932876,
"abiVersion": "0.5.0",
"contracts": {
"IssuerRegistry": "0x5c1cc061675BF1b83e16102F074c3f2c444bf94a",
"CredentialRegistry": "0x1969950754144Af2ecEAa3A78F96e58Bf19D4297",
"StatusRegistry": "0x5df854C1380c8A48DE158ee02480309b837554A2",
"HolderRegistry": "0x882817b7f50C3be9c878b1E1146589570eB5DF96",
"RecordTypeRegistry": "0xa93Cf8f90CbFBd326A929D808432c8CB7216bA93"
}
}
]
}
v0.3 manifest, schema 1
Schema 1 is one set with its fields at the top level (schemaVersion, network, chainId, deployedAt, deploymentBlock, abiVersion, contracts). The parser reads it as a list of one set, labelled with its abiVersion, which is also the active set. A first local deployment (deployment.31337.json) and the example use it. A schema 1 file has no timelock.
Access gate manifest
| Field | Type | Meaning |
|---|---|---|
schemaVersion | number | 1 |
network, chainId, deployedAt, deploymentBlock, abiVersion | As above; abiVersion is 0.3.0-access.1 on Sepolia | |
credentialRegistry | address | The one credential registry this gate serves |
verifierCodeHash | bytes32 | Code hash of the Semaphore verifier the deploy script checked against the pinned value |
poseidonCodeHash | bytes32 | Code hash of the Poseidon library, checked the same way |
contracts | object | PoseidonT3, SemaphoreVerifier, Semaphore, CertichainAccessGate |
The access features in the app appear only where this manifest exists and its credentialRegistry matches the credential's registry. See ZK access.
Legacy v0.2 record
blockchain/deployments/sepolia.json records the v2.0.0 deployment (RecordStorage, AcademicRecords, StudentManagement, the super admin, a timestamp). The v0.2 frontend does not read it: it takes the AcademicRecords address from NEXT_PUBLIC_CONTRACT_ADDRESS. The older blockchain/deployment-info.json (2025) is kept as history. See Legacy v0.2 contracts.
How the files are read
| Function | Use |
|---|---|
loadManifestSets(chainId) | Imports ./deployment.<chainId>.json, parses it and checks the chain id. Returns null only when no file exists (the app then says it is not deployed on this network); any other problem throws. |
loadManifest(chainId) | The active set only |
parseManifestSets(raw) | Validates schema 1 or 2; errors read "Invalid deployment manifest: ..." |
findSetByRegistry(sets, registry) | The set that owns a CredentialRegistry, any letter case, or null |
addSet, withActiveSet, withTimelock | Return a new manifest object; the scripts then write it with serializeManifestSets (two-space JSON, trailing newline) |
The browser picks the chain from NEXT_PUBLIC_NETWORK_ID, so a build serves exactly one chain. Because the file is bundled, a manifest change (a new set, a new active set) reaches users only after a rebuild.
Who writes them
Script (in blockchain/) | Change |
|---|---|
deploy:v03:local, deploy:v03:sepolia | Writes a new manifest, or with ADD_SET=1 adds a set (and with ACTIVATE_SET=1 makes it active). A plain deployment never replaces a public chain's file. |
deploy:timelock:sepolia | Records timelock after reading the roles back from the chain |
set:activate:sepolia | Rewrites activeSet after the readiness check; sends no transaction |
deploy:access:local, deploy:access:sepolia | Writes the access gate manifest |
Every write is a reviewed commit, so the history of the files is the history of what the app trusts.
Source files
src/contracts/v03/manifest.ts,src/contracts/zk/manifest.tssrc/contracts/v03/deployment.11155111.json,src/contracts/zk/deployment.11155111.json, the twodeployment.example.jsonfiles (localdeployment.31337.jsonfiles are gitignored)blockchain/scripts/lib/record-deployment.ts,blockchain/scripts/deploy-v03.ts,blockchain/scripts/deploy-timelock.ts,blockchain/scripts/activate-set.ts,blockchain/scripts/deploy-access-gate.tssrc/services/credentials/credential-gateway.ts,src/services/credentials/access-gateway.ts
Tests
src/contracts/v03/manifest.test.ts,src/contracts/zk/manifest.test.tssrc/services/credentials/credential-gateway.test.ts