URI Schemes & QR Transport

The wallet handles four classes of encoded payload, all routed through core/src/uri/detectQrContent.js:

  • BIP21 — payment URIs (bitcoin:, dogecoin:, litecoin:, xchain:)
  • PSBT-QR — chunked transport for unsigned / partially-signed PSBTs
  • Multisig PSBT envelope — wrapped PSBT payload for cosigner round-trips
  • Sign-in challenge — Sign-In with XChain (delegated to the bridge)

URI registration

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

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:

xchain:psbt?seq=N/M&data=<base64-chunk>
Field Purpose
seq=N/M Frame index (1-based) and total frame count
data=... URL-safe base64 chunk of the PSBT bytes

The decoder buffers frames until all M are seen and reconstructs the original PSBT. 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.

Multisig PSBT envelope

core/src/uri/multisigPsbtEnvelope.js wraps a PSBT in a typed envelope used by the multisig signing session. The envelope identifies which session the partial belongs to and which cosigner produced it:

xchain:multisig-psbt?session=<id>&from=<cosigner-pubkey>&data=<base64-psbt>
Field Purpose
session Multisig signing session id (matches a multisigSigningSessions record in the vault)
from Producing cosigner’s pubkey, so the coordinator knows which slot to fill
data Base64 of the partial PSBT (chunked across frames if needed)

The paste-inbox in MultisigSigningSession.jsx accepts both raw multisig-PSBT envelopes and chunked PSBT-QR streams. The session state machine routes the partial to the right cosigner 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). The wallet’s detectQrContent recognizes a sign-in-challenge string and routes it to the same approval popup the bridge uses, so the user can sign a Sign-In challenge presented as a QR (e.g. by a desktop app to a wallet on a phone).

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.kind) {
    case 'bip21':            return handleBip21(detected.value);
    case 'psbt-qr':          return handlePsbtQrFrame(detected.value);
    case 'multisig-psbt':    return handleMultisigPartial(detected.value);
    case 'sign-in-challenge': return handleSignInChallenge(detected.value);
    case 'address':          return handleBareAddress(detected.value);
    case 'mnemonic':         return handleMnemonic(detected.value);
    default:                 return handleUnknown(detected.raw);
}

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 ↗