Enrolment and wallet linking
A credential names its holder only through a salted subject commitment, so the chain cannot say "these are Asha's credentials". Enrolment adds an opt-in link: the issuer signs an invitation for one wallet, the student redeems it with that wallet, and the HolderRegistry records that this wallet holds this enrolment at this issuer. Credentials issued with delivery ticked use the same commitment, so the student's wallet can find them (see Holder inbox).
Key terms
| Term | Meaning |
|---|---|
| Student salt | keccak256 of the issuer wallet's signature over StudentSalt(address issuer, string studentId) on the domain { name: "Certichain Student Salt", version: "1" }. Repeatable for a plain wallet, so the issuer stores nothing. As secret as the issuer key. |
| Enrolment commitment | subjectCommitment(issuer, studentId, studentSalt): the key of the binding and the subject of every delivered credential for that student |
| Binding | holderOf[issuer][commitment] = wallet and enrolmentOf[issuer][wallet] = commitment, both one-to-one per issuer |
| Epoch | A counter per issuer and commitment. Binding, rebinding, unbinding and cancelling all raise it. An invitation is valid only at the epoch it names. |
Bindings are namespaced by issuer: one wallet may hold one enrolment at each institution, and an institution can only address bindings in its own namespace.
Preconditions
- v0.3, with the active set's
HolderRegistrydeployed. - The issuer is an active issuer on a plain wallet. A contract-wallet issuer cannot derive student salts ("This issuer account is a contract wallet, which cannot derive student identifiers yet. Use a plain wallet account to invite students.").
- The system is not paused for redeeming. Unbinding and cancelling are not stopped by the pause.
Where it starts
The issuer opens Students (/students/invite), "Link a student's wallet with a signed invitation, so the credentials you issue reach them, and look after the links you have made." The page has two sections:
| Section | Fields and buttons |
|---|---|
| Invite a student | Student ID, Student wallet ("The address the student will accept the invitation with."), Valid for (1, 7, 14 or 29 days), button Create invitation |
| Manage a student's link | Look up by student ID, Look up, then Unlink wallet and Cancel invitations |
The student accepts on /student, section Accept an invitation ("Join a university").
Inviting and accepting
1. The issuer creates the invitation
EnrolmentService.createInvitation (src/services/domain/enrolment-service.ts):
- Checks the wallet address and the lifetime (at most 29 days).
- Derives the enrolment: one
StudentSaltsignature per student per session (cached and shared by concurrent callers), checked to recover to the issuer. - Reads the current epoch for the commitment.
- Builds
Invitation(issuer, enrolmentCommitment, wallet, expiry, epoch, nonce)with a random 256-bit nonce andexpiry = now + lifetime, on the domain of the activeHolderRegistry, and asks the wallet to sign it. - Confirms the signature recovers to the issuer ("The wallet account changed while signing. Reconnect the university account and try again." otherwise) and downloads the invitation file.
The success notice reads "Invitation created and downloaded. Send it to the student, who accepts it on their Student page. It is valid for 7 days." Nothing is written on chain at this point. The file holds a commitment, not the student's name or ID.
The section explains the privacy cost: "Credentials you issue to one student share that hash, so they are linkable on-chain to each other (never to other universities)."
2. The student accepts
The student chooses the file under Invitation file (read in the browser, at most 64 KiB) and presses Accept invitation. The panel warns: "Accepting links this wallet to your university on a public blockchain: anyone can see that the wallet is enrolled there, but not your name or student ID."
EnrolmentService.accept reads the file, checks that its invitationId matches its fields, then asks the chain whether the commitment is already bound. It sends redeem when it is not and rebind when it is; the file decides, not the user. Before sending, it checks that the domain is this chain and this HolderRegistry, that the invitation names the connected wallet, and that it has not expired.
The contract checks again, in _consume:
| Check | Revert |
|---|---|
| Wallet is not zero | ZeroWallet |
Caller is invitation.wallet | NotWallet |
Not expired (expiry > block.timestamp) | InvitationExpired |
| Expiry at most 30 days ahead | ExpiryTooFar |
| Issuer is active (registered, not retired) | NotIssuer |
| Invitation id not used before | InvitationAlreadyRedeemed |
invitation.epoch equals the current epoch | StaleInvitation |
| Signature valid for the issuer (EOA or ERC-1271) | InvalidSignature |
Then redeem requires the commitment to be unbound (CommitmentAlreadyBound) and rebind requires it to be bound (NotBound). Both require the wallet to hold no other enrolment at this issuer (WalletAlreadyBound). Binding raises the epoch, so every other outstanding invitation for that student dies at once. On success the panel says "Your wallet is now linked to your student record at 0x...."
Managing a link
Look up shows "Student S-1001 is linked to wallet 0x..." or "Student S-1001 is not linked to a wallet yet."
| Action | Dialog | Contract call | Effect |
|---|---|---|---|
| Unlink wallet | Unlink this wallet? "0x... will stop showing the credentials of student S-1001. The credentials themselves stay valid and verifiable." | unbind(commitment) | Deletes both mappings, raises the epoch, emits HolderUnbound. Notice: "The wallet was unlinked from this student. Their credentials stay valid; link a new wallet with a new invitation." |
| Cancel invitations | Cancel outstanding invitations? "Every invitation file you already sent for student ID S-1001 stops working. The student needs a new one to link a wallet." | revokeInvitations(commitment) | Raises the epoch, emits InvitationsRevoked. Notice: "Outstanding invitations for this student are cancelled." |
Both calls address only the caller's own namespace, so no issuer can touch another's bindings. A retired issuer key is refused (NotIssuer) and its bindings stay as they are.
Moving a student to a new wallet
The issuer creates a new invitation for the new wallet. Because the commitment is already bound, the student's accept sends rebind, which moves the binding from the old wallet to the new one in one transaction (HolderRebound). The old wallet stops seeing the credentials.
Epochs at a glance
An invitation signed at epoch N works only while the epoch is still N. Any of these events makes every earlier invitation for that student stale.
Wallet prompts
| Who | Prompt | Kind |
|---|---|---|
| Issuer | StudentSalt per student ID per session | Signature |
| Issuer | Invitation | Signature |
| Issuer | unbind or revokeInvitations | Transaction |
| Student | redeem or rebind | Transaction |
Errors shown
| Cause | Message |
|---|---|
SystemPaused | "The system is paused, so invitations cannot be accepted right now. Try again when it restarts." |
InvitationAlreadyRedeemed | "This invitation has already been used." |
InvitationExpired | "This invitation has expired. Ask your university for a new one." |
StaleInvitation | "This invitation was replaced or cancelled by your university. Ask for a new one." |
WalletAlreadyBound | "This wallet is already linked to a student at this university." |
CommitmentAlreadyBound | "This student is already linked to a wallet. Ask your university for a re-link invitation." |
NotBound | "There is no wallet linked to this student at this university." |
NotIssuer | "The university that issued this invitation is no longer registered." |
InvalidSignature | "The invitation signature is not valid." |
| Wrong wallet | "This invitation was issued for a different wallet. Connect the wallet you gave your university." |
| Wrong network | "This invitation was issued for a different network or deployment." |
| Too large | "This file is not an invitation: it is too large." |
| Account switched while signing the salt | "The wallet account changed while signing, so the signature came from a different account than the issuer wallet. Switch back to the issuer account and try again." |
Limits
- 30 days. The contract refuses an expiry more than 30 days after the redeeming block (
MAX_INVITATION_TTL). The UI offers at most 29 days for clock drift. - Contract-wallet issuers can sign invitations on chain (ERC-1271 is accepted), but the app cannot derive their student salts, so they have no roster in the app.
- Issuer key changes change every student salt. A retired key's bindings stay, but a new key derives different commitments.
- Linkability. Delivered credentials of one student at one issuer share a commitment and are linkable to each other on chain.
Code map
| Concern | File |
|---|---|
| Service | src/services/domain/enrolment-service.ts (enrol, createInvitation, accept, unbind, revokeInvitations, holderOf) |
| Salts and commitments | src/lib/commitments.ts |
| Invitation | src/lib/invitation.ts, src/lib/invitation-file.ts |
| Messages | src/lib/holder-errors.ts |
| Issuer pages | src/app/students/invite/page.tsx, InviteStudentPanel.tsx, ManageStudentPanel.tsx |
| Student panel | src/app/student/AcceptInvitationPanel.tsx |
| Issue form link check | src/app/records/add/StudentLinkCheck.tsx |
| Contract | blockchain/contracts/v03/HolderRegistry.sol |
Tests
src/services/domain/enrolment-service.test.ts,src/lib/invitation.test.ts,src/lib/invitation-file.test.ts,src/lib/holder-errors.test.ts,src/lib/commitments.test.tssrc/app/student/AcceptInvitationPanel.test.tsx,src/app/records/add/StudentLinkCheck.test.tsxblockchain/test/v03/HolderRegistry.test.ts- E2E:
e2e/journeys/invite-and-inbox.journey.ts,manage-student-link.journey.ts,delivery.journey.ts