Compact codes
The phone wallet and the in-person check (see Phone wallet and in-person checks) move data through QR codes. JSON files are too large for that, so the same facts are packed into short binary codes.
| Prefix | Name | Carries | Made by | Read by | Max length |
|---|---|---|---|---|---|
v1. | Static code | A credential's signed facts, optionally the holder's details and selective disclosure data | Issued card (Show a QR code for the holder's phone), wallet (Show the code), device store | /wallet, /present | 4,096 characters |
c1. | Challenge code | The verifier's one-time 32-byte challenge and an optional label | /present | /wallet | 200 characters |
d1. | Disclosure code | A holder-signed disclosure for that challenge (a disclosure file in bytes) | /wallet (Sign with my wallet) | /present | 8,192 characters |
Common rules
- Text form:
<prefix>.<base64url>, using the URL-safe alphabet without padding. Decoding is strict: the text must be the canonical encoding of its bytes, so one set of bytes has one spelling. - Numbers are unsigned and big-endian:
u8,u16,u32,u64. - Fixed values (addresses, hashes, salts) are raw bytes of their exact size: 20 bytes for an address, 32 for a hash or salt.
- Blobs (signatures, text) are a
u16length followed by that many bytes, each with its own cap. - The first byte of every code is the version,
1. Another value gives "This code was made by another version of the app." - A reader must reach the end exactly. Leftover bytes give "This code has extra data at the end."; missing bytes give "This code is cut short."
- Every code assumes the Certichain signing domain (
Certichain, version1), so the domain name and version are not stored.
In links
A code travels after the # of a link, which browsers never send to the server:
/wallet#v1....adds a credential;/wallet#c1....opens a challenge to sign./present#v1....checks a credential;/present#d1....checks a signed answer.
parseFragment in src/lib/present/url.ts accepts at most 16,384 characters of the form ^([a-z]\d)\.[A-Za-z0-9_-]+$ and only the three known prefixes. Anything else gives "This link does not hold a code this app knows."
v1. static code
| Order | Field | Encoding | Notes |
|---|---|---|---|
| 1 | version | u8 | 1 |
| 2 | flags | u8 | bit 0 (1): details follow. Bit 1 (2): selective disclosure data follows (only with bit 0). Other bits refused. |
| 3 | chainId | u64 | |
| 4 | verifyingContract | 20 bytes | The CredentialRegistry |
| 5 | issuer | 20 bytes | |
| 6 | subjectCommitment | 32 bytes | |
| 7 | documentHash | 32 bytes | |
| 8 | metadataHash | 32 bytes | |
| 9 | recordType | u32 | |
| 10 | issuedAt | u64 | Unix seconds (must fit a safe JavaScript integer) |
| 11 | validUntil | u64 | 0 for no expiry |
| 12 | nonce | 32 bytes | The uint256 nonce, big-endian |
| 13 | signature | blob, up to 512 bytes | The issuer's signature (65 bytes for a plain account, longer for ERC-1271) |
The credential id is not stored. The reader recomputes it as the EIP-712 digest of fields 3 to 12, so a code cannot claim one id and carry another credential's fields.
With flag bit 0:
| Order | Field | Encoding | Notes |
|---|---|---|---|
| 14 | subjectSalt | 32 bytes | |
| 15 | metadataSalt | 32 bytes | |
| 16 | display | blob, up to 1,024 bytes | UTF-8 JSON of the display block (version, holderName, studentId, optional programme, award, awardedOn), validated on read |
With flag bit 1 as well:
| Order | Field | Encoding | Notes |
|---|---|---|---|
| 17 | attributes version | u32 | |
| 18 | salts | 8 times 32 bytes | One per attribute slot |
| 19 | root | 32 bytes | Must equal the root recomputed from the display and the salts |
| 20 | attestation | blob, up to 512 bytes | The issuer's AttributeSet signature |
The wallet stores codes with selective disclosure data so it can sign later. The code it shows to a verifier never includes that data.
c1. challenge code
| Order | Field | Encoding | Notes |
|---|---|---|---|
| 1 | version | u8 | 1 |
| 2 | challenge | 32 bytes | Random, made by DisclosureService.newChallenge |
| 3 | label | the remaining bytes, 0 to 60 | Optional UTF-8 name of the verifier. Control characters, zero-width characters, line separators and bidirectional marks are refused. |
The check code shown on both screens is challengeFingerprint: the first four bytes of the challenge as lowercase hex in two groups, for example 7777 7777. A holder who sees a different check code than the verifier's screen is looking at a relayed challenge and should not sign.
d1. disclosure code
| Order | Field | Encoding | Notes |
|---|---|---|---|
| 1 | version | u8 | 1 |
| 2 | chainId | u64 | |
| 3 | verifyingContract | 20 bytes | |
| 4 | credentialId | 32 bytes | |
| 5 | issuer | 20 bytes | |
| 6 | attributesRoot | 32 bytes | |
| 7 | attributesVersion | u32 | |
| 8 | attestation | blob, up to 512 bytes | Issuer's AttributeSet signature |
| 9 | challenge | 32 bytes | The verifier's challenge |
| 10 | holder address | 20 bytes | |
| 11 | holder signature | blob, up to 512 bytes | DisclosureRequest signature |
| 12 | field count | u8 | 1 to 8 |
| 13 | each field | u8 index, blob value (1 to 1,500 bytes), 32-byte salt, three 32-byte siblings | In slot order, each slot once |
The field names are not stored; they come from the slot index. The decoder rebuilds a disclosure file and runs it through parseDisclosure, so every rule of the disclosure file applies. A code over 8,192 characters is refused at creation with "The chosen details are too large for one code. Choose fewer."
Examples
These are the frozen version 1 vectors from src/lib/present/vectors.test.ts. They must keep decoding to the same facts and encoding back to the same text.
Challenge code with challenge 0x7777...77 and label "Admissions desk":
c1.AXd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3QWRtaXNzaW9ucyBkZXNr
Static code without details (chain id 0xaa36a7, 11155111; record type 4; nonce 7; issued by a Hardhat test account):
v1.AQAAAAAAAKo2p1P7VoIuUHuvXAIh2ZMQhJXOGTHqGefjdufCE7fn5-Rsxwpd0Iba_yrsCtb6VM_dtoraOIYR0ZIJ0SPpM-TpG1X4qWTEDP5ixwICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICvLEaExrkiUGmvx-KeIFW7bQooyx6DIpASwhhpMgpgs8AAAAEAAAAAGtJ0gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAEFZbdtiGQBgymUAXIba_ZYrO94laaNYDTeL20Xg9TjwXiADLyQGTCtR-_xgrW9ppU6fn8kEMcUrAurD6LRjD3JqGw
Reading the first bytes: 01 (version), 00 (no flags), 0000000000aa36a7 (chain id), then the 20-byte registry.
The same vector file also holds a static code with the details of "Asha Rai" (S-1001, Computer Science, BSc) and a disclosure code that shows only the programme.
How it is checked
openCode: size cap, prefix, strict base64url.- The decoder reads every field with its size and cap, refuses unknown flags and leftover bytes, and checks addresses with
getAddress. - Static codes:
readSignedFieldsvalidates the signed fields, the credential id is recomputed, details are validated withvalidateDisplay, the attribute root is recomputed. - Disclosure codes: rebuilt into a disclosure file and parsed with
parseDisclosure. - Then the normal checks run:
PresentationService.checkStaticforv1.,PresentationService.checkDisclosureCode(which usesDisclosureService.verify) ford1..
Source files
src/lib/present/bytes.ts(ByteWriter,ByteReader,openCode,closeCode)src/lib/present/static-code.ts,src/lib/present/challenge-code.ts,src/lib/present/disclosure-code.tssrc/lib/present/url.ts(walletUrl,presentUrl,parseFragment)src/lib/doc-crypto/encoding.ts(toBase64Url,fromBase64Url)
Tests
src/lib/present/vectors.test.ts(frozen version 1 codes)src/lib/present/static-code.test.ts,challenge-code.test.ts,disclosure-code.test.ts,url.test.ts