Phone wallet and in-person checks
The phone wallet is part of the web app, not a store app. Two pages make it up:
| Route | Who | What it does |
|---|---|---|
/wallet | Holder | Keeps credentials in the phone's browser, shows them as QR codes, signs answers to a verifier's challenge |
/present | Verifier | Scans or pastes a code and checks it. No wallet needed |
Both can be added to the home screen (public/manifest.webmanifest, name "Certichain Wallet", start URL /wallet) and keep working without a network after the first visit.
Everything travels as short compact codes: v1. (a credential), c1. (a verifier's challenge) and d1. (a holder's signed answer). Their byte layouts are in Compact codes. The holder's answer is a selective disclosure in compact form, so the same checks apply.
Who takes part
| Actor | Needs |
|---|---|
| Issuer | Optionally shows a QR code on the issued card that adds the credential to the holder's phone |
| Holder | A phone browser. For signing an answer: a wallet inside that browser (for example the built-in browser of a wallet app) and a wallet bound to them on chain |
| Verifier | A phone or computer browser with /present; a camera helps but a picture or pasted text also works |
| Chain RPC | Read only, used by /present when there is a network |
Preconditions
- A v0.3 credential. The code carries the signing domain (
Certichain,1), so only credentials from that domain fit. - To keep or show the holder's details, a credential package with
display,subjectSaltandmetadataSalt. A verifiable credential file adds only the signed facts; Open Badge files are refused ("Open Badge files are not added here; use the credential package or the verifiable credential file."). - To sign for a verifier: the package also carries the issuer's selective disclosure data (
attributes), and the holder's wallet is the oneHolderRegistrybinds to the student (see Enrolment).
Step 1: putting a credential on the phone
/wallet, panel Add a credential. Three ways in:
- Credential file: a credential package or a
.vc.jsonfile. - Code or link: paste a
v1.code or a/wallet#v1....link and press Add, or press Scan with the camera and point at a code on another screen. - A link from the issuer: opening
/wallet#v1....shows the credential for review straight away.
The issuer's link comes from the issued card on /records/add: Show a QR code for the holder's phone, with an optional tick Include the holder's name, student ID and programme. The code carries the signed facts (and the details when ticked), never the document.
Whatever the source, nothing is stored until the holder reviews it. The review card (Add this credential?) says who signed it and whether the details match what the issuer signed. Details that do not match are dropped with "The details in this code do not match what the issuer signed. They will not be kept." The holder then presses Add to this phone or Not now.
By default only the signed facts are kept: hashes, the issuer address and the issuer signature. Ticking Keep my name and details on this phone (needed to show them or to sign for a verifier) keeps the details too.
Where it is stored
| Item | Value |
|---|---|
| Storage | IndexedDB database certichain-wallet, object store credentials, key id (the credential id) |
| Entry | { id, code, hasDetails, addedAt } where code is a v1. code |
| Limit | 200 entries per device (DEFAULT_LIMIT in src/lib/present/store.ts) |
| Rule | A fuller entry (with details) is never replaced by a plainer one |
| Persistence | The page asks the browser to keep its storage (navigator.storage.persist), which a browser may refuse |
Remove from this phone deletes the copy on that device only. Clearing the site data also removes everything, so the original file should be kept.
Step 2: showing a credential (static code)
On a stored credential, Show the code displays a QR code of /present#v1..... The tick Include my name and details in the code adds the details (the selective disclosure data is never put in a code shown to a verifier).
The verifier opens /present (Check a credential), section Scan a credential code: Scan with the camera, a picture, or Or paste a code or link.
Results
| Headline | Meaning |
|---|---|
| Credential is valid (holder not verified) | All offline checks pass and the chain agrees: found, anchor matches, issuer registered, active, signature valid |
| Signed by the issuer address shown, status not checked | Offline checks pass, the chain could not be read. Revocation and registration are unknown, and anyone can sign with their own address |
| Signature not confirmed offline | The issuer's own address did not sign it and the chain could not be read. A contract wallet issuer can only be confirmed on chain |
| Not accepted | Something failed, with reasons such as "The credential is no longer active (revoked, suspended, expired or its issuer is suspended).", "The issuer is not registered.", "This credential is on a different network than this page reads." |
Every static result carries the caveat: "This code does not prove who is showing it: anyone can photograph a code. To be sure the holder is present, ask them to sign for you below."
Step 3: proving the holder is present
The verifier uses section Ask the holder to prove it is theirs. The page shows a challenge QR (/wallet#c1....) and a check code: the first four bytes of the challenge in two groups of four hex characters. An optional Your name, shown to the holder (optional) label (up to 60 bytes of plain text, no control or direction-changing characters) is put in the code.
On the holder's phone, inside a wallet app's browser, /wallet section Sign for a verifier:
- Scan or paste the challenge in Challenge from the verifier. The page shows "A verifier is asking you to prove this credential." (or the verifier's label) and the check code, which must match the verifier's screen.
- Pick the Credential (only entries with selective disclosure data are listed) and the fields to show.
- Press Sign with my wallet. The wallet connects and signs the EIP-712
DisclosureRequestover the credential id, the attribute root, the field mask and the challenge. - The page shows the answer as a QR code of
/present#d1.....
The verifier scans the answer. /present keeps the current challenge in session storage for the tab (key certichain.present.challenge), so an answer opened from a link in the same tab is checked against the challenge that was shown.
Results
| Headline | Meaning |
|---|---|
| Presentation verified | The wallet bound to the student on chain signed the chosen fields for this challenge, the issuer attested the fields, the credential is active and the issuer registered |
| Presentation not accepted | For example "It was made for another verifier or an older challenge. Ask for a new one.", "It was not signed by the wallet bound to this student.", "The issuer did not sign this set of details." |
| Signed, not checked against the chain | Offline checks pass, the chain could not be read |
| Signature not confirmed offline | The issuer's own address did not sign the details and the chain could not be read |
The caveat is "Fields that are not shown are unknown. This check says nothing about them." After a verified answer the page makes a new challenge, so the same answer scanned again gives Presentation not accepted. New challenge replaces it by hand.
Wallet prompts
| Step | Prompt | Kind |
|---|---|---|
| Sign for a verifier | Connect (if not connected), then DisclosureRequest | Signature, no gas |
Adding, showing and checking need no wallet. Without one, signing stops with "Signing needs a wallet. Open this page inside your wallet app's browser, then try again."
What stays where
| Data | Where |
|---|---|
| Codes in links | After the #, which browsers never send to the server |
| Camera frames and pictures | Read in the browser with jsQR 1.4.0; never uploaded |
| Stored credentials | IndexedDB on the phone only; the site has no accounts and no copy |
| Verifier's challenge | Page memory and the tab's session storage |
| Status | Always read from the chain; never cached |
/wallet and /present send Referrer-Policy: no-referrer and Permissions-Policy: camera=(self) (src/lib/security-headers.ts).
Offline shell
public/sw.js is a service worker registered by both pages (RegisterOfflineShell). It caches only the two pages, the manifest and icons, and content-hashed files under /_next/static/. A page is served from the network when it answers within 3 seconds, else from the cache. Chain reads, API calls and every other page go straight to the network, so the cache never holds a credential's status.
Contract sets
A code names the registry it was signed for. PresentationRouter sends a code to the services of the contract set that owns that registry, and a code naming no listed set to the active set (which reports the mismatch). See Contract sets in the frontend.
Limits
- A static code can be photographed and replayed. Only the challenge flow proves possession.
- A QR code holds a few thousand characters: a
v1.code is capped at 4,096 characters, ad1.code at 8,192. A too-large answer says "The chosen details are too large for one code. Choose fewer." - Signing needs an injected wallet in the phone browser. WalletConnect is not supported.
- Safari on iPhone may remove site data after a long time without a visit.
- The real two-phone test in
docs/mobile/README.mdis a manual check that is still open as of 2026-10-24.
Source files
src/app/wallet/WalletContent.tsx,AddCredentialPanel.tsx,CredentialCard.tsx,SignForVerifierPanel.tsx,use-device-store.tssrc/app/present/PresentContent.tsx,ChallengePanel.tsx,StaticFacts.tsx,DisclosureFacts.tsx,VerdictView.tsx,use-challenge.ts,verdict-text.tssrc/app/records/add/SendToPhone.tsxsrc/lib/present/static-code.ts,challenge-code.ts,disclosure-code.ts,bytes.ts,url.ts,entry.ts,import-source.ts,store.ts,idb-backend.ts,persistence.ts,qr-decode.tssrc/components/present/QrScanner.tsx,PresentationQr.tsx,RegisterOfflineShell.tsxsrc/services/domain/presentation-service.ts,src/services/domain/presentation-router.tspublic/sw.js,public/manifest.webmanifestdocs/mobile/README.md(student and verifier guide)
Tests
src/lib/present/*.test.ts(static, challenge and disclosure codes, frozen vectors invectors.test.ts, URL parsing, entry checks, import sources, store, persistence, QR decode, service worker)src/app/wallet/WalletContent.test.tsx,src/app/present/PresentContent.test.tsx,src/app/present/verdict-text.test.tssrc/components/present/QrScanner.test.tsx,PresentationQr.test.tsx- E2E:
e2e/journeys/mobile-wallet.journey.ts(2 tests on an emulated phone: show, check offline and online, revoke; challenge, sign, verify, replay refused)