Skip to main content

Issuer registry

IssuerRegistry answers four questions for the rest of a contract set:

  1. Is this address a registered issuer, and what is its name?
  2. Is this key still in charge, or has it been retired to a successor?
  3. Is this account a guardian?
  4. 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) {}
BaseFromWhat it adds
AccessControlDefaultAdminRulesOpenZeppelin 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
PausableOpenZeppelin 5The paused() flag with _pause and _unpause
IIssuerRegistryblockchain/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​

MappingMeaning
_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​

FunctionReturns
isIssuer(address account) boolTrue when account has a non-empty name. A retired key stays an issuer in this sense
isActiveIssuer(address account) boolisIssuer(account) and no successor. This is the check for creating anything new
successorOf(address account) addressThe direct successor, or zero
currentIssuerOf(address account) addressFollows 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) stringThe registered name, or empty
isGuardian(address account) boolTrue for holders of GUARDIAN_ROLE or DEFAULT_ADMIN_ROLE
paused() boolThe set's pause flag
contractVersion() string"0.5.0" in the current source
supportsInterface(bytes4) boolERC-165, true for IIssuerRegistry and the access control interfaces

State-changing functions​

FunctionWho may callChecks, in orderEffectsEventReverts
addIssuer(address issuer, string name)Default adminIssuer not zero; not already registered; never retired; name rules (below)Stores the name and reserves itIssuerAdded(issuer, name, addedBy)InvalidIssuer, IssuerAlreadyRegistered, KeyRetired, EmptyName, NameTooLong, NameAlreadyInUse
removeIssuer(address issuer)Default adminRegisteredDeletes the name and frees itIssuerRemoved(issuer, removedBy)NotIssuer
setIssuerName(address issuer, string name)Default adminRegistered; name rulesFrees the old name, reserves the new oneIssuerUpdated(issuer, name)NotIssuer, name errors
retireIssuer(address retired, address successor)Default adminretired 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 8Records the successor and updates the successor's chain lengthIssuerRetired(retired, successor, retiredBy)NotIssuer, KeyRetired, InvalidSuccessor, LineageTooLong
pause()Guardian or default adminNot already pausedSets the pause flagPaused(account)AccessControlUnauthorizedAccount, EnforcedPause
unpause()Guardian or default adminPausedClears the pause flagUnpaused(account)AccessControlUnauthorizedAccount, ExpectedPause
grantRole(GUARDIAN_ROLE, account) and revokeRoleDefault adminOpenZeppelin role rulesAdds or removes a guardianRoleGranted, RoleRevokedAccessControlUnauthorizedAccount
beginDefaultAdminTransfer, acceptDefaultAdminTransfer, cancelDefaultAdminTransfer, changeDefaultAdminDelay, rollbackDefaultAdminDelayDefault admin (accept: the nominee, after the delay)OpenZeppelin AccessControlDefaultAdminRules rulesMoves the admin role in two stepsOpenZeppelin 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.

Unpause in older sets

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:

ActionRetired keySuccessor (key in charge)
Issue a credential, register a record typeRefused (isActiveIssuer is false)Allowed
Suspend, reinstate, revoke, supersede old credentialsRefused (the status registry checks currentIssuerOf)Allowed
Switch a record type off, unbind a holder, cancel invitations in its own namespaceRefusedIts own namespace only (bindings and custom types stay keyed by the key that created them)
Re-issue an old credential with issueReplacingRefusedAllowed 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 addIssuerRefused (KeyRetired)

Rules worth knowing:

  • A retired key keeps its name, so isIssuer stays 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 currentIssuerOf does 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 (and unpause() 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 pausedKeeps working while paused
CredentialRegistry.issue, issueReplacingEvery status change in StatusRegistry, including setIssuerSuspended
HolderRegistry.redeem, rebindHolderRegistry.unbind, revokeInvitations
RecordTypeRegistry.registerTypeRecordTypeRegistry.setTypeActive
CertichainAccessGate.admit, proveAccessCertichainAccessGate.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.ts reads paused, isGuardian, defaultAdmin, pendingDefaultAdmin and defaultAdminDelay for the governance screen, lists retirements from IssuerRetired events, and sends pause() when a guardian clicks the pause control.
  • src/lib/issuer-lineage.ts (lineageOf) calls currentIssuerOf when the registry has it, and src/services/credentials/issuer-standing.ts then asks isIssuer of 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.ts reads isIssuer, the lineage and isGuardian for 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.sol
  • blockchain/contracts/v03/interfaces/IIssuerRegistry.sol
  • blockchain/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 bytes
  • blockchain/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 unpause
  • blockchain/test/v03/Governance.test.ts: two-step admin transfer, guardian role, pause coverage
  • blockchain/test/v03/PauseClassification.test.ts: every state-changing function classified as stopped or working while paused
  • blockchain/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