Holder registry
Credentials name their subject only by a salted commitment, so the chain cannot tell which wallet belongs to which student. Some features still need that link: the holder inbox lists credentials for the connected wallet, and the Access gate lets the holder's wallet admit parties. HolderRegistry stores the link, and only in one way: the issuer signs an invitation naming the student's enrolment commitment and wallet, and that wallet redeems it.
Nobody else can write a binding. The contract has no admin and no admin path that writes one (a test checks this).
Constructor and constants
uint64 public constant MAX_INVITATION_TTL = 30 days;
- Inherits
EIP712("Certichain", "1")andERC165(OpenZeppelin 5). - Constructor:
(IIssuerRegistry issuers), stored as the immutableissuerRegistry().
The invitation type string:
Invitation(address issuer,bytes32 enrolmentCommitment,address wallet,uint64 expiry,uint64 epoch,uint256 nonce)
The enrolment commitment is the same salted value an issuer puts in each credential's subjectCommitment for that student (the app computes both with subjectCommitment(issuer, studentId, salt) in src/lib/commitments.ts). That is what lets the chain connect a credential to its holder's wallet.
Storage in words
All bindings are kept per issuer, so two universities using the same student number never collide, and one wallet can hold one enrolment at each university.
| Mapping | Meaning |
|---|---|
_holders[issuer][commitment] | The bound wallet for an enrolment |
_enrolments[issuer][wallet] | The reverse link: which enrolment a wallet holds at that issuer |
_epochs[issuer][commitment] | The binding epoch, a counter that every bind, rebind, unbind and cancellation increases |
_redeemed[invitationId] | Invitations already used |
Functions
Views
| Function | Returns |
|---|---|
holderOf(address issuer, bytes32 enrolmentCommitment) address | The bound wallet or zero |
enrolmentOf(address issuer, address wallet) bytes32 | The bound enrolment or zero |
epochOf(address issuer, bytes32 enrolmentCommitment) uint64 | The current epoch; a new invitation must carry this value |
isInvitationRedeemed(bytes32 id) bool | Whether an invitation was used |
invitationId(Invitation invitation) bytes32 | The EIP-712 digest, which is also what the issuer signs |
contractVersion() string, supportsInterface(bytes4) bool | "0.5.0" and ERC-165 |
State-changing
| Function | Who may call | Checks | Effects | Event | Reverts |
|---|---|---|---|---|---|
redeem(Invitation invitation, bytes signature) | The wallet named in the invitation | Not paused; invitation checks (below); the commitment is not bound yet; the wallet holds no enrolment at this issuer | Binds both directions, increases the epoch | HolderBound(issuer, enrolmentCommitment, wallet) | SystemPaused, invitation errors, CommitmentAlreadyBound, WalletAlreadyBound |
rebind(Invitation invitation, bytes signature) | The new wallet named in a fresh invitation | Not paused; invitation checks; the commitment is bound; the new wallet holds no enrolment at this issuer | Removes the old reverse link, binds the new wallet, increases the epoch | HolderRebound(issuer, enrolmentCommitment, previousWallet, newWallet) | SystemPaused, invitation errors, NotBound, WalletAlreadyBound |
unbind(bytes32 enrolmentCommitment) | Any account, but only inside its own namespace (msg.sender is the issuer); a retired key is refused | The commitment is bound in the caller's namespace | Deletes both links, increases the epoch | HolderUnbound(issuer, enrolmentCommitment, wallet) | NotIssuer (retired key), NotBound |
revokeInvitations(bytes32 enrolmentCommitment) | Same as unbind | Not a retired key | Increases the epoch, which kills every outstanding invitation for that student | InvitationsRevoked(issuer, enrolmentCommitment, newEpoch) | NotIssuer |
unbind and revokeInvitations keep working while the set is paused, because they are corrections.
Invitation checks (_consume)
In order:
| Check | Error |
|---|---|
wallet is not zero (no bearer invitations) | ZeroWallet() |
msg.sender == wallet | NotWallet(caller) |
expiry > block.timestamp | InvitationExpired(expiry) |
expiry <= block.timestamp + 30 days | ExpiryTooFar(expiry) |
issuerRegistry.isActiveIssuer(issuer) | NotIssuer(issuer) |
| The invitation id was not redeemed | InvitationAlreadyRedeemed(id) |
epoch equals the current epoch for that issuer and commitment | StaleInvitation(currentEpoch, invitationEpoch) |
SignatureChecker.isValidSignatureNow(issuer, id, signature) (EOA or ERC-1271) | InvalidSignature() |
Then the invitation is marked redeemed.
Binding epochs
Without epochs, an issuer who sent two invitations for the same student (for example to an old and a new address) could not take the first one back. The epoch fixes this: each invitation carries the epoch it was signed for, and any change to the binding moves the epoch on.
The same happens after unbind, rebind or revokeInvitations: every invitation signed before is dead. A test signs several invitations and checks that each of these operations kills the ones outstanding.
Lifecycle of one enrolment
Key retirement and namespaces
Bindings are keyed by the issuer address that signed the invitation. After a key is retired:
- the retired key can no longer
unbindorrevokeInvitationsin its namespace, and its unredeemed invitations can no longer be redeemed (the issuer must be active); - bindings it made stay as they are, and the access gate keeps reading them for credentials of that key;
- the successor key has its own, empty namespace.
Invariants the tests enforce
holderOfandenrolmentOfalways mirror each other and equal the reference model's bindings (invariant suite).- Only the named wallet can redeem; a replay, an expired or too-distant expiry, a changed field, a non-issuer signer and a stale epoch are refused.
- Two issuers can bind the same commitment value without collision.
- No admin function writes a binding.
How the app uses it
- The issuer creates an invitation on
/students/invite.src/services/domain/enrolment-service.tsreadsepochOf, builds the invitation and asks the issuer wallet for the EIP-712 signature. The client offers lifetimes up to 29 days (one day under the contract's 30, for clock drift) and defaults to 7 days (src/lib/invitation.ts). The file format is described in Invitation file. - The student accepts it on
/student.EnrolmentService.acceptreadsholderOfand sendsredeemwhen the commitment is unbound andrebindwhen it is already bound to another wallet; the file decides, not the user. src/services/domain/inbox-service.tsusesenrolmentOfto find the connected wallet's credentials (see Holder inbox).- The issue form, the VC and disclosure checks read
holderOf.
The full flow is in Enrolment.
Gas
From blockchain/gas-snapshot.json: redeem 137,402; rebind 103,590; revokeInvitations 34,767; unbind 36,203.
Source files
blockchain/contracts/v03/HolderRegistry.solblockchain/contracts/v03/interfaces/IHolderRegistry.solsrc/services/domain/enrolment-service.ts,src/lib/invitation.ts,src/lib/invitation-file.ts,src/lib/commitments.ts
Tests
blockchain/test/v03/HolderRegistry.test.ts: redeem rules, namespaces, rebind and unbind, no admin path, invitation epochs, the shared digest vector, ERC-1271 issuersblockchain/test/v03/KeyLineage.test.ts: a retired key cannot unbind or cancel invitations; an invitation signed before retirement is refusedblockchain/test/invariants/Invariants.test.ts: random redeem, rebind, unbind and cancel actions against the modelsrc/lib/invitation.test.ts,src/lib/invitation-file.test.ts