Status registry
A credential anchor never changes, but whether the credential still stands does. StatusRegistry records that second fact. It holds one StatusEntry per credential, moves it only through allowed transitions, keeps an issuer-wide suspension flag, and answers the question every verifier asks: isValid(id).
Nothing in this contract deletes anything. A revoked or superseded credential stays on the chain with its reason and history in events.
Inheritance, constructor and binding
contract StatusRegistry is IStatusRegistry, AccessControlDefaultAdminRules {
uint16 private constant RESERVED_FROM = 8;
uint16 private constant ISSUER_DEFINED_FROM = 1000;
AccessControlDefaultAdminRules(OpenZeppelin 5) gives one default admin with a delayed two-step transfer, exactly as in the Issuer registry.- Constructor:
(uint48 adminDelay, address admin, IIssuerRegistry issuers). A zero issuer registry revertsInvalidAddress(). - After deployment the admin calls
bindCredentialRegistry(registry)once. It revertsAlreadyBound()if already bound,InvalidAddress()for zero, andRegistryMismatch(registry)unless that credential registry'sstatusRegistry()is this contract. It emitsCredentialRegistryBound(registry). The deploy script does this right after deploying the credential registry.
Storage in words
| Item | Meaning |
|---|---|
credentialRegistry | The one credential registry allowed to call initialise and supersedeFor |
_entries[id] | The StatusEntry: status, reason, time, actor, replacement id, admin hold |
_meta[id] | The credential's issuer and validUntil, copied at initialise so isValid needs no call to the credential registry |
_issuerSuspended[issuer] | Issuer-wide suspension flag |
The status machine
RevokedandSupersededare terminal. No function leaves them.- "Issuer" always means the key in charge:
issuerRegistry.currentIssuerOf(credential issuer). After a key retirement the successor holds this authority and the retired key loses it. - Status changes never check the pause flag. In an incident, revocation must keep working.
Who may do what
This matrix is the one blockchain/test/v03/StatusRegistry.test.ts generates and checks case by case (6 starting states, 4 actions, 3 callers). "Suspended by admin" means a suspension with adminHold set.
| Starting state | Action | Issuer | Admin | Anyone else |
|---|---|---|---|---|
| None (unknown id) | any | UnknownCredential | UnknownCredential | UnknownCredential |
| Active | suspend | ok | ok, sets admin hold | NotAuthorised |
| Active | reinstate | InvalidTransition | InvalidTransition | NotAuthorised |
| Active | revoke | ok | NotAuthorised | NotAuthorised |
| Active | supersede | ok | NotAuthorised | NotAuthorised |
| Suspended | suspend | InvalidTransition | InvalidTransition | NotAuthorised |
| Suspended | reinstate | ok | NotAuthorised | NotAuthorised |
| Suspended | revoke | ok | NotAuthorised | NotAuthorised |
| Suspended | supersede | ok | NotAuthorised | NotAuthorised |
| Suspended by admin | reinstate | NotAuthorised | ok, clears the hold | NotAuthorised |
| Suspended by admin | revoke | ok, clears the hold | NotAuthorised | NotAuthorised |
| Suspended by admin | supersede | InvalidTransition | NotAuthorised | NotAuthorised |
| Revoked or Superseded | any | InvalidTransition (reinstate, revoke, supersede, suspend) | InvalidTransition or NotAuthorised | NotAuthorised |
The admin can only hold a credential. It cannot revoke, supersede or invent status for anyone's credential, so admin power over credentials is limited to a reversible freeze.
Functions
State-changing
| Function | Who may call | Checks | Effects and events | Reverts |
|---|---|---|---|---|
initialise(bytes32 id, address issuer, uint64 validUntil) | The bound credential registry only | Not initialised before | Stores issuer and expiry; status Active; StatusChanged(id, None, Active, 0, issuer, 0) | NotAuthorised, AlreadyInitialised |
suspend(bytes32 id, uint16 reason, bytes32 noteHash) | Key in charge, or admin | Known id; issuer calls need a valid reason; status is Active | Status Suspended; adminHold true when the admin (who is not also the issuer) suspends; StatusChanged | UnknownCredential, NotAuthorised, InvalidReason, InvalidTransition |
reinstate(bytes32 id, bytes32 noteHash) | Key in charge, or admin when there is an admin hold | Known id; status is Suspended; a hold can only be lifted by the admin, an issuer suspension only by the issuer | Status Active, reason 0, hold cleared; StatusChanged | UnknownCredential, NotAuthorised, InvalidTransition |
revoke(bytes32 id, uint16 reason, bytes32 noteHash) | Key in charge only | Known id; valid reason; status Active or Suspended | Status Revoked, hold cleared; StatusChanged | UnknownCredential, NotAuthorised, InvalidReason, InvalidTransition |
supersede(bytes32 id, bytes32 replacementId, bytes32 noteHash) | Key in charge only | Status Active or Suspended, no admin hold; replacement differs, has the same key in charge and is Active | Status Superseded, supersededBy set; StatusChanged and CredentialSuperseded(id, replacementId, this set's credential registry) | UnknownCredential, NotAuthorised, InvalidTransition, ReplacementInvalid |
supersedeFor(address issuer, bytes32 id, bytes32 replacementId, bytes32 noteHash) | The bound credential registry only (from issueReplacing) | issuer is the key in charge of id; then as supersede | As supersede, with issuer as the actor | NotAuthorised and as supersede |
supersedeToRegistry(bytes32 id, bytes32 replacementId, address successorRegistry, bytes32 noteHash) | Key in charge only | Status Active or Suspended, no admin hold; replacement not zero and not id; successorRegistry not zero and not this set's credential registry | As supersede, but the event names successorRegistry. The replacement is not checked on chain, because it lives in another set | UnknownCredential, NotAuthorised, InvalidTransition, ReplacementInvalid, InvalidAddress |
setIssuerSuspended(address issuer, bool suspended, uint16 reason) | Suspend: guardian or admin. Lift: admin only | Role check only | Sets the flag; IssuerSuspensionChanged(issuer, suspended, reason, by) | AccessControlUnauthorizedAccount |
bindCredentialRegistry(address registry) | Admin | See above | CredentialRegistryBound | AlreadyBound, InvalidAddress, RegistryMismatch |
| Role and admin transfer functions | Admin | OpenZeppelin rules | OpenZeppelin events | OpenZeppelin errors |
Views
| Function | Returns |
|---|---|
statusOf(bytes32 id) StatusEntry | The full entry (all zero for an unknown id) |
isIssuerSuspended(address issuer) bool | The issuer-wide flag for that exact address |
isValid(bytes32 id) bool | The good-standing answer, below |
credentialRegistry() address | The bound registry |
contractVersion() string, supportsInterface(bytes4) bool | "0.5.0" and ERC-165 |
Reason codes
_requireIssuerReason refuses codes 8 to 999 in suspend (issuer calls) and revoke. Codes 0 to 7 are defined by the app (src/lib/status-reasons.ts, listed on Credential types), and 1000 and above are free for an issuer's own meanings. Admin suspensions skip the check. reinstate and supersede always record reason 0.
The noteHash argument is a hash of a free-text note the issuer may keep off chain. The app builds it in src/services/domain/note-hash.ts; the contract only stores it in the event.
isValid: good standing
function isValid(bytes32 id) external view returns (bool) {
Meta storage meta = _meta[id];
if (_entries[id].status != CredentialTypes.Status.Active || _issuerSuspended[issuerRegistry.currentIssuerOf(meta.issuer)]) return false;
return meta.validUntil == 0 || block.timestamp <= meta.validUntil;
}
A credential is in good standing when all three hold:
- its status is
Active; - the key in charge of its issuer is not suspended;
- it has no expiry, or the block time is at or before
validUntil(inclusive;ExpiryBoundary.test.tspins valid atvalidUntiland invalid one second later).
Points to note:
- Expiry does not change the stored status. An expired credential stays
ActiveinstatusOfwhileisValidreturns false. - Issuer suspension follows the lineage. If the guardian suspends a key and the admin then retires it to a fresh successor, the old credentials return to good standing; if the successor is suspended, they leave it.
isValiddoes not check that the issuer is still registered. A removed issuer's credentials stay valid here. The verify page and the API check issuer registration separately and show it as its own result (see Verification).- A removed issuer that has no successor is still its own key in charge, so it can still suspend, revoke or supersede the credentials it issued.
Cross-set supersede
When an issuer moves to a newer contract set, old credentials can point forward to their replacements:
The old set cannot read the new one, so a verifier must check the named replacement itself. The verify page does: it finds the replacement in any listed set and checks that it has the same key in charge and is in good standing. See Contract set upgrade.
Invariants the tests enforce
- Terminal states are never left; anchors never change (invariant suite, after every random action).
status,adminHold,supersededByandisValidalways equal a pure reference model (blockchain/test/invariants/model.ts).- No function deletes a credential or a status (
StatusRegistry.test.ts, AC-6). - Only the bound credential registry can initialise and
supersedeFor; binding happens once. - One set's status registry cannot change a credential of another set (
TwoSets.test.ts).
How the app uses it
src/services/domain/status-service.tssendssuspend,reinstate,revoke,supersedeandsupersedeToRegistry, and readsstatusOf,isValidandisIssuerSuspended.src/services/domain/routed-status.tssends a change to the set that holds the credential.- The Issuer tools section on the verify page (
src/app/verify/ManageCredential.tsx, with a Reason list and a Note (only its hash is stored on-chain) field) and the issuer dashboard expose these actions to the issuer. - The status lists, the verification API, and the VC, badge, disclosure and presentation checks all read
statusOforisValid.
The user-facing flow is in Credential lifecycle.
Gas
From blockchain/gas-snapshot.json: suspend 44,519; reinstate 44,682; revoke 42,265; supersede 76,397; supersedeToRegistry 69,555; admin hold (an admin suspend) 66,189; issuer suspension by a guardian 52,190.
Source files
blockchain/contracts/v03/StatusRegistry.solblockchain/contracts/v03/interfaces/IStatusRegistry.solsrc/services/domain/status-service.ts,src/services/domain/routed-status.ts,src/services/domain/note-hash.ts,src/lib/status-reasons.ts
Tests
blockchain/test/v03/StatusRegistry.test.ts: the full transition matrix, initialise and binding, reasons, supersede rules, issuer suspension, expiry, removed issuers, no delete functionblockchain/test/v03/Revisions.test.ts:supersedeForandsupersedeToRegistry(R1, R3)blockchain/test/v03/KeyLineage.test.ts: authority and good standing after key retirementblockchain/test/v03/ExpiryBoundary.test.ts: the inclusive expiry boundaryblockchain/test/v03/TwoSets.test.ts,blockchain/test/v03/Governance.test.tsblockchain/test/invariants/Invariants.test.ts