URI Schemes & QR Transport

The wallet routes scanned or pasted input through core/src/uri/detectQrContent.js, which classifies the string into one of the following types:

  • BIP21 (type: 'bip21'): chain payment URIs (bitcoin:, dogecoin:, litecoin:)
  • XChain URI (type: 'xchain-uri'): xchain: URIs (action-share links, dApp deep-links, multisig invitations)
  • XCW chunk (type: 'xcw-chunk'): a single frame from a chunked PSBT-QR stream
  • PSBT hex (type: 'psbt-hex'): raw PSBT bytes in lowercase hex
  • WIF (type: 'wif'): a WIF-encoded private key
  • BIP39 mnemonic (type: 'mnemonic-bip39'): a 12/15/18/21/24-word BIP39 phrase
  • Counterwallet mnemonic (type: 'mnemonic-counterwallet'): a legacy 12-word Counterwallet phrase
  • Address (type: 'address'): a bare coin address (heuristic match)
  • Unknown (type: 'unknown'): unrecognised input

URI registration

Scheme Used for Classifier type
bitcoin: BTC payment requests, generated by Receive bip21
dogecoin: DOGE payment requests bip21
litecoin: LTC payment requests bip21
xchain: XChain-specific URIs: action-share links, multisig invitations, dApp deep-links xchain-uri

xchain: is a distinct classifier type from BIP21 chain URIs. In detectQrContent.js, a bitcoin: / dogecoin: / litecoin: URI resolves to type: 'bip21' (with a scheme field); an xchain: URI resolves to type: 'xchain-uri'. The structural parse is the same (parseBip21Uri), but the dispatch path and handler are separate. This matters for implementors: a scanner that treats xchain: as a BIP21 sub-case will hand it to the wrong handler.

Per-shell registration:

Shell Mechanism
Web Modern Web navigator.registerProtocolHandler API; surfaced as a one-click “make this site the default handler” prompt
Extension Chrome MV3 manifest doesn’t directly register protocol handlers; the extension installs a redirect page that intercepts URIs from companion sites
Desktop Electron’s app.setAsDefaultProtocolClient(scheme) on first run; OS-level handler registration

BIP21

core/src/uri/bip21.js parses and serializes BIP21 URIs:

bitcoin:bc1qmyaddress?amount=0.001&label=Payment&message=Thanks

Supported parameters:

Parameter Source Purpose
amount BIP21 Native-coin amount in BTC / LTC / DOGE
label BIP21 Receiver-facing label
message BIP21 Free-text note
tick XChain extension Token name (for token sends)
tokenamount XChain extension Token amount
r BIP70 Payment-request URL (rejected: wallet doesn’t speak BIP70)

Receive view (Receive.jsx) generates a BIP21 URI on demand with the user-supplied amount + label. Send view (Send.jsx) parses an incoming URI from paste or QR scan and pre-populates the form.

PSBT-QR

PSBTs that exceed the single-frame QR capacity are encoded as a chunked stream of frames. core/src/uri/psbtQr.js handles encode + decode; AnimatedQrFrames.jsx paints the stream at 3 fps with auto-advance, or with manual prev / next when prefers-reduced-motion: reduce is set at the OS level.

Frame format:

XCW:<n>/<total>:<crc32-hex>:<base64-bytes>
Field Purpose
<n>/<total> Frame index (1-based) and total frame count, repeated in every frame so out-of-order arrival still works
<crc32-hex> Lowercase 8-character CRC32 hex over the decoded base64 bytes; per-frame integrity check
<base64-bytes> Base64 chunk content. Chunk 1 prepends a 32-byte SHA256 of the full reassembled PSBT as an integrity anchor

The collector buffers frames until all total are seen and verifies the SHA256 against chunk 1 before handing the PSBT to the caller. Out-of-order arrival is fine; the receiver’s camera doesn’t have to capture frames in order, only all of them eventually.

QrScanner.jsx reads frames continuously and calls back with the reconstructed PSBT once the buffer fills.

Implementors building a scanner directly against psbtQr.js use the XcwCollector progressive-scan API:

import { createXcwCollector, addChunkToCollector } from 'core/src/uri/psbtQr.js';

const state = createXcwCollector();
// call addChunkToCollector(state, rawFrame) for each scanned frame string
// state.complete becomes true when all chunks are in and SHA256 verifies
// state.psbt holds the reassembled Uint8Array; state.error holds any failure reason

createXcwCollector returns a mutable state object. addChunkToCollector(state, frame) parses and CRC-validates the frame, extracts the SHA256 anchor from chunk 1, and accumulates payload slices. When the last chunk arrives it reassembles the full PSBT and verifies the SHA256. Duplicate scans (the animated QR loops) are silently ignored. state.error is set (and state.complete stays false) on any CRC mismatch, hash mismatch, or frame count conflict.

stateDiagram-v2
    [*] --> Buffering
    Buffering --> Buffering: frame received, CRC valid, not yet the last chunk
    Buffering --> Error: CRC mismatch or frame count conflict
    Buffering --> Reassembling: last chunk arrives
    Reassembling --> VerifyingSHA256: reassemble the full PSBT
    VerifyingSHA256 --> Complete: SHA256 anchor verifies
    VerifyingSHA256 --> Error: SHA256 mismatch
    Complete --> [*]
    Error --> [*]

UR (ur:crypto-psbt) ingestion

Air-gapped hardware signers in the Keystone / Passport family speak the Uniform Resources (UR) animated-QR format rather than the wallet’s own XCW framing. core/src/uri/urPsbt.js is a clean-room decoder for ur:crypto-psbt frames: bytewords decoding, minimal CBOR unwrapping, and Luby-transform fountain-code reassembly (Xoshiro256-seeded part selection), validated against bc-ur gold vectors including all-mixed fountain streams.

Because UR uses a fountain code, the receiver does not need every frame; it accumulates parts until the payload reconstructs, so a looping animated QR can be scanned starting anywhere.

import { parseUrFrame, UrPsbtDecoder, decodeUrPsbt } from 'core/src/uri/urPsbt.js';

const decoder = new UrPsbtDecoder();
// feed each scanned `ur:crypto-psbt/...` frame string:
//   decoder.receive(frame)  -> true when the frame contributed
//   decoder.complete        -> flips true when the PSBT reassembles
//   decoder.psbt / .psbtHex -> the decoded bytes (throws while incomplete)
// or decode a captured frame array in one shot: decodeUrPsbt(frames)

Both the PSBT sign form’s paste inbox and the camera scanner accept UR frames alongside XCW frames; decode failures raise a typed UrError. This is ingestion-only: the wallet displays outgoing PSBTs in its own XCW framing.

Multisig PSBT envelope

core/src/uri/multisigPsbtEnvelope.js wraps the multisig round-trip in a typed JSON envelope carried over the XCW chunked transport (prefix XCW-MS:). The envelope identifies the session by a fingerprint derived from the signing round parameters and carries the cosigner’s contribution:

{
  "v": 1,
  "kind": "<multisig-request-nonce | multisig-round-1-reply | ...>",
  "fingerprint": "<32-byte hex>",
  "sessionRef": { "scheme": "...", "threshold": N, "cosignerPubkeys": [...], ... },
  "contribution": { ... }
}
Field Purpose
kind Protocol step: multisig-request-nonce, multisig-round-1-reply, multisig-request-partial, multisig-round-2-reply, multisig-request-signature, multisig-classical-reply, or multisig-finalized
fingerprint SHA256 of the canonical sessionRef; routes the envelope to the right local session without shipping a UUID
sessionRef Session invariants (scheme, threshold, cosigner pubkeys, msgHash, psbtHex); present on request and finalized envelopes
contribution Round-specific output from the cosigner (public nonce, partial sig, or classical DER sig); present on reply and finalized envelopes

The paste-inbox in MultisigSigningSession.jsx accepts these envelopes (received as XCW QR frames or pasted as the raw JSON string). The session state machine routes each round’s contribution to the right slot and surfaces a green check.

Sign-in challenge

Sign-In with XChain challenges are not URI-encoded by the wallet; they’re produced and consumed by the bridge (@xchain-wallet/bridge-spec). Sign-in challenges are not classified by detectQrContent; the bridge layer handles them independently.

See Bridge; Sign-In with XChain.

Detect-and-route

core/src/uri/detectQrContent.js is the single entry point for any scanned or pasted string:

const detected = detectQrContent(input);
switch (detected.type) {
    case 'bip21':                   return handleBip21(detected);
    case 'xchain-uri':              return handleXChainUri(detected);
    case 'xcw-chunk':               return handleXcwChunk(detected);
    case 'psbt-hex':                return handlePsbtHex(detected);
    case 'wif':                     return handleWif(detected);
    case 'mnemonic-bip39':          return handleMnemonic(detected);
    case 'mnemonic-counterwallet':  return handleMnemonic(detected);
    case 'address':                 return handleBareAddress(detected);
    default:                        return handleUnknown(detected);
}

The same function powers the camera scanner, the paste handler in every form, and the URI-scheme entry-point on each shell. Every QR / URI / clipboard input the user feeds the wallet goes through this single classifier.

Animated QR cadence

State Cadence label Behavior
Single-frame PSBT single Static frame, no controls
Multi-frame, default motion 3 fps Auto-advance every 333 ms
Multi-frame, prefers-reduced-motion: reduce manual Auto-advance suspended; Prev / Next buttons rendered below the frame

The reduced-motion flip is observed via window.matchMedia('(prefers-reduced-motion: reduce)') with both modern (addEventListener) and Safari-<14 (addListener) listener wiring. The preference can flip mid-session and the component reacts. The wrapper exposes a data-reduced-motion attribute for downstream styling and tests.

The choice of “manual stepping” over “frozen first frame” preserves function: multi-frame PSBT-QRs (multisig partials, large PSBTs) are non-functional if you can’t reach frames 2…N. Manual prev / next removes the motion while preserving reachability, vestibular-trigger users still complete the workflow.


Copyright © 2026 Dankest, LLC

Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-or-later). See LICENSE and NOTICE for full terms.

Edit this page on GitHub ↗