Skip to main content

Credential registry

CredentialRegistry is where a credential comes into existence. An issuer signs the credential with EIP-712 in the browser and then sends the same data and signature in a transaction from the same key. The contract checks everything it can, stores a fixed anchor, asks the status registry to start the credential's status at Active, and emits two events. Nothing in the anchor can change afterwards, and the contract has no admin.

It is also the entry point of a set: its getters name the other four registries, so a client that knows one credential registry address can find the rest.

Inheritance and constructor​

contract CredentialRegistry is ICredentialRegistry, EIP712, ERC165 {
uint8 public constant SCHEMA_VERSION = 1;
uint64 public constant MAX_FUTURE_DRIFT = 5 minutes;
BasePurpose
EIP712("Certichain", "1") (OpenZeppelin 5)Typed data hashing bound to the chain id and this contract's address
ERC165supportsInterface, true for ICredentialRegistry
ICredentialRegistryEvents, errors and the external interface

Constructor arguments, stored as immutables:

ArgumentGetter
IIssuerRegistry issuersissuerRegistry()
IStatusRegistry statusesstatusRegistry()
IHolderRegistry holdersholderRegistry()
IRecordTypeRegistry recordTypesrecordTypeRegistry()

The credential registry does not call the holder registry itself; it stores the address so clients and the access gate can find it.

Storage in words​

MappingMeaning
_credentials[credentialId]The CredentialAnchor (see Credential types). An anchor with a zero issuer means "not issued"
_usedNonces[issuer][nonce]Nonces an issuer has spent. Each nonce works once per issuer, whatever the content

Functions​

Views​

FunctionReturns
getCredential(bytes32 id) CredentialAnchorThe stored anchor. Unknown ids return an all-zero struct (no revert)
isNonceUsed(address issuer, uint256 nonce) boolWhether the nonce is spent
credentialId(Credential credential) bytes32The EIP-712 digest of the struct, which is the credential id
contractVersion() string"0.5.0" in the current source
supportsInterface(bytes4) boolERC-165
eip712Domain()The standard EIP-5267 domain getter inherited from OpenZeppelin

issue​

function issue(
CredentialTypes.Credential calldata credential,
bytes calldata signature
) external returns (bytes32)

Who may call: an active issuer, for a credential that names itself as issuer. Checks run in this order (from _issue), and the first failure reverts:

StepCheckError
1The set is not paused (issuerRegistry.paused())SystemPaused()
2credential.issuer == msg.sender and issuerRegistry.isActiveIssuer(msg.sender)NotIssuer(msg.sender)
3recordTypeRegistry.isTypeAllowed(issuer, recordType)RecordTypeNotAllowed(issuer, recordType)
4subjectCommitment is not zeroInvalidCommitment()
5metadataHash is not zeroInvalidMetadataHash()
6documentHash is not zeroZeroDocumentHash()
7issuedAt <= block.timestamp + 5 minutesInvalidIssuedAt(issuedAt)
8validUntil == 0 or validUntil > issuedAtInvalidValidity(issuedAt, validUntil)
9The nonce is unused for this issuerNonceAlreadyUsed(issuer, nonce)
10SignatureChecker.isValidSignatureNow(issuer, id, signature)InvalidSignature()
11No anchor exists for the idCredentialAlreadyIssued(id)

Effects, in order: the nonce is marked used; the anchor is stored with schemaVersion = 1; statusRegistry.initialise(id, issuer, validUntil) sets the status to Active; then the two events are emitted:

event CredentialIssued(bytes32 indexed credentialId, address indexed issuer, bytes32 indexed subjectCommitment, uint32 recordType);
event CredentialSigned(bytes32 indexed credentialId, address indexed issuer, uint256 nonce, bytes signature);

CredentialSigned carries the signature, so a verifier can fetch it from the logs by the indexed id and issuer and check it offline, without the holder's package. The status registry also emits StatusChanged(id, None, Active, 0, issuer, 0) in the same transaction.

Why each check exists:

  • Step 2 means a third party cannot submit somebody else's signed credential. The signer and the sender are the same key, and that key must not be retired.
  • Step 3 stops issuance under the reserved ids 32 to 999, under another issuer's custom id, or under a custom type the issuer has switched off.
  • Steps 4 to 6 refuse anchors that would prove nothing: a zero commitment binds no subject, a zero document hash binds no file.
  • Step 7 allows backdating (a degree awarded last year can be issued today) but refuses dates more than 5 minutes in the future, which would only be clock skew or a mistake.
  • Step 9 gives each issuer a replay guard that does not depend on content: the same content under a new nonce is a new credential with a new id, and a reused nonce is refused even for different content.
  • Step 10 accepts an EOA signature (ECDSA, with high-s malleated signatures refused) or a contract wallet that answers ERC-1271 isValidSignature for the id. A test issues through MockERC1271Wallet.
  • Step 11 is a defensive check. Because the id is a digest over the issuer and the nonce, step 9 already refuses an exact repeat.

issueReplacing​

function issueReplacing(
CredentialTypes.Credential calldata credential,
bytes calldata signature,
bytes32 replacedId,
bytes32 noteHash
) external returns (bytes32 id)

Runs every check and effect of issue, then calls statusRegistry.supersedeFor(msg.sender, replacedId, id, noteHash) in the same transaction. If the old credential cannot be superseded, the whole transaction reverts and no new credential is left behind. The status registry requires that:

  • msg.sender is the key in charge of the old credential (currentIssuerOf of its issuer), so a successor key can re-issue its predecessor's credentials;
  • the old credential is Active or Suspended and not under an admin hold;
  • the replacement is a different credential, is Active and has the same key in charge.

Errors from that call (NotAuthorised, InvalidTransition, ReplacementInvalid, UnknownCredential) come from the Status registry. This function exists from contract version 0.4.0 (revision R1). On a 0.3.0 set the app issues first and then calls supersede in a second transaction.

Issuance sequence​

The browser side of this flow (hashing the document, building commitments, encrypting and uploading, the two wallet prompts) is described in Issuing.

Credential ids​

credentialId(credential) returns _hashTypedDataV4(keccak256(abi.encode(CREDENTIAL_TYPEHASH, ...fields))). The id therefore depends on every signed field, the nonce, the chain id and the registry address. Consequences:

  • The same content issued in two sets has two different ids, and a signature made for one deployment is refused by another (tests: "gives the same payload a different id on another deployment", "rejects a signature made for another deployment").
  • A verifier who has the package can recompute the id and compare it with the one in a link or QR code.
  • The app computes ids with src/lib/eip712.ts; a shared vector keeps both sides equal.

Invariants the tests enforce​

  • An anchor never changes after issuance (invariant suite, checked after every random action).
  • Every stored anchor's issuer is the account that sent the issuance.
  • Each credential starts Active with a matching StatusChanged event.
  • No v0.3 contract has an unbounded loop, push or dynamic storage array, and no function returns a list (BoundedWork.test.ts). Gas for issuance stays flat with 1, 100 and 1,000 stored items.
  • Only the credential registry can call StatusRegistry.supersedeFor and initialise.

Differences in older sets​

Contract versionDifference
0.3.0 (active set on Sepolia as of 2026-10-24)Stores the issue block (issueBlock(id)), checks isIssuer rather than isActiveIssuer (there is no lineage), has no issueReplacing and no supportsInterface
0.4.0Adds R1 to R5: issueReplacing, no stored block, the successor hint event in the status registry, ERC-165 and a version label on every registry, the signing domain rule
0.5.0 (current source; set deployed on Sepolia, not active)Adds key lineage: issuance requires isActiveIssuer

The app checks the version with contractVersion() and src/lib/contract-revisions.ts (hasRevisions is true from 0.4.0) before it uses issueReplacing.

How the app uses it​

  • src/services/domain/credential-service.ts builds the credential, asks the wallet for the EIP-712 signature, calls issue or issueReplacing, and reads anchors with getCredential.
  • src/app/records/add/submit-record.ts drives the issue form; src/lib/import/run.ts and src/services/import/ issue rows of a bulk import one by one.
  • The verify page, the API (src/lib/server/api/wiring.ts), VC, disclosure and presentation services read anchors to check documents and signatures.

Gas​

From blockchain/gas-snapshot.json: issue 238,790; issueExpiring (with a validUntil) 238,917; issueReplacing 284,333. See Gas and limits.

Source files​

  • blockchain/contracts/v03/CredentialRegistry.sol
  • blockchain/contracts/v03/interfaces/ICredentialRegistry.sol
  • blockchain/contracts/v03/libraries/CredentialTypes.sol
  • blockchain/contracts/v03/mocks/MockERC1271Wallet.sol (test only)
  • src/services/domain/credential-service.ts, src/lib/eip712.ts, src/lib/contract-revisions.ts

Tests​

  • blockchain/test/v03/CredentialIssuance.test.ts: anchor and events, ids, wrong signer, changed field, malformed and high-s signatures, removed or unregistered issuers, nonce reuse, cross-deployment ids, zero fields, future issuedAt, inverted validity, ERC-1271 issuers, the shared digest vector
  • blockchain/test/v03/CredentialRecordTypes.test.ts: built-in, reserved, foreign and inactive type ids
  • blockchain/test/v03/Revisions.test.ts: R1 atomic replacement, R2 no stored block, R4 interface and version getters, R5 signing domain rule
  • blockchain/test/v03/TwoSets.test.ts: two sets side by side, different ids per set
  • blockchain/test/v03/BoundedWork.test.ts, blockchain/test/v03/NoPlaintext.test.ts
  • blockchain/test/invariants/Invariants.test.ts