Issuing a credential
Issuing turns a document and a few details into a credential: a signed, salted fingerprint anchored in the CredentialRegistry, an encrypted copy of the document on IPFS, and a credential package for the holder. Everything secret (the document, the key, the name and student ID) stays in the issuer's browser or goes into the package. The chain receives only hashes, commitments, a record type, a date and the issuer's signature.
This page describes the v0.3 flow on /records/add. The legacy v0.2 form is summarised under Variants.
Who takes part
| Party | Role in this flow |
|---|---|
| Issuer | A wallet registered in the active set's IssuerRegistry and not retired |
| Issuer's browser | Hashes, encrypts, derives salts, builds the EIP-712 message, builds the package |
| Wallet (MetaMask) | Signs typed data and sends the transaction |
| Upload route | Checks the sign-in session and the container, pins it to IPFS through Pinata |
CredentialRegistry | Checks the signature and fields, stores the anchor, starts the status |
StatusRegistry | Records the credential as Active, or marks the old one Superseded on re-issue |
Preconditions
NEXT_PUBLIC_CONTRACT_VERSIONisv03and the deployment manifest names the active contract set.- The connected wallet is an active issuer.
useIssuerSetupsends a visitor with no wallet to/loginand a wallet without the issuer role to/records. On v0.3 the check isIssuerRegistry.isIssuer(address)and not retired. - The wallet is a plain account (EOA). A contract wallet cannot protect documents ("This account is a contract wallet, which cannot protect documents yet. Use a plain wallet account.") and cannot derive student salts. See Contract-wallet issuers.
- The system is not paused. While the
IssuerRegistryis paused the Issue Record button is disabled and the contract would refuse withSystemPaused. - The server has its Pinata and session settings. Without them the upload route answers that it is not configured.
Where it starts
The issuer dashboard (/dashboard) has the buttons Issue a credential (to /records/add) and Bulk import (to /records/import, see Bulk import). A row's Re-issue action opens /records/add?replaces=<id>.
The page header reads Issue New Record (or Re-issue Record), title Add Academic Record, "Issuing on behalf of" the registered name, with shortcuts Invite a student to link their wallet and Manage your record types. Beside the form a live Preview card shows the record type, the holder's name, the document, whether it is stored ("Encrypted") and the delivery choice ("Linked wallet" or "Package and link only").
The four-part form
| Part | Fields | Notes |
|---|---|---|
| 1 Student | Student Name, Student ID, and the Check link button | Check link asks the wallet for one signature, then shows "Linked to wallet 0x.... A linked credential reaches them." and ticks delivery, or "This student ID is not linked to a wallet yet. Tick the box below and the credential will reach them once they accept an invitation for this ID." |
| 2 Credential | Record Type, Programme (optional), Award (optional) | The list holds the 32 built-in types and the issuer's active custom types, marked "(issuer-defined)". If the custom types cannot be read: "Your own record types could not be loaded, so only the standard types are listed." The page notes that the name, ID, programme and award are not stored on-chain. |
| 3 Document | Document file (PDF, PNG or JPEG up to 3.5 MB), button Encrypt and upload; read-only IPFS Hash and Document SHA-256 | "Your wallet asks for a signature to protect this document. Only you can read the stored copy." |
| 4 Delivery | Checkbox Deliver to the student's linked wallet | Ticked, the credential uses the issuer's stable salt for this student, so it appears in their wallet once they accept an invitation (before or after issuing). Unticked, no wallet can ever receive it. |
The submit button is Issue Record ("Your wallet asks you to confirm before anything is written on-chain."). With delivery unticked, a dialog asks first: Issue without delivering to a wallet?, "No wallet will show this credential, now or later. This cannot be changed after issuing.", buttons Issue without delivery and back. A missing required field gives "Please fill in all required fields."
Step by step
1. Encrypt and upload
DocumentService.upload (src/services/domain/document-service.ts):
- Checks the type, size and the file's leading bytes (
assertSupportedFile). - Computes the SHA-256 of the plaintext. This is the document hash that will be anchored, so anyone holding the file can later check it.
- Derives the issuer's encryption key (two identical
EncryptionKeysignatures, once per session) and encrypts the file into a container with a fresh 32-byte document key, wrapped for the issuer's own key only. - Sends the container to
POST /api/uploadwith the headerX-Document-SHA256. If the server has no valid session (401 or 403) the browser fetches a nonce, asks the wallet to sign a sign-in message, posts it to/api/auth/verify, and retries once. - Accepts the answer only if
blobHashequals the SHA-256 of what it sent anddocumentHashequals its own hash.
The server checks the session, rate limits (30 uploads per wallet per hour), that the container is not plaintext, that its header names the signed-in wallet, and that the claimed hash matches the header, then pins it. The document key never leaves the browser. Details: Document storage and Upload and documents.
2. Build and sign the credential
submitRecord (src/app/records/add/submit-record.ts) validates the form and calls CredentialService.issue:
| Field signed | Value |
|---|---|
issuer | The connected wallet |
subjectCommitment | subjectCommitment(issuer, studentId, subjectSalt); subjectSalt is the stable student salt when delivering, otherwise 32 fresh random bytes |
documentHash | From the upload |
metadataHash | keccak256 of the RFC 8785 form of the display data plus a fresh metadataSalt |
recordType | The chosen type id |
issuedAt | Now, in Unix seconds |
validUntil | 0 (no expiry; the app has no expiry field) |
nonce | 32 random bytes |
The stable student salt is keccak256 of a signature over StudentSalt(address issuer, string studentId) (see Enrolment). The wallet then signs the Credential typed data on the domain { name: "Certichain", version: "1", chainId, verifyingContract: CredentialRegistry }. The EIP-712 digest is the credential id.
3. Anchor on chain
The browser sends issue(credential, signature), or issueReplacing(credential, signature, replacedId, noteHash) for an atomic re-issue. The CredentialRegistry checks, in order:
| Check | Revert | Message shown |
|---|---|---|
| System not paused | SystemPaused | "The system is paused, so new credentials cannot be issued right now. Try again when it restarts." |
Caller is credential.issuer and an active issuer | NotIssuer | "Your account is not registered as an issuer, or is no longer registered." |
| Record type allowed for this issuer | RecordTypeNotAllowed | "This record type is not available to you. It may be switched off, or it may belong to another institution." |
| Non-zero commitment, metadata hash and document hash | InvalidCommitment, InvalidMetadataHash, ZeroDocumentHash | Generic failure |
issuedAt at most 5 minutes in the future | InvalidIssuedAt | Generic failure |
validUntil zero or after issuedAt | InvalidValidity | Generic failure |
| Nonce unused for this issuer | NonceAlreadyUsed | "This issue request was already used. Please try again." |
| Signature valid (EOA or ERC-1271) | InvalidSignature | "The issuer signature was not accepted. Sign with the issuing account." |
| Id not issued before | CredentialAlreadyIssued | "This exact credential has already been issued." |
On success it stores the anchor, calls StatusRegistry.initialise (status Active), and emits CredentialIssued(credentialId, issuer, subjectCommitment, recordType) and CredentialSigned(credentialId, issuer, nonce, signature). The signature lives only in the event; the verify page reads it back from there. The browser checks that the emitted id equals the digest it computed. Other failures show "Failed to add record. Please try again or contact support." Contract details: Credential registry.
4. Disclosure attestation
After the credential exists, submitRecord asks for one more signature: the issuer's attestation over a salted tree of the display fields, which lets the holder later show some fields without the others. It is optional. If it is refused or fails, the credential stays valid and the card shows "The credential is issued, but the selective disclosure signature was not added. Add it later from the issuer dashboard." See Selective disclosure.
5. The issued card
The card Credential issued shows the seal drawn from the id, the full credential id and Open the verification page, then three groups:
| Group | Contents |
|---|---|
| Give to the holder | Download credential package (credential-<8 hex>.json), with "Keep this file private: it opens the document." and Show a QR code for the holder's phone (a compact code that adds the credential to the phone wallet; it carries the signed facts, optionally the name and ID, never the document) |
| Verification link | A QR code of https://<site>/verify?id=<credential id>. The link carries only the id. |
| Other formats | Download as verifiable credential, Download as Open Badge, and Download as Open Badge with standard proof (three more signatures the first time) |
Issue another opens a fresh form. The credential package exists only on this page; the chain cannot rebuild its salts or key, so the issuer must download it before leaving.
Wallet prompts in order
For one credential with a document, delivery ticked, in a fresh session:
| # | When | Kind | What |
|---|---|---|---|
| 1 | Check link (optional) | Signature | StudentSalt typed data. Cached for the session, so step 5 reuses it. |
| 2, 3 | Encrypt and upload | Signature × 2 | EncryptionKey typed data, same message twice. Once per session. |
| 4 | Upload, if not signed in | Signature | Sign-in message for the upload session |
| 5 | Issue Record | Signature | StudentSalt, if not cached and delivery is ticked |
| 6 | Issue Record | Signature | Credential typed data |
| 7 | Issue Record | Transaction | issue or issueReplacing |
| 8 | After confirmation | Signature | Selective disclosure attestation (optional) |
| 9 | Two-step or cross-set re-issue | Transaction | supersede or supersedeToRegistry on the old credential |
What goes where
| On chain (public) | To the server | Stays in the browser or the package |
|---|---|---|
Anchor: issuer, subject commitment, document hash, metadata hash, record type, issuedAt, validUntil | The encrypted container, the claimed document hash, the sign-in session | The document, the document key |
CredentialIssued and CredentialSigned events (signature and nonce) | Pin metadata: document hash and issuer address | Name, student ID, programme, award |
| Status Active, or Superseded for a replaced credential | Subject and metadata salts, the package |
Re-issue with supersede
A row's Re-issue link opens /records/add?replaces=<id>. Delivery is pre-ticked. useReplacement (src/app/records/add/use-replacement.ts) locates the old credential in any listed contract set and decides how it can be replaced:
| Mode | Panel text | Transactions |
|---|---|---|
| Atomic | "The old credential is marked as superseded in the same transaction that issues the new one, so you confirm once." | One: issueReplacing |
| Two-step | "When you issue the new one, the old credential is marked as superseded and points to the new one. Your wallet asks for one more confirmation for that." | issue, then StatusRegistry.supersede |
| Cross-set | "The old credential is in an older contract set. When you issue the new one, your wallet asks for one more confirmation to mark the old credential as superseded there, pointing to the new one." | issue in the active set, then supersedeToRegistry in the old set, naming the active registry |
Whether a set supports atomic replace is read from the contract: hasRevisions(contractVersion()) is true for labels 0.4.0 and later (src/lib/contract-revisions.ts). As of 2026-10-24 the active Sepolia set is 0.3.0, so a re-issue there takes two steps.
Blocked cases show: "This credential is already revoked, so it cannot be replaced.", "This credential was already replaced by another credential.", "An administrator holds this credential, so it cannot be replaced.", "This credential is managed by 0x.... Switch your wallet to that account to replace it.", "This credential belongs to an older contract set that cannot point to a replacement. Issue the new credential, then revoke this one from its verify page.", "The credential to replace is not a valid credential id.", "Could not check the credential to replace, so nothing was issued. Reload the page to try again."
Pending replacements
Before the second transaction of a two-step or cross-set replacement, the page records { issuer, oldId, newId } in browser storage (certichain:pending-replacements:v1, at most 50 entries). If the second transaction fails, the panel says the old credential "could not be marked as superseded: ... Until then both show as active." with Try again. If the page is closed, the dashboard shows Replacement not finished. with Finish replacement and Dismiss. The dashboard first rechecks: an admin hold ("The old credential is on hold by an administrator, so it cannot be replaced until the hold is lifted."), a missing old credential, or a replacement that is no longer active stops it.
The contract rules for supersede are in Lifecycle.
Variants
v0.2 (legacy)
With NEXT_PUBLIC_CONTRACT_VERSION not v03, the form has three parts. Part 1 asks for Student Ethereum Address, part 3 uploads the plain file with Upload to IPFS (the server checks the type and hash and pins it unencrypted, 4 MB limit), and Issue Record calls AcademicRecords.addRecord with metadataHash = keccak256(cid). There is no package, no delivery choice and no re-issue; the page returns to /dashboard?success=true. See Legacy v0.2 contract.
Contract-wallet issuers
The registries accept ERC-1271 signatures, so a contract wallet can be registered and its credentials verify. The web app, however, refuses to derive document keys or student salts from a contract wallet, so such an issuer cannot issue through this form today. The onboarding handbook says the same.
Older contract sets
New credentials are always issued in the active set. Older sets stay readable and their credentials are re-issued across sets as above. See Contract sets routing.
Limits
- One credential per form submission and one wallet prompt sequence per credential. Cohorts use Bulk import.
validUntilis always0; there is no expiry in the UI, although the contracts support it.issuedAtis the signer's clock and may be up to 5 minutes ahead of the chain.- The package cannot be rebuilt. If it is lost, re-issue.
Code map
| Concern | File and function |
|---|---|
| Page and form | src/app/records/add/AddRecordPage.tsx, IssueSession.tsx, FormPart.tsx, IssuePreview.tsx |
| Issuer check and types | use-issuer-setup.ts, issue-types.ts |
| Upload field | DocumentUploadField.tsx |
| Link check and delivery | StudentLinkCheck.tsx, DeliveryOption.tsx, UndeliveredDialog.tsx |
| Submit | submit-record.ts (submitRecord, withAttestation) |
| Credential | src/services/domain/credential-service.ts (issue) |
| Document | src/services/domain/document-service.ts (upload), src/services/document-api.ts |
| Replacement | replacement.ts, use-replacement.ts, ReplacementPanel.tsx, src/lib/pending-replacements.ts, src/app/dashboard/PendingReplacements.tsx |
| Issued card | CredentialIssuedCard.tsx, SendToPhone.tsx |
| Messages | src/lib/credential-errors.ts, src/services/documents/document-error-messages.ts |
| Contracts | blockchain/contracts/v03/CredentialRegistry.sol, StatusRegistry.sol |
Tests
src/app/records/add/submit-record.test.ts,replacement.test.ts,use-replacement.test.tsx,ReplacementPanel.test.tsx,StudentLinkCheck.test.tsx,DeliveryOption.test.tsx,DocumentUploadField.test.tsx,CredentialIssuedCard.test.tsx,SendToPhone.test.tsx,issue-types.test.tssrc/services/domain/credential-service.test.ts,src/services/domain/document-service.test.ts,src/lib/pending-replacements.test.ts,src/lib/credential-errors.test.tsblockchain/test/v03/CredentialIssuance.test.ts,Revisions.test.ts,CredentialRecordTypes.test.ts- E2E:
e2e/journeys/issue-and-verify.journey.ts,delivery.journey.ts,encrypted-documents.journey.ts,dashboard-actions.journey.ts,contract-sets.journey.ts,pause-and-restart.journey.ts