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.