Mirror of Riften Labs WizardConnect - protocol + library for connecting dapps to HD wallets over an encrypted relay (LGPL-3.0-or-later). Upstream: https://gitlab.com/riftenlabs/lib/wizardconnect https://docs.riftenlabs.com/wizardconnect/
Find a file
Håvard Kittelsen 460e113e75 feat: multislot — more than one wallet key per transaction input
`inputPaths` names one HD key per entry, which is the whole story for a P2PKH
input: one input, one signature. A contract input is not like that. Its unlocking
bytecode may carry several sig/pubkey placeholders — an N-of-N agreement, or a
function taking (sig a, pubkey A, sig b, pubkey B) — and nothing in a 3-tuple can
say which placeholder a key fills.

So entries gain an optional fourth element, `slot`, and an input's index is listed
once per placeholder. `slot` defaults to 0, so every existing 3-tuple keeps
meaning exactly what it meant and no current dapp needs a capability check.
Sending `slot > 0`, or repeating an inputIndex, requires the wallet to advertise
`multislot` — an older wallet keeps one key per input and would return a
transaction missing signatures with nothing to say why.

This supersedes the `multislot` branch, which was cut before the
randomTradeSummary revert and still carries the reverted `txSummary` field. The
protocol design there is good and is kept: the placeholder format (65-byte
Schnorr sig push, 33-byte compressed pubkey push, spliced value-for-value so
offsets survive), the capability gate, and the three wallet rules — don't
deduplicate by inputIndex, compute each input's sighash once, reject rather than
under-fill. Two things are changed.

SLOTS ARE POSITIONAL, NOT BY VACANCY

The earlier definition numbered slots by scanning the template for zero-filled
pushes. That renumbers them as they fill, and a template is not always all
zeroes: in the N-of-N case the docs give as motivation, the dapp may have already
written the counterparty's signature into the first position. Verified against
that implementation's own reference fill, on a two-slot input with slot 0
pre-filled:

  asked for slot 1 -> not filled at all (under-fill)
  asked for slot 0 -> writes into the SECOND slot, silently

Here a push already holding a value still occupies its slot, so `slot` means the
same thing to the dapp that built the template and the wallet filling it,
whatever order things happen in. Overwriting a filled slot is an error rather
than a no-op, because discarding a counterparty's signature is not recoverable.

THE SCAN PARSES PUSHES INSTEAD OF SEARCHING FOR BYTES

A hex-substring search for the placeholder pattern can match bytes that merely
sit inside a larger push, splicing a signature into the middle of unrelated data.
findPlaceholders walks the script's push structure (direct pushes and
OP_PUSHDATA1/2/4) and throws on a truncated template rather than guessing at one
it cannot parse.

WHY THE HELPERS ARE IN THE LIBRARY

The placeholder layout is part of the wire contract — the dapp builds the
template, the wallet splices into it, and they must agree byte for byte or the
transaction is silently unspendable. Leaving every wallet to implement the scan
is how that goes wrong, and both failure modes above come from a reasonable
implementation of a reasonable-sounding rule.

fillPlaceholder throws on a missing slot, a wrong-length value, or an already
filled slot; unfilledPlaceholders is what "reject, don't under-fill" checks. That
makes the rule enforceable rather than something to remember.

Deliberately NOT included: the sighash-validation module from the earlier branch.
It is a separate concern — the library does no transaction signing today, so
adding a validator for it is new surface that deserves its own review, and its
signature-detection heuristic needs work (a 65-byte push is treated as a
signature, which an uncompressed public key also is).

23 core tests, including both misplacement cases above, the inside-a-larger-push
false positive, OP_PUSHDATA1 headers, truncated templates, and fill-order
independence.

Docs: protocol.md, extensions.md, wallet.md, dapp.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:37:42 +02:00
contrib ci: Fix version comparison bug 2026-04-14 09:15:19 +02:00
docs feat: multislot — more than one wallet key per transaction input 2026-08-07 16:37:42 +02:00
linters Add 'react' package with QR code and modal dialog 2026-03-18 16:34:41 +01:00
packages feat: multislot — more than one wallet key per transaction input 2026-08-07 16:37:42 +02:00
.gitignore First commit for WizardConnect 2026-03-06 11:38:09 +01:00
.gitlab-ci.yml ci: Build before running tests 2026-03-23 08:43:00 +01:00
CLAUDE.md Active reconnect. 2026-04-29 09:06:53 +00:00
eslint.config.cjs Add 'react' package with QR code and modal dialog 2026-03-18 16:34:41 +01:00
LICENSE.txt First commit for WizardConnect 2026-03-06 11:38:09 +01:00
package-lock.json Active reconnect. 2026-04-29 09:06:53 +00:00
package.json Add 'react' package with QR code and modal dialog 2026-03-18 16:34:41 +01:00
README.md Add additional default relay 2026-04-14 08:42:34 +02:00
tsconfig.base.json First commit for WizardConnect 2026-03-06 11:38:09 +01:00
zensical.toml First commit for WizardConnect 2026-03-06 11:38:09 +01:00

WizardConnect

A dapp-to-wallet protocol built for Bitcoin Cash.

WizardConnect lets a dapp scan a wallet's QR code and immediately start deriving addresses, constructing transactions, and requesting signatures — no seed phrases, no trusted servers, no round trips.

Why not WalletConnect?

WalletConnect was built for Ethereum, where a wallet is a single address. BCH wallets are HD wallets with thousands of addresses for privacy. Forcing an Ethereum protocol onto BCH means:

  • One address per session. WalletConnect gives the dapp a single address. That breaks the HD wallet model and destroys the privacy that multiple addresses provide.
  • Unreliable on BCH. BCH support in WalletConnect is a second-class citizen — a bridge on top of an Ethereum-native protocol. Connections drop, sessions fail to restore, and there's no community maintaining it for BCH.

WizardConnect replaces all of this with a protocol designed from the ground up for UTXO chains and HD wallets.

How it works

Wallet                          Relay                           Dapp
  │                               │                               │
  │   ◄───── QR code scan ──────  │  ◄──── shows QR (wiz://...)  │
  │                               │                               │
  │   wallet_ready ──────────────►│──────────────────────────────►│
  │   (xpubs + key exchange)      │                               │
  │                               │          derive addresses     │
  │                               │          locally from xpubs   │
  │                               │          (no more round trips)│
  │                               │                               │
  │   ◄──────────────────────────│◄──── sign_transaction_request │
  │   user approves on wallet     │                               │
  │   sign_transaction_response──►│──────────────────────────────►│
  │                               │                    broadcast  │
  1. The dapp generates a wiz:// URI and displays it as a QR code.
  2. The wallet scans the QR, connects to the relay, and sends its xpubs.
  3. The dapp derives all addresses locally — no further contact with the wallet needed for address generation.
  4. When the dapp needs a signature, it sends a request. The user approves on the wallet. The signed transaction comes back.

All communication is end-to-end encrypted via Nostr NIP-17 gift wrap. The relay sees only ciphertext.

Key features

Full HD wallet support. The wallet shares BIP32 xpubs for specific derivation paths (receive, change, DeFi). The dapp derives unlimited addresses locally. No more single-address sessions.

Zero round trips for addresses. After the initial handshake, the dapp never needs to ask the wallet for a public key. It derives them on demand from the xpubs, in microseconds.

Decentralized transport. Built on Nostr — an open protocol with hundreds of public relays. No proprietary bridge server. Users can point to any relay for additional privacy or self-host their own.

Reconnection-proof. Both sides can disconnect and reconnect independently (app switch, browser refresh, network drop) and converge back to a live session without user action.

Extensible. The protocol negotiates capabilities during the handshake. hdwalletv1 is the first protocol; future protocols (multisig, post-quantum, token-aware) can be added without breaking existing connections.

Open source (LGPL-3.0). Free to use in commercial and non-commercial applications. Modifications to the library itself must be shared back.

Packages

npm install @wizardconnect/core    # transport, protocol types, key exchange
npm install @wizardconnect/dapp    # dapp-side session + address derivation
npm install @wizardconnect/wallet  # wallet-side connection + signing
Package Description
@wizardconnect/core Relay client, NIP-17 encryption, URI encoding, protocol message types
@wizardconnect/dapp DappConnectionManager, DappPubkeyStateManager — session management and on-demand pubkey derivation
@wizardconnect/wallet WalletConnectionManager, WalletAdapter interface — multi-connection management and sign request dispatch

Quick start: dapp

import { initiateDappRelay } from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";

const dapp = new DappConnectionManager("My Dapp", "https://example.com/icon.png");

const relay = initiateDappRelay(
  (payload) => dapp.updateConnection(payload.client, payload.status),
  { explicitRelayUrls: ["wss://relay.riften.net:443"] },
);

// Display relay.uri as a QR code for the wallet to scan

dapp.on("walletready", () => {
  // Connected! Derive addresses locally:
  const receivePubkey = dapp.getPubkey(0, 0n);  // receive path, index 0
  const changePubkey  = dapp.getPubkey(1, 0n);  // change path, index 0
});

Quick start: wallet

import { WalletConnectionManager } from "@wizardconnect/wallet";

const manager = new WalletConnectionManager(myWalletAdapter);

// When the user scans a dapp's QR code:
const connectionId = manager.connect("wiz://?p=...&s=...");

// When a sign request arrives, show it to the user:
manager.on("pendingSignRequest", async ({ connectionId, request }) => {
  const approved = await showApprovalUI(request);
  if (approved) {
    await manager.sendSignResponse(connectionId, request.sequence, signedTxHex);
  } else {
    await manager.sendSignError(connectionId, request.sequence, "User rejected");
  }
});

See wallet integration guide for WalletAdapter implementation details.

Privacy model

WizardConnect shares xpubs at the chain level, not the account level. A dapp receiving the receive xpub can derive receive addresses but cannot derive change addresses or any other internal wallet activity. This is the same level of key material used by watch-only wallets.

For wallets that want stronger isolation, the protocol supports per-session xpub rotation — the dapp sees only the xpub node, never the derivation path.

See xpub sharing: why it's safe for a full discussion.

Security

  • Private keys never leave the wallet. Signing happens on-device.
  • All relay traffic is end-to-end encrypted (NIP-17 gift wrap). The relay cannot read messages.
  • Key exchange includes an 8-byte shared secret (embedded in the QR code) for MITM prevention.
  • No trusted intermediary. The relay is a dumb message broker — compromise it and you get ciphertext.

Documentation

Topic Link
Protocol messages and handshake protocol.md
Connection URI and key exchange connection-uri.md
Relay transport and encryption transport.md
Wallet integration guide wallet.md
Dapp integration guide dapp.md
xpub delivery and derivation pubkey-derivation.md
xpub sharing: why it's safe xpub-sharing.md

Building

npm install
npm run build   # builds all packages in dependency order
npm run test    # unit tests (fast, no network)

Integration tests hit a live relay:

cd packages/wallet && npm run test:integration

License

LGPL-3.0-or-later. Free for commercial and non-commercial use. Modifications to the library must be released under the same license.

Built by Riften Labs.