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;
| Base | Purpose |
|---|---|
EIP712("Certichain", "1") (OpenZeppelin 5) | Typed data hashing bound to the chain id and this contract's address |
ERC165 | supportsInterface, true for ICredentialRegistry |
ICredentialRegistry | Events, errors and the external interface |
Constructor arguments, stored as immutables:
| Argument | Getter |
|---|---|
IIssuerRegistry issuers | issuerRegistry() |
IStatusRegistry statuses | statusRegistry() |
IHolderRegistry holders | holderRegistry() |
IRecordTypeRegistry recordTypes | recordTypeRegistry() |
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
| Mapping | Meaning |
|---|---|
_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
| Function | Returns |
|---|---|
getCredential(bytes32 id) CredentialAnchor | The stored anchor. Unknown ids return an all-zero struct (no revert) |
isNonceUsed(address issuer, uint256 nonce) bool | Whether the nonce is spent |
credentialId(Credential credential) bytes32 | The EIP-712 digest of the struct, which is the credential id |
contractVersion() string | "0.5.0" in the current source |
supportsInterface(bytes4) bool | ERC-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:
| Step | Check | Error |
|---|---|---|
| 1 | The set is not paused (issuerRegistry.paused()) | SystemPaused() |
| 2 | credential.issuer == msg.sender and issuerRegistry.isActiveIssuer(msg.sender) | NotIssuer(msg.sender) |
| 3 | recordTypeRegistry.isTypeAllowed(issuer, recordType) | RecordTypeNotAllowed(issuer, recordType) |
| 4 | subjectCommitment is not zero | InvalidCommitment() |
| 5 | metadataHash is not zero | InvalidMetadataHash() |
| 6 | documentHash is not zero | ZeroDocumentHash() |
| 7 | issuedAt <= block.timestamp + 5 minutes | InvalidIssuedAt(issuedAt) |
| 8 | validUntil == 0 or validUntil > issuedAt | InvalidValidity(issuedAt, validUntil) |
| 9 | The nonce is unused for this issuer | NonceAlreadyUsed(issuer, nonce) |
| 10 | SignatureChecker.isValidSignatureNow(issuer, id, signature) | InvalidSignature() |
| 11 | No anchor exists for the id | CredentialAlreadyIssued(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
isValidSignaturefor the id. A test issues throughMockERC1271Wallet. - 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.senderis the key in charge of the old credential (currentIssuerOfof its issuer), so a successor key can re-issue its predecessor's credentials;- the old credential is
ActiveorSuspendedand not under an admin hold; - the replacement is a different credential, is
Activeand 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
issueris the account that sent the issuance. - Each credential starts
Activewith a matchingStatusChangedevent. - 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.supersedeForandinitialise.
Differences in older sets
| Contract version | Difference |
|---|---|
| 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.0 | Adds 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.tsbuilds the credential, asks the wallet for the EIP-712 signature, callsissueorissueReplacing, and reads anchors withgetCredential.src/app/records/add/submit-record.tsdrives the issue form;src/lib/import/run.tsandsrc/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.solblockchain/contracts/v03/interfaces/ICredentialRegistry.solblockchain/contracts/v03/libraries/CredentialTypes.solblockchain/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, futureissuedAt, inverted validity, ERC-1271 issuers, the shared digest vectorblockchain/test/v03/CredentialRecordTypes.test.ts: built-in, reserved, foreign and inactive type idsblockchain/test/v03/Revisions.test.ts: R1 atomic replacement, R2 no stored block, R4 interface and version getters, R5 signing domain ruleblockchain/test/v03/TwoSets.test.ts: two sets side by side, different ids per setblockchain/test/v03/BoundedWork.test.ts,blockchain/test/v03/NoPlaintext.test.tsblockchain/test/invariants/Invariants.test.ts