Skip to main content

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") and ERC165 (OpenZeppelin 5).
  • Constructor: (IIssuerRegistry issuers), stored as the immutable issuerRegistry().

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.

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

FunctionReturns
holderOf(address issuer, bytes32 enrolmentCommitment) addressThe bound wallet or zero
enrolmentOf(address issuer, address wallet) bytes32The bound enrolment or zero
epochOf(address issuer, bytes32 enrolmentCommitment) uint64The current epoch; a new invitation must carry this value
isInvitationRedeemed(bytes32 id) boolWhether an invitation was used
invitationId(Invitation invitation) bytes32The EIP-712 digest, which is also what the issuer signs
contractVersion() string, supportsInterface(bytes4) bool"0.5.0" and ERC-165

State-changing​

FunctionWho may callChecksEffectsEventReverts
redeem(Invitation invitation, bytes signature)The wallet named in the invitationNot paused; invitation checks (below); the commitment is not bound yet; the wallet holds no enrolment at this issuerBinds both directions, increases the epochHolderBound(issuer, enrolmentCommitment, wallet)SystemPaused, invitation errors, CommitmentAlreadyBound, WalletAlreadyBound
rebind(Invitation invitation, bytes signature)The new wallet named in a fresh invitationNot paused; invitation checks; the commitment is bound; the new wallet holds no enrolment at this issuerRemoves the old reverse link, binds the new wallet, increases the epochHolderRebound(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 refusedThe commitment is bound in the caller's namespaceDeletes both links, increases the epochHolderUnbound(issuer, enrolmentCommitment, wallet)NotIssuer (retired key), NotBound
revokeInvitations(bytes32 enrolmentCommitment)Same as unbindNot a retired keyIncreases the epoch, which kills every outstanding invitation for that studentInvitationsRevoked(issuer, enrolmentCommitment, newEpoch)NotIssuer

unbind and revokeInvitations keep working while the set is paused, because they are corrections.

Invitation checks (_consume)​

In order:

CheckError
wallet is not zero (no bearer invitations)ZeroWallet()
msg.sender == walletNotWallet(caller)
expiry > block.timestampInvitationExpired(expiry)
expiry <= block.timestamp + 30 daysExpiryTooFar(expiry)
issuerRegistry.isActiveIssuer(issuer)NotIssuer(issuer)
The invitation id was not redeemedInvitationAlreadyRedeemed(id)
epoch equals the current epoch for that issuer and commitmentStaleInvitation(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 unbind or revokeInvitations in 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​

  • holderOf and enrolmentOf always 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.ts reads epochOf, 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.accept reads holderOf and sends redeem when the commitment is unbound and rebind when it is already bound to another wallet; the file decides, not the user.
  • src/services/domain/inbox-service.ts uses enrolmentOf to 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.sol
  • blockchain/contracts/v03/interfaces/IHolderRegistry.sol
  • src/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 issuers
  • blockchain/test/v03/KeyLineage.test.ts: a retired key cannot unbind or cancel invitations; an invitation signed before retirement is refused
  • blockchain/test/invariants/Invariants.test.ts: random redeem, rebind, unbind and cancel actions against the model
  • src/lib/invitation.test.ts, src/lib/invitation-file.test.ts