doDisconnect fired the courtesy `disconnect` message without awaiting it, then
tore the relay connection down on the next line:
conn.client.relay(disconnectMsg).catch(() => {}); // fire and forget
...
conn.cleanup(); // closes the pool underneath it
relay() resolves only after `Promise.allSettled(pool.publish(...))` — a real
round trip to every configured relay. cleanup() closed the pool while that
publish was still in flight, so the message usually never left and the dapp went
on believing the wallet was connected until its own liveness timeout fired.
Downstream wallets were patching this out of the published package.
Teardown now splits into two halves with opposite timing requirements.
Registry removal stays synchronous. getConnections() is what a UI renders, and
connect() returns an existing connection for a URI, so leaving this one in the
map while its teardown is pending would hand a caller a dying connection. This
is the one place this differs from !30 and from the downstream patches, which
defer the registry removal along with the teardown.
Relay teardown is deferred until the publish settles, bounded by
DISCONNECT_PUBLISH_TIMEOUT_MS (5s). The bound matters: "the publish never
settles" is exactly the case where a relay is unreachable, and a socket that is
never closed is worse than a courtesy message that is never delivered.
disconnect() keeps its synchronous void signature — not a breaking change.
Why it shipped broken: disconnect.test.ts only covered dapp → wallet. Nothing
exercised wallet → dapp, and the failure is invisible from the wallet's side —
its own state is correct either way, and only the peer notices.
disconnect-delivery.test.ts covers that direction over a live relay, including
the two invariants the deferral must not break (registry cleared immediately,
URI reusable afterwards).
Also fixes a latent hang in the integration harness that the new file exposed.
setupConnection gated both the dapp_ready send and the message handler inside
the keyexchangecomplete callback, so the handshake hung on receiving the wallet's
single wallet_ready for the cycle. Miss it — the dapp's subscription can come up
after the wallet has already published — and key exchange never resolves, the
handler never registers, no dapp_ready is ever sent, and the wallet, guarded by
walletReadySentThisCycle, has nothing prompting it to retry. It now re-announces
dapp_ready(wallet_discovered=false) every 2s until key exchange completes, which
resets that guard and earns another wallet_ready: the recovery path mutual
discovery already specifies, which the harness was not using. Plus retry: 2 on
the integration config, since these tests talk to live relays and a dropped
connection is an environment failure rather than a regression.
docs/wallet.md gains a "Sending disconnect" section for the synchronous/deferred
split and what a caller may rely on. docs/protocol.md gains the sender-side half
of the courtesy-disconnect semantics, which previously read as though "no
acknowledgement" licensed fire-and-forget. That reading is what produced the bug.
The race was diagnosed and first fixed by hantyrram (Ronaldo Ramano) in !30,
which this supersedes — the deferral is their fix; this changes only how it is
scoped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|---|---|---|
| contrib | ||
| docs | ||
| linters | ||
| packages | ||
| .gitignore | ||
| .gitlab-ci.yml | ||
| CLAUDE.md | ||
| eslint.config.cjs | ||
| LICENSE.txt | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.base.json | ||
| zensical.toml | ||
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 │
- The dapp generates a
wiz://URI and displays it as a QR code. - The wallet scans the QR, connects to the relay, and sends its xpubs.
- The dapp derives all addresses locally — no further contact with the wallet needed for address generation.
- 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.