Invitation file
An invitation file lets a student link a wallet to their record at one institution. The issuer signs an EIP-712 Invitation for one enrolment commitment and one wallet address. The student redeems it with that wallet on the HolderRegistry. The file names no student: it holds a salted commitment, not a name or student ID. The flow is described in Enrolment.
| Property | Value |
|---|---|
| File name | invitation- followed by the first 8 hex characters of the invitation id, .json |
| Version | 1 (INVITATION_FILE_VERSION) |
| Made on | /students/invite, section Invite a student, button Create invitation |
| Accepted on | /student, section Accept an invitation, field Invitation file, button Accept invitation |
| Size accepted | 64 KiB |
| Produced by | buildInvitationFile in src/lib/invitation-file.ts |
| Read by | parseInvitationFile, then verifyInvitationFile |
Fields
| Field | Type | Meaning | Validation |
|---|---|---|---|
version | number | File version | Must be 1 |
domain.name | string | EIP-712 domain name, "Certichain" | Non-empty string |
domain.version | string | "1" | Non-empty string |
domain.chainId | string | Chain id, decimal | Decimal |
domain.verifyingContract | string | Address of the HolderRegistry of the active contract set | Address |
invitation.issuer | string | The issuer wallet that signed | Address |
invitation.enrolmentCommitment | string | The student's enrolment commitment at this issuer | 32-byte hex |
invitation.wallet | string | The only wallet that may redeem it | Address |
invitation.expiry | number | Unix seconds after which it is dead | Safe integer |
invitation.epoch | number | The binding epoch the issuer read when signing | Safe integer |
invitation.nonce | string | Random 256-bit nonce, decimal | Decimal |
signature | string | The issuer's EIP-712 signature | 64 or 65 byte hex signature |
invitationId | string | EIP-712 digest of domain and invitation, the on-chain id | 32-byte hex |
The signed type, the same in TypeScript and Solidity:
Invitation(address issuer,bytes32 enrolmentCommitment,address wallet,uint64 expiry,uint64 epoch,uint256 nonce)
How the values are chosen
| Field | Source |
|---|---|
enrolmentCommitment | subjectCommitment(issuer, studentId, studentSalt), where studentSalt = keccak256(signature) of a typed-data signature by the issuer wallet over StudentSalt(address issuer, string studentId) on the chain-free domain { name: "Certichain Student Salt", version: "1" }. The same student id always gives the same commitment for the same issuer key, so the issuer stores no salts. |
expiry | Now plus the lifetime chosen in Valid for: 1, 7, 14 or 29 days (default 7). The client allows at most 29 days so a signer clock that runs ahead stays inside the contract's 30-day limit. |
epoch | HolderRegistry.epochOf(issuer, enrolmentCommitment) at signing time |
nonce | 32 random bytes |
domain | The chain id and the HolderRegistry address of the active set |
The student salt is as secret as the issuer key, and it changes if the issuer key changes. A contract-wallet issuer cannot derive it ("This issuer account is a contract wallet, which cannot derive student identifiers yet. Use a plain wallet account to invite students.").
How it is checked
The browser checks are a convenience; the contract checks all of them again, plus the epoch, the issuer's registration and the signature (EOA or ERC-1271). The issuer's app also confirms right after signing that the signature recovers to the issuer address, and refuses with "The wallet account changed while signing. Reconnect the university account and try again." if not.
| Check | Where | Message shown |
|---|---|---|
invitationId does not match the fields | Browser | "Invalid invitation file: invitationId does not match the fields" |
| Domain is another chain or registry | Browser | "This invitation was issued for a different network or deployment." |
| Wallet is not the connected one | Browser and contract (NotWallet) | "This invitation was issued for a different wallet. Connect the wallet you gave your university." |
| Expired | Browser and contract (InvitationExpired) | "This invitation has expired. Ask your university for a new one." |
| Expiry more than 30 days ahead | Contract (ExpiryTooFar) | "The invitation expiry is too far in the future for the contract." |
| Issuer not active | Contract (NotIssuer) | "The university that issued this invitation is no longer registered." |
| Already used | Contract (InvitationAlreadyRedeemed) | "This invitation has already been used." |
| Epoch moved on | Contract (StaleInvitation) | "This invitation was replaced or cancelled by your university. Ask for a new one." |
| Bad signature | Contract (InvalidSignature) | "The invitation signature is not valid." |
Example
Fake values.
{
"version": 1,
"domain": {
"name": "Certichain",
"version": "1",
"chainId": "11155111",
"verifyingContract": "0x3333333333333333333333333333333333333333"
},
"invitation": {
"issuer": "0x1111111111111111111111111111111111111111",
"enrolmentCommitment": "0x0000000000000000000000000000000000000000000000000000000000000a01",
"wallet": "0x4444444444444444444444444444444444444444",
"expiry": 1793404800,
"epoch": 0,
"nonce": "5521908734120938471209384712093847120938471209384712093847"
},
"signature": "0x<65-byte signature>",
"invitationId": "0x0000000000000000000000000000000000000000000000000000000000000f06"
}
Source files
src/lib/invitation.ts:SignedInvitation,INVITATION_TYPES,invitationId, lifetime constantssrc/lib/invitation-file.ts:buildInvitationFile,parseInvitationFile,verifyInvitationFilesrc/lib/commitments.ts:deriveStudentSalt,subjectCommitmentsrc/services/domain/enrolment-service.ts:createInvitation,accept, error classessrc/lib/holder-errors.ts: revert messagesblockchain/contracts/v03/HolderRegistry.sol:invitationId,redeem,rebind
Tests
src/lib/invitation.test.ts,src/lib/invitation-file.test.ts,src/lib/holder-errors.test.tssrc/services/domain/enrolment-service.test.tsblockchain/test/v03/HolderRegistry.test.ts- E2E:
e2e/journeys/invite-and-inbox.journey.ts,e2e/journeys/manage-student-link.journey.ts