Skip to main content

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​

TermMeaning
Student saltkeccak256 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 commitmentsubjectCommitment(issuer, studentId, studentSalt): the key of the binding and the subject of every delivered credential for that student
BindingholderOf[issuer][commitment] = wallet and enrolmentOf[issuer][wallet] = commitment, both one-to-one per issuer
EpochA 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 HolderRegistry deployed.
  • 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:

SectionFields and buttons
Invite a studentStudent 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 linkLook 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):

  1. Checks the wallet address and the lifetime (at most 29 days).
  2. Derives the enrolment: one StudentSalt signature per student per session (cached and shared by concurrent callers), checked to recover to the issuer.
  3. Reads the current epoch for the commitment.
  4. Builds Invitation(issuer, enrolmentCommitment, wallet, expiry, epoch, nonce) with a random 256-bit nonce and expiry = now + lifetime, on the domain of the active HolderRegistry, and asks the wallet to sign it.
  5. 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:

CheckRevert
Wallet is not zeroZeroWallet
Caller is invitation.walletNotWallet
Not expired (expiry > block.timestamp)InvitationExpired
Expiry at most 30 days aheadExpiryTooFar
Issuer is active (registered, not retired)NotIssuer
Invitation id not used beforeInvitationAlreadyRedeemed
invitation.epoch equals the current epochStaleInvitation
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...."

Look up shows "Student S-1001 is linked to wallet 0x..." or "Student S-1001 is not linked to a wallet yet."

ActionDialogContract callEffect
Unlink walletUnlink 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 invitationsCancel 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​

WhoPromptKind
IssuerStudentSalt per student ID per sessionSignature
IssuerInvitationSignature
Issuerunbind or revokeInvitationsTransaction
Studentredeem or rebindTransaction

Errors shown​

CauseMessage
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​

ConcernFile
Servicesrc/services/domain/enrolment-service.ts (enrol, createInvitation, accept, unbind, revokeInvitations, holderOf)
Salts and commitmentssrc/lib/commitments.ts
Invitationsrc/lib/invitation.ts, src/lib/invitation-file.ts
Messagessrc/lib/holder-errors.ts
Issuer pagessrc/app/students/invite/page.tsx, InviteStudentPanel.tsx, ManageStudentPanel.tsx
Student panelsrc/app/student/AcceptInvitationPanel.tsx
Issue form link checksrc/app/records/add/StudentLinkCheck.tsx
Contractblockchain/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.ts
  • src/app/student/AcceptInvitationPanel.test.tsx, src/app/records/add/StudentLinkCheck.test.tsx
  • blockchain/test/v03/HolderRegistry.test.ts
  • E2E: e2e/journeys/invite-and-inbox.journey.ts, manage-student-link.journey.ts, delivery.journey.ts