The v0.3 contracts return no lists, so events are the system's index. "My credentials", the issuer dashboard, the share inbox, the record type list, the access group members and the governance queue are all rebuilt by scanning logs. Errors are custom Solidity errors with arguments, which the app decodes into messages.
Which events feed which screens
The holder events (HolderBound, HolderRebound, HolderUnbound) are not scanned: the inbox asks enrolmentOf for the connected wallet and then finds CredentialIssued logs by the indexed subject commitment. The server-side status lists read StatusChanged (src/lib/server/status-lists/chain-state.ts), and the monitor watches the governance events listed in src/lib/server/ops/checks/chain-events.ts.
Log scanning lives in src/services/indexing/ (behind a CredentialIndex interface with a browser cache) and src/lib/zk/group-events.ts. See Reading the chain.
v0.3 events
CredentialRegistry
| Event | Indexed | Other fields | Emitted by |
|---|
CredentialIssued(bytes32 credentialId, address issuer, bytes32 subjectCommitment, uint32 recordType) | credentialId, issuer, subjectCommitment | recordType | issue, issueReplacing |
CredentialSigned(bytes32 credentialId, address issuer, uint256 nonce, bytes signature) | credentialId, issuer | nonce, signature | issue, issueReplacing |
Indexing subjectCommitment lets a holder find credentials for their enrolment commitment without anyone else learning who they are.
StatusRegistry
| Event | Indexed | Other fields | Emitted by |
|---|
StatusChanged(bytes32 credentialId, Status from, Status to, uint16 reason, address by, bytes32 noteHash) | credentialId, by | from, to, reason, noteHash | initialise (None to Active) and every transition |
CredentialSuperseded(bytes32 credentialId, bytes32 replacementId, address successorRegistry) | all three | | supersede, supersedeFor, supersedeToRegistry |
IssuerSuspensionChanged(address issuer, bool suspended, uint16 reason, address by) | issuer, by | suspended, reason | setIssuerSuspended |
CredentialRegistryBound(address credentialRegistry) | credentialRegistry | | bindCredentialRegistry |
successorRegistry is this set's own credential registry for a replacement in the same set, and another set's registry otherwise (revision R3, from 0.4.0).
IssuerRegistry
| Event | Indexed | Other fields | Emitted by |
|---|
IssuerAdded(address issuer, string name, address addedBy) | issuer, addedBy | name | addIssuer |
IssuerRemoved(address issuer, address removedBy) | both | | removeIssuer |
IssuerUpdated(address issuer, string name) | issuer | name | setIssuerName |
IssuerRetired(address retired, address successor, address retiredBy) | all three | | retireIssuer (from 0.5.0) |
HolderRegistry
| Event | Indexed | Other fields | Emitted by |
|---|
HolderBound(address issuer, bytes32 enrolmentCommitment, address wallet) | all three | | redeem |
HolderRebound(address issuer, bytes32 enrolmentCommitment, address previousWallet, address newWallet) | issuer, enrolmentCommitment | previousWallet, newWallet | rebind |
HolderUnbound(address issuer, bytes32 enrolmentCommitment, address wallet) | all three | | unbind |
InvitationsRevoked(address issuer, bytes32 enrolmentCommitment, uint64 epoch) | issuer, enrolmentCommitment | epoch (the new value) | revokeInvitations |
RecordTypeRegistry
| Event | Indexed | Other fields | Emitted by |
|---|
RecordTypeRegistered(address issuer, uint32 id, string name, string description) | issuer, id | name, description | registerType |
RecordTypeActiveChanged(address issuer, uint32 id, bool active) | issuer, id | active | setTypeActive (only when the flag changes) |
CertichainAccessGate
| Event | Indexed | Other fields | Emitted by |
|---|
MemberAdmitted(bytes32 credentialId, uint256 groupId, uint256 identityCommitment, address actor) | credentialId, groupId, actor | identityCommitment | admit |
MemberRemoved(bytes32 credentialId, uint256 groupId, uint256 identityCommitment, address actor) | credentialId, groupId, actor | identityCommitment | removeMember |
AccessProven(bytes32 credentialId, uint256 epoch, uint256 nullifier, address caller) | credentialId, epoch, caller | nullifier | proveAccess |
The Semaphore contract emits its own events in the same transactions (for example GroupCreated, MemberAdded, MemberRemoved and ProofValidated, declared in ISemaphore and ISemaphoreGroups).
VersionAnnouncer
| Event | Indexed | Other fields |
|---|
VersionAnnounced(bytes32 labelHash, string label, string abiVersion, address credentialRegistry, address issuerRegistry, address statusRegistry, address holderRegistry, address recordTypeRegistry) | labelHash | all others |
Inherited OpenZeppelin events
| Event | From | Where |
|---|
Paused(address account), Unpaused(address account) | Pausable | IssuerRegistry |
RoleGranted, RoleRevoked, RoleAdminChanged | AccessControl | IssuerRegistry, StatusRegistry, VersionAnnouncer |
DefaultAdminTransferScheduled, DefaultAdminTransferCanceled, DefaultAdminDelayChangeScheduled, DefaultAdminDelayChangeCanceled | AccessControlDefaultAdminRules | Same three |
CallScheduled, CallExecuted, CallSalt, Cancelled, MinDelayChange | TimelockController | CertichainTimelock |
EIP712DomainChanged | EIP712 (declared by EIP-5267, never emitted here) | CredentialRegistry, HolderRegistry |
v0.3 custom errors
Shared names
Several contracts declare an error with the same name. The selector is the same when the argument types are the same, so the app decodes them by name and contract.
| Error | Declared in | Meaning |
|---|
SystemPaused() | Credential, holder, record type registries | The set's pause flag is on |
NotIssuer(address) | Issuer, credential, holder and record type registries | Not a registered, active or non-retired issuer, depending on the call |
InvalidSignature() | Credential and holder registries | Signature does not verify for the issuer (EOA or ERC-1271) |
CredentialRegistry
| Error | Raised when |
|---|
SystemPaused() | Issuing while paused |
NotIssuer(address signer) | Sender is not the credential's issuer, or is not an active issuer |
RecordTypeNotAllowed(address issuer, uint32 recordType) | Reserved id, foreign or unknown custom id, or switched-off type |
InvalidCommitment() | subjectCommitment is zero |
InvalidMetadataHash() | metadataHash is zero |
ZeroDocumentHash() | documentHash is zero |
InvalidIssuedAt(uint64 issuedAt) | issuedAt more than 5 minutes ahead of the block |
InvalidValidity(uint64 issuedAt, uint64 validUntil) | validUntil set and not after issuedAt |
NonceAlreadyUsed(address issuer, uint256 nonce) | Nonce spent before |
InvalidSignature() | Signature check failed |
CredentialAlreadyIssued(bytes32 credentialId) | Defensive: an anchor already exists for the id |
StatusRegistry
| Error | Raised when |
|---|
UnknownCredential(bytes32 credentialId) | No credential with that id in this set |
NotAuthorised(address caller) | Caller is not the key in charge, not the admin where the admin may act, or not the bound credential registry |
InvalidTransition(Status from, Status to) | The move is not allowed from the current status (also: supersede under an admin hold) |
ReplacementInvalid(bytes32 credentialId) | Replacement is the same id, zero, not Active, or has a different key in charge |
InvalidReason(uint16 reason) | Reason in the reserved range 8 to 999 |
AlreadyInitialised(bytes32 credentialId) | initialise called twice |
AlreadyBound() | bindCredentialRegistry called twice |
InvalidAddress() | Zero address in the constructor or binding; a bad successor registry |
RegistryMismatch(address credentialRegistry) | The registry being bound does not name this status registry |
IssuerRegistry
| Error | Raised when |
|---|
InvalidIssuer() | addIssuer with the zero address |
IssuerAlreadyRegistered(address account) | addIssuer for a registered address |
NotIssuer(address account) | Remove, rename or retire an unregistered address |
EmptyName(), NameTooLong(uint256 length), NameAlreadyInUse(string name) | Name rules (1 to 128 bytes, unique) |
KeyRetired(address account) | Registering or retiring an already retired key |
InvalidSuccessor(address successor) | Successor is the same key or not an active issuer |
LineageTooLong() | The chain would exceed 8 hops |
HolderRegistry
| Error | Raised when |
|---|
ZeroWallet() | Invitation names no wallet |
NotWallet(address caller) | Sender is not the named wallet |
InvitationExpired(uint64 expiry) | Expiry has passed |
ExpiryTooFar(uint64 expiry) | Expiry more than 30 days ahead |
NotIssuer(address signer) | Issuer not active (redeem, rebind), or caller is a retired key (unbind, revoke invitations) |
InvitationAlreadyRedeemed(bytes32 invitationId) | Invitation used before |
StaleInvitation(uint64 currentEpoch, uint64 invitationEpoch) | The binding epoch moved since the invitation was signed |
InvalidSignature() | Signature check failed |
CommitmentAlreadyBound(address issuer, bytes32 enrolmentCommitment) | redeem for a bound enrolment |
WalletAlreadyBound(address wallet) | The wallet already holds an enrolment at this issuer |
NotBound(address issuer, bytes32 enrolmentCommitment) | rebind or unbind with no binding |
SystemPaused() | Redeem or rebind while paused |
RecordTypeRegistry
| Error | Raised when |
|---|
SystemPaused() | Registering while paused |
NotIssuer(address account) | Not an active issuer (register) or a retired key (switch) |
EmptyTypeName(), TypeNameTooLong(uint256 length), TypeDescriptionTooLong(uint256 length) | Name 1 to 64 bytes, description at most 256 bytes |
TypeNameInUse(string name) | The issuer used the name before |
UnknownRecordType(address issuer, uint32 id) | No such custom type for that issuer |
CertichainAccessGate
| Error | Raised when |
|---|
AccessGate__ZeroAddress() | Zero address in the constructor |
AccessGate__Paused() | Admit or prove while the set is paused |
AccessGate__UnknownCredential(bytes32) | No such credential in the served set |
AccessGate__NotHolderOrIssuer(bytes32, address) | Caller is neither the bound holder nor the (registered, for admission) issuer |
AccessGate__CredentialNotValid(bytes32) | isValid is false |
AccessGate__ZeroCommitment() | Identity commitment is zero |
AccessGate__GroupFull(bytes32) | 64 commitments were already admitted over the credential's life |
AccessGate__NoGroup(bytes32) | Nobody was ever admitted |
AccessGate__EpochOutOfWindow(uint256 epoch, uint256 current) | Epoch not within one day of the current one |
AccessGate__DepthNotServed(uint256 depth) | Proof tree depth above 6 |
AccessGate__WrongScope(uint256 expected, uint256 actual) | Proof made for another credential, epoch, gate or chain |
AccessGate__WrongMessage(uint256 expected, uint256 actual) | Proof bound to another submitting address |
AccessGate__NullifierSpent(bytes32, uint256) | This member already proved for this credential in this epoch |
AccessGate__InvalidProof() | Semaphore rejected the proof |
VersionAnnouncer
| Error | Raised when |
|---|
InvalidLabel() | Empty or longer than 32 bytes, or empty ABI version |
LabelAlreadyAnnounced(string label) | Label used before |
SetMismatch() | Zero address, or the five addresses do not belong together |
Inherited OpenZeppelin errors
| Error | When |
|---|
AccessControlUnauthorizedAccount(address account, bytes32 neededRole) | Missing role. pause, unpause and issuer suspension report GUARDIAN_ROLE as the needed role |
EnforcedPause(), ExpectedPause() | Pausing a paused set, unpausing a running one |
AccessControlEnforcedDefaultAdminRules(), AccessControlEnforcedDefaultAdminDelay(uint48), AccessControlInvalidDefaultAdmin(address) | Breaking the two-step admin rules (instant grant, accepting early, wrong nominee) |
TimelockUnexpectedOperationState, TimelockInsufficientDelay, TimelockUnauthorizedCaller and others | Timelock misuse (executing early, scheduling with a short delay, scheduling without the proposer role) |
How the app turns errors into messages
Each registry has a small error module in src/lib/ that maps error names to user messages: credential-errors.ts, status-errors.ts, holder-errors.ts, record-type-errors.ts and zk/gate-errors.ts. The name comes from ethers' decoded revert, or, when a send fails at gas estimation and ethers leaves only raw data, from parsing that data against the contract's ABI (revertName in src/lib/credential-errors.ts). For example, RecordTypeNotAllowed becomes "This record type is not available to you. It may be switched off, or it may belong to another institution."
A wallet rejection is recognised separately (ACTION_REJECTED, or code 4001, in src/services/wallet/errors.ts, and "Transaction rejected in wallet" in src/lib/contract-errors.ts), so pressing reject is never reported as a contract failure.
Legacy v0.2
v0.2 uses require strings. The main ones:
| Message | Contract and function |
|---|
| "Not super admin" | RoleManager, super admin functions |
| "Cannot remove super admin", "Already an admin", "Not an admin" | addAdmin, removeAdmin |
| "Invalid address", "Invalid name", "Name already in use" | University and student registration |
| "Use registry functions" | grantRole, revokeRole, renounceRole for registry roles |
| "University name not set", "Student ID mismatch", "Student wallet mismatch" | addRecord |
| "Record does not exist", "No permission to view this record" | Record reads |
| "Not a registered student", "Not your record", "Share limit reached" | Sharing |
| "Not the creator", "Custom type does not exist" | Custom types |
| "Only AcademicRecords", "Student ID already registered", "Student not registered" | StudentManagement |
Events: RecordAdded, RecordVerified, RecordAccessed, StudentDeleted, RecordShared, RecordUnshared, CustomRecordTypeCreated, CustomRecordTypeUpdated (IAcademicRecords); AdminAdded, AdminRemoved, UniversityAdded, UniversityRemoved, UniversityNameUpdated (RoleManager); StudentRegistered, StudentAddressUpdated (StudentManagement). The app rebuilds v0.2 sharing lists from RecordShared and RecordUnshared.
Source files
blockchain/contracts/v03/interfaces/ICredentialRegistry.sol, IStatusRegistry.sol, IIssuerRegistry.sol, IHolderRegistry.sol, IRecordTypeRegistry.sol
blockchain/contracts/zk/interfaces/ICertichainAccessGate.sol
blockchain/contracts/v03/VersionAnnouncer.sol
blockchain/contracts/interfaces/IAcademicRecords.sol, blockchain/contracts/abstract/RoleManager.sol, blockchain/contracts/modules/StudentManagement.sol
src/services/indexing/, src/lib/zk/group-events.ts
src/lib/credential-errors.ts, status-errors.ts, holder-errors.ts, record-type-errors.ts, contract-errors.ts, src/lib/zk/gate-errors.ts, src/services/wallet/errors.ts
Tests
The contract tests of each registry assert its errors with revertedWithCustomError (see Contract tests), and the invariant suite compares the outcome of every random action, success or error name, with the reference model (see Invariants and gas). The app's error modules have their own unit tests next to them in src/lib/.