feat(restore): scan a recovery QR instead of typing the seed - #916
Conversation
Restoring a wallet meant typing twelve words on a phone keyboard. This
adds the air-gapped path people actually asked for: point the camera at
the backup a signing device or a metal plate already carries.
Four formats, covering how a BIP-39 seed really travels on a QR:
- Standard SeedQR (SeedSigner) — four zero-padded digits per word
- CompactSeedQR (SeedSigner) — raw entropy, byte mode, ~40% smaller
- ur:seed / ur:crypto-seed (Blockchain Commons) — Jade, Keystone,
Passport, Krux, and what Nunchuk speaks
- a plain mnemonic written into a QR
CompactSeedQR is what forces the interesting part. It is raw entropy, so
a UTF-8 decoder replaces invalid sequences with U+FFFD and destroys the
seed before anything can validate it. `qr` 0.6.0 added a `textDecoder`
hook, so `Scanner` gains a `binary` mode that decodes byte segments
latin1 — one character per byte — and pins the engine, since only that
one is byte-accurate. ASCII payloads pass through unchanged, so the same
scanner reads all four formats.
UR support is deliberately single-part: a seed is 16-32 bytes, which
lands around 60 Bytewords, so it always fits in one symbol. Animated URs
exist for PSBTs and descriptors, and refusing them costs no
interoperability while saving the fountain decoder entirely. A scanned
multi-part frame says so by name, as does an xpub or a PSBT — the UR type
is read from the URI before the body, so a wrong QR is named even when
its checksum would not survive.
The scanned phrase is never rendered. Scanning is the one restore path
where the words do not have to appear on screen, so the confirmation
shows the format and word count instead, and the existing validator does
the rest. An nsec or raw hex key still passes straight through to it.
Two fixes fall out of making the `qr` engine load-bearing: its camera and
frame loop now live in refs with an unmount cleanup (plain locals were
rebuilt as undefined on re-render, so closing the scanner left the stream
running), and the arbitrary `devices[devices.length - 1]` override is
gone — `frontalCamera` already asks for the environment-facing camera,
and the last enumerated device is often an ultra-wide or the front one.
Verified against published vectors: SeedSigner's documented digit stream
and Blockchain Commons' "Yinmn Blue" ur:seed. The CompactSeedQR path is
covered by a round trip through the real encoder and decoder, and the
whole chain was driven in Chromium with a CompactSeedQR streamed into the
fake camera.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BztSdzLek2iLodqN9Fs6tf
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Deploying wallet-bitcoin with
|
| Latest commit: |
66edfe1
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://3a2fe05c.wallet-bitcoin.pages.dev |
| Branch Preview URL: | https://claude-qr-code-seed-scan-7tf.wallet-bitcoin.pages.dev |
|
This PR has been open for 3+ days without review. @tiero is anyone looking at this? |
|
This PR has been open for 4+ days without review. @claude is anyone looking at this? |
|
This PR has been open 3+ days without review. @tiero is anyone looking at this? |
|
This PR has been open for 7+ days without review. @tiero is anyone looking at this? |
Restoring a wallet currently means typing twelve words on a phone keyboard. This adds the air-gapped path users asked for (the thread — "restoring in an air gapped like fashion… similar to how Nunchuk does it"): point the camera at the backup a signing device or a metal plate already carries.
What it reads
Four formats, covering how a BIP-39 seed actually travels on a QR today:
ur:seed/ur:crypto-seedAll five BIP-39 lengths (12/15/18/21/24 words) work in every format. An
nsecor raw hex key still passes straight through to the existing validator, so there is one restore path, not two.The interesting part: CompactSeedQR is binary
CompactSeedQR is raw entropy in byte mode. A UTF-8 decoder replaces invalid sequences with U+FFFD, destroying the seed before anything can validate it — and neither scanner engine in the app returned bytes.
qr0.6.0 added atextDecoderhook toQRCanvas/decodeQR. SoScannergains abinaryprop that decodes byte segments latin1 — one character per byte, code point equal to the byte — and pins the engine to the byte-accurate one (the aux engine-switcher disappears, since only that engine is lossless). ASCII payloads are unaffected, so the same scanner reads all four formats and theonData(string)contract is unchanged.qris bumped^0.5.2 → ^0.6.0. That version also setsautoplay/muted/playsinlineon the video element, which is what iOS inline preview needs.Single-part UR, deliberately
A seed is 16–32 bytes, which lands around 60 Bytewords — it always fits in one symbol. Animated (fountain-coded) URs exist for PSBTs and output descriptors, so refusing them costs no interoperability while saving the entire Luby-transform decoder. No new dependencies: Bytewords, CRC-32 and a small definite-length CBOR reader are ~250 lines in
src/lib/ur.ts.The UR type is read from the URI before the body is decoded, so a wrong QR is named even when its checksum would not survive a partial capture:
ur:crypto-hdkey→ "That QR holds a public key (ur:crypto-hdkey), not a seed…"ur:seed/2-7/…→ "That is frame 2 of a 7-frame animated QR. A seed fits in one QR — export a static one."xpub→ "That QR holds an extended key, not a seed."The scanned phrase is never rendered
Scanning is the one restore path where the words never have to appear on screen, so the input is replaced by a confirmation showing only format and word count. Clearing it returns the input.
Reuse
Everything visible is existing:
InputWithScanner(which already carries the Paste + Scan QR pills),InputContainer,ClearButtonOnInput,OkIcon,Scanner, and theoverlaySlideUpoverlay pattern from Send. The only new UI is the confirmation row, ~15 lines inside the input's own shell.InputNsecis deleted — this was its only call site.Two fixes that fall out of making the
qrengine load-bearinguseEffect; every later render rebuilt them asundefined, so closing the scanner from a re-rendered tree left the camera stream running.camera.setDevice(devices[devices.length - 1])override is removed.frontalCameraalready requestsfacingMode: 'environment'; the last enumerated device is often an ultra-wide or the front camera. Flagging this one explicitly in case it was working around something device-specific.Verification
vacuum bridge buddy supreme…→1924022202351743…) and Blockchain Commons' published "Yinmn Blue Acid Exam"ur:seed, decoded down to its CBOR map (payload, name, note, tagged date).qr, rasterised, and decoded back throughdecodeQRwith the samebytesToLatin1the scanner installs. This is the test that catches a decoder mangling bytes on the way in — the failure that would silently lose a seed.12 words · CompactSeedQR.tsc --noEmitclean, lint and prettier clean,pnpm buildsucceeds.Not in scope
ts-sdk. The codec is BIP-39 backup transport, not Arkade protocol, and the wallet consumes@arkade-os/sdkfrom npm, so putting it there would have required a release to use it. Happy to promote it if you'd rather it live in the SDK.Review notes
useEffectthat typed input goes through.Generated by Claude Code