Issuer registry
IssuerRegistry answers four questions for the rest of a contract set:
- Is this address a registered issuer, and what is its name?
- Is this key still in charge, or has it been retired to a successor?
- Is this account a guardian?
- Is the set paused?
Every other registry asks it before it acts. It is one of the two contracts in a set that have an admin (the other is the Status registry).
Inheritance and constructor
contract IssuerRegistry is IIssuerRegistry, AccessControlDefaultAdminRules, Pausable {
uint256 public constant MAX_NAME_BYTES = 128;
bytes32 public constant GUARDIAN_ROLE = keccak256("CERTICHAIN_GUARDIAN");
uint8 public constant MAX_LINEAGE_HOPS = 8;
constructor(uint48 adminDelay, address admin) AccessControlDefaultAdminRules(adminDelay, admin) {}
| Base | From | What it adds |
|---|---|---|
AccessControlDefaultAdminRules | OpenZeppelin 5 (@openzeppelin/contracts-v5) | Roles, plus a single default admin that can only change through a delayed two-step transfer (beginDefaultAdminTransfer, wait adminDelay, acceptDefaultAdminTransfer). An instant grant or renounce of the admin role is refused |
Pausable | OpenZeppelin 5 | The paused() flag with _pause and _unpause |
IIssuerRegistry | blockchain/contracts/v03/interfaces/ | Events, errors and the views other contracts call |
adminDelay is chosen at deployment. The deploy script defaults to 3 days (DEFAULT_ADMIN_DELAY_SEC in blockchain/scripts/lib/deploy-v03.ts). How the Safe and the timelock take over this admin role is described on the Timelock page and in Governance.
Storage in words
| Mapping | Meaning |
|---|---|
_names[issuer] | The registered name. A non-empty name is what makes an address an issuer |
_nameHolder[keccak256(name)] | Which issuer holds a name, so two issuers can never share one |
_successor[retired] | The key that took over from a retired key (zero if not retired) |
_hopsBehind[key] | Length of the longest chain of retired keys that ends at this key, used to cap lineage at 8 hops |
Roles live in OpenZeppelin's role storage: DEFAULT_ADMIN_ROLE (one holder) and GUARDIAN_ROLE (any number of holders). The pause flag lives in Pausable.
Functions
Views
| Function | Returns |
|---|---|
isIssuer(address account) bool | True when account has a non-empty name. A retired key stays an issuer in this sense |
isActiveIssuer(address account) bool | isIssuer(account) and no successor. This is the check for creating anything new |
successorOf(address account) address | The direct successor, or zero |
currentIssuerOf(address account) address | Follows successors for up to MAX_LINEAGE_HOPS (8) hops and returns the key in charge now. An address with no successor returns itself |
issuerName(address issuer) string | The registered name, or empty |
isGuardian(address account) bool | True for holders of GUARDIAN_ROLE or DEFAULT_ADMIN_ROLE |
paused() bool | The set's pause flag |
contractVersion() string | "0.5.0" in the current source |
supportsInterface(bytes4) bool | ERC-165, true for IIssuerRegistry and the access control interfaces |
State-changing functions
| Function | Who may call | Checks, in order | Effects | Event | Reverts |
|---|---|---|---|---|---|
addIssuer(address issuer, string name) | Default admin | Issuer not zero; not already registered; never retired; name rules (below) | Stores the name and reserves it | IssuerAdded(issuer, name, addedBy) | InvalidIssuer, IssuerAlreadyRegistered, KeyRetired, EmptyName, NameTooLong, NameAlreadyInUse |
removeIssuer(address issuer) | Default admin | Registered | Deletes the name and frees it | IssuerRemoved(issuer, removedBy) | NotIssuer |
setIssuerName(address issuer, string name) | Default admin | Registered; name rules | Frees the old name, reserves the new one | IssuerUpdated(issuer, name) | NotIssuer, name errors |
retireIssuer(address retired, address successor) | Default admin | retired is registered, or is already somebody's successor in a chain; retired has no successor yet; successor differs from retired and is an active issuer; the new chain length is at most 8 | Records the successor and updates the successor's chain length | IssuerRetired(retired, successor, retiredBy) | NotIssuer, KeyRetired, InvalidSuccessor, LineageTooLong |
pause() | Guardian or default admin | Not already paused | Sets the pause flag | Paused(account) | AccessControlUnauthorizedAccount, EnforcedPause |
unpause() | Guardian or default admin | Paused | Clears the pause flag | Unpaused(account) | AccessControlUnauthorizedAccount, ExpectedPause |
grantRole(GUARDIAN_ROLE, account) and revokeRole | Default admin | OpenZeppelin role rules | Adds or removes a guardian | RoleGranted, RoleRevoked | AccessControlUnauthorizedAccount |
beginDefaultAdminTransfer, acceptDefaultAdminTransfer, cancelDefaultAdminTransfer, changeDefaultAdminDelay, rollbackDefaultAdminDelay | Default admin (accept: the nominee, after the delay) | OpenZeppelin AccessControlDefaultAdminRules rules | Moves the admin role in two steps | OpenZeppelin events (DefaultAdminTransferScheduled and others) | OpenZeppelin errors |
Name rules (_assignName): the name must be 1 to 128 bytes (not characters; a test checks that multi-byte characters count by bytes), and no other issuer may hold the same exact string. Uniqueness stops one institution posing as another by name.
In this source a guardian may unpause (revision R8, contract version 0.5.0). In the contract set 0.3.0, which is the active set on Sepolia as of 2026-10-24, unpause is admin only. The guardian may pause in both.
Key retirement and lineage
An institution may need to replace its signing key: a wallet is lost, a person leaves, or a key is suspected compromised. Removing the old key would leave its credentials with nobody who can revoke them. Retirement instead names a successor that inherits authority over everything the old key issued.
What retirement changes:
| Action | Retired key | Successor (key in charge) |
|---|---|---|
| Issue a credential, register a record type | Refused (isActiveIssuer is false) | Allowed |
| Suspend, reinstate, revoke, supersede old credentials | Refused (the status registry checks currentIssuerOf) | Allowed |
| Switch a record type off, unbind a holder, cancel invitations in its own namespace | Refused | Its own namespace only (bindings and custom types stay keyed by the key that created them) |
Re-issue an old credential with issueReplacing | Refused | Allowed in one transaction |
Good standing of old credentials (isValid) | Follows the successor: suspending the successor takes old credentials out of good standing | |
Register again with addIssuer | Refused (KeyRetired) |
Rules worth knowing:
- A retired key keeps its name, so
isIssuerstays true and verifiers can still show who issued old credentials. - A key can be retired only once, and the successor must be active at that moment.
- Chains are capped at 8 hops so
currentIssuerOfdoes bounded work. When two retired keys share a successor, the longest chain counts. - A key that was removed but sits inside a chain can still be retired onward, so a compromised tip of a chain can be replaced.
- An invitation signed by a key before it was retired cannot be redeemed afterwards (the holder registry requires an active issuer).
The full procedure, including the timelock batch, is in Key retirement.
Key states
Removed is not stored as a state; it is a key with no name and no successor. A removed key with no successor is still currentIssuerOf itself, so it can still suspend, revoke or supersede the credentials it issued (the status registry test "lets a removed issuer still manage the credentials it issued" pins this). It cannot issue anything new. Retired is final: such an address can never be registered again.
The guardian and the pause
The guardian is an emergency role. It may:
pause()the set (andunpause()from 0.5.0);- suspend an issuer at once through
StatusRegistry.setIssuerSuspended(issuer, true, reason).
It may not register or remove issuers, lift an issuer suspension, grant roles or touch a single credential. The admin counts as a guardian in every check.
What the pause flag stops is fixed by blockchain/test/v03/PauseClassification.test.ts, which fails if any new state-changing function is not classified:
| Stopped while paused | Keeps working while paused |
|---|---|
CredentialRegistry.issue, issueReplacing | Every status change in StatusRegistry, including setIssuerSuspended |
HolderRegistry.redeem, rebind | HolderRegistry.unbind, revokeInvitations |
RecordTypeRegistry.registerType | RecordTypeRegistry.setTypeActive |
CertichainAccessGate.admit, proveAccess | CertichainAccessGate.removeMember, verifyAccess |
All issuer management and role functions in IssuerRegistry |
The idea: in an incident nobody can create new facts, but every correction (revoking a forged credential, unlinking a wallet) still works. See Guardian.
How the app uses it
src/services/domain/platform-governance-service.tsreadspaused,isGuardian,defaultAdmin,pendingDefaultAdminanddefaultAdminDelayfor the governance screen, lists retirements fromIssuerRetiredevents, and sendspause()when a guardian clicks the pause control.src/lib/issuer-lineage.ts(lineageOf) callscurrentIssuerOfwhen the registry has it, andsrc/services/credentials/issuer-standing.tsthen asksisIssuerof the key in charge, so a credential of a retired key is judged by its successor. On a 0.3.0 registry, which has no lineage, the issuer itself is used.src/services/domain/issuer-access-service.tsreadsisIssuer, the lineage andisGuardianfor the connected wallet; the issuer pages (/dashboard,/records/add,/records/import,/records/types,/students/invite) use it to decide whether to show the issuer workspace.
Registering, renaming, retiring and removing issuers are admin actions sent through the Safe and the timelock with blockchain/scripts/timelock-propose.ts (TARGET=IssuerRegistry FUNCTION=addIssuer ...); see Deployment and scripts.
Gas
From blockchain/gas-snapshot.json: addIssuer 75,343; removeIssuer 29,502; pause by a guardian 46,965; unpause by the admin 27,101. retireIssuer is not in the snapshot. See Gas and limits.
Source files
blockchain/contracts/v03/IssuerRegistry.solblockchain/contracts/v03/interfaces/IIssuerRegistry.solblockchain/scripts/lib/deploy-v03.ts(constructor arguments, guardian grant)src/services/domain/platform-governance-service.ts,src/services/domain/issuer-access-service.ts,src/services/credentials/issuer-standing.ts,src/lib/issuer-lineage.ts
Tests
blockchain/test/v03/IssuerRegistry.test.ts: add, rename, remove, unique names, the 128 byte bound counted in bytesblockchain/test/v03/KeyLineage.test.ts: retirement rules, eight hops, shared successors, what a retired key can no longer do, good standing following the successor, guardian unpauseblockchain/test/v03/Governance.test.ts: two-step admin transfer, guardian role, pause coverageblockchain/test/v03/PauseClassification.test.ts: every state-changing function classified as stopped or working while pausedblockchain/test/invariants/KeyLineage.invariants.test.ts: random sequences of add, remove, retire and issue; a retired key never issues and lineage always ends at a key without a successor