Skip to content

feat(restore): scan a recovery QR instead of typing the seed - #916

Draft
tiero wants to merge 1 commit into
masterfrom
claude/qr-code-seed-scan-7tflgg
Draft

feat(restore): scan a recovery QR instead of typing the seed#916
tiero wants to merge 1 commit into
masterfrom
claude/qr-code-seed-scan-7tflgg

Conversation

@tiero

@tiero tiero commented Aug 13, 2026

Copy link
Copy Markdown
Member

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:

Format What it is Who writes it
Standard SeedQR Each BIP-39 index as four zero-padded digits, concatenated (48 digits for 12 words, 96 for 24). Numeric QR mode. SeedSigner, Jade, Krux, SeedHammer, most metal plates
CompactSeedQR The raw entropy in byte mode, checksum bits dropped since BIP-39 derives them. ~40% fewer modules — the format punched plates use. SeedSigner, Jade Plus, Krux
ur:seed / ur:crypto-seed Blockchain Commons Uniform Resource: CBOR in Bytewords with a CRC-32. Jade, Keystone, Passport, Krux — and what Nunchuk speaks
Plain mnemonic The words written straight into a QR. Various

All five BIP-39 lengths (12/15/18/21/24 words) work in every format. An nsec or 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.

qr 0.6.0 added a textDecoder hook to QRCanvas/decodeQR. So Scanner gains a binary prop 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 the onData(string) contract is unchanged.

qr is bumped ^0.5.2 → ^0.6.0. That version also sets autoplay/muted/playsinline on 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."
  • an 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.

┌─────────────────────────────────────────┐
│ Recovery phrase or private key          │
│ ┌─────────────────────────────────────┐ │
│ │ ✓  Recovery phrase scanned      (×) │ │
│ │    12 words · CompactSeedQR         │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘

Reuse

Everything visible is existing: InputWithScanner (which already carries the Paste + Scan QR pills), InputContainer, ClearButtonOnInput, OkIcon, Scanner, and the overlaySlideUp overlay pattern from Send. The only new UI is the confirmation row, ~15 lines inside the input's own shell. InputNsec is deleted — this was its only call site.

Two fixes that fall out of making the qr engine load-bearing

  • Camera and frame loop now live in refs with an unmount cleanup. They were plain locals assigned inside a useEffect; every later render rebuilt them as undefined, so closing the scanner from a re-rendered tree left the camera stream running.
  • The camera.setDevice(devices[devices.length - 1]) override is removed. frontalCamera already requests facingMode: '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

  • External vectors: SeedSigner's own documented digit stream (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).
  • Round trip through the real codec: a CompactSeedQR is encoded with qr, rasterised, and decoded back through decodeQR with the same bytesToLatin1 the scanner installs. This is the test that catches a decoder mangling bytes on the way in — the failure that would silently lose a seed.
  • Bytewords table integrity: 256 words, 256 unique minimal pairs. A transcription slip would otherwise collapse two pairs onto one byte and decode the wrong seed.
  • Whole chain in a browser: drove the real restore flow in Chromium with a CompactSeedQR streamed into the fake camera as Y4M. The app decoded it and rendered 12 words · CompactSeedQR.
  • 649 unit tests pass, tsc --noEmit clean, lint and prettier clean, pnpm build succeeds.

Not in scope

  • Export. The wallet can now read a SeedQR but not write one, so the air-gap loop is half-closed. Displaying a seed as a QR has its own security tradeoffs (screenshots, shoulder-surfing, screen capture) and deserves a separate decision.
  • Animated URs and BBQr — neither is needed for a payload this small.
  • ts-sdk. The codec is BIP-39 backup transport, not Arkade protocol, and the wallet consumes @arkade-os/sdk from 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

  • Nothing here touches key storage, derivation, or the existing validation path — a scan produces a mnemonic string and hands it to the same useEffect that typed input goes through.
  • Every format verifies its own checksum (BIP-39 for Standard/plain, CRC-32 for UR, and CompactSeedQR entropy regenerates the checksum by construction), so a misread QR fails loudly rather than restoring a wrong wallet.

Generated by Claude Code

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
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f316dd7-78fb-4f82-a1b9-482deebfe015

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying wallet-bitcoin with  Cloudflare Pages  Cloudflare Pages

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

View logs

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 3+ days without review. @tiero is anyone looking at this?

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 4+ days without review. @claude is anyone looking at this?

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open 3+ days without review. @tiero is anyone looking at this?

@arkana-ai-bot

Copy link
Copy Markdown

This PR has been open for 7+ days without review. @tiero is anyone looking at this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants