Skip to main content

Phone wallet and in-person checks

The phone wallet is part of the web app, not a store app. Two pages make it up:

RouteWhoWhat it does
/walletHolderKeeps credentials in the phone's browser, shows them as QR codes, signs answers to a verifier's challenge
/presentVerifierScans 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​

ActorNeeds
IssuerOptionally shows a QR code on the issued card that adds the credential to the holder's phone
HolderA 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
VerifierA phone or computer browser with /present; a camera helps but a picture or pasted text also works
Chain RPCRead 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, subjectSalt and metadataSalt. 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 one HolderRegistry binds 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.json file.
  • 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​

ItemValue
StorageIndexedDB database certichain-wallet, object store credentials, key id (the credential id)
Entry{ id, code, hasDetails, addedAt } where code is a v1. code
Limit200 entries per device (DEFAULT_LIMIT in src/lib/present/store.ts)
RuleA fuller entry (with details) is never replaced by a plainer one
PersistenceThe 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​

HeadlineMeaning
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 checkedOffline checks pass, the chain could not be read. Revocation and registration are unknown, and anyone can sign with their own address
Signature not confirmed offlineThe 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 acceptedSomething 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:

  1. 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.
  2. Pick the Credential (only entries with selective disclosure data are listed) and the fields to show.
  3. Press Sign with my wallet. The wallet connects and signs the EIP-712 DisclosureRequest over the credential id, the attribute root, the field mask and the challenge.
  4. 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​

HeadlineMeaning
Presentation verifiedThe 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 acceptedFor 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 chainOffline checks pass, the chain could not be read
Signature not confirmed offlineThe 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​

StepPromptKind
Sign for a verifierConnect (if not connected), then DisclosureRequestSignature, 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​

DataWhere
Codes in linksAfter the #, which browsers never send to the server
Camera frames and picturesRead in the browser with jsQR 1.4.0; never uploaded
Stored credentialsIndexedDB on the phone only; the site has no accounts and no copy
Verifier's challengePage memory and the tab's session storage
StatusAlways 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, a d1. 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.md is 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.ts
  • src/app/present/PresentContent.tsx, ChallengePanel.tsx, StaticFacts.tsx, DisclosureFacts.tsx, VerdictView.tsx, use-challenge.ts, verdict-text.ts
  • src/app/records/add/SendToPhone.tsx
  • src/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.ts
  • src/components/present/QrScanner.tsx, PresentationQr.tsx, RegisterOfflineShell.tsx
  • src/services/domain/presentation-service.ts, src/services/domain/presentation-router.ts
  • public/sw.js, public/manifest.webmanifest
  • docs/mobile/README.md (student and verifier guide)

Tests​

  • src/lib/present/*.test.ts (static, challenge and disclosure codes, frozen vectors in vectors.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.ts
  • src/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)