Phase 1 of the docs-by-domain reorg. Every snapshot now carries YAML frontmatter (domain + summary + tags); the auto-guess was hand-corrected against the full index (T6/interchain -> cross-chain, CDP/MBA/stablecoin -> fiatm, CitizenToken/Ballot -> governance, etc.). No file renamed/moved. build-snapshot-index.mjs now reads the frontmatter and emits a greppable INDEX.md: a top Quick-find table (No | domain | tags | summary) plus per-domain sections with summary + tags. 224/224 tagged, 8 domains, zero uncategorised. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
| domain | summary | tags |
|---|---|---|
| wallet | Cross-platform QR scanner — native Capacitor + web getUserMedia, wired into 3 callsites |
Snapshot 0153: Cross-platform QR scanner — native Capacitor + web getUserMedia, wired into 3 callsites
Captured 2026-06-13
Git state
- Rev:
(no git) - New:
shells/app/src/qr/qr-uri.ts,shells/app/src/qr/QrScanner.tsx,shells/app/test/qr-uri.test.ts. - Updated:
shells/app/package.json(+@capacitor-mlkit/barcode-scanning, +jsqr),shells/app/src/components/QuickActions.tsx(Scan tile → scanner + routing),shells/app/src/components/SendFlow.tsx(Scan QR button on the recipient field + acceptsinitialRecipient/initialAmountBchprops from QuickActions),shells/app/src/screens/AppsScreen.tsx(Scan QR button in the Connect-URI view +detectProtonow recognises the canonicalcc:prefix).
Scope
Close the user-visible "QR doesn't work" gap. The shell already had explicit
hooks for it — QuickActions.tsx:11 (// camera seam — needs the Capacitor barcode plugin on a device), AppsScreen.tsx:294 ("Camera scanning requires
the device build (Capacitor plugin)"), AppsScreen.tsx:178 ("Scan a QR or
paste a session URI") — but no implementation. This snapshot ships it.
Implementation
Cross-platform component, runtime backend selection via
Capacitor.isNativePlatform():
- Native (iOS / Android):
@capacitor-mlkit/barcode-scanning@^7.3.0. Google ML Kit on Android, AVFoundation on iOS — surfaces the platform's own full-screen scanner. We request camera permission, install the ML Kit scanner module if needed (Android one-shot), callBarcodeScanner.scan({ formats: [BarcodeFormat.QrCode] }), and propagate the result. Dynamic import so the web bundle doesn't drag in the native plugin. - Web (PWA / desktop browser):
navigator.mediaDevices.getUserMedia+jsqr@^1.4.0. Renders a<video>preview behind a scan-frame overlay, pumps frames into a hidden<canvas>onrequestAnimationFrame, decodes each frame withjsQR(imageData, w, h, { inversionAttempts: 'attemptBoth' }). First hit stops the loop. Camera prefersfacingMode: 'environment'(rear camera on phones, default elsewhere). All resources cleaned up on unmount (tracks stopped, RAF cancelled, video paused, srcObject nulled). jsQR is dynamic-imported → code-split into its own 130 KB / 47 KB-gzipped chunk that only loads when the scanner opens (see verification below).
Error states surfaced explicitly: permission denied
(NotAllowedError/SecurityError), no camera (NotFoundError/OverconstrainedError),
unsupported browser (no mediaDevices), unsupported native device (ML Kit
isSupported() returns false).
URI router
src/qr/qr-uri.ts is the pure routing layer the scanner feeds into. Four
classes, in detection order:
- Connect URI —
wc:/cc:/cashconnect:/bchconnect:→ protocol-tagged for the multi-protocol responder (ADR-0043). - Payment URI (BIP-21) —
bitcoincash:<addr>?amount=…&label=…&message=…(mainnet) /bchtest:<addr>?…(chipnet).amountis in BCH. - Bare cashaddr —
bitcoincash:q…/bchtest:q…/bchreg:q…. - Unknown — raw text surfaced verbatim; never silently guessed.
Plus bchToSats(bch) for the BIP-21 amount, with the consensus 21M BCH cap
as a sanity guard. Pure module — no DOM, no SDK, no I/O — fully unit-tested.
JSON token definitions deliberately NOT inlined: QR capacity is too small for
the ChipnetTokenFile shape under v40 even with binary mode; the right
encoding is a URL pointing at the definition. Out of scope for this slice.
Three integration points (the existing UI hooks)
-
QuickActionsScan tile — central FAB → Scan QR →QrScannermounts →parseQrUri(text)routes:payment/address→ opensSendFlowwithinitialRecipient(+initialAmountBchfor BIP-21) pre-filled.connect→ copies URI to clipboard with a "Open Apps → Connect, paste it" hint. (Deep-link into the Apps tab is owned byApp.tsx— separate UI slice.)unknown→ copies raw text to clipboard.
-
AppsScreenConnectUriView — adds a⌗ Scan QRbutton below the URI textarea. On hit, only fills the URI field if the decoded text is a connect-URI (wc:/cc:/...); otherwise drops raw in so the existing "Unknown protocol" warning catches it. Also fixeddetectPrototo recognise the canonicalcc:prefix (the cashconnect bundle rewriteswc:→cc:at proposal time — snapshot 0061). -
SendFlowrecipient field — adds an inline⌗ Scanbutton beside the input. On hit, parses withparseQrUri; forpayment/addressfillsrecipient(andamountif BIP-21 supplied one); for unknown payloads, drops the raw text in for visibility, with the existinglooksLikeAddresswarning catching invalid shapes.SendFlownow also acceptsinitialRecipient+initialAmountBchprops so QuickActions can hand off the scan result.
Apps deep-link (this turn's follow-up)
The original release punted the QuickActions → Apps handoff to "clipboard + hint" because it crossed a tab boundary. That gap is now closed:
App.tsxcarries apendingConnectUristate and anopenAppsConnect(uri)callback. The callback collapses any settings/pushed overlay, switchestabto'apps', and sets the pending URI.QuickActionsaccepts an optionalonOpenAppsConnect?: (uri) => voidprop. On aconnect-kind QR scan it calls that, then closes the FAB sheet — falling back to clipboard copy only if the prop isn't wired.AppsScreenacceptspendingConnectUri+onPendingConsumed. A smalluseEffectwatches for an incoming URI: when one lands, it pre-fillspendingUri, jumps toview='connect-uri', then callsonPendingConsumed()so the parent clears its state and re-renders don't loop. The existingConnectUriViewthen takes over (and from there the liveuseConnect.pair()runs the WC handshake — already wired in 0152).
End-to-end: open FAB → Scan QR → scan a wc: / cc: URI → Apps tab opens
with the URI pre-filled in the textarea → tap Connect → the live responder
pairs and surfaces the proposal preview. Same flow on phone, same on web.
Verification (all green)
npx tsc --noEmit(shell) → exit 0 (after the deep-link wiring).npx vitest run→ 74/74 passing (26 newqr-uritests + 48 prior tests including the snapshot-0152 connector + classifier + signer suites).npx vite build→ production bundle clean in 11–14 s across iterations. jsQR is correctly code-split into its own dynamic chunk (jsQR-*.js, 130.64 KB → 47.44 KB gzipped) and only loads on first scanner open — the static bundle'sindexchunk grew by ~8 KB (QrScanner + qr-uri).- Vite dev preview reloaded post-edits;
useConnectinitialised without errors (only the expectedmetadata.url ≠ localhostdev warning from WalletKit, which is harmless on dev and disappears in the deployed PWA). - Camera-permission flow itself can't be exercised in the headless dev
preview — the browser refuses
getUserMediawithout a user gesture in a trusted context. That step is the on-device validation gate.
Permission notes (for whoever runs cap add ios / native test builds)
- iOS:
Info.plistmust includeNSCameraUsageDescription— string like "Scan QR codes to pair with apps and fill payment addresses." Apple rejects the build without it. iOS platform hasn't been added yet (onlyandroid/exists today). - Android: the
@capacitor-mlkit/barcode-scanningplugin addsandroid.permission.CAMERAto the manifest automatically oncap sync. On first scan, ML Kit downloads the scanner module — surfaced by theinstallGoogleBarcodeScannerModule()step inrunNativeScan; the user sees the system "Installing scanner…" dialog briefly. No app-level permission text required beyond the system prompt. - Web PWA: browsers gate camera access on HTTPS or
localhost. The chipnet beta deploy (https://testchipnet.duckdns.org/, snapshot 0109) already satisfies this.
WizardConnect research (this turn's second ask)
Fetched + summarised https://docs.riftenlabs.com/wizardconnect/ to settle the
"is WizardConnect WC2-under-the-hood or its own SDK" question that ADR-0041 §6
- snapshot 0062 left as a probe. It is a distinct proprietary protocol —
NOT WalletConnect v2 — built on Nostr NIP-17 gift-wrap encryption over a
relay. So the previous plan ("if Paytaca pairs via
wc:it's WC2 and free") is wrong as a fallback; if Paytaca-as-WizardConnect is the integration target, it needs its own adapter slot inshells/app/src/connect/.
Genuine advantages over WalletConnect / CashConnect
| WC2 / wc2-bch-bcr | CashConnect (CashRPC) | WizardConnect |
|---|---|---|
| TLS-only relay (relay sees pairing metadata) | Same relay (WC2) | NIP-17 gift-wrap end-to-end encryption — "relay sees only ciphertext" |
| Reconnection requires re-pairing or session resurrection | Same | "Both sides can disconnect and reconnect independently and converge back to a live session without user action" |
| Per-address round-trips for new addresses | Same | "Wallet sends BIP32 xpubs in the handshake; the dApp derives all addresses locally with no further round-trips" |
Hardcoded bch:bchtest / bch:bitcoincash chains |
Same | "Derivation paths identified by semantic names (receive, change, defi) rather than numeric indices" |
SDK shape (four packages)
@wizardconnect/core— transport + encryption + protocol types@wizardconnect/wallet—WalletConnectionManager, multi-connection@wizardconnect/dapp—DappConnectionManager, xpub state@wizardconnect/react— components, hooks, QR dialog
License is LGPL-3.0-or-later with CLA — copyleft on modifications. Worth flagging for the project's license posture (the rest of Deviant ships under a non-copyleft license; consuming an LGPL library is usually fine but modifying the WizardConnect packages would copy back the LGPL terms).
Adapter slot in the multi-protocol responder
Today shells/app/src/connect/classifier.ts routes by WC2 proposal shape +
URI prefix; both branches assume the relay is WC2. WizardConnect won't fit
under that classifier — it's a parallel stack with its own relay (Nostr) and
its own session lifecycle. Architecturally:
- Add a third sibling next to
wc-handler.ts/cc-handler.ts:wz-handler.ts, wrapping@wizardconnect/walletWalletConnectionManager. responder.tswould carry two clients now (WalletKit + Wizard manager). They don't conflict — they're isolated by transport — but the UI state surface (sessions list, pending proposal, sign request) needs to union both.- URI classification: a fourth prefix (TBD — the docs page doesn't pin the
scheme; needs the SDK source to confirm) + a fourth branch in
classifyUri.
This is a real follow-up, not "free" the way the Paytaca-might-be-WC2
hypothesis suggested. Recommendation: add WizardConnect when a concrete
dApp the Deviant wallet needs to support speaks only WizardConnect — Paryon
USD is the named precedent in wiki/connector.md. For now, the
multi-protocol responder (WC + CC) covers the BCH dApp ecosystem the
project already integrates with.
Why it's still interesting
The xpub-handshake-then-derive-locally model is genuinely better than the "dApp asks for each address" pattern of WC2 BCH — and the end-to-end encryption against the relay raises the privacy ceiling. Worth tracking if the BCH dApp ecosystem migrates; not worth pre-empting the migration today.
Deferred / next
- D-1 contract recognition in
SigningPreview— wiresrc/verify/'s multi-template verifier (ADR-0008 / 0015 / 0018) so a scanned-and-then- paired covenant tx renders "✓ canonical<template>" before approval (carry-over from snapshot 0152 / ADR-0041 §4). - Token-definition QR — render a URL pointing at a hosted definition
rather than the JSON itself; route through
TokenImportModal's fetch path once it lands. Out of scope this snapshot. - iOS platform add —
cap add ios+Info.plistpermission text + first build & sign — separate session, gated on Apple Developer access. - Device validation — pair a
cc:URI fromspike/wc-connect/ cashconnect.mjsagainst the shell scanner; scan abitcoincash:?amount=…QR for Send; confirm the ML Kit module install on a fresh Android device. - WizardConnect adapter (per WizardConnect research above) — only when a
dApp the wallet must support (e.g. Paryon USD) speaks WizardConnect-only.
It's NOT a
wc:fallback; it's a third sibling next towc-handler.ts/cc-handler.tsrunning its own Nostr-relay client. License check (LGPL-3- CLA) before adopting any of the four
@wizardconnect/*packages.
- CLA) before adopting any of the four