First commit for WizardConnect

This commit is contained in:
Dagur Valberg Johannsson 2026-02-26 11:19:47 +01:00
commit 6fce9b47cb
No known key found for this signature in database
GPG key ID: FD701804AEE88107
61 changed files with 10955 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
*.js.map
site/
.venv/

83
CLAUDE.md Normal file
View file

@ -0,0 +1,83 @@
# wizardconnect
Relay-based protocol library for connecting dapps to HD wallets over WebSocket (Nostr NIP-17 gift-wrapped messages).
## Testing philosophy
Tests are more important than the code itself. A correct implementation that lacks tests is worse than a slightly imperfect implementation that is well-tested, because untested code will silently break and the breakage will only surface in production — often as subtle protocol bugs that are hard to reproduce.
This codebase communicates over a live relay with timing-sensitive handshakes and stateful reconnection logic. These bugs cannot be caught by reading the code. They only appear in integration tests that exercise the real relay.
**Always write tests before or alongside any non-trivial change.** If you add a feature, add a test. If you fix a bug, add a regression test that would have caught it.
### Test types
**Unit tests** (`npm test` in a package):
- Fast, no network, run on every change
- Test individual functions and classes in isolation
- Use vitest, mock external deps
- Should cover all edge cases in pure logic (state managers, message builders, URI encoding, etc.)
**Integration tests** (`npm run test:integration` in a package):
- Hit the real relay at `wss://relay.cauldron.quest:443`
- Test the full protocol handshake end-to-end
- Located in `src/__tests__/*.integration.test.ts`
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
- Must pass before any release
### Running tests
```bash
# All packages, unit tests only (fast):
npm run test
# Wallet package integration tests (requires network):
cd packages/wallet && npm run test:integration
# Custom relay:
TEST_RELAY_URL=wss://your-relay:443 npm run test:integration
```
### Test CLI (manual/exploratory testing)
```bash
# Start a dapp session and watch the protocol:
npm run dapp
# Connect a test wallet to a dapp URI:
npm run wallet -- --uri wiz://...
# Test the approval flow:
npm run dapp -- --sign
```
The test CLI (`packages/test-cli/`) is not a substitute for automated tests — it is a debugging tool.
## Documentation
Protocol and architecture documentation lives in `docs/`. Keep it up to date when making changes:
| File | Update when… |
|------|-------------|
| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change |
| `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes |
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |
| `docs/dapp.md` | `DappConnectionManager` API or session lifecycle changes |
| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes |
| `docs/index.md` | New top-level docs files are added |
## Packages
- `@wizardconnect/core` — transport + protocol primitives (relay client, key exchange, hdwalletv1 message types)
- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`)
- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes)
## Build
```bash
npm install
npm run build # builds all packages in dependency order
```
Packages must be built before integration tests run (tests import from `dist/`).

165
LICENSE.txt Normal file
View file

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

174
docs/connection-uri.md Normal file
View file

@ -0,0 +1,174 @@
# Connection URI and key exchange
A WizardConnect session starts with the dapp generating a connection URI. The wallet scans
this URI (typically as a QR code) and both sides use it to establish an encrypted channel.
## URI format
```
wiz://?p=<pubkey_bech32>&s=<secret_bech32>
```
When using a non-default relay:
```
wiz://<hostname>:<port>?p=<pubkey_bech32>&s=<secret_bech32>&pr=<protocol>
```
| Parameter | Encoding | Size | Meaning |
|-----------|----------|------|---------|
| `p` | bech32-padded | 32 bytes | Dapp's Nostr public key (x-only secp256k1) |
| `s` | bech32-padded | 8 bytes | Shared secret for key exchange verification |
| `hostname` | URL authority | — | Relay hostname (default: `relay.cauldron.quest`) |
| `port` | URL authority | — | Relay port (default: `443`) |
| `pr` | query param | — | `ws` or `wss` (default: `wss`, omitted when default) |
The URI omits the authority entirely when relay defaults are used, keeping QR codes compact.
### QR code encoding — alphanumeric mode
QR codes support several character encodings. **Alphanumeric mode** encodes each character in
5.5 bits instead of 8 bits (byte mode), producing ~30 % smaller codes for the same data.
The alphanumeric charset covers: `AZ`, `09`, and the symbols `$`, `%`, `*`, `+`, `-`, `.`,
`/`, `:`, and space.
The bech32 alphabet (`az`, `27`) maps entirely into this charset when uppercased — every
bech32 character is either a letter or a digit. The URI scheme characters (`WIZ`, `://`) and
port numbers are also fully alphanumeric-safe.
However, the standard URI separators `?`, `=`, and `&` are **not** in the alphanumeric charset.
Their percent-encoded forms are safe:
| Char | Encoding | All chars alphanumeric? |
|------|----------|------------------------|
| `?` | `%3F` | `%``3``F` ✅ |
| `=` | `%3D` | `%``3``D` ✅ |
| `&` | `%26` | `%``2``6` ✅ |
`encodeKeyExchangeURI()` returns both a standard URI and a QR-safe URI:
```typescript
const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
// uri → "wiz://?p=qpzry9x8...&s=qpzry9x8" (standard, for copy-paste)
// qrUri → "WIZ://%3FP%3DQPZRY9X8...%26S%3DQPZRY9X8" (QR alphanumeric-safe)
```
Use `uri` for display and clipboard copy. Use `qrUri` for QR code generation.
`decodeKeyExchangeURI()` accepts both formats — it detects the QR encoding by looking for
`%3f` without a `?` and reverses the substitutions before parsing. Both uppercase and
lowercase variants are accepted.
### Example URIs
Default relay, standard format (as produced by `encodeKeyExchangeURI`):
```
wiz://?p=qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3jn54khce6mua7l&s=qpzry9x8
```
QR-alphanumeric-safe format (use this for QR code generation):
```
WIZ://%3FP%3DQPZRY9X8GF2TVD...%26S%3DQPZRY9X8
```
Custom relay:
```
wiz://my-relay.example.com:8443?p=qpzry9x8...&s=qpzry9x8&pr=wss
```
## Credentials
The dapp generates a fresh keypair and a random 8-byte secret for each new connection:
```typescript
interface KeyExchangeCredentials {
privateKey: string; // hex, 32 bytes — dapp's Nostr private key
publicKey: string; // hex, 32 bytes — derived from privateKey (x-only)
secret: string; // hex, 8 bytes — shared secret for MITM prevention
}
```
`generateKeyExchangeCredentials()` creates a fresh set. `encodeKeyExchangeURI(publicKey, secret)`
produces the scannable URI from them.
### Reconnection
A dapp can reconnect to an existing session by passing `existingCredentials: { privateKey, secret }`
to `initiateDappRelay()`. The wallet's public key is re-exchanged on reconnect.
## Key exchange flow
Once the wallet scans the URI, the following happens over the relay:
```
1. Wallet sends: wallet_ready { ..., public_key: <wallet_nostr_pubkey_hex>,
secret: <shared_secret_hex> }
(wallet_ready carries both the application handshake data and the key exchange fields)
2. Dapp receives wallet_ready:
- Verifies secret matches its own credentials.secret.
- Calls client.setPairedPublicKey(walletPublicKey).
- Fires keyexchangecomplete event.
- Processes the session data (xpubs) as usual.
- From this point, all outbound messages are encrypted to the wallet's pubkey.
3. Both sides now have each other's Nostr public keys.
Subsequent messages are encrypted and filtered to the paired peer.
```
The key exchange data (`public_key`, `secret`) is embedded directly in `wallet_ready`. The wallet
sends a single message and the dapp learns the wallet's pubkey atomically with the session data.
### Why a shared secret?
The secret prevents a MITM or a curious third party from completing the key exchange with a
wallet using a stolen URI. The wallet must echo back the correct 8-byte secret; if it doesn't
match, the dapp ignores the `wallet_ready`. Eight bytes (64-bit) is enough entropy for
a short-lived pairing code — it is not a long-term secret.
### Why Nostr keys?
Nostr keys are secp256k1 keys, the same curve used in Bitcoin/BCH. Using a Nostr relay and NIP-17
gift wrap gets us:
- A widely-deployed, censorship-resistant message bus.
- End-to-end encryption with a simple, audited encryption scheme.
- No WizardConnect-operated server in the critical path — anyone can run a relay.
The "Nostr x-only pubkey" format is just the 32-byte x-coordinate of the secp256k1 public key
(no 02/03 prefix). `deriveNostrPublicKey(privateKey)` computes this from a 32-byte private key.
## Peer filtering
After key exchange, `RelayClient` compares the `pubkey` field of every decrypted Nostr event
against the paired public key. Messages from any other pubkey are dropped with a warning log.
`wallet_ready` bypasses this filter because the dapp does not yet know the wallet's pubkey when
it first arrives — it is the message that *establishes* the pairing.
## API surface
```typescript
// Generate fresh credentials (called internally by initiateDappRelay)
generateKeyExchangeCredentials(): KeyExchangeCredentials
// Encode a URI from an existing keypair — returns both standard and QR-safe forms
encodeKeyExchangeURI(publicKey: string, secret: string, options?): KeyExchangeURIResult
interface KeyExchangeURIResult {
uri: string; // standard URI for copy-paste: wiz://?p=...&s=...
qrUri: string; // QR-alphanumeric-safe URI: WIZ://%3FP%3D...%26S%3D...
}
// Decode and validate a wiz:// URI
// Accepts standard format, QR format (%3F/%3D/%26), uppercase, and mixed-case
decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI
interface DecodedKeyExchangeURI {
publicKey: string; // hex, 32 bytes
secret: string; // hex, 8 bytes
hostname: string;
port: number;
protocol: "ws" | "wss";
}
```

214
docs/dapp.md Normal file
View file

@ -0,0 +1,214 @@
# Dapp integration
Dapp integration uses `@wizardconnect/dapp` (for session management and pubkey state) together
with `@wizardconnect/core` (for the relay connection and URI generation).
## DappConnectionManager
Manages a single dappwallet session. Handles the handshake, xpub storage, and sign request
round-trips. Most dapps only ever have one active session at a time.
```typescript
class DappConnectionManager extends EventEmitter {
readonly pubkeyState: DappPubkeyStateManager;
walletName: string | null;
walletIcon: string | null;
/** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
protocol: string | null;
constructor(dappName?: string, dappIcon?: string)
/** Call from the RelayStatusCallback each time the relay status changes.
* Attaches the message listener exactly once (on first client seen).
* Triggers onConnected() on each "connected" event. */
updateConnection(client: RelayClient | null, status: RelayStatus): void
isWalletDiscovered(): boolean
/** Get the next sequence number for a SignTransactionRequest. */
nextSequence(): number
/** Send a sign request and wait for the wallet's response.
* Rejects if the wallet returns an error or if the connection drops. */
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
/** Send a UserDisconnect courtesy message to the wallet.
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
sendDisconnect(message?: string): Promise<void>
// Events
on("walletready", (msg: WalletReadyMessage) => void)
on("messagesent", (msg: ProtocolMessage) => void)
on("messagereceived", (msg: ProtocolMessage) => void)
on("disconnect", (reason: DisconnectReason, message: string | undefined) => void)
}
```
## Pubkey state — convenience delegation
`DappConnectionManager` delegates to `pubkeyState` for all pubkey operations. These methods
are also available directly on the manager:
```typescript
// Get a pubkey (derives on demand from xpub if not cached)
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined
// Get all cached pubkeys for a path
getPubkeys(childIndex: number): Map<bigint, Uint8Array>
// Get/set the current address index for a path
getAddressIndex(childIndex: number): bigint
setAddressIndex(childIndex: number, index: bigint): void
// Get a smart "next index to use" (see pubkey-derivation.md)
getIndexToUse(childIndex: number, options?: { index?: bigint; reuseLast?: boolean }): bigint
// Get the min/max indices seen for a path
getIndexRange(childIndex: number): { min?: bigint; max?: bigint }
// Remove a used change address from the gap-fill queue
removeFromChangeQueue(index: bigint): void
// Get the stored xpub node (after wallet_ready)
getXpubNode(childIndex: number): HdPublicNodeValid | undefined
```
Child index values: `0` = receive, `1` = change, `7` = defi (Cauldron). These are
internal to the dapp layer; use `childIndexOfPathName()` to convert from `PathName` if needed.
## Session lifecycle
### Initial connect
```typescript
import { initiateDappRelay } from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";
const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.png");
const relay = initiateDappRelay(
(payload) => {
dappMgr.updateConnection(payload.client, payload.status);
// also update your own UI state here (connected/disconnected indicator)
},
{ explicitRelayUrls: ["wss://relay.cauldron.quest:443"] },
);
// Show relay.uri as a QR code for the wallet to scan.
console.log("Scan this URI:", relay.uri);
```
### After wallet connects
```typescript
dappMgr.on("walletready", (msg) => {
console.log("Wallet:", msg.wallet_name);
// pubkeyState is now populated with xpub nodes.
// You can start deriving addresses.
});
```
### Deriving addresses
```typescript
// Get the first receive address pubkey:
const RECEIVE = 0;
const pubkey = dappMgr.getPubkey(RECEIVE, 0n); // derives from xpub if needed
```
See [pubkey-derivation.md](pubkey-derivation.md) for full details.
### Sending a sign request
```typescript
const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: seq,
time: Math.floor(Date.now() / 1000),
transaction: {
transaction: { inputs, outputs, version: 2, locktime: 0 },
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
};
try {
const response = await dappMgr.sendSignRequest(request);
console.log("Signed tx:", response.signedTransaction);
} catch (err) {
console.error("Signing failed:", err.message);
}
```
`sendSignRequest` returns a Promise that resolves when the wallet sends back a
`sign_transaction_response` with the matching `sequence`. It rejects if the wallet sends an
error response.
### Disconnecting
```typescript
// Dapp-initiated: send courtesy message, then tear down the relay
await dappMgr.sendDisconnect("user closed the tab");
relay.cleanup();
// Listen for wallet-initiated disconnect or protocol mismatch:
dappMgr.on("disconnect", (reason, message) => {
if (reason === DisconnectReason.ProtocolMismatch) {
console.error("Protocol mismatch:", message);
} else {
console.log("Wallet disconnected:", reason, message);
}
relay.cleanup();
});
```
The `disconnect` event fires in two cases:
1. **Remote disconnect**: the wallet sent a `disconnect` message (any reason).
2. **Protocol mismatch**: `handleWalletReady` found no overlap between the dapp's and wallet's
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
wallet before emitting the event.
### Reconnection
`updateConnection()` is called on every relay status change. When `status.status === "connected"`,
it calls `onConnected()` which waits for key exchange and then sends a fresh `dapp_ready`. The
`walletDiscovered` flag carries over reconnects (it is only reset by creating a new manager),
so the correct `wallet_discovered` value is sent on each reconnect.
## Using initiateDappRelay without DappConnectionManager
If you need lower-level control (e.g., in the test-cli), you can work directly with the
`RelayClient` and handle `wallet_ready` manually:
```typescript
const relay = initiateDappRelay(statusCallback, options);
relay.events.on("keyexchangecomplete", async (walletPubkey) => {
// Key exchange done — wait for relay client to be fully ready
while (!relay.client.isKeyExchangeComplete()) {
await sleep(50);
}
relay.client.on("message", (msg) => {
if (isDappReadyMessage(msg)) { /* ... */ }
if (isWalletReadyMessage(msg)) { /* ... */ }
});
await relay.client.relay({ action: RelayMsgAction.DappReady, ... });
});
```
## Cauldron (cauldron-beta) implementation notes
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
Key design points:
- `DappConnectionManager` is created per connection session (not a singleton).
- `updateConnection()` is called from the relay status callback.
- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns.
- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName
when processing `wallet_ready` data.

79
docs/index.md Normal file
View file

@ -0,0 +1,79 @@
# WizardConnect
WizardConnect is a protocol and library for connecting dapps to HD cryptocurrency wallets over
an encrypted relay. A wallet shows a QR code; the dapp scans it. After that, the dapp can derive
any number of the wallet's public keys locally and request transaction signatures — no seed phrase
exposure, no trusted intermediary, minimal round-trips.
## Design goals
- **Private**: all relay messages are end-to-end encrypted (Nostr NIP-17 gift wrap). The relay sees
only ciphertext addressed to the recipient's Nostr public key.
- **Reconnection-proof**: both sides can disconnect and reconnect independently (mobile app switch,
browser refresh, relay drop) and converge back to a live session without user action.
- **Low-latency pubkey delivery**: the wallet sends BIP32 xpubs in the handshake; the dapp derives
all addresses locally with no further round-trips.
- **Protocol-agnostic paths**: the protocol identifies derivation paths by name (`receive`, `change`,
`defi`) rather than numeric child indices. The wallet chooses how it actually derives those xpubs.
## Package structure
```
libwizardconnect/
├── packages/core — @wizardconnect/core
│ Transport (Nostr relay, key exchange, encryption),
│ protocol message types and type guards.
├── packages/wallet — @wizardconnect/wallet
│ WalletConnectionManager, WalletAdapter interface.
│ Multi-connection management, sign-request queuing.
├── packages/dapp — @wizardconnect/dapp
│ DappConnectionManager, DappPubkeyStateManager.
│ Single-session dapp helper, on-demand xpub derivation.
└── packages/test-cli — @wizardconnect/test-cli (private)
CLI for manual and exploratory testing.
```
The dependency graph is strictly one-directional:
```
test-cli → wallet, dapp → core
```
`core` has no knowledge of wallets or dapps; `wallet` and `dapp` have no knowledge of each other.
## License
WizardConnect is licensed under the [GNU Lesser General Public License v3.0](https://www.gnu.org/licenses/lgpl-3.0.html) (LGPL-3.0-or-later).
You may use, link against, and distribute WizardConnect in both open-source and proprietary
applications. Modifications to the library itself must be released under the same license.
Every source file must carry the copyright header:
```ts
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
```
A linter enforces this: `npm run lint:copyright`.
### Contributing
All contributors must sign a Contributor License Agreement (CLA) before their contributions can be
accepted. The CLA assigns copyright to Whiterun LLC so that the project can be relicensed in the
future if needed. You will be prompted to sign the CLA when you open your first pull request.
## Quick navigation
| Topic | File |
|-------|------|
| Protocol messages and handshake | [protocol.md](protocol.md) |
| Connection URI and key exchange | [connection-uri.md](connection-uri.md) |
| Relay transport and encryption | [transport.md](transport.md) |
| Wallet integration guide | [wallet.md](wallet.md) |
| Dapp integration guide | [dapp.md](dapp.md) |
| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) |

347
docs/protocol.md Normal file
View file

@ -0,0 +1,347 @@
# Protocol
The application-level protocol has two layers:
- **Base protocol** — the handshake messages (`dapp_ready`, `wallet_ready`, `disconnect`) that are
shared across all application protocols and live in `@wizardconnect/core/protocols/base.ts`.
- **hdwalletv1** — the BCH HD-wallet protocol, carrying session data (xpubs) and
sign request round-trips. Defined in `@wizardconnect/core/protocols/hdwalletv1.ts`.
Both layers use the same encrypted relay channel (see [transport.md](transport.md)).
Protocol selection happens during the handshake via `supported_protocols` lists, not via a
hard-coded field. This allows forward-compatible negotiation when future protocol versions are added.
## Message envelope
Every message shares a base shape:
```typescript
interface ProtocolMessage {
action: string; // one of the RelayMsgAction values below
time: number; // Unix timestamp (seconds). Used for replay filtering.
}
```
The `time` field is checked by the relay client: messages older than the last-processed timestamp
are silently dropped. This prevents stale messages buffered at the relay from being re-delivered
after a reconnect.
## Actions
```
dapp_ready — dapp → wallet, signals dapp is alive + lists supported protocols
wallet_ready — wallet → dapp, signals wallet is alive + delivers session data + key exchange
sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
sign_transaction_response — wallet → dapp, returns signed tx or error
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
disconnect — either → either, courtesy notification before tearing down
```
---
## Base protocol
### dapp_ready
```typescript
interface DappReadyMessage {
action: "dapp_ready";
supported_protocols: string[]; // protocols this dapp supports, in preference order
selected_protocol?: string; // set only on the reactive dapp_ready (after seeing wallet_ready)
wallet_discovered: boolean; // true if dapp already saw this wallet this session
dapp_name?: string; // optional, sent on first message for wallet UI
dapp_icon?: string; // optional icon URL or data-URI
time: number;
}
```
`dapp_name` and `dapp_icon` are captured by the wallet on the first `dapp_ready` that includes
them. The wallet shows these in its connections list.
`selected_protocol` is absent on the proactive `dapp_ready` (sent before the dapp has seen
the wallet). Once the dapp receives `wallet_ready` and picks a protocol, the reactive `dapp_ready`
carries `selected_protocol` so the wallet can confirm the agreed protocol.
### wallet_ready
```typescript
interface WalletReadyMessage {
action: "wallet_ready";
supported_protocols: string[]; // protocols this wallet supports
wallet_name: string;
wallet_icon: string;
dapp_discovered: boolean; // true if wallet already saw this dapp this session
session: Record<string, unknown>; // keyed by protocol name; each value is protocol-specific
public_key: string; // wallet's Nostr x-only pubkey (hex, 32 bytes) — key exchange
secret: string; // echo of the shared secret from the URI — MITM prevention
time: number;
}
```
`wallet_ready` is the most important message in the protocol. It serves two purposes:
1. **Key exchange**`public_key` is the wallet's Nostr pubkey; `secret` is echoed from the
connection URI for MITM prevention. The dapp verifies the secret and calls
`setPairedPublicKey(public_key)` before processing the rest of the message. This is why
`wallet_ready` bypasses the relay-client peer filter.
2. **Application handshake** — the wallet populates `session` for every protocol it supports.
The dapp picks the first protocol from its own `supported_protocols` list that also appears
in the wallet's list, then reads `session[selectedProtocol]` for the protocol-specific data.
### Protocol negotiation
1. The dapp sends its `supported_protocols` list in the proactive `dapp_ready`.
2. The wallet replies with its own `supported_protocols` and the `session` map.
3. The dapp selects `agreed = dapp.supported_protocols.find(p => wallet.supported_protocols.includes(p))`.
4. If no overlap: the dapp sends `disconnect(reason: "protocol_mismatch")` and emits a `disconnect`
event. No further communication happens.
5. If agreed: the dapp reads `session[agreed]` and sends a reactive `dapp_ready` with
`selected_protocol = agreed`.
### Handshake
The handshake uses a **mutual-discovery** pattern. The goal is for both sides to converge to a
live session regardless of who reconnects first. "Discovered" means "I have received and processed
a ready message from the other side in this runtime session."
#### Rules
1. On every connect/reconnect, each side sends its own "ready" message proactively. The wallet
sends `wallet_ready` immediately (it already knows the dapp's pubkey from the URI). The dapp
sends `dapp_ready` once the relay is connected and key exchange resolves.
2. Each "ready" message carries a boolean indicating whether the sender has already seen the
other party (`wallet_discovered` in `dapp_ready`, `dapp_discovered` in `wallet_ready`).
3. On receiving a "ready" with the discovery flag `false`, the receiver must send back its own
"ready" — *even if it already sent one* — because the other side has lost state and needs
a fresh delivery.
4. The wallet guards against duplicate `wallet_ready` messages within a single connection cycle
via `walletReadySentThisCycle`. This resets to `false` on each new connect/reconnect.
Receiving `dapp_ready(wallet_discovered=false)` also resets this flag.
#### Scenarios
**Initial connect (neither has seen the other):**
```
Dapp ──dapp_ready(supported=["hdwalletv1"], wallet_discovered=false)──▶ Wallet (proactive)
Dapp ◀──wallet_ready(supported=["hdwalletv1"], session={...}, dapp_discovered=false)── Wallet
Dapp ──dapp_ready(supported=["hdwalletv1"], selected="hdwalletv1", wallet_discovered=true)──▶ Wallet
```
After step 3 the wallet sets `dappDiscovered = true`. No more ready messages unless a reconnect.
**Wallet reconnects (dapp still running, walletDiscovered=true):**
```
Dapp ──dapp_ready(wallet_discovered=true)──────────────────────────────▶ Wallet (proactive)
Dapp ◀──wallet_ready(dapp_discovered=false, session={...})────────────── Wallet (proactive)
Dapp ──dapp_ready(selected="hdwalletv1", wallet_discovered=true)────────▶ Wallet (reactive)
```
**Dapp reconnects (browser refresh, wallet still running):**
```
Dapp ──dapp_ready(wallet_discovered=false)──────────────────────────────▶ Wallet (proactive)
Dapp ◀──wallet_ready(dapp_discovered=true, session={...})─────────────── Wallet (reactive)
```
(No third message: `dapp_discovered=true` means the dapp does not need to send a reactive reply.)
#### Design decision: why mutual discovery?
An alternative is a fixed initiator/responder role (only the dapp initiates). That breaks when the
wallet reconnects while the dapp is still alive — the wallet would wait for a dapp message that
never comes because the dapp thinks the session is live. Mutual discovery means each side sends a
"hello" on reconnect without depending on the other side's state.
---
## hdwalletv1 protocol
The `hdwalletv1` session data is carried in `wallet_ready.session["hdwalletv1"]`. It delivers
everything the dapp needs to derive an unlimited number of addresses without further contact with
the wallet.
### Hdwalletv1Session
```typescript
interface Hdwalletv1Session {
paths: PathXpub[]; // BIP32 xpubs for receive/change/defi
}
```
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
See [pubkey-derivation.md](pubkey-derivation.md) for the full xpub story.
### PathXpub
```typescript
interface PathXpub {
name: PathName; // "receive" | "change" | "defi"
xpub: string; // BIP32 base58-encoded extended public key
}
```
`name` is the protocol-level identifier. The dapp uses the name to know what kind of addresses to
derive from the xpub; it does not need to know (or care) where the wallet derived the xpub from.
**Highly recommended derivation paths.** To ensure addresses are recognised by other wallets and
blockchain explorers, wallets should derive xpubs from the standard BIP44 paths for BCH:
| Name | Recommended derivation path | Purpose |
|------|-----------------------------|---------|
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
Using these paths means the same addresses will appear in any BIP44-compatible wallet that holds
the same seed, making fund recovery straightforward.
**Privacy-first alternative: any path per session.** The protocol does not enforce the recommended
paths. A wallet that prioritises privacy may derive xpubs from non-standard or randomly-chosen
paths, and may even rotate them each session. The dapp derives addresses correctly regardless —
it never sees the path, only the xpub. The trade-off is that funds sent to session-specific paths
will not be found by standard wallet recovery tools without additional metadata.
**Design decision: names instead of child indices.** The protocol uses human-readable names rather
than numeric child indices because the derivation path is a wallet-internal detail. A name like
`"receive"` is stable and meaningful; the corresponding BIP44 index is an implementation concern
that only the wallet (and internal dapp state) need to know.
### PathName
```typescript
type PathName = "receive" | "change" | "defi";
```
| Name | Recommended BIP44 path | Purpose |
|------|------------------------|---------|
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
### sign_transaction_request
```typescript
interface SignTransactionRequest {
action: "sign_transaction_request";
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
sequence: number;
time: number;
}
```
`sequence` is a unique number generated by `RelayClient.nextSequence()`. It starts at a random
offset (to avoid collisions across sessions) and increments by 2 per call. The dapp uses `sequence`
to match responses to requests.
`WcSignTransactionRequest` describes a Bitcoin Cash transaction: inputs, outputs, source outputs
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
wallet UI.
### sign_transaction_response
```typescript
interface SignTransactionResponse {
action: "sign_transaction_response";
sequence: number;
signedTransaction: string; // hex-encoded fully signed transaction
error?: string; // if present, signing failed; signedTransaction is ""
time: number;
}
```
The wallet either returns the signed transaction hex or an error string. The dapp rejects the
pending Promise associated with the `sequence` in the error case.
### sign_cancel
```typescript
interface SignCancelMessage {
action: "sign_cancel";
sequence: number; // must match the sequence of the sign_transaction_request being cancelled
reason?: string; // optional human-readable explanation
time: number;
}
```
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should
dismiss the corresponding sign dialog immediately upon receipt.
Use cases:
- User presses cancel on the dapp side while waiting for the wallet to sign.
- Dapp replaces a stale request with a new one (e.g., trade price has changed).
**Dapp side** (`DappConnectionManager`):
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
then sends `sign_cancel` to the wallet.
**Wallet side** (`WalletConnectionManager`):
- Incoming `sign_cancel` emits a `signCancelled` event (`connectionId`, `sequence`, `reason`).
The host app is responsible for dismissing the sign dialog.
---
## disconnect
Either side may send a `disconnect` message before tearing down the relay connection. This is a
courtesy notification — the remote side treats the connection as closed immediately upon receipt
(no acknowledgement).
```typescript
enum DisconnectReason {
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
UserDisconnect = "user_disconnect", // explicit user or application action
}
interface DisconnectMessage {
action: "disconnect";
reason: DisconnectReason;
message?: string; // optional human-readable detail
time: number;
}
```
**Wallet side** (`WalletConnectionManager`):
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
- Incoming `disconnect` emits a `remoteDisconnect` event
(`connectionId`, `reason`, `message`) and removes the connection.
**Dapp side** (`DappConnectionManager`):
- `sendDisconnect(message?)` sends `UserDisconnect`. Caller then calls `dappRelay.cleanup()`.
- Protocol mismatch during `handleWalletReady` sends `ProtocolMismatch` and emits a `disconnect`
event (`reason`, `message`).
- Incoming `disconnect` emits a `disconnect` event.
---
## Type guards
`@wizardconnect/core` exports runtime type guards for all protocol messages:
```typescript
isProtocolMessage(obj) → ProtocolMessage
isDappReadyMessage(obj) → DappReadyMessage
isWalletReadyMessage(obj) → WalletReadyMessage
isDisconnectMessage(obj) → DisconnectMessage
isHdwalletv1Session(obj) → Hdwalletv1Session
isPathXpub(obj) → PathXpub
isErrorMessage(obj) → ErrorMessage
isSignTransactionRequest(obj) → SignTransactionRequest
isSignCancelMessage(obj) → SignCancelMessage
```
These are used internally to validate incoming messages before dispatch.
## Helper: childIndexOfPathName
```typescript
function childIndexOfPathName(name: PathName): number
// "receive" → 0, "change" → 1, "defi" → 7
```
The protocol uses string names for paths, but code that manages key state internally (such as
`DappPubkeyStateManager`) keys its maps by numeric child index. This helper converts between the
two representations. It is not a protocol concern — the numeric indices never appear on the wire.

142
docs/pubkey-derivation.md Normal file
View file

@ -0,0 +1,142 @@
# xpub delivery and pubkey derivation
One of the main design choices in `hdwalletv1` is that the wallet delivers BIP32 extended
public keys (xpubs) in `wallet_ready` and the dapp derives all addresses locally. This page
explains the design, the on-demand derivation model, and the change-address gap-fill logic.
## Why xpubs instead of individual pubkeys
### The old model (pre-xpub)
Earlier relay protocols required the dapp to ask the wallet for pubkeys by sending `pubkey_batch`
requests, waiting for `pubkey_batch_response`, and tracking "subscribe_paths" subscriptions. This:
- Added round-trips before the dapp could construct a transaction.
- Required the wallet to be online and responsive during transaction construction.
- Created complex state management for "have I received pubkeys for indices N…M?".
### The xpub model
The wallet sends one xpub per named path in `wallet_ready`. The dapp can then derive any child
pubkey `m/<xpub>/n` locally for any index `n` in O(1) with no network. BIP32 public-child
derivation is deterministic and requires no private key material.
**Design decision: wallet chooses derivation path.** The xpub is delivered with a name
(`receive`, `change`, `defi`) but no information about the wallet's internal derivation path.
The dapp derives addresses correctly regardless — it never sees the path, only the xpub node.
**Highly recommended: standard BIP44 paths.** For maximum interoperability — so that funds are
found by any BIP44-compatible wallet during seed recovery — wallets should use:
| Name | Recommended path |
|------|-----------------|
| `receive` | `m/44'/145'/0'/0` |
| `change` | `m/44'/145'/0'/1` |
| `defi` | `m/44'/145'/0'/7` |
**Privacy alternative: non-standard or rotating paths.** A wallet that prioritises privacy may
derive xpubs from arbitrary paths, or choose a fresh random derivation path each session. The
protocol carries only the xpub, so the dapp is unaffected. The trade-off is that standard
recovery tools won't find funds at non-standard paths without extra metadata.
## DappPubkeyStateManager
`DappPubkeyStateManager` (in `@wizardconnect/dapp`) is the dapp's local address book.
### Storage
```
xpubNodes: Map<childIndex, HdPublicNodeValid> — decoded xpub node per path
pubkeys: Map<childIndex, Map<index, Uint8Array>> — cached 33-byte compressed pubkeys
addressIndices: Map<childIndex, bigint> — current "next index" per path
changeAddressQueue: Set<bigint> — gap-fill queue for change path
```
### On-demand derivation
`getPubkey(childIndex, index)` checks the cache first. On a miss it derives the child key from
the xpub node:
```typescript
const child = deriveHdPublicNodeChild(xpubNode, Number(index));
// child.publicKey is the 33-byte compressed secp256k1 public key
```
The derived key is added to the cache so subsequent calls are instant. This means the dapp
never pre-fetches — it derives exactly the indices it needs, when it needs them.
### getIndexToUse
```typescript
getIndexToUse(childIndex, options?): bigint
```
Returns the "right" index for the next address on a path, with three cases in priority order:
1. If `options.index` is provided, use it directly (caller knows exactly what they want).
2. If the path is change (`childIndex === 1`) and there are gap-fill entries, return the lowest
available gap address (see below).
3. Otherwise return `addressIndex + 1` (the next fresh index).
With `options.reuseLast = true`, returns `addressIndex` instead of `+1` (for reusing the last
issued address, e.g., when re-displaying an invoice).
### getIndexRange
Returns the min and max indices currently cached for a path. Useful for knowing the spread of
addresses the dapp has worked with.
## Change address gap-filling
Change addresses are spend-once by convention. When a transaction is constructed but not yet
broadcast, the dapp allocates a change address. If the transaction is later abandoned, that
change index becomes a "gap" — it was never actually used on-chain, but the dapp has already
handed it out. Requesting a new fresh index from `addressIndex + 1` would skip the gap, wasting
an address and potentially causing the wallet to over-scan.
### How it works
`addPubkey(1, index, pubkey)` checks whether `index < currentAddressIndex`. If so, the address
is in the "past" — it was issued earlier but not yet used (a gap). It gets added to
`changeAddressQueue`.
`getIndexToUse(1)` checks `changeAddressQueue` first and returns a gap address if one exists
and its pubkey is cached. The caller calls `removeFromChangeQueue(index)` once the address is
actually used in a broadcast transaction.
This is primarily useful for dapps that optimistically allocate change addresses before confirming
broadcast. If the user cancels mid-flow, the gap address goes back into the queue.
## BIP32 derivation depth
The xpub delivered in `wallet_ready` is the path node — for example, the node at
`m/44'/145'/0'/0` for the receive path. Deriving child index `n` from it gives the same key as
deriving `m/44'/145'/0'/0/n` from the master key. The `/n` level is the address level (unhardened).
BIP32 requires unhardened derivation for xpub child derivation (hardened derivation requires
the private key). Standard BIP44 address indices are always unhardened, so this works correctly.
## Example: deriving receive addresses
```typescript
// After wallet_ready:
const RECEIVE = 0; // childIndex for receive
// Derive address at index 0:
const pubkey = dappMgr.getPubkey(RECEIVE, 0n);
// pubkey is a 33-byte Uint8Array (compressed secp256k1 public key)
// Derive several addresses for display (no network needed):
for (let i = 0n; i < 5n; i++) {
const pk = dappMgr.getPubkey(RECEIVE, i);
// convert to cashaddr, P2PKH, etc.
}
```
## Integration test coverage
`integration.test.ts` and `pubkeybatch.test.ts` (in `packages/wallet/src/integration/`) verify:
- `wallet_ready` carries paths for all three named paths (receive, change, defi).
- Each xpub is valid base58 and decodes as a valid BIP32 node.
- Deriving child indices from the xpub matches `adapter.getPublicKey(path, index)` exactly.

193
docs/transport.md Normal file
View file

@ -0,0 +1,193 @@
# Relay transport
The relay layer handles everything below the application protocol: WebSocket connectivity,
message encryption, reconnection, and message queuing. Application code (wallet, dapp) should
never touch NDK or Nostr directly.
## Stack
```
Application messages (hdwalletv1 JSON)
↓ JSON.stringify
NDK PrivateDirectMessage (kind 14, rumor)
↓ NIP-17 gift wrap (kind 1059, encrypted to recipient)
NDK → Nostr relay (WebSocket)
↓ stored as kind 1059 event, tagged with recipient pubkey
NDK ← Nostr relay subscription (filter: kind=1059, #p=<our_pubkey>)
↓ giftUnwrap → PrivateDirectMessage → JSON.parse
Application message handler
```
## RelayClient
`RelayClient` is the low-level building block. One instance per peer connection.
### Responsibilities
- Owns an NDK instance configured with explicit relay URLs.
- Subscribes to `kind:1059` (GiftWrap) events tagged with its own pubkey.
- Decrypts and unwraps incoming events using NIP-17 `giftUnwrap`.
- Publishes outbound messages using `giftWrap` addressed to the paired peer.
- Filters incoming messages by peer pubkey (rejects messages from unknown senders).
- Discards messages older than `lastProcessedTimestamp` (replay protection).
- Queues outbound messages until at least one relay is ready.
### Construction
```typescript
new RelayClient({
explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.cauldron.quest:443"]
signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity)
pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange)
logNetworkActivity?: boolean; // default true
})
```
### Key methods
```typescript
connect(): Promise<void>
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
disconnect(): Promise<void>
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
relay(message: ProtocolMessage): Promise<void>
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
setPairedPublicKey(key: Uint8Array): void
// Called after key exchange. Enables outbound messages and incoming peer filtering.
isKeyExchangeComplete(): boolean
// true once pairedPublicKey is set and non-zero.
nextSequence(): number
// Returns a unique sequence number (starts at random offset, increments by 2).
getLastProcessedTimestamp() / setLastProcessedTimestamp(ts): void
// Persist/restore across reconnects to avoid re-delivering buffered messages.
```
### Message queue
Before any relay is confirmed as connected, outbound `relay()` calls are enqueued in a
`MessageQueue`. The client polls every 100 ms for up to 5 seconds for at least one relay with
status 1 (connected). After that timeout it assumes the relay is ready and flushes the queue
regardless (avoiding silent message loss on slow connections).
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
held rather than dropped.
### Replay protection
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimestamp` is silently
dropped. This prevents the relay from re-delivering messages that were already handled before a
disconnect.
### Sequence numbers
`nextSequence()` starts at a random offset in the safe integer range and increments by 2. This
means two instances are unlikely to collide (they start at different offsets), and the step of 2
leaves room for error responses at odd offsets if needed in the future.
## initiateRelay
`initiateRelay()` (in `relay-handler.ts`) wraps `RelayClient` with a reconnect loop:
```typescript
initiateRelay(
statusCallback: RelayStatusCallback,
privateKey: Uint8Array,
initialPairedKey: Uint8Array,
options: { explicitRelayUrls, reconnectInterval?, maxReconnectAttempts? }
): () => void // returns cleanup function
```
It calls `statusCallback` with a `RelayUpdatePayload` on every state change:
```typescript
interface RelayUpdatePayload {
client: RelayClient;
status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" }
}
```
The reconnect loop is simple: on a disconnect event from the client it waits
`reconnectInterval` (default 5000 ms) and calls `client.connect()` again. No exponential backoff
currently — connections are expected to be stable (relay and mobile network) and fast to re-establish.
## initiateDappRelay
Higher-level helper that bundles credential generation, URI encoding, key exchange handling,
and reconnection into one call:
```typescript
initiateDappRelay(
statusCallback: RelayStatusCallback,
options?: {
explicitRelayUrls?: string[];
reconnectInterval?: number;
maxReconnectAttempts?: number;
existingCredentials?: { privateKey: string; secret: string };
}
): DappRelayResult
interface DappRelayResult {
client: RelayClient;
uri: string; // wiz:// URI to encode into QR
credentials: KeyExchangeCredentials;
events: EventEmitter<{ keyexchangecomplete: [walletPublicKey: Uint8Array] }>;
cleanup: () => void;
}
```
Internally it wraps the caller's `statusCallback` to intercept `wallet_ready` messages,
verify the secret, and call `client.setPairedPublicKey()` before the message is processed by
`DappConnectionManager`. On reconnect, if `walletPublicKeyNostr` is already known it immediately
restores the paired key so outbound messages can be sent before the next `wallet_ready` arrives.
## initiateWalletRelay
```typescript
initiateWalletRelay(
statusCallback: RelayStatusCallback,
options: {
uri: string; // wiz:// URI from QR scan
walletPrivateKey: Uint8Array; // wallet's relay identity key (may be ephemeral)
}
): { cleanup: () => void }
```
Decodes the URI, sets the dapp's public key as the initial paired key, and starts the relay.
On each connect/reconnect, the wallet sends `wallet_ready` which carries the key exchange fields
(`public_key`, `secret`) alongside the session data. This gives the dapp the wallet's pubkey
atomically with the application handshake data.
## Encryption: NIP-17 gift wrap
NIP-17 gift wrap works like a sealed envelope:
1. **Rumor** (kind 14, `PrivateDirectMessage`): the plaintext message, signed by the sender.
Content is the JSON payload. Tags include `["p", recipient_pubkey_hex]`.
2. **Seal** (kind 13): the rumor encrypted with a shared ECDH secret derived from sender's
private key and recipient's public key. No `p` tag — unlinkable to sender/recipient.
3. **Wrap** (kind 1059, `GiftWrap`): the seal encrypted to the recipient's public key using a
freshly generated throwaway key. Has a `["p", recipient_pubkey_hex]` tag so the relay can
route it to the right subscription.
The relay sees only kind 1059 with a recipient tag. It cannot read content or link messages to
senders. The throwaway outer key means even the relay cannot correlate multiple messages from
the same sender.
NDK handles all three layers in `giftWrap()` / `giftUnwrap()`.
## Default relay
```
wss://relay.cauldron.quest:443
```
This is a Cauldron-operated Nostr relay. Nothing in the protocol prevents using any other
standard Nostr relay. The relay is specified in the connection URI, so wallet and dapp always
use the same relay without out-of-band coordination.

212
docs/wallet.md Normal file
View file

@ -0,0 +1,212 @@
# Wallet integration
Wallet integration uses `@wizardconnect/wallet`. The wallet implements the `WalletAdapter`
interface and hands it to `WalletConnectionManager`, which handles everything else.
## WalletAdapter
```typescript
interface WalletAdapter {
walletName: string; // shown in the dapp's connection UI
walletIcon: string; // URL or data-URI, shown in the dapp's connection UI
/**
* Return the relay identity private key for this session.
* May be ephemeral (random per session) or stable (HD-derived) — both work.
* The dapp learns the wallet's public key from the wallet_ready message,
* so stability across restarts is not required.
*/
getRelayPrivateKey(): Uint8Array;
/** Returns the compressed 33-byte secp256k1 public key at path/index. */
getPublicKey(path: DerivationPath, index: bigint): Uint8Array;
/** Returns the BIP32 base58-encoded xpub for the given derivation path.
* The dapp derives all addresses from this — no further pubkey requests needed. */
getXpub(path: DerivationPath): string;
/** Sign the transaction. May show approval UI to the user.
* Called when the wallet has received and validated a sign_transaction_request. */
signTransaction(request: SignTransactionRequest): Promise<SignTransactionResult>;
}
```
### DerivationPath
```typescript
enum DerivationPath {
Receive = 0, // m/44'/145'/0'/0 — external (receive) addresses
Change = 1, // m/44'/145'/0'/1 — internal (change) addresses
Cauldron = 7, // m/44'/145'/0'/7 — DeFi/Cauldron addresses
}
```
The numeric values are wallet-internal; the protocol uses names (`receive`, `change`, `defi`).
`childIndexOfPath(path)` and `pathOfChildIndex(index)` convert between them.
### SignTransactionResult
```typescript
interface SignTransactionResult {
signedTransactionHex: string;
}
```
## WalletConnectionManager
```typescript
class WalletConnectionManager extends EventEmitter {
constructor(adapter: WalletAdapter)
// Connect to a dapp. Returns a stable connection ID.
// If a connection for this URI already exists, returns the existing ID.
connect(uri: string): string
// Tear down a specific connection, sending a UserDisconnect courtesy message.
disconnect(connectionId: string): void
// Tear down all connections.
disconnectAll(): void
// Snapshot of all connections for UI rendering.
getConnections(): Record<string, RelayConnectionState>
// Send the signed transaction back to the dapp.
sendSignResponse(connectionId: string, sequence: number, signedTx: string): Promise<void>
// Send an error back to the dapp (user rejected, signing failed, etc.)
sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
// Events
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
on("pendingSignRequest", (req: PendingSignRequest) => void)
on("connectionsChanged", () => void)
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
}
```
### RelayConnectionState
```typescript
interface RelayConnectionState {
id: string;
uri: string;
status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" }
label: string; // dapp name once known, otherwise "Connecting..."
dappName: string | null;
dappIcon: string | null;
connectedAt: number; // Unix ms
}
```
### PendingSignRequest
```typescript
interface PendingSignRequest {
connectionId: string;
request: SignTransactionRequest;
}
```
## Connection lifecycle
### connect()
1. A unique `connectionId` is generated.
2. `initiateWalletRelay(statusCallback, { uri, walletPrivateKey })` is called.
3. The relay decodes the URI, extracts the dapp's public key and secret, and connects.
4. On the first `"connected"` status, `onConnected()` is called.
### onConnected()
1. `walletReadySentThisCycle` is reset to `false`.
2. A notification processor interval is started (1 second, for retry on send errors).
3. The wallet polls until `client.isKeyExchangeComplete()` (key exchange with dapp done).
4. `pushWalletReady()` is called.
### pushWalletReady()
Sends `wallet_ready` with:
- `supported_protocols: ["hdwalletv1"]`
- `wallet_name`, `wallet_icon` from the adapter.
- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi).
- `dapp_discovered`: whether the dapp was seen in this runtime session.
The message is pushed to a per-connection `notificationQueue` and flushed immediately. Retry
is handled by the interval processor — if `relay()` throws (e.g. network drop), the message
stays in the queue and is retried on the next tick.
### Receiving dapp_ready
```
wallet_discovered=false → reset walletReadySentThisCycle, call pushWalletReady() again
wallet_discovered=true → set dappDiscovered=true, no further action
```
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
### Receiving disconnect
When a `disconnect` message arrives from the dapp:
1. The `remoteDisconnect` event is emitted with `(connectionId, reason, message)`.
2. The connection is cleaned up without sending a reply disconnect.
### Receiving sign_transaction_request
The wallet emits `pendingSignRequest` with the `connectionId` and the full request. The host
application is responsible for:
1. Queueing or displaying the request.
2. Getting user approval.
3. Calling `sendSignResponse(connectionId, sequence, signedTxHex)` or
`sendSignError(connectionId, sequence, errorMessage)`.
The wallet library does not auto-sign or auto-reject anything.
## Minimal example
```typescript
import { WalletConnectionManager } from "@wizardconnect/wallet";
import type { WalletAdapter, DerivationPath } from "@wizardconnect/wallet";
class MyAdapter implements WalletAdapter {
walletName = "My Wallet";
walletIcon = "";
getRelayPrivateKey() { return crypto.getRandomValues(new Uint8Array(32)); }
getPublicKey(path: DerivationPath, index: bigint) { /* ... */ }
getXpub(path: DerivationPath) { /* ... */ }
async signTransaction(request) {
// show approval UI, sign, return hex
return { signedTransactionHex: "..." };
}
}
const manager = new WalletConnectionManager(new MyAdapter());
// When user scans a QR code:
const connId = manager.connect("wiz://?p=...&s=...");
// When a sign request arrives:
manager.on("pendingSignRequest", async ({ connectionId, request }) => {
try {
const result = await showApprovalUI(request);
await manager.sendSignResponse(connectionId, request.sequence, result.signedTransactionHex);
} catch {
await manager.sendSignError(connectionId, request.sequence, "User rejected");
}
});
// When the dapp disconnects:
manager.on("remoteDisconnect", (id, reason, message) => {
console.log(`Dapp disconnected: ${reason}`, message);
store.dispatch(setConnections(manager.getConnections()));
});
// For Redux / UI updates:
manager.on("connectionsChanged", () => {
store.dispatch(setConnections(manager.getConnections()));
});
```

37
eslint.config.cjs Normal file
View file

@ -0,0 +1,37 @@
const js = require("@eslint/js");
const globals = require("globals");
const parser = require("@typescript-eslint/parser");
const plugin = require("@typescript-eslint/eslint-plugin");
module.exports = [
{
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"],
},
{
files: ["**/*.ts"],
languageOptions: {
parser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
globals: {
...globals.browser,
...globals.node,
...globals.es2022,
},
},
plugins: {
"@typescript-eslint": plugin,
},
rules: {
...js.configs.recommended.rules,
"no-console": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_" },
],
},
},
];

View file

@ -0,0 +1,59 @@
#!/usr/bin/env node
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, dirname, relative } from "node:path";
import { fileURLToPath } from "node:url";
const OUR_DIR = dirname(fileURLToPath(import.meta.url));
const ROOT = join(OUR_DIR, "..");
const REQUIRED_LINES = [
"This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.",
"A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html",
];
// Matches: "Copyright (C) 2026 Whiterun LLC" or "Copyright (C) 2024-2026 Whiterun LLC"
const COPYRIGHT_PATTERN = /Copyright \(C\) (\d{4}-)?\d{4} Whiterun LLC/;
const EXTENSIONS = [".ts", ".mjs"];
const DIRECTORIES = ["packages", "linters"];
function walk(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === "dist") continue;
results.push(...walk(full));
} else if (EXTENSIONS.some((ext) => entry.endsWith(ext))) {
results.push(full);
}
}
return results;
}
let missing = 0;
for (const directory of DIRECTORIES) {
const dirPath = join(ROOT, directory);
for (const filePath of walk(dirPath)) {
const content = readFileSync(filePath, "utf-8");
const hasRequired = REQUIRED_LINES.every((line) => content.includes(line));
const hasCopyright = COPYRIGHT_PATTERN.test(content);
if (!hasRequired || !hasCopyright) {
console.log(`${relative(ROOT, filePath)}: Missing`);
missing++;
}
}
}
if (missing) {
console.log(`${missing} file(s) are missing copyright headers`);
process.exit(1);
} else {
console.log("OK");
}

4164
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "wizardconnect",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build --workspaces",
"test": "npm run test --workspaces --if-present",
"test:integration": "npm run test:integration --workspaces --if-present",
"dapp": "npm run dapp --workspace @wizardconnect/test-cli",
"wallet": "npm run wallet --workspace @wizardconnect/test-cli",
"docs:serve": "zensical serve",
"docs:build": "zensical build",
"lint:copyright": "node linters/copyright_check.mjs",
"lint:prettier": "npm run lint:prettier --workspaces --if-present",
"lint:eslint": "npm run lint:eslint --workspaces --if-present",
"lint": "npm run lint:eslint && npm run lint:prettier",
"fix": "npm run fix --workspaces --if-present",
"fix:prettier": "npm run fix:prettier --workspaces --if-present",
"fix:eslint": "npm run fix:eslint --workspaces --if-present"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"eslint": "^10.0.1",
"globals": "^17.3.0",
"prettier": "^3.2.5",
"typescript-eslint": "^8.56.0"
}
}

View file

@ -0,0 +1,34 @@
{
"name": "@wizardconnect/core",
"version": "0.1.0",
"type": "module",
"description": "Transport and protocol primitives for WizardConnect",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/"
],
"scripts": {
"build": "tsc",
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
"lint:prettier": "prettier . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",
"fix": "npm run fix:eslint && npm run fix:prettier",
"fix:prettier": "prettier . --write",
"fix:eslint": "npm run lint:eslint -- --fix"
},
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"@nostr-dev-kit/ndk": "^2.18.1",
"@bitauth/libauth": "^3.1.0-next.2",
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
}
}

View file

@ -0,0 +1,228 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { debug, Scope, LogScope } from "./log.js";
import { EventEmitter } from "eventemitter3";
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export interface VisibilityChangeContext<TClient> {
client: TClient;
state: "hidden" | "visible";
setPaused: (_value: boolean) => void;
isPaused: () => boolean;
startConnectionLoop: () => void;
}
export interface ConnectionManagerOptions<
TClient extends ConnectionClient = ConnectionClient,
> {
reconnectInterval?: number;
maxReconnectAttempts?: number;
enableVisibilityHandling?: boolean;
scope?: LogScope;
onVisibilityChange?: (
_context: VisibilityChangeContext<TClient>,
) => void | Promise<void>;
}
export interface ConnectionClient extends EventEmitter {
connect(): Promise<void>;
disconnect(..._args: any[]): Promise<void | boolean>;
}
export interface ConnectionManagerResult {
cleanup: () => Promise<void>;
startConnectionLoop: () => void;
}
export const createConnectionManager = <TClient extends ConnectionClient>(
client: TClient,
callbacks: {
onConnected: (_client: TClient) => void;
onReconnecting: (_client: TClient, _reason: string | null) => void;
onDisconnected: (_client: TClient) => void;
onError?: (_client: TClient, _error: any) => void;
},
events: {
connected: string;
disconnected: string;
error?: string;
},
options: ConnectionManagerOptions<TClient> = {},
): ConnectionManagerResult => {
const {
reconnectInterval = 5000,
maxReconnectAttempts = Infinity,
enableVisibilityHandling = true,
scope = Scope.Network,
onVisibilityChange,
} = options;
let isPaused = false;
let reconnectLoop: Promise<void> | null = null;
let visibilityChangeHandler: (() => void) | null = null;
const triggerReconnect = (reason: string | null) => {
if (!isPaused) {
callbacks.onReconnecting(client, reason);
reconnectLoop = null;
startConnectionLoop();
}
};
const onConnected = () => callbacks.onConnected(client);
client.on(events.connected, onConnected);
const onDisconnected = (...args: any[]) => {
const err = args[0] instanceof Error ? args[0].message : null;
debug(scope, "Disconnected event received", err);
triggerReconnect(err);
};
const onError = (err: any) => {
const errorMsg = err?.message || String(err);
debug(scope, "Error event received", errorMsg);
triggerReconnect(errorMsg);
if (callbacks.onError) {
callbacks.onError(client, err);
}
};
client.on(events.disconnected, onDisconnected);
if (events.error) {
client.on(events.error, onError);
}
const setupVisibilityHandling = () => {
if (typeof document === "undefined") {
return;
}
visibilityChangeHandler = () => {
const state = document.visibilityState as "hidden" | "visible";
if (onVisibilityChange) {
const context: VisibilityChangeContext<TClient> = {
client,
state,
setPaused: (value: boolean) => {
isPaused = value;
},
isPaused: () => isPaused,
startConnectionLoop,
};
(async () => {
try {
await onVisibilityChange(context);
} catch (err) {
debug(scope, "Error in custom visibility change handler:", err);
}
})();
return;
}
if (state === "hidden") {
isPaused = true;
(async () => {
try {
await client.disconnect();
callbacks.onDisconnected(client);
} catch (err) {
debug(scope, "Error disconnecting on visibility change:", err);
}
})();
} else if (state === "visible") {
if (isPaused) {
isPaused = false;
startConnectionLoop();
}
}
};
document.addEventListener("visibilitychange", visibilityChangeHandler);
};
const startConnectionLoop = () => {
if (reconnectLoop) {
return;
}
reconnectLoop = (async () => {
let reconnectAttempts = 0;
let wasConnected = false;
while (true) {
if (isPaused) {
await sleep(1000);
continue;
}
try {
await client.connect();
// onConnected is fired via the "connection" event emitted inside client.connect()
// — do NOT call callbacks.onConnected here too, that would double-fire it.
reconnectAttempts = 0;
wasConnected = true;
reconnectLoop = null;
return;
} catch (e) {
reconnectAttempts++;
if (wasConnected || reconnectAttempts === 1) {
callbacks.onReconnecting(client, `${e}`);
wasConnected = false;
}
if (reconnectAttempts > maxReconnectAttempts) {
callbacks.onDisconnected(client);
reconnectLoop = null;
return;
}
try {
await client.disconnect();
} catch (disconnectError) {
debug(scope, "Failed to disconnect client", disconnectError);
}
await sleep(reconnectInterval);
}
}
})();
};
if (enableVisibilityHandling) {
setupVisibilityHandling();
}
const cleanup = async () => {
isPaused = true;
if (typeof document !== "undefined" && visibilityChangeHandler !== null) {
document.removeEventListener("visibilitychange", visibilityChangeHandler);
}
client.off(events.connected, onConnected);
client.off(events.disconnected, onDisconnected);
if (events.error) {
client.off(events.error, onError);
}
callbacks.onDisconnected(client);
try {
await client.disconnect();
} catch (e) {
debug(scope, "Failed to disconnect client during cleanup", e);
}
};
return {
cleanup,
startConnectionLoop,
};
};

View file

@ -0,0 +1,189 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { RelayClient } from "./relay-client.js";
import {
RelayUpdatePayload,
RelayStatusCallback,
initiateRelay,
} from "./relay-handler.js";
import { RelayMsgAction, ProtocolMessage } from "./protocols/hdwalletv1.js";
import { WalletReadyMessage } from "./protocols/base.js";
import {
generateKeyExchangeCredentials,
encodeKeyExchangeURI,
KeyExchangeCredentials,
DEFAULT_RELAY_HOSTNAME,
DEFAULT_RELAY_PORT,
DEFAULT_RELAY_PROTOCOL,
} from "./key-exchange.js";
import { hexToBin, binToHex } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3";
import { deriveNostrPublicKey } from "./utilnostr.js";
import { error as logError, warn, Scope } from "./log.js";
export interface DappRelayOptions {
explicitRelayUrls?: string[];
reconnectInterval?: number;
maxReconnectAttempts?: number;
existingCredentials?: {
privateKey: string;
secret: string;
};
}
export interface DappRelayResult {
client: RelayClient;
uri: string;
qrUri: string;
credentials: KeyExchangeCredentials;
events: EventEmitter<{
keyexchangecomplete: [walletPublicKey: Uint8Array];
}>;
cleanup: () => void;
}
export function initiateDappRelay(
statusCallback: RelayStatusCallback,
options: DappRelayOptions = {},
): DappRelayResult {
const events = new EventEmitter<{
keyexchangecomplete: [walletPublicKey: Uint8Array];
}>();
let credentials: KeyExchangeCredentials;
let dappPrivateKey: Uint8Array;
if (options.existingCredentials) {
const privateKeyHex = options.existingCredentials.privateKey;
if (privateKeyHex.length !== 64) {
throw new Error("Private key must be 64 hex characters (32 bytes)");
}
dappPrivateKey = hexToBin(privateKeyHex);
const dappPublicKeyHex = deriveNostrPublicKey(dappPrivateKey);
credentials = {
privateKey: privateKeyHex,
publicKey: dappPublicKeyHex,
secret: options.existingCredentials.secret,
};
} else {
credentials = generateKeyExchangeCredentials();
dappPrivateKey = hexToBin(credentials.privateKey);
}
let uriOptions: {
hostname?: string;
port?: number;
protocol?: "ws" | "wss";
} = {};
if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) {
const relayUrl = options.explicitRelayUrls[0];
const hostMatch = relayUrl.match(/^wss?:\/\/([^:/]+)/);
const portMatch = relayUrl.match(/:(\d+)/);
if (hostMatch) {
uriOptions.hostname = hostMatch[1];
}
if (portMatch) {
uriOptions.port = parseInt(portMatch[1], 10);
}
if (relayUrl.startsWith("wss://")) {
uriOptions.protocol = "wss";
} else if (relayUrl.startsWith("ws://")) {
uriOptions.protocol = "ws";
}
}
const { uri, qrUri } = encodeKeyExchangeURI(
credentials.publicKey,
credentials.secret,
uriOptions,
);
let relayClient: RelayClient | null = null;
let keyExchanged = false;
let walletPublicKeyNostr: Uint8Array | null = null;
const wrappedCallback: RelayStatusCallback = (
payload: RelayUpdatePayload,
) => {
if (!relayClient) {
relayClient = payload.client;
relayClient.on("message", async (message: ProtocolMessage) => {
if (message.action === RelayMsgAction.WalletReady) {
const walletReady = message as WalletReadyMessage;
if (walletReady.secret !== credentials.secret) {
if (!keyExchanged) {
logError(Scope.Relay, "Key exchange failed: secret mismatch");
}
return;
}
const receivedWalletKey = hexToBin(walletReady.public_key);
if (receivedWalletKey.length !== 32) {
logError(Scope.Relay, "Invalid wallet public key length");
return;
}
if (keyExchanged && walletPublicKeyNostr) {
if (
binToHex(receivedWalletKey) !== binToHex(walletPublicKeyNostr)
) {
warn(
Scope.Relay,
"Different wallet connected (different public key)",
);
}
}
walletPublicKeyNostr = receivedWalletKey;
relayClient!.setPairedPublicKey(receivedWalletKey);
if (!keyExchanged) {
keyExchanged = true;
events.emit("keyexchangecomplete", receivedWalletKey);
}
// NOTE: do NOT return — message also propagates to DappConnectionManager's listener
}
});
}
if (payload.status.status === "connected") {
if (walletPublicKeyNostr && relayClient) {
relayClient.setPairedPublicKey(walletPublicKeyNostr);
keyExchanged = true;
}
}
statusCallback(payload);
};
const relayUrls =
options.explicitRelayUrls && options.explicitRelayUrls.length > 0
? options.explicitRelayUrls
: [
`${DEFAULT_RELAY_PROTOCOL}://${DEFAULT_RELAY_HOSTNAME}:${DEFAULT_RELAY_PORT}`,
];
const cleanup = initiateRelay(
wrappedCallback,
dappPrivateKey,
new Uint8Array(33),
{
explicitRelayUrls: relayUrls,
reconnectInterval: options.reconnectInterval,
maxReconnectAttempts: options.maxReconnectAttempts,
},
);
return {
client: relayClient!,
uri,
qrUri,
credentials,
events,
cleanup,
};
}

View file

@ -0,0 +1,37 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
export { RelayClient } from "./relay-client.js";
export type { RelayClientConfig } from "./relay-client.js";
export { RelayStatus, initiateRelay } from "./relay-handler.js";
export type {
RelayUpdatePayload,
RelayStatusCallback,
} from "./relay-handler.js";
export { initiateDappRelay } from "./dapp-relay.js";
export type { DappRelayOptions, DappRelayResult } from "./dapp-relay.js";
export { initiateWalletRelay } from "./wallet-relay.js";
export type { WalletRelayOptions, WalletRelayResult } from "./wallet-relay.js";
export {
generateKeyExchangeCredentials,
encodeKeyExchangeURI,
decodeKeyExchangeURI,
DEFAULT_RELAY_HOSTNAME,
DEFAULT_RELAY_PORT,
DEFAULT_RELAY_PROTOCOL,
} from "./key-exchange.js";
export type {
KeyExchangeCredentials,
DecodedKeyExchangeURI,
KeyExchangeURIOptions,
KeyExchangeURIResult,
} from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js";
export * from "./protocols/base.js";
export {
binToHex,
hexToBin,
binToBech32Padded,
bech32PaddedToBin,
} from "@bitauth/libauth";

View file

@ -0,0 +1,374 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect } from "vitest";
import {
encodeKeyExchangeURI,
decodeKeyExchangeURI,
generateKeyExchangeCredentials,
} from "./key-exchange.js";
describe("key-exchange", () => {
describe("generateKeyExchangeCredentials", () => {
it("should generate valid credentials", () => {
const credentials = generateKeyExchangeCredentials();
expect(credentials.privateKey).toHaveLength(64); // 32 bytes in hex
expect(credentials.publicKey).toHaveLength(64); // 32 bytes in hex
expect(credentials.secret).toHaveLength(16); // 8 bytes in hex
// Verify all are valid hex strings
expect(/^[0-9a-f]+$/.test(credentials.privateKey)).toBe(true);
expect(/^[0-9a-f]+$/.test(credentials.publicKey)).toBe(true);
expect(/^[0-9a-f]+$/.test(credentials.secret)).toBe(true);
});
it("should generate different credentials on each call", () => {
const cred1 = generateKeyExchangeCredentials();
const cred2 = generateKeyExchangeCredentials();
expect(cred1.privateKey).not.toBe(cred2.privateKey);
expect(cred1.publicKey).not.toBe(cred2.publicKey);
expect(cred1.secret).not.toBe(cred2.secret);
});
});
describe("encodeKeyExchangeURI", () => {
it("should encode valid public key and secret to URI", () => {
const publicKey = "a".repeat(64); // 32 bytes in hex
const secret = "b".repeat(16); // 8 bytes in hex
const { uri } = encodeKeyExchangeURI(publicKey, secret);
// With defaults, should use minimal URI format
expect(uri).toMatch(
/^wiz:\/\/\?p=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+&s=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/,
);
expect(uri.startsWith("wiz://?")).toBe(true);
expect(uri.includes("?p=")).toBe(true);
expect(uri.includes("&s=")).toBe(true);
});
it("should always produce lowercase bech32 encoding", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { uri } = encodeKeyExchangeURI(publicKey, secret);
// Extract the bech32 parts (minimal URI format)
const parts = uri.match(/^wiz:\/\/\?p=([^&]+)&s=(.+)$/);
expect(parts).not.toBeNull();
if (parts) {
expect(parts[1]).toBe(parts[1].toLowerCase());
expect(parts[2]).toBe(parts[2].toLowerCase());
}
});
it("should throw error for invalid public key length", () => {
// Use a hex string that decodes to wrong length (odd length hex = invalid)
const invalidPublicKey = "a".repeat(62); // 31 bytes when decoded
const secret = "b".repeat(16);
expect(() => encodeKeyExchangeURI(invalidPublicKey, secret)).toThrow(
"Invalid public key length",
);
});
it("should throw error for invalid secret length", () => {
const publicKey = "a".repeat(64);
// Use a hex string that decodes to wrong length (odd length hex = invalid)
const invalidSecret = "b".repeat(14); // 7 bytes when decoded
expect(() => encodeKeyExchangeURI(publicKey, invalidSecret)).toThrow(
"Invalid secret length",
);
});
it("should handle invalid hex gracefully", () => {
// hexToBin from libauth might be lenient with invalid hex
// This test verifies the function doesn't crash, but the actual behavior
// depends on libauth's hexToBin implementation
const invalidPublicKey = "g".repeat(64); // 'g' is not valid hex
const secret = "b".repeat(16);
// The function should either throw or produce a result
// If it doesn't throw, it means libauth's hexToBin is lenient
// In that case, it will likely fail at length validation
try {
encodeKeyExchangeURI(invalidPublicKey, secret);
// If it doesn't throw, that's also acceptable behavior
} catch (error) {
// If it throws, that's expected
expect(error).toBeDefined();
}
});
});
describe("encodeKeyExchangeURI — qrUri", () => {
it("qrUri is fully uppercase", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
expect(qrUri).toBe(qrUri.toUpperCase());
});
it("qrUri contains no ?, =, or &", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
expect(qrUri).not.toContain("?");
expect(qrUri).not.toContain("=");
expect(qrUri).not.toContain("&");
});
it("qrUri passes QR alphanumeric charset regex", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true);
});
it("qrUri contains %3F, %3D, %26 for default relay", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
expect(qrUri).toContain("%3F");
expect(qrUri).toContain("%3D");
expect(qrUri).toContain("%26");
});
it("custom relay encodes all separators", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { qrUri } = encodeKeyExchangeURI(publicKey, secret, {
hostname: "example.com",
port: 8080,
protocol: "ws",
});
expect(qrUri).not.toContain("?");
expect(qrUri).not.toContain("=");
expect(qrUri).not.toContain("&");
expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true);
});
});
describe("decodeKeyExchangeURI", () => {
it("should decode valid URI to public key and secret", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { uri } = encodeKeyExchangeURI(publicKey, secret);
const decoded = decodeKeyExchangeURI(uri);
expect(decoded.publicKey).toBe(publicKey);
expect(decoded.secret).toBe(secret);
});
it("should handle case-insensitive URIs", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { uri } = encodeKeyExchangeURI(publicKey, secret);
// Test uppercase URI (but keep scheme lowercase as URL parsing requires it)
const upperUri = uri.replace(/wiz:\/\/([^?]+)/, (match, host) => {
return `wiz://${host.toUpperCase()}`;
});
const decoded = decodeKeyExchangeURI(upperUri);
expect(decoded.publicKey).toBe(publicKey);
expect(decoded.secret).toBe(secret);
});
it("should handle mixed case URIs", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { uri } = encodeKeyExchangeURI(publicKey, secret);
// Mix case
const mixedUri = uri.replace(/r/i, "R").replace(/c/i, "C");
const decoded = decodeKeyExchangeURI(mixedUri);
expect(decoded.publicKey).toBe(publicKey);
expect(decoded.secret).toBe(secret);
});
it("should throw error for invalid URI format", () => {
const invalidUri = "invalid://uri";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
});
it("should throw error for wrong scheme", () => {
const invalidUri = "wrong://relay.cauldron.quest?p=abc&s=def";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
"Invalid URI scheme",
);
});
it("should handle URIs with custom hostname and port", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { uri } = encodeKeyExchangeURI(publicKey, secret, {
hostname: "example.com",
port: 8080,
protocol: "ws",
});
expect(uri).toMatch(/^wiz:\/\/example\.com:8080\?p=/);
const decoded = decodeKeyExchangeURI(uri);
expect(decoded.hostname).toBe("example.com");
expect(decoded.port).toBe(8080);
expect(decoded.protocol).toBe("ws");
});
it("should throw error for missing parameters", () => {
const invalidUri = "wiz://relay.cauldron.quest?p=abc";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
"Invalid URI format",
);
});
it("should throw error for invalid bech32 encoding", () => {
// Use invalid bech32 characters (bech32 only uses: qpzry9x8gf2tvdw0s3jn54khce6mua7l)
// This will fail at bech32 decoding
const invalidUri = "wiz://relay.cauldron.quest?p=invalid&s=chars";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
"Invalid bech32 encoding",
);
});
it("should throw error for wrong public key length after decoding", () => {
// Create a bech32 string that decodes to wrong length
const shortBech32 = "q";
const { uri: validSecret } = encodeKeyExchangeURI(
"a".repeat(64),
"b".repeat(16),
);
const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || "";
const invalidUri = `wiz://relay.cauldron.quest?p=${shortBech32}&s=${secretPart}`;
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
});
it("should throw error for wrong secret length after decoding", () => {
const { uri: validPublicKey } = encodeKeyExchangeURI(
"a".repeat(64),
"b".repeat(16),
);
const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || "";
const shortBech32 = "q";
const invalidUri = `wiz://relay.cauldron.quest?p=${publicKeyPart}&s=${shortBech32}`;
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
});
});
describe("decodeKeyExchangeURI — QR format", () => {
it("decodes qrUri to same values as standard uri", () => {
const publicKey = "0123456789abcdef".repeat(4);
const secret = "fedcba9876543210";
const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
const fromUri = decodeKeyExchangeURI(uri);
const fromQr = decodeKeyExchangeURI(qrUri);
expect(fromQr.publicKey).toBe(fromUri.publicKey);
expect(fromQr.secret).toBe(fromUri.secret);
expect(fromQr.hostname).toBe(fromUri.hostname);
expect(fromQr.port).toBe(fromUri.port);
expect(fromQr.protocol).toBe(fromUri.protocol);
});
it("accepts lowercase version of qrUri", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
const fromUri = decodeKeyExchangeURI(uri);
const fromLowerQr = decodeKeyExchangeURI(qrUri.toLowerCase());
expect(fromLowerQr.publicKey).toBe(fromUri.publicKey);
expect(fromLowerQr.secret).toBe(fromUri.secret);
});
it("round-trip: encodeKeyExchangeURI → qrUri → decodeKeyExchangeURI", () => {
const credentials = generateKeyExchangeCredentials();
const { qrUri } = encodeKeyExchangeURI(
credentials.publicKey,
credentials.secret,
);
const decoded = decodeKeyExchangeURI(qrUri);
expect(decoded.publicKey).toBe(credentials.publicKey);
expect(decoded.secret).toBe(credentials.secret);
});
it("round-trip with custom relay via qrUri", () => {
const publicKey = "a".repeat(64);
const secret = "b".repeat(16);
const { qrUri } = encodeKeyExchangeURI(publicKey, secret, {
hostname: "example.com",
port: 8080,
protocol: "ws",
});
const decoded = decodeKeyExchangeURI(qrUri);
expect(decoded.publicKey).toBe(publicKey);
expect(decoded.secret).toBe(secret);
expect(decoded.hostname).toBe("example.com");
expect(decoded.port).toBe(8080);
expect(decoded.protocol).toBe("ws");
});
});
describe("round-trip encoding/decoding", () => {
it("should round-trip encode and decode correctly", () => {
const publicKey = "0123456789abcdef".repeat(4); // 32 bytes
const secret = "fedcba9876543210"; // 8 bytes
const { uri } = encodeKeyExchangeURI(publicKey, secret);
const decoded = decodeKeyExchangeURI(uri);
expect(decoded.publicKey).toBe(publicKey);
expect(decoded.secret).toBe(secret);
});
it("should round-trip with generated credentials", () => {
const credentials = generateKeyExchangeCredentials();
const { uri } = encodeKeyExchangeURI(
credentials.publicKey,
credentials.secret,
);
const decoded = decodeKeyExchangeURI(uri);
expect(decoded.publicKey).toBe(credentials.publicKey);
expect(decoded.secret).toBe(credentials.secret);
});
it("should round-trip with random hex values", () => {
const randomHex = (length: number) => {
const chars = "0123456789abcdef";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
};
const publicKey = randomHex(64);
const secret = randomHex(16);
const { uri } = encodeKeyExchangeURI(publicKey, secret);
const decoded = decodeKeyExchangeURI(uri);
expect(decoded.publicKey).toBe(publicKey);
expect(decoded.secret).toBe(secret);
});
});
});

View file

@ -0,0 +1,216 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import {
generatePrivateKey,
binToHex,
hexToBin,
binToBech32Padded,
bech32PaddedToBin,
} from "@bitauth/libauth";
import { deriveNostrPublicKey } from "./utilnostr.js";
export const DEFAULT_RELAY_HOSTNAME = "relay.cauldron.quest";
export const DEFAULT_RELAY_PORT = 443;
export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss";
export interface KeyExchangeCredentials {
privateKey: string;
publicKey: string;
secret: string;
}
export function generateKeyExchangeCredentials(): KeyExchangeCredentials {
const privateKey = generatePrivateKey();
const privateKeyHex = binToHex(privateKey);
const publicKeyHex = deriveNostrPublicKey(privateKey);
const secretBytes = generatePrivateKey();
const secretShort = secretBytes.slice(0, 8);
const secret = binToHex(secretShort);
return {
privateKey: privateKeyHex,
publicKey: publicKeyHex,
secret: secret,
};
}
export interface DecodedKeyExchangeURI {
publicKey: string;
secret: string;
hostname: string;
port: number;
protocol: "ws" | "wss";
}
export interface KeyExchangeURIOptions {
hostname?: string;
port?: number;
protocol?: "ws" | "wss";
}
export interface KeyExchangeURIResult {
/** Standard URI for copy-paste and display: wiz://?p=...&s=... */
uri: string;
/** Fully QR-alphanumeric-safe URI for QR code generation: WIZ://%3FP%3D... */
qrUri: string;
}
export function encodeKeyExchangeURI(
publicKey: string,
secret: string,
options: KeyExchangeURIOptions = {},
): KeyExchangeURIResult {
const publicKeyBin = hexToBin(publicKey);
const secretBin = hexToBin(secret);
if (publicKeyBin.length !== 32) {
throw new Error(
`Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`,
);
}
if (secretBin.length !== 8) {
throw new Error(
`Invalid secret length: expected 8 bytes, got ${secretBin.length}`,
);
}
const publicKeyBech32 = binToBech32Padded(publicKeyBin).toLowerCase();
const secretBech32 = binToBech32Padded(secretBin).toLowerCase();
const hostname = options.hostname || DEFAULT_RELAY_HOSTNAME;
const port = options.port ?? DEFAULT_RELAY_PORT;
const protocol = options.protocol || DEFAULT_RELAY_PROTOCOL;
const isDefaultHostname = hostname === DEFAULT_RELAY_HOSTNAME;
const defaultPort = protocol === "wss" ? 443 : 80;
const isDefaultPort = port === defaultPort;
const isDefaultProtocol = protocol === DEFAULT_RELAY_PROTOCOL;
let uri: string;
if (isDefaultHostname && isDefaultPort && isDefaultProtocol) {
uri = `wiz://?p=${publicKeyBech32}&s=${secretBech32}`;
} else {
const portPart = isDefaultPort ? "" : `:${port}`;
const authority = `${hostname}${portPart}`;
uri = `wiz://${authority}?p=${publicKeyBech32}&s=${secretBech32}`;
if (!isDefaultProtocol) {
uri += `&pr=${protocol}`;
}
}
const qrUri = uri
.toUpperCase()
.replace("?", "%3F")
.replace(/=/g, "%3D")
.replace(/&/g, "%26");
return { uri, qrUri };
}
export function decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI {
let url: URL;
try {
const lower = uri.toLowerCase();
const isQr = lower.includes("%3f") && !lower.includes("?");
const toParse = isQr
? lower.replace("%3f", "?").replace(/%3d/g, "=").replace(/%26/g, "&")
: lower;
url = new URL(toParse);
} catch (error) {
throw new Error(
`Invalid URI format: ${error instanceof Error ? error.message : "unknown error"}`,
{ cause: error },
);
}
if (url.protocol !== "wiz:") {
throw new Error("Invalid URI scheme. Expected: wiz://");
}
let hostname = url.hostname || DEFAULT_RELAY_HOSTNAME;
let port: number;
if (url.port) {
const parsedPort = parseInt(url.port, 10);
if (isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
throw new Error(`Invalid port number: ${url.port}`);
}
port = parsedPort;
} else {
port = DEFAULT_RELAY_PORT;
}
const publicKeyBech32 = url.searchParams.get("p");
const secretBech32 = url.searchParams.get("s");
if (!publicKeyBech32 || !secretBech32) {
throw new Error(
"Invalid URI format. Missing required parameters: p (public key) or s (secret)",
);
}
const protocol = url.searchParams.get("pr") as "ws" | "wss" | null;
const publicKeyBech32Normalized = publicKeyBech32.toLowerCase();
const secretBech32Normalized = secretBech32.toLowerCase();
let publicKeyBin: Uint8Array;
let secretBin: Uint8Array;
try {
const pubkeyResult = bech32PaddedToBin(publicKeyBech32Normalized);
const secretResult = bech32PaddedToBin(secretBech32Normalized);
if (
pubkeyResult instanceof Uint8Array &&
secretResult instanceof Uint8Array
) {
publicKeyBin = pubkeyResult;
secretBin = secretResult;
} else {
const pubkeyError =
typeof pubkeyResult === "string" ? pubkeyResult : "Unknown error";
const secretError =
typeof secretResult === "string" ? secretResult : "Unknown error";
throw new Error(
`Bech32 decoding failed: pubkey=${pubkeyError}, secret=${secretError}`,
);
}
} catch (error) {
throw new Error(
`Invalid bech32 encoding: ${error instanceof Error ? error.message : "unknown error"}`,
{ cause: error },
);
}
if (publicKeyBin.length !== 32) {
throw new Error(
`Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`,
);
}
if (secretBin.length !== 8) {
throw new Error(
`Invalid secret length: expected 8 bytes, got ${secretBin.length}`,
);
}
if (protocol && protocol !== "ws" && protocol !== "wss") {
throw new Error(`Invalid protocol: ${protocol}. Must be 'ws' or 'wss'`);
}
if (!url.port && protocol === "ws") {
port = 80;
}
return {
publicKey: binToHex(publicKeyBin),
secret: binToHex(secretBin),
hostname: hostname,
port: port,
protocol: protocol || DEFAULT_RELAY_PROTOCOL,
};
}

32
packages/core/src/log.ts Normal file
View file

@ -0,0 +1,32 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Simple console-based logger shim.
* Replaces @riftenlabs/log without any external dependencies.
*/
export enum Scope {
Relay = "relay",
Network = "network",
Misc = "misc",
}
export type LogScope = Scope | string;
export function debug(scope: LogScope, ...args: unknown[]): void {
console.log(`[${scope}]`, ...args);
}
export function info(scope: LogScope, ...args: unknown[]): void {
console.info(`[${scope}]`, ...args);
}
export function warn(scope: LogScope, ...args: unknown[]): void {
console.warn(`[${scope}]`, ...args);
}
export function error(scope: LogScope, ...args: unknown[]): void {
console.error(`[${scope}]`, ...args);
}

View file

@ -0,0 +1,221 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect, beforeEach, vi } from "vitest";
import { MessageQueue } from "./message-queue.js";
import { ProtocolMessage, RelayMsgAction } from "./protocols/hdwalletv1.js";
describe("MessageQueue", () => {
let queue: MessageQueue;
let publishFn: ReturnType<typeof vi.fn>;
beforeEach(() => {
queue = new MessageQueue({ logActivity: false });
publishFn = vi.fn().mockResolvedValue(undefined);
});
describe("initialization", () => {
it("should start with ready state false", () => {
expect(queue.getReady()).toBe(false);
});
it("should start with empty queue", () => {
expect(queue.getQueueLength()).toBe(0);
});
});
describe("enqueue", () => {
it("should queue messages when not ready", async () => {
const message: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
const promise = queue.enqueue(message);
expect(queue.getQueueLength()).toBe(1);
expect(queue.getReady()).toBe(false);
// Message should be queued, promise should not resolve yet
await expect(
Promise.race([
promise,
new Promise((resolve) => setTimeout(() => resolve("timeout"), 10)),
]),
).resolves.toBe("timeout");
});
it("should return resolved promise when already ready", async () => {
queue.setReady(publishFn);
const message: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
await expect(queue.enqueue(message)).resolves.toBeUndefined();
expect(queue.getQueueLength()).toBe(0);
});
});
describe("setReady", () => {
it("should mark queue as ready", () => {
queue.setReady(publishFn);
expect(queue.getReady()).toBe(true);
});
it("should process queued messages when set to ready", async () => {
const message1: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
const message2: ProtocolMessage = {
action: RelayMsgAction.WalletReady,
time: Math.floor(Date.now() / 1000),
};
const promise1 = queue.enqueue(message1);
const promise2 = queue.enqueue(message2);
expect(queue.getQueueLength()).toBe(2);
await queue.setReady(publishFn);
expect(publishFn).toHaveBeenCalledTimes(2);
expect(publishFn).toHaveBeenCalledWith(message1);
expect(publishFn).toHaveBeenCalledWith(message2);
expect(queue.getQueueLength()).toBe(0);
// Promises should resolve
await expect(promise1).resolves.toBeUndefined();
await expect(promise2).resolves.toBeUndefined();
});
it("should reject queued messages if publish fails", async () => {
const error = new Error("Publish failed");
publishFn.mockRejectedValueOnce(error);
const message: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
const promise = queue.enqueue(message);
await queue.setReady(publishFn);
await expect(promise).rejects.toThrow("Publish failed");
});
it("should process messages in order", async () => {
const messages: ProtocolMessage[] = [];
const callOrder: number[] = [];
publishFn.mockImplementation(async (msg: ProtocolMessage) => {
const index = messages.length;
messages.push(msg);
callOrder.push(index);
});
for (let i = 0; i < 5; i++) {
const message: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
queue.enqueue(message);
}
await queue.setReady(publishFn);
expect(callOrder).toEqual([0, 1, 2, 3, 4]);
expect(messages).toHaveLength(5);
});
});
describe("setNotReady", () => {
it("should mark queue as not ready", () => {
queue.setReady(publishFn);
expect(queue.getReady()).toBe(true);
queue.setNotReady();
expect(queue.getReady()).toBe(false);
});
it("should reject all queued messages", async () => {
const message1: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
const message2: ProtocolMessage = {
action: RelayMsgAction.WalletReady,
time: Math.floor(Date.now() / 1000),
};
const promise1 = queue.enqueue(message1);
const promise2 = queue.enqueue(message2);
queue.setNotReady("Custom error message");
await expect(promise1).rejects.toThrow("Custom error message");
await expect(promise2).rejects.toThrow("Custom error message");
expect(queue.getQueueLength()).toBe(0);
});
it("should use default error message if not provided", async () => {
const message: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
const promise = queue.enqueue(message);
queue.setNotReady();
await expect(promise).rejects.toThrow(
"Connection closed before message could be sent",
);
});
});
describe("clear", () => {
it("should clear all queued messages", async () => {
const message: ProtocolMessage = {
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
};
const promise = queue.enqueue(message);
expect(queue.getQueueLength()).toBe(1);
queue.clear();
expect(queue.getQueueLength()).toBe(0);
await expect(promise).rejects.toThrow("Queue cleared");
});
it("should not affect ready state", () => {
queue.setReady(publishFn);
expect(queue.getReady()).toBe(true);
queue.clear();
expect(queue.getReady()).toBe(true);
});
});
describe("getQueueLength", () => {
it("should return correct queue length", () => {
expect(queue.getQueueLength()).toBe(0);
queue.enqueue({
action: RelayMsgAction.DappReady,
time: Math.floor(Date.now() / 1000),
});
expect(queue.getQueueLength()).toBe(1);
queue.enqueue({
action: RelayMsgAction.WalletReady,
time: Math.floor(Date.now() / 1000),
});
expect(queue.getQueueLength()).toBe(2);
});
});
});

View file

@ -0,0 +1,91 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { ProtocolMessage } from "./protocols/hdwalletv1.js";
import { debug, Scope } from "./log.js";
interface QueuedMessage {
message: ProtocolMessage;
resolve: () => void;
reject: (_error: Error) => void;
}
export class MessageQueue {
private queue: QueuedMessage[] = [];
private isReady: boolean = false;
private logActivity: boolean = false;
constructor(options?: { logActivity?: boolean }) {
this.logActivity = options?.logActivity ?? false;
}
getReady(): boolean {
return this.isReady;
}
getQueueLength(): number {
return this.queue.length;
}
enqueue(message: ProtocolMessage): Promise<void> {
if (this.isReady) {
return Promise.resolve();
}
if (this.logActivity) {
debug(
Scope.Relay,
`net: Relays not ready, queuing message ${message.action}`,
);
}
return new Promise<void>((resolve, reject) => {
this.queue.push({ message, resolve, reject });
});
}
async setReady(
publishFn: (_message: ProtocolMessage) => Promise<void>,
): Promise<void> {
this.isReady = true;
const queuedMessages = [...this.queue];
this.queue = [];
if (queuedMessages.length > 0 && this.logActivity) {
debug(Scope.Relay, `Processing ${queuedMessages.length} queued messages`);
}
for (const queued of queuedMessages) {
try {
await publishFn(queued.message);
queued.resolve();
} catch (error) {
queued.reject(error as Error);
}
}
}
setNotReady(
errorMessage: string = "Connection closed before message could be sent",
): void {
this.isReady = false;
const queuedMessages = [...this.queue];
this.queue = [];
for (const queued of queuedMessages) {
queued.reject(new Error(errorMessage));
}
}
clear(): void {
const queuedMessages = [...this.queue];
this.queue = [];
for (const queued of queuedMessages) {
queued.reject(new Error("Queue cleared"));
}
}
}

View file

@ -0,0 +1,23 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Minimal primitives shim replaces @riftenlabs/primitives.
*/
export const throwUnless = (x: boolean, what: string): void => {
if (!x) {
throw Error(`Internal application error: ${what}`);
}
};
export function unwrap<T>(value: string | Error | T): T {
if (typeof value === "string") {
throw new Error(`unwrap: ${value}`);
}
if (value instanceof Error) {
throw value;
}
return value as T;
}

View file

@ -0,0 +1,95 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/// Base protocol — DappReadyMessage and WalletReadyMessage
///
/// These are protocol-agnostic handshake messages shared across all
/// application-level protocols (hdwalletv1, future hdwalletv2, etc.).
///
/// The wallet populates `session` for every protocol it supports, keyed by
/// protocol name. The dapp selects the first protocol from its own
/// `supported_protocols` list that is also present in the wallet's list,
/// then reads `session[selectedProtocol]` for protocol-specific data.
import {
RelayMsgAction,
ProtocolMessage,
isProtocolMessage,
} from "./hdwalletv1.js";
export interface DappReadyMessage extends ProtocolMessage {
action: RelayMsgAction.DappReady;
/// Protocols this dapp supports, in preference order.
supported_protocols: string[];
/// Set on the reactive dapp_ready (after seeing wallet_ready).
/// Absent on the proactive send (before the dapp has seen the wallet).
selected_protocol?: string;
/// True if the dapp has already seen this wallet in the current runtime session.
wallet_discovered: boolean;
dapp_name?: string;
dapp_icon?: string;
}
export interface WalletReadyMessage extends ProtocolMessage {
action: RelayMsgAction.WalletReady;
wallet_name: string;
wallet_icon: string;
/// True if the wallet has already seen this dapp in the current runtime session.
/// The dapp uses this to skip sending a reactive dapp_ready when already known.
dapp_discovered: boolean;
/// Protocols this wallet supports.
supported_protocols: string[];
/// Session data keyed by protocol name.
/// e.g. { "hdwalletv1": { paths: PathXpub[], next_indices: NextIndex[] } }
/// The dapp picks the first compatible protocol and reads session[selectedProtocol].
session: Record<string, unknown>;
/// Wallet's Nostr x-only public key (hex, 32 bytes). Replaces the old key_exchange_response.
public_key: string;
/// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention.
secret: string;
}
export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage {
return (
isProtocolMessage(msg) &&
msg.action === RelayMsgAction.DappReady &&
Array.isArray((msg as DappReadyMessage).supported_protocols) &&
typeof (msg as DappReadyMessage).wallet_discovered === "boolean"
);
}
export function isWalletReadyMessage(msg: unknown): msg is WalletReadyMessage {
const m = msg as WalletReadyMessage;
return (
isProtocolMessage(msg) &&
msg.action === RelayMsgAction.WalletReady &&
typeof m.wallet_name === "string" &&
typeof m.wallet_icon === "string" &&
typeof m.dapp_discovered === "boolean" &&
Array.isArray(m.supported_protocols) &&
m.session !== null &&
typeof m.session === "object" &&
typeof m.public_key === "string" &&
typeof m.secret === "string"
);
}
export enum DisconnectReason {
ProtocolMismatch = "protocol_mismatch",
UserDisconnect = "user_disconnect",
}
export interface DisconnectMessage extends ProtocolMessage {
action: RelayMsgAction.Disconnect;
reason: DisconnectReason;
message?: string;
}
export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage {
return (
isProtocolMessage(msg) &&
(msg as DisconnectMessage).action === RelayMsgAction.Disconnect &&
typeof (msg as DisconnectMessage).reason === "string"
);
}

View file

@ -0,0 +1,169 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/// This is a protocol within the relayed messages (which is protocol agnostic).
import { WcSignTransactionRequest } from "@bch-wc2/interfaces";
/// Dapp <-> Wallet messages
export const PROTOCOL_NAME = "hdwalletv1" as const;
/// What this message is about
export enum RelayMsgAction {
/// Notification: Dapp has joined the session and is ready to receive relayed messages.
DappReady = "dapp_ready",
/// Notification: Wallet has joined the session and is ready to send relayed messages.
/// Also carries key exchange data (public_key, secret) embedded in the message.
WalletReady = "wallet_ready",
/// Request: The dapp wants wallet to sign a transaction.
SignTransactionRequest = "sign_transaction_request",
SignTransactionResponse = "sign_transaction_response",
/// Dapp-only: cancels an in-flight sign_transaction_request.
SignCancel = "sign_cancel",
/// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect",
}
export interface ProtocolMessage {
action: RelayMsgAction;
time: number;
}
export type PathName = "receive" | "change" | "defi";
export interface PathXpub {
name: PathName; // "receive" | "change" | "defi"
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
}
export function isPathXpub(obj: any): obj is PathXpub {
return (
obj &&
typeof obj === "object" &&
typeof obj.name === "string" &&
typeof obj.xpub === "string"
);
}
/** Returns the numeric BIP44 child index for the given named path.
* Use only for internal HD derivation not a protocol concern. */
export function childIndexOfPathName(name: PathName): number {
switch (name) {
case "receive":
return 0;
case "change":
return 1;
case "defi":
return 7;
}
}
/// Handshake Protocol
///
/// Either side may connect or reconnect at any time (mobile app switch, browser
/// refresh, relay drop). The handshake uses a **mutual-discovery** pattern so
/// both sides converge regardless of who connects first:
///
/// 1. On every connect/reconnect, both sides independently send their "ready"
/// message once key exchange completes.
/// 2. Each "ready" carries a boolean indicating whether the sender has already
/// seen the other party before (in this runtime session).
/// 3. On receiving a "ready" with `_discovered=false`, the receiver sends back
/// their own "ready" — **only if they have not already sent one this cycle**.
/// 4. Once both sides have exchanged wallet_ready/dapp_ready, the wallet sends
/// its xpubs and the dapp derives all needed pubkeys locally.
///
/// INITIAL CONNECT (neither has seen the other):
///
/// Dapp --dapp_ready(supported=["v1"])--> Wallet (proactive)
/// Dapp <--wallet_ready(supported=["v1"], session={v1:{paths,...}})-- Wallet
/// Dapp --dapp_ready(supported=["v1"], selected="hdwalletv1")--> Wallet (reactive)
///
/// WALLET RECONNECTS (dapp still running, has walletDiscovered=true):
///
/// Dapp --dapp_ready(supported=["v1"], wallet_discovered=true)--> Wallet
/// Dapp <--wallet_ready(supported=["v1"], session={...})-- Wallet
/// Dapp --dapp_ready(selected="hdwalletv1", wallet_discovered=true)--> Wallet
///
/// See base.ts for DappReadyMessage / WalletReadyMessage definitions.
/// Session data for the hdwalletv1 protocol.
/// Carried inside WalletReadyMessage.session["hdwalletv1"].
export interface Hdwalletv1Session {
/// BIP32 xpubs for each named derivation path.
paths: PathXpub[];
}
export function isHdwalletv1Session(obj: unknown): obj is Hdwalletv1Session {
return (
obj !== null &&
typeof obj === "object" &&
Array.isArray((obj as Hdwalletv1Session).paths) &&
(obj as Hdwalletv1Session).paths.every((p) => isPathXpub(p))
);
}
export interface ErrorMessage extends ProtocolMessage {
error: string;
}
export interface SignTransactionRequest extends ProtocolMessage {
action: RelayMsgAction.SignTransactionRequest;
transaction: WcSignTransactionRequest;
sequence: number;
}
export interface SignTransactionResponse extends ProtocolMessage {
action: RelayMsgAction.SignTransactionResponse;
sequence: number;
signedTransaction: string;
error?: string;
}
export interface SignCancelMessage extends ProtocolMessage {
action: RelayMsgAction.SignCancel;
sequence: number;
reason?: string;
}
// Type guard functions
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
return (
payload &&
typeof payload === "object" &&
typeof payload.action === "string" &&
typeof payload.time === "number"
);
}
export function isErrorMessage(payload: any): payload is ErrorMessage {
return (
payload && typeof payload === "object" && typeof payload.error === "string"
);
}
export function isSignTransactionRequest(
msg: any,
): msg is SignTransactionRequest {
return (
msg &&
typeof msg === "object" &&
msg.action === RelayMsgAction.SignTransactionRequest &&
msg.transaction &&
typeof msg.transaction === "object" &&
typeof msg.sequence === "number"
);
}
export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
return (
msg &&
typeof msg === "object" &&
msg.action === RelayMsgAction.SignCancel &&
typeof msg.sequence === "number"
);
}

View file

@ -0,0 +1,406 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { throwUnless, unwrap } from "./primitives.js";
import NDK, {
NDKPrivateKeySigner,
NDKUser,
NDKEvent,
NDKKind,
giftWrap,
giftUnwrap,
NDKSubscription,
} from "@nostr-dev-kit/ndk";
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3";
import {
isProtocolMessage,
ProtocolMessage,
RelayMsgAction,
} from "./protocols/hdwalletv1.js";
import { deriveNostrPublicKey } from "./utilnostr.js";
import { MessageQueue } from "./message-queue.js";
import { debug, error as logError, Scope } from "./log.js";
export interface RelayClientConfig {
explicitRelayUrls: string[];
signerPrivateKey: Uint8Array;
pairedPublicKey?: Uint8Array;
logNetworkActivity?: boolean;
}
export class RelayClient extends EventEmitter {
private ndk: NDK;
private paired: NDKUser;
private config: RelayClientConfig;
private messageSubscription: NDKSubscription | null = null;
private myPubkey: Uint8Array;
private myPubkeyHex: string;
private lastProcessedTimestamp: number = 0;
private messageQueue: MessageQueue;
private sequence: number = Math.floor(
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
);
private pendingCalls = new Map<
number,
{
resolve: (_value: any) => void;
reject: (_error: Error) => void;
}
>();
private pendingDeliveries = new Map<
number,
{
resolve: (_value: any) => void;
reject: (_error: Error) => void;
}
>();
constructor(config: RelayClientConfig) {
super();
this.config = {
logNetworkActivity: true,
...config,
};
this.ndk = new NDK({
explicitRelayUrls: this.config.explicitRelayUrls,
signer: new NDKPrivateKeySigner(this.config.signerPrivateKey),
enableOutboxModel: false,
autoConnectUserRelays: false,
});
this.messageQueue = new MessageQueue({
logActivity: this.config.logNetworkActivity,
});
this.myPubkey = unwrap(
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
);
this.myPubkeyHex = deriveNostrPublicKey(this.config.signerPrivateKey);
if (this.config.pairedPublicKey) {
const pairedNostrPubkey =
this.config.pairedPublicKey.length === 33
? this.config.pairedPublicKey.slice(1)
: this.config.pairedPublicKey;
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
} else {
this.paired = new NDKUser({ pubkey: "" });
}
}
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
this.config.pairedPublicKey = pairedPublicKey;
const pairedNostrPubkey =
pairedPublicKey.length === 33
? pairedPublicKey.slice(1)
: pairedPublicKey;
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
this.emit("paired");
}
getPublicKey(): Uint8Array {
return this.myPubkey;
}
getPublicKeyHex(): string {
return this.myPubkeyHex;
}
isKeyExchangeComplete(): boolean {
if (!this.config.pairedPublicKey) {
return false;
}
return !this.config.pairedPublicKey.every((byte) => byte === 0);
}
async connect(): Promise<void> {
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Connecting to relay...`);
}
if (this.lastProcessedTimestamp === 0) {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
}
try {
await this.ndk.connect();
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `NDK connect() resolved`);
}
this.messageSubscription = this.ndk.subscribe(
{
kinds: [NDKKind.GiftWrap],
"#p": [this.myPubkeyHex],
},
{ closeOnEose: false },
);
this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => {
try {
const signer = this.ndk.signer;
if (!signer) {
throw new Error("No signer available");
}
const rumor = await giftUnwrap(wrappedEvent, undefined, signer);
if (rumor.kind === NDKKind.PrivateDirectMessage) {
let payload: ProtocolMessage;
try {
payload = JSON.parse(rumor.content);
} catch (e) {
if (this.config.logNetworkActivity) {
logError(
Scope.Relay,
"Failed to parse message content as JSON:",
e,
);
}
return;
}
if (
!payload.time ||
(this.lastProcessedTimestamp > 0 &&
payload.time < this.lastProcessedTimestamp)
) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
);
}
return;
}
// wallet_ready carries the key exchange data (public_key + secret) so it
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
const isKeyExchangeMessage =
payload.action === RelayMsgAction.WalletReady;
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
const pairedNostrPubkey =
this.config.pairedPublicKey.length === 33
? binToHex(this.config.pairedPublicKey.slice(1))
: binToHex(this.config.pairedPublicKey);
if (rumor.pubkey !== pairedNostrPubkey) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`,
);
}
return;
}
}
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Received message ${payload.action} from relay`,
);
}
this.handleRelayMessage(payload);
} else {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
);
}
}
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Error handling incoming message:", error);
}
this.emitError(error as Error);
}
});
this.messageSubscription.on("close", () => {
if (this.config.logNetworkActivity) {
debug(Scope.Relay, "Subscription closed, connection may be stale");
}
this.emit("disconnect", new Error("Subscription closed"));
});
this.waitForRelaysReady();
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Subscription created and handler set up, emitting connection event`,
);
}
this.emit("connection");
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, `Connection failed:`, error);
}
throw error;
}
}
async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
this.messageQueue.setNotReady();
if (this.messageSubscription) {
this.messageSubscription.stop();
this.messageSubscription = null;
}
}
getLastProcessedTimestamp(): number {
return this.lastProcessedTimestamp;
}
setLastProcessedTimestamp(timestamp: number): void {
this.lastProcessedTimestamp = timestamp;
}
async relay(message: ProtocolMessage): Promise<void> {
if (!this.config.pairedPublicKey) {
throw new Error(
"Cannot relay message: paired public key not set. Call setPairedPublicKey() first.",
);
}
if (!this.messageQueue.getReady()) {
return this.messageQueue.enqueue(message);
}
return this.publishMessage(message);
}
private async publishMessage(message: ProtocolMessage): Promise<void> {
this.netlog("send", message.action);
const signer = this.ndk.signer;
if (!signer) {
throw new Error("No signer available");
}
const rumor = new NDKEvent(this.ndk);
rumor.kind = NDKKind.PrivateDirectMessage;
rumor.content = JSON.stringify(message);
rumor.created_at = Math.floor(Date.now() / 1000);
rumor.tags = [["p", this.paired.pubkey]];
const wrappedEvent = await giftWrap(rumor, this.paired, signer);
try {
await wrappedEvent.publish();
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Published message ${message.action} to relay`);
}
} catch (error) {
if (this.config.logNetworkActivity) {
logError(
Scope.Relay,
`Failed to publish message ${message.action}:`,
error,
);
}
throw error;
}
}
private async waitForRelaysReady(): Promise<void> {
const maxWaitTime = 5000;
const checkInterval = 100;
const startTime = Date.now();
const checkRelays = async (): Promise<void> => {
const pool = (this.ndk as any).pool;
if (pool) {
const relays = pool.relays || [];
// NDKRelayStatus: DISCONNECTED=1, CONNECTED=5, AUTHENTICATED=8
const connectedRelays = Array.from(relays.values()).filter(
(relay: any) => relay.status >= 5,
);
if (connectedRelays.length > 0) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Relays ready (${connectedRelays.length} connected), processing queued messages`,
);
}
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
return;
}
}
if (Date.now() - startTime < maxWaitTime) {
setTimeout(checkRelays, checkInterval);
} else {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
"Relay ready check timeout, assuming ready and processing queued messages",
);
}
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
}
};
setTimeout(checkRelays, 200);
}
isConnected(): boolean {
return true;
}
private netlog(
direction: "recv" | "send",
what: string,
sequence?: number,
): void {
if (!this.config.logNetworkActivity) {
return;
}
const us = binToHex(hash256(this.config.signerPrivateKey)).slice(-6);
const them = this.config.pairedPublicKey
? binToHex(this.config.pairedPublicKey).slice(-6)
: "??????";
const pending = `c${this.pendingCalls.size} d${this.pendingDeliveries.size}`;
if (direction === "send") {
debug(
Scope.Relay,
`net [${sequence ?? "?"} ${pending}] ${us} -> ${them}: ${what}`,
);
} else {
debug(
Scope.Relay,
`net [${sequence ?? "?"} ${pending}] ${us} <- ${them}: ${what}`,
);
}
}
private async handleRelayMessage(message: ProtocolMessage): Promise<void> {
throwUnless(
isProtocolMessage(message),
`Invalid protocol message: ${message}`,
);
this.emit("message", message);
}
public nextSequence(): number {
const current = this.sequence;
this.sequence += 2;
return current;
}
private emitError(error: Error): void {
this.emit("error", error);
}
}

View file

@ -0,0 +1,167 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { RelayClient } from "./relay-client.js";
import {
createConnectionManager,
VisibilityChangeContext,
} from "./connection-manager.js";
import { debug, error as logError, Scope } from "./log.js";
type RelayStatusCode =
| "connected"
| "reconnecting"
| "disconnected"
| "session_deleted";
export class RelayStatus {
status: RelayStatusCode;
error: string | null;
sessionId: string | null;
private constructor(
status: RelayStatusCode,
error: string | null,
sessionId: string | null = null,
) {
this.status = status;
this.error = error;
this.sessionId = sessionId;
}
static connected(sessionId?: string): RelayStatus {
return new RelayStatus("connected", null, sessionId || null);
}
static reconnecting(reason: string | null, sessionId?: string): RelayStatus {
return new RelayStatus("reconnecting", reason, sessionId || null);
}
static disconnected(): RelayStatus {
return new RelayStatus("disconnected", null, null);
}
static sessionDeleted(): RelayStatus {
return new RelayStatus("session_deleted", null, null);
}
}
export interface RelayUpdatePayload {
client: RelayClient;
status: RelayStatus;
}
export type RelayStatusCallback = (_payload: RelayUpdatePayload) => void;
export const initiateRelay = (
dispatchCallback: RelayStatusCallback,
signerPrivateKey: Uint8Array,
pairPublicKey: Uint8Array,
options?: {
explicitRelayUrls: string[];
reconnectInterval?: number;
maxReconnectAttempts?: number;
enableVisibilityHandling?: boolean;
},
) => {
const client = new RelayClient({
explicitRelayUrls: options?.explicitRelayUrls ?? [],
signerPrivateKey: signerPrivateKey,
pairedPublicKey: pairPublicKey,
});
let lastProcessedTimestamp: number = 0;
const connectionManager = createConnectionManager(
client,
{
onConnected: () => {
if (lastProcessedTimestamp > 0) {
client.setLastProcessedTimestamp(lastProcessedTimestamp);
} else {
lastProcessedTimestamp = client.getLastProcessedTimestamp();
}
dispatchCallback({
client,
status: RelayStatus.connected(),
});
},
onReconnecting: (_client: RelayClient, reason: string | null) => {
dispatchCallback({
client,
status: RelayStatus.reconnecting(reason),
});
},
onDisconnected: () => {
dispatchCallback({
client,
status: RelayStatus.disconnected(),
});
},
onError: (_client: RelayClient, _error: any) => {
// Error handling is already done in onReconnecting
},
},
{
connected: "connection",
disconnected: "disconnect",
error: "error",
},
{
reconnectInterval: options?.reconnectInterval,
maxReconnectAttempts: options?.maxReconnectAttempts,
enableVisibilityHandling: options?.enableVisibilityHandling ?? true,
scope: Scope.Relay,
onVisibilityChange: async (
context: VisibilityChangeContext<RelayClient>,
) => {
if (context.state === "hidden") {
debug(Scope.Relay, "Page hidden, disconnecting relay");
context.setPaused(true);
try {
await client.disconnect();
lastProcessedTimestamp = client.getLastProcessedTimestamp();
debug(
Scope.Relay,
`Disconnected, last processed timestamp: ${lastProcessedTimestamp}`,
);
dispatchCallback({
client,
status: RelayStatus.disconnected(),
});
} catch (error) {
logError(
Scope.Relay,
"Error disconnecting on visibility change:",
error,
);
}
} else if (context.state === "visible") {
if (context.isPaused()) {
debug(Scope.Relay, "Page visible, reconnecting relay");
context.setPaused(false);
context.startConnectionLoop();
}
}
},
},
);
connectionManager.startConnectionLoop();
return () => {
dispatchCallback({
client,
status: RelayStatus.disconnected(),
});
(async () => {
try {
await connectionManager.cleanup();
} catch {
// Ignore disconnect errors during cleanup
}
})();
};
};

View file

@ -0,0 +1,104 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect } from "vitest";
import {
deriveNostrPublicKey,
deriveNostrPublicKeyBytes,
} from "./utilnostr.js";
import {
generatePrivateKey,
hexToBin,
binToHex,
secp256k1,
} from "@bitauth/libauth";
describe("utilnostr", () => {
describe("deriveNostrPublicKey", () => {
it("should derive a valid Nostr public key from a private key", () => {
const privateKey = generatePrivateKey();
const publicKey = deriveNostrPublicKey(privateKey);
// Nostr public key should be 64 hex characters (32 bytes)
expect(publicKey).toHaveLength(64);
expect(/^[0-9a-f]+$/.test(publicKey)).toBe(true);
});
it("should produce the same public key for the same private key", () => {
const privateKey = generatePrivateKey();
const publicKey1 = deriveNostrPublicKey(privateKey);
const publicKey2 = deriveNostrPublicKey(privateKey);
expect(publicKey1).toBe(publicKey2);
});
it("should produce different public keys for different private keys", () => {
const privateKey1 = generatePrivateKey();
const privateKey2 = generatePrivateKey();
const publicKey1 = deriveNostrPublicKey(privateKey1);
const publicKey2 = deriveNostrPublicKey(privateKey2);
expect(publicKey1).not.toBe(publicKey2);
});
it("should handle private key from hex string", () => {
const privateKeyHex =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const privateKey = hexToBin(privateKeyHex);
const publicKey = deriveNostrPublicKey(privateKey);
expect(publicKey).toHaveLength(64);
expect(/^[0-9a-f]+$/.test(publicKey)).toBe(true);
});
it("should match the pattern used in key-exchange", () => {
const privateKey = generatePrivateKey();
const publicKey = deriveNostrPublicKey(privateKey);
// Verify it's the same as the manual pattern
const result = secp256k1.derivePublicKeyCompressed(privateKey);
if (typeof result === "string") throw new Error(result);
const publicKeyNostr = result.slice(1);
const publicKeyHex = binToHex(publicKeyNostr);
expect(publicKey).toBe(publicKeyHex);
});
});
describe("deriveNostrPublicKeyBytes", () => {
it("should derive a valid Nostr public key as bytes", () => {
const privateKey = generatePrivateKey();
const publicKeyBytes = deriveNostrPublicKeyBytes(privateKey);
// Nostr public key should be 32 bytes
expect(publicKeyBytes).toHaveLength(32);
expect(publicKeyBytes).toBeInstanceOf(Uint8Array);
});
it("should match the hex version when converted", () => {
const privateKey = generatePrivateKey();
const publicKeyHex = deriveNostrPublicKey(privateKey);
const publicKeyBytes = deriveNostrPublicKeyBytes(privateKey);
expect(binToHex(publicKeyBytes)).toBe(publicKeyHex);
});
it("should produce the same bytes for the same private key", () => {
const privateKey = generatePrivateKey();
const publicKeyBytes1 = deriveNostrPublicKeyBytes(privateKey);
const publicKeyBytes2 = deriveNostrPublicKeyBytes(privateKey);
expect(publicKeyBytes1).toEqual(publicKeyBytes2);
});
it("should produce different bytes for different private keys", () => {
const privateKey1 = generatePrivateKey();
const privateKey2 = generatePrivateKey();
const publicKeyBytes1 = deriveNostrPublicKeyBytes(privateKey1);
const publicKeyBytes2 = deriveNostrPublicKeyBytes(privateKey2);
expect(publicKeyBytes1).not.toEqual(publicKeyBytes2);
});
});
});

View file

@ -0,0 +1,21 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { binToHex, secp256k1 } from "@bitauth/libauth";
import { unwrap } from "./primitives.js";
export function deriveNostrPublicKey(privateKey: Uint8Array): string {
const publicKeyCompressed = unwrap(
secp256k1.derivePublicKeyCompressed(privateKey),
);
const publicKeyNostr = publicKeyCompressed.slice(1);
return binToHex(publicKeyNostr);
}
export function deriveNostrPublicKeyBytes(privateKey: Uint8Array): Uint8Array {
const publicKeyCompressed = unwrap(
secp256k1.derivePublicKeyCompressed(privateKey),
);
return publicKeyCompressed.slice(1);
}

View file

@ -0,0 +1,111 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { RelayClient } from "./relay-client.js";
import {
RelayUpdatePayload,
RelayStatusCallback,
initiateRelay,
} from "./relay-handler.js";
import { decodeKeyExchangeURI, DEFAULT_RELAY_PORT } from "./key-exchange.js";
import { hexToBin } from "@bitauth/libauth";
import { deriveNostrPublicKeyBytes } from "./utilnostr.js";
export interface WalletRelayOptions {
uri: string;
walletPrivateKey: Uint8Array;
explicitRelayUrls?: string[];
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
export interface WalletRelayResult {
client: RelayClient;
dappPublicKey: Uint8Array;
walletPublicKey: Uint8Array;
secret: string;
cleanup: () => void;
}
export function initiateWalletRelay(
statusCallback: RelayStatusCallback,
options: WalletRelayOptions,
): WalletRelayResult {
let decoded;
try {
decoded = decodeKeyExchangeURI(options.uri);
} catch (err: any) {
throw new Error(`Failed to decode connection URI: ${err.message}`, {
cause: err,
});
}
const dappPublicKeyHex = decoded.publicKey;
const secret = decoded.secret;
const dappPublicKeyNostr = hexToBin(dappPublicKeyHex);
const hostname = decoded.hostname;
const protocol = decoded.protocol;
const port = decoded.port;
const relayUrl = `${protocol}://${hostname}${port === (protocol === "wss" ? DEFAULT_RELAY_PORT : 80) ? "" : `:${port}`}`;
const walletPublicKeyNostr = deriveNostrPublicKeyBytes(
options.walletPrivateKey,
);
let relayClient: RelayClient | null = null;
const wrappedCallback: RelayStatusCallback = (
payload: RelayUpdatePayload,
) => {
if (!relayClient) {
relayClient = payload.client;
}
if (payload.status.status === "connected") {
// Set dapp's pubkey so outbound messages are addressed correctly.
// The wallet's own pubkey + secret are delivered via wallet_ready.
relayClient!.setPairedPublicKey(dappPublicKeyNostr);
}
statusCallback(payload);
};
const relayUrls: string[] = [relayUrl];
if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) {
for (const explicitUrl of options.explicitRelayUrls) {
if (!relayUrls.includes(explicitUrl)) {
relayUrls.push(explicitUrl);
}
}
}
const cleanup = initiateRelay(
wrappedCallback,
options.walletPrivateKey,
dappPublicKeyNostr,
{
explicitRelayUrls: relayUrls,
reconnectInterval: options.reconnectInterval,
maxReconnectAttempts: options.maxReconnectAttempts,
},
);
const result: WalletRelayResult = {
get client() {
if (!relayClient) {
throw new Error(
"Relay client not yet initialized. Wait for connection status callback.",
);
}
return relayClient;
},
dappPublicKey: dappPublicKeyNostr,
walletPublicKey: walletPublicKeyNostr,
secret,
cleanup,
};
return result;
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -0,0 +1,11 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
},
});

View file

@ -0,0 +1,29 @@
{
"name": "@wizardconnect/dapp",
"version": "0.1.0",
"type": "module",
"description": "Dapp-side integration helpers for WizardConnect",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/"
],
"scripts": {
"build": "tsc",
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
"lint:prettier": "prettier . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",
"fix": "npm run fix:eslint && npm run fix:prettier",
"fix:prettier": "prettier . --write",
"fix:eslint": "npm run lint:eslint -- --fix"
},
"dependencies": {
"@wizardconnect/core": "*",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
}
}

Binary file not shown.

View file

@ -0,0 +1,340 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { EventEmitter } from "eventemitter3";
import { decodeHdPublicKey } from "@bitauth/libauth";
import type { HdPublicNodeValid } from "@bitauth/libauth";
import {
RelayClient,
RelayStatus,
RelayMsgAction,
DappReadyMessage,
WalletReadyMessage,
DisconnectMessage,
DisconnectReason,
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
ProtocolMessage,
PROTOCOL_NAME,
PathName,
childIndexOfPathName,
isHdwalletv1Session,
} from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
export interface DappConnectionManagerEvents {
/** Fired after wallet_ready is received and state is updated. */
walletready: [msg: WalletReadyMessage];
/** Fired for every protocol message sent by the dapp. */
messagesent: [msg: ProtocolMessage];
/** Fired for every protocol message received from the wallet. */
messagereceived: [msg: ProtocolMessage];
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
disconnect: [reason: DisconnectReason, message: string | undefined];
}
/**
* Manages the dapp side of a single WizardConnect session.
*
* Protocol responsibilities:
* - Sends dapp_ready after key exchange completes (and on reconnect)
* - Receives wallet_ready, sign_transaction_response
* - Tracks pubkeys and address indices (via xpub on-demand derivation)
*
* No chain-specific logic child indices are plain numbers:
* 0 = Receive, 1 = Change, 7 = Cauldron (BCH convention)
*/
export class DappConnectionManager extends EventEmitter<DappConnectionManagerEvents> {
private conn: RelayClient | null = null;
private listenerAttached = false;
/** Pubkey state — exposed for callers that need to query by index. */
readonly pubkeyState: DappPubkeyStateManager;
walletName: string | null = null;
walletIcon: string | null = null;
protocol: string | null = null;
/** Protocols this dapp supports, in preference order. */
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
private walletDiscovered = false;
private pendingSignatureRequests = new Map<
number,
{
resolve: (r: SignTransactionResponse) => void;
reject: (e: Error) => void;
}
>();
/**
* @param dappName Optional display name of the dapp (sent in dapp_ready).
* @param dappIcon Optional icon URL/data-URI of the dapp (sent in dapp_ready).
*/
constructor(
private dappName?: string,
private dappIcon?: string,
) {
super();
this.pubkeyState = new DappPubkeyStateManager();
}
/**
* Call this from the RelayStatusCallback passed to `initiateDappRelay`.
* Attaches the message listener exactly once and re-sends dapp_ready
* each time the connection is established (handles reconnects).
*/
updateConnection(
client: RelayClient | null | undefined,
status: RelayStatus,
): void {
if (client) {
// Attach message listener once (same RelayClient object is reused across reconnects)
if (!this.listenerAttached) {
this.listenerAttached = true;
client.on("message", (msg: ProtocolMessage) => this.handleMessage(msg));
}
this.conn = client;
}
if (status.status === "connected" && this.conn) {
this.onConnected();
}
}
isWalletDiscovered(): boolean {
return this.walletDiscovered;
}
/**
* Get a sequence number from the relay client.
* Use this to populate the `sequence` field of a SignTransactionRequest.
*/
nextSequence(): number {
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
return this.conn.nextSequence();
}
/**
* Send a sign transaction request and wait for the wallet's response.
* The caller is responsible for creating the full SignTransactionRequest
* (including sequence from `nextSequence()`).
*/
async sendSignRequest(
request: SignTransactionRequest,
): Promise<SignTransactionResponse> {
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
return new Promise<SignTransactionResponse>((resolve, reject) => {
this.pendingSignatureRequests.set(request.sequence, { resolve, reject });
this.conn!.relay(request)
.then(() => {
this.emit("messagesent", request);
})
.catch((err) => {
this.pendingSignatureRequests.delete(request.sequence);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
/**
* Cancel an in-flight sign request.
* Immediately rejects the pending Promise and sends sign_cancel to the wallet.
*/
async sendSignCancel(sequence: number, reason?: string): Promise<void> {
// Reject pending promise immediately — no response will come
const handlers = this.pendingSignatureRequests.get(sequence);
if (handlers) {
this.pendingSignatureRequests.delete(sequence);
handlers.reject(new Error(reason ?? "Sign request cancelled"));
}
if (!this.conn) return;
const msg: SignCancelMessage = {
action: RelayMsgAction.SignCancel,
sequence,
...(reason !== undefined && { reason }),
time: Math.floor(Date.now() / 1000),
};
await this.conn.relay(msg);
this.emit("messagesent", msg);
}
/**
* Send a disconnect message to the wallet (courtesy notification).
* The caller is responsible for calling dappRelay.cleanup() afterwards.
*/
async sendDisconnect(message?: string): Promise<void> {
if (!this.conn) return;
const msg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
...(message !== undefined && { message }),
};
await this.conn.relay(msg);
this.emit("messagesent", msg);
}
// --- Pubkey state delegation ------------------------------------------------
// Convenience methods that forward to pubkeyState.
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined {
return this.pubkeyState.getPubkey(childIndex, index);
}
/** Returns true if an xpub node is available for this child index. */
hasPath(childIndex: number): boolean {
return this.pubkeyState.hasPath(childIndex);
}
/**
* Returns the stored xpub node for the given child index.
* Available after wallet_ready is received.
*/
getXpubNode(childIndex: number): HdPublicNodeValid | undefined {
return this.pubkeyState.getXpubNode(childIndex);
}
// --- Private protocol handling -------------------------------------------
private onConnected(): void {
(async () => {
// Wait until key exchange is complete before sending dapp_ready
while (this.conn && !this.conn.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 100));
}
if (this.conn) {
await this.pushDappReady();
}
})().catch((e) =>
console.error("[wizardconnect/dapp] Error in onConnected:", e),
);
}
private async pushDappReady(): Promise<void> {
if (!this.conn) return;
const msg: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: this.supportedProtocols,
wallet_discovered: this.walletDiscovered,
time: Math.floor(Date.now() / 1000),
// Include selected_protocol on the reactive send (after the dapp has seen the wallet)
...(this.walletDiscovered &&
this.protocol && { selected_protocol: this.protocol }),
...(this.dappName !== undefined && { dapp_name: this.dappName }),
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
};
await this.conn.relay(msg);
this.emit("messagesent", msg);
}
private handleMessage(msg: ProtocolMessage): void {
this.emit("messagereceived", msg);
switch (msg.action) {
case RelayMsgAction.WalletReady:
this.handleWalletReady(msg as WalletReadyMessage);
break;
case RelayMsgAction.SignTransactionResponse:
this.handleSignTransactionResponse(msg as SignTransactionResponse);
break;
case RelayMsgAction.Disconnect:
this.handleRemoteDisconnect(msg as DisconnectMessage);
break;
case RelayMsgAction.DappReady:
// Not expected on dapp side — silently ignore
break;
default:
console.warn(
"[wizardconnect/dapp] Unknown message action:",
msg.action,
);
}
}
private handleRemoteDisconnect(msg: DisconnectMessage): void {
this.emit("disconnect", msg.reason, msg.message);
}
private handleWalletReady(msg: WalletReadyMessage): void {
this.walletDiscovered = true;
this.walletName = msg.wallet_name;
this.walletIcon = msg.wallet_icon;
// Protocol selection: first match in dapp's preference order
const agreed = this.supportedProtocols.find((p) =>
msg.supported_protocols.includes(p),
);
if (!agreed) {
const detail = `No protocol overlap. Wallet: [${msg.supported_protocols}], Dapp: [${this.supportedProtocols}]`;
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.ProtocolMismatch,
message: detail,
time: Math.floor(Date.now() / 1000),
};
this.conn?.relay(disconnectMsg).catch(() => {});
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
return;
}
this.protocol = agreed;
// Extract and validate protocol-specific session data
const sessionData = msg.session[agreed];
if (!isHdwalletv1Session(sessionData)) {
console.error(
"[wizardconnect/dapp] Invalid hdwalletv1 session data:",
sessionData,
);
return;
}
// Store xpub nodes — no eager derivation; consumer drives it
for (const pathInfo of sessionData.paths) {
const decoded = decodeHdPublicKey(pathInfo.xpub);
if (typeof decoded === "string") {
console.warn(
"[wizardconnect/dapp] Bad xpub for path",
pathInfo.name,
decoded,
);
continue;
}
const ci = childIndexOfPathName(pathInfo.name as PathName);
this.pubkeyState.setXpubNode(ci, decoded.node);
}
if (!msg.dapp_discovered) {
this.pushDappReady().catch((e) =>
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
);
}
this.emit("walletready", msg);
}
private handleSignTransactionResponse(
response: SignTransactionResponse,
): void {
const handlers = this.pendingSignatureRequests.get(response.sequence);
if (!handlers) {
console.warn(
"[wizardconnect/dapp] No pending request for sequence:",
response.sequence,
);
return;
}
this.pendingSignatureRequests.delete(response.sequence);
if (response.error) {
handlers.reject(new Error(response.error));
} else {
handlers.resolve(response);
}
}
}

View file

@ -0,0 +1,7 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
export { DappConnectionManager } from "./dapp-connection-manager.js";
export type { DappConnectionManagerEvents } from "./dapp-connection-manager.js";
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";

View file

@ -0,0 +1,27 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Dapp-side PubkeyStateManager tests.
* Uses plain number child indices (0=Receive, 1=Change, 7=Cauldron)
* instead of DerivationPath enum.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
const Receive = 0;
describe("DappPubkeyStateManager", () => {
let manager: DappPubkeyStateManager;
beforeEach(() => {
manager = new DappPubkeyStateManager();
});
describe("hasPath", () => {
it("should return false when no xpub node is set", () => {
expect(manager.hasPath(Receive)).toBe(false);
});
});
});

View file

@ -0,0 +1,43 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Dapp-side pubkey state manager.
*
* Stores xpub nodes received from the wallet and derives pubkeys on demand.
*
* Uses plain `number` for child indices (0=Receive, 1=Change, 7=Cauldron)
* so this package has no dependency on chain-specific types.
*/
import { deriveHdPublicNodeChild } from "@bitauth/libauth";
import type { HdPublicNodeValid } from "@bitauth/libauth";
export class DappPubkeyStateManager {
// xpub nodes for on-demand pubkey derivation
private xpubNodes: Map<number, HdPublicNodeValid> = new Map();
/** Derive a pubkey on demand from the stored xpub node. */
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined {
const xpubNode = this.xpubNodes.get(childIndex);
if (!xpubNode) return undefined;
const child = deriveHdPublicNodeChild(xpubNode, Number(index));
if (typeof child === "string") return undefined;
return child.publicKey;
}
/** Returns true if an xpub node is available for this child index. */
hasPath(childIndex: number): boolean {
return this.xpubNodes.has(childIndex);
}
setXpubNode(childIndex: number, node: HdPublicNodeValid): void {
this.xpubNodes.set(childIndex, node);
}
getXpubNode(childIndex: number): HdPublicNodeValid | undefined {
return this.xpubNodes.get(childIndex);
}
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -0,0 +1,11 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
},
});

View file

@ -0,0 +1,35 @@
{
"name": "@wizardconnect/test-cli",
"version": "0.1.0",
"description": "CLI for testing WizardConnect protocol",
"type": "module",
"private": true,
"bin": {
"wiz-test": "dist/cli.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts",
"dapp": "tsx src/cli.ts dapp",
"wallet": "tsx src/cli.ts wallet",
"lint:prettier": "prettier . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",
"fix": "npm run fix:eslint && npm run fix:prettier",
"fix:prettier": "prettier . --write",
"fix:eslint": "npm run lint:eslint -- --fix"
},
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
"@wizardconnect/wallet": "*",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"ora": "^8.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.2"
}
}

View file

@ -0,0 +1,60 @@
#!/usr/bin/env node
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { Command } from "commander";
import { runDappMode } from "./dapp.js";
import { runWalletMode } from "./wallet.js";
const program = new Command();
program
.name("wiz-test")
.description("WizardConnect protocol test CLI")
.version("0.1.0");
program
.command("dapp")
.description(
"Run as a dapp — generates URI, waits for wallet connection, logs all messages",
)
.option(
"-r, --relay <url>",
"Nostr relay WebSocket URL",
"wss://relay.cauldron.quest:443",
)
.option(
"-k, --private-key <hex>",
"Existing dapp private key (64 hex chars) — for reconnection testing",
)
.option("--secret <hex>", "Existing secret (hex) — for reconnection testing")
.option(
"--sign",
"Send a dummy sign request after wallet is ready (tests approval flow)",
)
.action(async (options) => {
await runDappMode(options);
});
program
.command("wallet")
.description("Run as a wallet — connects to a dapp URI, pushes test pubkeys")
.option(
"-r, --relay <url>",
"Nostr relay WebSocket URL",
"wss://relay.cauldron.quest:443",
)
.requiredOption("-u, --uri <uri>", "wiz:// URI from dapp")
.option(
"-k, --private-key <hex>",
"Wallet private key (64 hex chars) — random if omitted",
)
.action(async (options) => {
await runWalletMode(options);
});
// Strip bare "--" that npm inserts when forwarding arguments through
// multiple script layers (e.g. `npm run wallet -- --uri ...`).
const argv = process.argv.filter((arg) => arg !== "--");
program.parse(argv);

View file

@ -0,0 +1,297 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import chalk from "chalk";
import ora from "ora";
import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
type RelayUpdatePayload,
type DappReadyMessage,
type WalletReadyMessage,
type Hdwalletv1Session,
type SignTransactionResponse,
type ProtocolMessage,
type RelayClient,
} from "@wizardconnect/core";
// ---- State ----
interface DappState {
walletDiscovered: boolean;
walletName: string | null;
walletIcon: string | null;
// Whether we've received at least one wallet_ready
walletReady: boolean;
sequence: number;
}
function makeState(): DappState {
return {
walletDiscovered: false,
walletName: null,
walletIcon: null,
walletReady: false,
sequence: 1,
};
}
// ---- Send dapp_ready ----
async function sendDappReady(
client: RelayClient,
state: DappState,
): Promise<void> {
const msg: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: state.walletDiscovered,
time: Math.floor(Date.now() / 1000),
};
console.log(
chalk.blue(`→ dapp_ready`) +
chalk.dim(` wallet_discovered=${msg.wallet_discovered}`),
);
try {
await client.relay(msg);
} catch (err: any) {
console.error(chalk.red(" send error:"), err.message);
}
}
// ---- Send dummy sign request ----
async function sendSignRequest(
client: RelayClient,
state: DappState,
): Promise<void> {
const sequence = state.sequence++;
// Minimal dummy transaction for approval flow testing
const msg = {
action: RelayMsgAction.SignTransactionRequest,
sequence,
transaction: {
transaction: {
inputs: [],
outputs: [],
version: 2,
locktime: 0,
},
sourceOutputs: [],
userPrompt: "Test sign request from wiz-test CLI",
broadcast: false,
},
time: Math.floor(Date.now() / 1000),
};
console.log(
chalk.yellow(`→ sign_transaction_request`) + chalk.dim(` seq=${sequence}`),
);
try {
await (client as any).relay(msg);
} catch (err: any) {
console.error(chalk.red(" send error:"), err.message);
}
}
// ---- Handle incoming messages ----
function handleMessage(
message: ProtocolMessage,
client: RelayClient,
state: DappState,
sendSign: boolean,
): void {
const now = chalk.dim(new Date().toISOString().slice(11, 23));
switch (message.action) {
case RelayMsgAction.WalletReady: {
const msg = message as WalletReadyMessage;
state.walletDiscovered = true;
state.walletName = msg.wallet_name;
state.walletIcon = msg.wallet_icon;
state.walletReady = true;
const hdwv1 = msg.session?.[PROTOCOL_NAME] as
| Hdwalletv1Session
| undefined;
const pathsSummary = hdwv1?.paths?.map((p) => p.name).join(",") ?? "none";
console.log(
`${now} ` +
chalk.green("← wallet_ready") +
` wallet="${chalk.bold(msg.wallet_name)}"` +
` dapp_discovered=${msg.dapp_discovered}` +
` protocols=[${msg.supported_protocols.join(",")}]` +
` paths=[${pathsSummary}]`,
);
if (!msg.dapp_discovered) {
// Wallet hasn't seen us — reply with dapp_ready(wallet_discovered: true)
sendDappReady(client, state).catch(() => {});
}
// If --sign flag set, schedule a sign request after wallet_ready
if (sendSign && state.walletReady) {
console.log(
chalk.dim(" (--sign: scheduling sign request in 500ms...)"),
);
setTimeout(() => {
sendSignRequest(client, state).catch(() => {});
}, 500);
}
break;
}
case RelayMsgAction.SignTransactionResponse: {
const msg = message as SignTransactionResponse;
if (msg.error) {
console.log(
chalk.red("← sign_response") +
` seq=${msg.sequence} error="${msg.error}"`,
);
} else {
console.log(
chalk.green("← sign_response") +
` seq=${msg.sequence} tx=${msg.signedTransaction.slice(0, 32)}...`,
);
}
break;
}
case RelayMsgAction.DappReady:
console.log(chalk.dim("← dapp_ready (unexpected as dapp, ignoring)"));
break;
default:
console.log(chalk.dim(`← unknown action: ${message.action}`));
}
}
// ---- Main ----
export async function runDappMode(options: {
relay: string;
privateKey?: string;
secret?: string;
sign?: boolean;
}): Promise<void> {
const state = makeState();
console.log(chalk.bold("\nwiz-test dapp mode"));
console.log(chalk.dim(`relay: ${options.relay}`));
if (options.sign)
console.log(
chalk.yellow(
"--sign: will send dummy sign request after first pubkey batch",
),
);
console.log();
let connected = false;
let capturedClient: RelayClient | null = null;
const dappRelay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
// Capture the client reference here — dappRelay.client is null at creation time
if (payload.client) capturedClient = payload.client;
const s = payload.status.status;
if (s === "connected" && !connected) {
connected = true;
console.log(chalk.green("relay: connected"));
} else if (s === "reconnecting") {
console.log(chalk.yellow("relay: reconnecting..."));
connected = false;
} else if (s === "disconnected") {
console.log(chalk.red("relay: disconnected"));
connected = false;
}
},
{
explicitRelayUrls: [options.relay],
existingCredentials:
options.privateKey && options.secret
? { privateKey: options.privateKey, secret: options.secret }
: undefined,
},
);
// ---- Print URI ----
console.log(chalk.bgCyan(chalk.black(" CONNECTION URI ")));
console.log();
console.log(" " + chalk.cyan(chalk.bold(dappRelay.uri)));
console.log();
console.log(
chalk.dim(
"Dev shortcut:\n npm run dev -- --wiz=" +
encodeURIComponent(dappRelay.uri),
),
);
if (options.privateKey) {
console.log(
chalk.dim(
`\nReconnect:\n wiz-test dapp --private-key ${options.privateKey} --secret ${dappRelay.credentials.secret}`,
),
);
} else {
console.log(
chalk.dim(
`\nReconnect:\n wiz-test dapp --private-key ${dappRelay.credentials.privateKey} --secret ${dappRelay.credentials.secret}`,
),
);
}
console.log();
const keySpinner = ora("Waiting for wallet to connect...").start();
// ---- Key exchange ----
dappRelay.events.on("keyexchangecomplete", async (walletPubkey) => {
const hex = Buffer.from(walletPubkey).toString("hex");
keySpinner.succeed(
chalk.green("Key exchange complete!") +
chalk.dim(` wallet nostr key: ${hex.slice(0, 16)}...`),
);
// Wait for client to be fully ready
await new Promise((r) => setTimeout(r, 50));
const client = capturedClient;
if (!client) {
console.error(chalk.red("No relay client after key exchange"));
return;
}
// Wait for key exchange to complete on the client side
while (!client.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 50));
}
// Register message handler
client.on("message", (message: ProtocolMessage) => {
handleMessage(message, client, state, options.sign ?? false);
});
// Send initial dapp_ready
await sendDappReady(client, state);
});
// ---- Keep alive ----
console.log(chalk.dim("Press Ctrl+C to quit\n"));
process.on("SIGINT", () => {
keySpinner.stop();
console.log(chalk.yellow("\nShutting down..."));
dappRelay.cleanup();
process.exit(0);
});
// Keep process alive
await new Promise(() => {});
}

View file

@ -0,0 +1,151 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import chalk from "chalk";
import ora from "ora";
import {
WalletConnectionManager,
type WalletAdapter,
DerivationPath,
} from "@wizardconnect/wallet";
import { generateKeyExchangeCredentials, hexToBin } from "@wizardconnect/core";
import {
deriveHdPrivateNodeFromSeed,
deriveHdPrivateNodeChild,
deriveHdPath,
deriveHdPublicNode,
encodeHdPublicKey,
secp256k1,
} from "@bitauth/libauth";
// ---- Build WalletAdapter ----
async function buildAdapter(
relayPrivateKey: Uint8Array,
): Promise<WalletAdapter> {
// Use relay private key as HD seed for deterministic derivation
const hdMaster = deriveHdPrivateNodeFromSeed(relayPrivateKey);
const hdMain = deriveHdPath(hdMaster, "m/0") as any;
const hdChange = deriveHdPath(hdMaster, "m/1") as any;
const hdDefi = deriveHdPath(hdMaster, "m/7") as any;
return {
walletName: "wiz-test CLI wallet",
walletIcon: "",
getRelayPrivateKey(): Uint8Array {
return relayPrivateKey;
},
getPublicKey(path: DerivationPath, index: bigint): Uint8Array {
const hdChain =
(path as number) === 1
? hdChange
: (path as number) === 7
? hdDefi
: hdMain;
const child = deriveHdPrivateNodeChild(hdChain, Number(index));
const pubKey = secp256k1.derivePublicKeyCompressed(child.privateKey);
if (typeof pubKey === "string") throw new Error(`secp256k1: ${pubKey}`);
return pubKey;
},
getXpub(path: DerivationPath): string {
const hdChain =
(path as number) === 1
? hdChange
: (path as number) === 7
? hdDefi
: hdMain;
const result = encodeHdPublicKey({
node: deriveHdPublicNode(hdChain),
network: "mainnet",
});
if (typeof result === "string")
throw new Error(`encodeHdPublicKey: ${result}`);
return result.hdPublicKey;
},
async signTransaction(_request): Promise<any> {
throw new Error("signTransaction not implemented in test wallet");
},
};
}
// ---- Main ----
export async function runWalletMode(options: {
relay: string;
uri: string;
privateKey?: string;
}): Promise<void> {
console.log(chalk.bold("\nwiz-test wallet mode"));
console.log(chalk.dim(`relay: ${options.relay}`));
console.log(chalk.dim(`uri: ${options.uri}`));
console.log();
// Derive relay private key
let relayPrivKey: Uint8Array;
if (options.privateKey) {
if (options.privateKey.length !== 64) {
console.error(chalk.red("--private-key must be 64 hex chars"));
process.exit(1);
}
relayPrivKey = hexToBin(options.privateKey);
} else {
const creds = generateKeyExchangeCredentials();
relayPrivKey = hexToBin(creds.privateKey);
console.log(chalk.dim(`generated relay key: ${creds.privateKey}`));
}
const buildSpinner = ora("Building test wallet adapter...").start();
const adapter = await buildAdapter(relayPrivKey);
buildSpinner.succeed("Wallet adapter ready");
const manager = new WalletConnectionManager(adapter);
manager.on("connectionStatusChanged", (connectionId, status) => {
const s = status.status;
if (s === "connected") {
console.log(chalk.green(`connection ${connectionId}: connected`));
} else if (s === "reconnecting") {
console.log(chalk.yellow(`connection ${connectionId}: reconnecting...`));
} else if (s === "disconnected") {
console.log(chalk.red(`connection ${connectionId}: disconnected`));
}
});
manager.on("pendingSignRequest", (request) => {
console.log(
chalk.yellow("← sign_request") +
chalk.dim(
` conn=${request.connectionId} seq=${request.request.sequence}`,
),
);
console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)"));
manager
.sendSignError(
request.connectionId,
request.request.sequence,
"Test wallet cannot sign",
)
.catch(() => {});
});
console.log(chalk.dim("\nConnecting to dapp..."));
const connectionId = manager.connect(options.uri);
console.log(chalk.dim(`connection id: ${connectionId}`));
console.log(chalk.dim("Press Ctrl+C to quit\n"));
process.on("SIGINT", () => {
console.log(chalk.yellow("\nShutting down..."));
manager.disconnectAll();
process.exit(0);
});
await new Promise(() => {});
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View file

@ -0,0 +1,31 @@
{
"name": "@wizardconnect/wallet",
"version": "0.1.0",
"type": "module",
"description": "Wallet-side integration helpers for WizardConnect",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist/"
],
"scripts": {
"build": "tsc",
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
"test:integration": "vitest --config vitest.integration.config.ts --run",
"lint:prettier": "prettier . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",
"fix": "npm run fix:eslint && npm run fix:prettier",
"fix:prettier": "prettier . --write",
"fix:eslint": "npm run lint:eslint -- --fix"
},
"dependencies": {
"@wizardconnect/core": "*",
"@bitauth/libauth": "^3.1.0-next.2",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
}
}

View file

@ -0,0 +1,33 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* BCH HD wallet derivation paths.
* child_index values as used in hdwalletv1 protocol:
* 0 = Receive (m/44'/145'/0'/0)
* 1 = Change (m/44'/145'/0'/1)
* 7 = Cauldron (m/44'/145'/0'/7)
*/
export enum DerivationPath {
Receive = 0,
Change = 1,
Cauldron = 7,
}
export function childIndexOfPath(path: DerivationPath): number {
return path as number;
}
export function pathOfChildIndex(child: number): DerivationPath | undefined {
switch (child) {
case 0:
return DerivationPath.Receive;
case 1:
return DerivationPath.Change;
case 7:
return DerivationPath.Cauldron;
default:
return undefined;
}
}

View file

@ -0,0 +1,16 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
export {
DerivationPath,
childIndexOfPath,
pathOfChildIndex,
} from "./derivation-path.js";
export type { WalletAdapter, SignTransactionResult } from "./wallet-adapter.js";
export { WalletConnectionManager } from "./wallet-connection-manager.js";
export type {
RelayConnectionState,
PendingSignRequest,
WalletConnectionManagerEvents,
} from "./wallet-connection-manager.js";

View file

@ -0,0 +1,148 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Disconnect tests verifies the disconnect message flow.
*
* Test: user_disconnect dapp sends a raw DisconnectMessage; wallet emits remoteDisconnect.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
DisconnectReason,
type RelayUpdatePayload,
type RelayClient,
type DappReadyMessage,
type WalletReadyMessage,
type DisconnectMessage,
type ProtocolMessage,
} from "@wizardconnect/core";
import { WalletConnectionManager } from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
describe("WalletConnectionManager — disconnect", () => {
let dappCleanup: () => void;
let dappClient: RelayClient | null = null;
let dappUri: string;
let manager: WalletConnectionManager;
let connectionId: string;
let walletReadyReceived = false;
const remoteDisconnectEvents: Array<{
connectionId: string;
reason: DisconnectReason;
message: string | undefined;
}> = [];
beforeAll(async () => {
// ---- Dapp setup ----
const dappRelay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
if (payload.client && !dappClient) dappClient = payload.client;
},
{ explicitRelayUrls: [TEST_RELAY_URL] },
);
dappUri = dappRelay.uri;
dappCleanup = dappRelay.cleanup;
// Wait for the dapp relay client to be available
await waitFor(() => dappClient !== null, {
timeoutMs: 10000,
what: "dapp relay client",
});
// Register keyexchange handler BEFORE wallet connects to avoid race condition
dappRelay.events.on("keyexchangecomplete", async () => {
await new Promise((r) => setTimeout(r, 50));
while (!dappClient!.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 50));
}
dappClient!.on("message", (msg: ProtocolMessage) => {
if (msg.action === RelayMsgAction.WalletReady) {
walletReadyReceived = true;
const wr = msg as WalletReadyMessage;
if (!wr.dapp_discovered) {
const reply: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: true,
time: Math.floor(Date.now() / 1000),
};
dappClient!.relay(reply).catch(() => {});
}
}
});
const initMsg: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: false,
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(initMsg);
});
// ---- Wallet side (connect AFTER registering dapp handlers) ----
const adapter = makeTestAdapter();
manager = new WalletConnectionManager(adapter);
manager.on("remoteDisconnect", (id, reason, message) => {
remoteDisconnectEvents.push({ connectionId: id, reason, message });
});
connectionId = manager.connect(dappUri);
// Wait for full handshake (wallet_ready received on dapp side)
await waitFor(() => walletReadyReceived, {
timeoutMs: 15000,
what: "wallet_ready received on dapp side",
});
// Give a moment for the reactive dapp_ready to be processed
await new Promise((r) => setTimeout(r, 300));
}, 30000);
afterAll(() => {
dappCleanup?.();
manager?.disconnectAll();
});
it("user_disconnect: dapp sends disconnect, wallet emits remoteDisconnect and removes connection", async () => {
expect(Object.keys(manager.getConnections())).toContain(connectionId);
// Dapp sends a raw disconnect message
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
message: "test disconnect",
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(disconnectMsg);
// Wallet should emit remoteDisconnect
await waitFor(() => remoteDisconnectEvents.length > 0, {
timeoutMs: 5000,
what: "remoteDisconnect event on wallet",
});
expect(remoteDisconnectEvents[0].connectionId).toBe(connectionId);
expect(remoteDisconnectEvents[0].reason).toBe(
DisconnectReason.UserDisconnect,
);
expect(remoteDisconnectEvents[0].message).toBe("test disconnect");
// Connection should be removed from manager
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
}, 15000);
});

View file

@ -0,0 +1,233 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Shared test helpers for integration tests.
*
* Architecture: the wallet side uses WalletConnectionManager (our package).
* The dapp side is implemented manually using initiateDappRelay from core.
*/
import {
generateRandomBytes,
deriveHdPrivateNodeFromSeed,
deriveHdPrivateNodeChild,
deriveHdPath,
deriveHdPublicNode,
encodeHdPublicKey,
secp256k1,
} from "@bitauth/libauth";
import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
type RelayClient,
type RelayUpdatePayload,
type DappReadyMessage,
type WalletReadyMessage,
type ProtocolMessage,
} from "@wizardconnect/core";
import {
WalletConnectionManager,
DerivationPath,
type WalletAdapter,
} from "@wizardconnect/wallet";
// ---- waitFor ----------------------------------------------------------------
export async function waitFor(
condition: () => boolean | Promise<boolean>,
options: { timeoutMs?: number; intervalMs?: number; what?: string } = {},
): Promise<void> {
const { timeoutMs = 15000, intervalMs = 100, what = "condition" } = options;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) return;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Timed out (${timeoutMs}ms) waiting for: ${what}`);
}
// ---- TestWalletAdapter ------------------------------------------------------
export function makeTestAdapter(seed?: Uint8Array): WalletAdapter {
const seedBytes = seed ?? generateRandomBytes(32);
const hdMaster = deriveHdPrivateNodeFromSeed(seedBytes);
const hdMain = deriveHdPath(hdMaster, "m/0") as any;
const hdChange = deriveHdPath(hdMaster, "m/1") as any;
const hdDefi = deriveHdPath(hdMaster, "m/7") as any;
return {
walletName: "Test Wallet",
walletIcon: "",
getRelayPrivateKey(): Uint8Array {
return hdMaster.privateKey;
},
getPublicKey(path: DerivationPath, index: bigint): Uint8Array {
const hdChain =
(path as number) === 1
? hdChange
: (path as number) === 7
? hdDefi
: hdMain;
const child = deriveHdPrivateNodeChild(hdChain, Number(index));
const pubKey = secp256k1.derivePublicKeyCompressed(child.privateKey);
if (typeof pubKey === "string") throw new Error(pubKey);
return pubKey;
},
getXpub(path: DerivationPath): string {
const hdChain =
(path as number) === 1
? hdChange
: (path as number) === 7
? hdDefi
: hdMain;
const result = encodeHdPublicKey({
node: deriveHdPublicNode(hdChain),
network: "mainnet",
});
if (typeof result === "string") throw new Error(result);
return result.hdPublicKey;
},
async signTransaction() {
throw new Error("signTransaction not implemented in test adapter");
},
};
}
// ---- DappHandle -------------------------------------------------------------
export interface DappHandle {
uri: string;
cleanup: () => void;
/** All wallet_ready messages received. */
walletReadyMessages: WalletReadyMessage[];
}
// ---- WalletHandle -----------------------------------------------------------
export interface WalletHandle {
manager: WalletConnectionManager;
connectionId: string;
adapter: WalletAdapter;
}
// ---- ConnectionHandles ------------------------------------------------------
export interface ConnectionHandles {
dapp: DappHandle;
wallet: WalletHandle;
cleanup(): void;
}
// ---- setupConnection --------------------------------------------------------
/**
* Sets up a full dapp + wallet connection.
*
* Waits for:
* 1. Key exchange to complete
* 2. The initial dapp_ready / wallet_ready handshake
* 3. wallet_ready with paths
*/
export async function setupConnection(
relayUrl: string = process.env.TEST_RELAY_URL ??
"wss://relay.cauldron.quest:443",
seed?: Uint8Array,
): Promise<ConnectionHandles> {
const adapter = makeTestAdapter(seed);
// ---- Dapp side ----
const walletReadyMessages: WalletReadyMessage[] = [];
let dappClient: RelayClient | null = null;
let keyExchanged = false;
const dappRelay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
if (payload.client && !dappClient) dappClient = payload.client;
},
{ explicitRelayUrls: [relayUrl] },
);
async function sendDappReady(wd: boolean): Promise<void> {
const msg: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: wd,
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(msg);
}
dappRelay.events.on("keyexchangecomplete", async () => {
keyExchanged = true;
// Wait for client to be ready
await new Promise((r) => setTimeout(r, 50));
while (!dappClient!.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 50));
}
// Register message handler
dappClient!.on("message", (message: ProtocolMessage) => {
if (message.action === RelayMsgAction.WalletReady) {
const msg = message as WalletReadyMessage;
walletReadyMessages.push(msg);
if (!msg.dapp_discovered) {
sendDappReady(true).catch(() => {});
}
}
});
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
await sendDappReady(false);
});
// ---- Wallet side ----
const manager = new WalletConnectionManager(adapter);
const connectionId = manager.connect(dappRelay.uri);
// ---- Wait for key exchange ----
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
// ---- Wait for wallet_ready with paths ----
await waitFor(
() =>
walletReadyMessages.length > 0 &&
(walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length >
0,
{ timeoutMs: 15000, what: "wallet_ready with paths" },
);
// ---- Build handle ----
const dappHandle: DappHandle = {
uri: dappRelay.uri,
cleanup: dappRelay.cleanup,
walletReadyMessages,
};
const walletHandle: WalletHandle = { manager, connectionId, adapter };
return {
dapp: dappHandle,
wallet: walletHandle,
cleanup() {
dappRelay.cleanup();
manager.disconnectAll();
},
};
}

View file

@ -0,0 +1,126 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Integration tests basic connection, key exchange, and xpub delivery.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { decodeHdPublicKey, deriveHdPublicNodeChild } from "@bitauth/libauth";
import {
setupConnection,
makeTestAdapter,
type ConnectionHandles,
} from "./helpers.js";
import { DerivationPath } from "@wizardconnect/wallet";
import type { Hdwalletv1Session } from "@wizardconnect/core";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
const PATHS = [
{ childIndex: 0, name: "receive" },
{ childIndex: 1, name: "change" },
{ childIndex: 7, name: "defi" },
];
// Fixed seed for deterministic tests
const TEST_SEED = new Uint8Array(32).fill(0xab);
describe("WalletConnectionManager — integration", () => {
let conn: ConnectionHandles;
beforeAll(async () => {
conn = await setupConnection(TEST_RELAY_URL, TEST_SEED);
}, 30000);
afterAll(() => {
conn?.cleanup();
});
it("should complete key exchange between dapp and wallet", () => {
expect(conn.dapp.walletReadyMessages.length).toBeGreaterThan(0);
});
it("should have received wallet name in wallet_ready", () => {
const msg = conn.dapp.walletReadyMessages[0];
expect(msg.wallet_name).toBe("Test Wallet");
expect(msg.supported_protocols).toContain("hdwalletv1");
});
it("wallet_ready session.hdwalletv1 contains paths", () => {
const msg = conn.dapp.walletReadyMessages[0];
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
expect(hdwv1).toBeDefined();
expect(Array.isArray(hdwv1.paths)).toBe(true);
expect(hdwv1.paths.length).toBeGreaterThanOrEqual(3);
});
it("wallet_ready contains paths for all three named paths", () => {
const msg = conn.dapp.walletReadyMessages[0];
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
expect(Array.isArray(hdwv1.paths)).toBe(true);
const names = hdwv1.paths.map((p) => p.name);
for (const { name } of PATHS) {
expect(names, `Expected path for ${name}`).toContain(name);
}
});
it("paths have all named paths", () => {
const msg = conn.dapp.walletReadyMessages[0];
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
for (const { name } of PATHS) {
const pathInfo = hdwv1.paths.find((p) => p.name === name);
expect(pathInfo, `Expected path info for ${name}`).toBeDefined();
}
});
it("xpubs are valid BIP32 base58", () => {
const msg = conn.dapp.walletReadyMessages[0];
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
const xpubRe = /^[xt]pub[1-9A-HJ-NP-Za-km-z]{107,108}$/;
for (const pathInfo of hdwv1.paths) {
expect(pathInfo.xpub, `xpub for ${pathInfo.name}`).toMatch(xpubRe);
}
});
it("xpubs decode and derive pubkeys consistent with adapter", () => {
const adapter = makeTestAdapter(TEST_SEED);
const msg = conn.dapp.walletReadyMessages[0];
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
for (const { childIndex, name } of PATHS) {
const pathInfo = hdwv1.paths.find((p) => p.name === name)!;
expect(pathInfo, `Expected path info for ${name}`).toBeDefined();
const decoded = decodeHdPublicKey(pathInfo.xpub);
expect(
typeof decoded,
`xpub decode failed for ${name}: ${decoded}`,
).not.toBe("string");
if (typeof decoded === "string") continue;
// Derive index 0 and 3 from xpub, compare with adapter
for (const i of [0, 3]) {
const child = deriveHdPublicNodeChild(decoded.node, i);
expect(
typeof child,
`child derivation failed for ${name}[${i}]`,
).not.toBe("string");
if (typeof child === "string") continue;
const fromXpub = child.publicKey;
const fromAdapter = adapter.getPublicKey(
childIndex as DerivationPath,
BigInt(i),
);
expect(
Array.from(fromXpub),
`${name}[${i}]: xpub-derived pubkey should match adapter`,
).toEqual(Array.from(fromAdapter));
}
}
});
});

View file

@ -0,0 +1,156 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Reconnection tests verifies that a second wallet with the same private key
* can reconnect to the same dapp session and receive wallet_ready without interference
* from old messages.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
type RelayUpdatePayload,
type RelayClient,
type DappReadyMessage,
type WalletReadyMessage,
type ProtocolMessage,
} from "@wizardconnect/core";
import { WalletConnectionManager } from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
import { generateRandomBytes } from "@bitauth/libauth";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
describe("WalletConnectionManager — reconnection", () => {
// Shared dapp that stays alive across both wallet connections
let dappCleanup: () => void;
let dappClient: RelayClient | null = null;
let dappUri: string;
let walletReadyMessages: WalletReadyMessage[] = [];
let walletDiscovered = false;
// Shared wallet relay private key — same key used for both connections
const walletSeed = generateRandomBytes(32);
beforeAll(async () => {
// ---- Dapp setup ----
const dappRelay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
if (payload.client && !dappClient) dappClient = payload.client;
},
{ explicitRelayUrls: [TEST_RELAY_URL] },
);
dappUri = dappRelay.uri;
dappCleanup = dappRelay.cleanup;
// Wait for dapp relay to connect
await waitFor(() => dappClient !== null, {
timeoutMs: 10000,
what: "dapp relay connection",
});
// Register message handler — stays alive for both wallet connections
dappClient!.on("message", (message: ProtocolMessage) => {
if (message.action === RelayMsgAction.WalletReady) {
const msg = message as WalletReadyMessage;
walletDiscovered = true;
walletReadyMessages.push(msg);
if (!msg.dapp_discovered) {
sendDappReady(true).catch(() => {});
}
}
});
dappRelay.events.on("keyexchangecomplete", async () => {
await new Promise((r) => setTimeout(r, 50));
while (!dappClient!.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 50));
}
await sendDappReady(false);
});
}, 15000);
afterAll(() => {
dappCleanup?.();
});
async function sendDappReady(wd: boolean): Promise<void> {
const msg: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: wd,
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(msg);
}
it("first wallet connects and sends wallet_ready with paths", async () => {
const adapter1 = makeTestAdapter(walletSeed);
const manager1 = new WalletConnectionManager(adapter1);
manager1.connect(dappUri);
// Wait for first wallet_ready with paths
await waitFor(
() =>
walletReadyMessages.length > 0 &&
(walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length >
0,
{
timeoutMs: 15000,
what: "wallet_ready with paths from first connection",
},
);
expect(walletReadyMessages.length).toBeGreaterThan(0);
expect(
(walletReadyMessages[0].session["hdwalletv1"] as any).paths.length,
).toBeGreaterThan(0);
expect(walletDiscovered).toBe(true);
// Disconnect first wallet
manager1.disconnectAll();
// Short pause to let disconnect propagate
await new Promise((r) => setTimeout(r, 1000));
// Store count for second connection comparison
(globalThis as any).__walletReadyCountAfterFirst =
walletReadyMessages.length;
}, 30000);
it("second wallet with same private key reconnects and sends wallet_ready", async () => {
const countBefore: number =
(globalThis as any).__walletReadyCountAfterFirst ?? 0;
// Reset walletDiscovered to verify second handshake completes
walletDiscovered = false;
const adapter2 = makeTestAdapter(walletSeed); // same seed = same relay private key
const manager2 = new WalletConnectionManager(adapter2);
manager2.connect(dappUri);
// Wait for second connection to deliver wallet_ready
await waitFor(() => walletReadyMessages.length > countBefore, {
timeoutMs: 15000,
what: "wallet_ready from second connection",
});
expect(walletReadyMessages.length).toBeGreaterThan(countBefore);
expect(walletDiscovered).toBe(true);
// Verify the new wallet_ready has paths
const newMsg = walletReadyMessages[walletReadyMessages.length - 1];
expect((newMsg.session["hdwalletv1"] as any).paths.length).toBeGreaterThan(
0,
);
manager2.disconnectAll();
}, 30000);
});

View file

@ -0,0 +1,144 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* sign_cancel tests verifies that an incoming sign_cancel from the dapp
* causes the WalletConnectionManager to emit a signCancelled event.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
type RelayUpdatePayload,
type RelayClient,
type DappReadyMessage,
type WalletReadyMessage,
type ProtocolMessage,
type SignCancelMessage,
} from "@wizardconnect/core";
import { WalletConnectionManager } from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
describe("WalletConnectionManager — sign_cancel", () => {
let dappCleanup: () => void;
let dappClient: RelayClient | null = null;
let manager: WalletConnectionManager;
let connectionId: string;
let walletReadyReceived = false;
const signCancelledEvents: Array<{
connectionId: string;
sequence: number;
reason: string | undefined;
}> = [];
beforeAll(async () => {
// ---- Dapp setup ----
const dappRelay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
if (payload.client && !dappClient) dappClient = payload.client;
},
{ explicitRelayUrls: [TEST_RELAY_URL] },
);
dappCleanup = dappRelay.cleanup;
dappRelay.events.on("keyexchangecomplete", async () => {
await new Promise((r) => setTimeout(r, 50));
while (!dappClient!.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 50));
}
dappClient!.on("message", (msg: ProtocolMessage) => {
if (msg.action === RelayMsgAction.WalletReady) {
walletReadyReceived = true;
const wr = msg as WalletReadyMessage;
if (!wr.dapp_discovered) {
const reply: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: true,
time: Math.floor(Date.now() / 1000),
};
dappClient!.relay(reply).catch(() => {});
}
}
});
const initMsg: DappReadyMessage = {
action: RelayMsgAction.DappReady,
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: false,
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(initMsg);
});
// ---- Wallet side ----
const adapter = makeTestAdapter();
manager = new WalletConnectionManager(adapter);
manager.on("signCancelled", (connId, sequence, reason) => {
signCancelledEvents.push({ connectionId: connId, sequence, reason });
});
connectionId = manager.connect(dappRelay.uri);
await waitFor(() => walletReadyReceived, {
timeoutMs: 15000,
what: "wallet_ready received on dapp side",
});
// Give time for reactive dapp_ready to be processed
await new Promise((r) => setTimeout(r, 300));
}, 30000);
afterAll(() => {
dappCleanup?.();
manager?.disconnectAll();
});
it("sign_cancel from dapp emits signCancelled on wallet with correct fields", async () => {
const cancelMsg: SignCancelMessage = {
action: RelayMsgAction.SignCancel,
sequence: 42,
reason: "user cancelled on dapp",
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(cancelMsg);
await waitFor(() => signCancelledEvents.length > 0, {
timeoutMs: 5000,
what: "signCancelled event on wallet",
});
expect(signCancelledEvents[0].connectionId).toBe(connectionId);
expect(signCancelledEvents[0].sequence).toBe(42);
expect(signCancelledEvents[0].reason).toBe("user cancelled on dapp");
}, 15000);
it("sign_cancel without reason emits signCancelled with undefined reason", async () => {
const cancelMsg: SignCancelMessage = {
action: RelayMsgAction.SignCancel,
sequence: 99,
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(cancelMsg);
await waitFor(() => signCancelledEvents.length >= 2, {
timeoutMs: 5000,
what: "second signCancelled event on wallet",
});
expect(signCancelledEvents[1].sequence).toBe(99);
expect(signCancelledEvents[1].reason).toBeUndefined();
}, 15000);
});

View file

@ -0,0 +1,48 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { DerivationPath } from "./derivation-path.js";
import { SignTransactionRequest } from "@wizardconnect/core";
export interface SignTransactionResult {
signedTransaction: string;
}
/**
* Abstract interface that wallets implement to integrate with WizardConnect.
*/
export interface WalletAdapter {
/** Human-readable wallet name shown to dapps */
walletName: string;
/** URL to wallet icon (used in wallet_ready handshake) */
walletIcon: string;
/**
* Return the relay identity private key for this session.
* May be ephemeral (random per session) or stable (HD-derived) both work.
* The dapp learns the wallet's public key dynamically via wallet_ready
* so stability across restarts is not required.
*/
getRelayPrivateKey(): Uint8Array;
/**
* Derive the compressed public key (33 bytes) for a given HD path and index.
*/
getPublicKey(path: DerivationPath, index: bigint): Uint8Array;
/**
* BIP32 xpub string for the given derivation path.
* Used to allow dapps to derive all needed pubkeys locally without round-trips.
*/
getXpub(path: DerivationPath): string;
/**
* Sign a transaction request from a dapp.
* The wallet should verify the request and return the signed transaction hex,
* or throw if the user rejects or signing fails.
*/
signTransaction(
request: SignTransactionRequest,
): Promise<SignTransactionResult>;
}

View file

@ -0,0 +1,457 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { EventEmitter } from "eventemitter3";
import {
RelayClient,
RelayStatus,
RelayUpdatePayload,
RelayStatusCallback,
initiateWalletRelay,
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
RelayMsgAction,
DappReadyMessage,
WalletReadyMessage,
DisconnectMessage,
DisconnectReason,
PathXpub,
Hdwalletv1Session,
ProtocolMessage,
PROTOCOL_NAME,
binToHex,
} from "@wizardconnect/core";
import { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
export interface RelayConnectionState {
id: string;
uri: string;
status: RelayStatus;
label: string;
dappName: string | null;
dappIcon: string | null;
connectedAt: number;
}
export interface PendingSignRequest {
connectionId: string;
request: SignTransactionRequest;
}
interface ActiveConnection {
id: string;
uri: string;
cleanup: () => void;
client: RelayClient | null;
status: RelayStatus;
label: string;
dappName: string | null;
dappIcon: string | null;
connectedAt: number;
dappDiscovered: boolean;
/// Prevents duplicate wallet_ready messages within a single connection cycle.
/// Reset to false on each new connect/reconnect; set to true after sending.
walletReadySentThisCycle: boolean;
notificationQueue: ProtocolMessage[];
notificationProcessor: ReturnType<typeof setInterval> | null;
/// Key exchange data embedded in wallet_ready
walletPublicKeyHex: string;
keyExchangeSecret: string;
}
export type WalletConnectionManagerEvents = {
connectionStatusChanged: [connectionId: string, status: RelayStatus];
pendingSignRequest: [request: PendingSignRequest];
connectionsChanged: [];
remoteDisconnect: [
connectionId: string,
reason: DisconnectReason,
message: string | undefined,
];
signCancelled: [
connectionId: string,
sequence: number,
reason: string | undefined,
];
};
/**
* Manages multiple simultaneous dapp connections for a wallet.
* Handles sign request queuing and reconnection.
*/
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
private connections: Map<string, ActiveConnection> = new Map();
private adapter: WalletAdapter;
constructor(adapter: WalletAdapter) {
super();
this.adapter = adapter;
}
/**
* Connect to a dapp using a wiz:// URI.
* Returns the connection ID.
*/
connect(uri: string): string {
// Return existing connection if one for this URI is already active
for (const conn of this.connections.values()) {
if (conn.uri === uri) {
return conn.id;
}
}
const id = `rc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const conn: ActiveConnection = {
id,
uri,
cleanup: () => {},
client: null,
status: RelayStatus.disconnected(),
label: "Connecting...",
dappName: null,
dappIcon: null,
connectedAt: Date.now(),
dappDiscovered: false,
walletReadySentThisCycle: false,
notificationQueue: [],
notificationProcessor: null,
walletPublicKeyHex: "",
keyExchangeSecret: "",
};
this.connections.set(id, conn);
const statusCallback: RelayStatusCallback = (
payload: RelayUpdatePayload,
) => {
// Attach message listener exactly once, the first time we receive the client.
if (!conn.client && payload.client) {
payload.client.on("message", (message: ProtocolMessage) => {
this.handleMessage(conn, message).catch((err) => {
console.error(
"[wizardconnect/wallet] Error handling message:",
err,
);
});
});
}
conn.client = payload.client;
conn.status = payload.status;
if (payload.status.status === "connected") {
this.onConnected(conn);
} else if (
payload.status.status === "disconnected" ||
payload.status.status === "reconnecting"
) {
if (conn.notificationProcessor) {
clearInterval(conn.notificationProcessor);
conn.notificationProcessor = null;
}
}
this.emit("connectionStatusChanged", id, payload.status);
this.emit("connectionsChanged");
};
const result = initiateWalletRelay(statusCallback, {
uri,
walletPrivateKey: this.adapter.getRelayPrivateKey(),
});
conn.cleanup = result.cleanup;
conn.walletPublicKeyHex = binToHex(result.walletPublicKey);
conn.keyExchangeSecret = result.secret;
this.emit("connectionsChanged");
return id;
}
/**
* Disconnect a specific dapp connection, sending a courtesy UserDisconnect message.
*/
disconnect(connectionId: string): void {
this.doDisconnect(connectionId, true);
}
/**
* Disconnect all connections.
*/
disconnectAll(): void {
for (const id of [...this.connections.keys()]) {
this.doDisconnect(id, true);
}
}
private doDisconnect(connectionId: string, sendMessage: boolean): void {
const conn = this.connections.get(connectionId);
if (!conn) return;
if (sendMessage && conn.client) {
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(disconnectMsg).catch(() => {});
}
clearInterval(conn.notificationProcessor ?? undefined);
conn.cleanup();
this.connections.delete(connectionId);
this.emit("connectionsChanged");
}
/**
* Get all connection states (for UI/Redux).
*/
getConnections(): Record<string, RelayConnectionState> {
const result: Record<string, RelayConnectionState> = {};
for (const [id, conn] of this.connections) {
result[id] = {
id: conn.id,
uri: conn.uri,
status: conn.status,
label: conn.label,
dappName: conn.dappName,
dappIcon: conn.dappIcon,
connectedAt: conn.connectedAt,
};
}
return result;
}
/**
* Send a sign transaction response back to the dapp.
*/
async sendSignResponse(
connectionId: string,
sequence: number,
signedTransactionHex: string,
): Promise<void> {
const conn = this.connections.get(connectionId);
if (!conn?.client) {
throw new Error(`Connection ${connectionId} not found or not connected`);
}
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence,
signedTransaction: signedTransactionHex,
time: Math.floor(Date.now() / 1000),
};
await conn.client.relay(response);
}
/**
* Send a sign error response back to the dapp.
*/
async sendSignError(
connectionId: string,
sequence: number,
errorMessage: string,
): Promise<void> {
const conn = this.connections.get(connectionId);
if (!conn?.client) {
return; // Already disconnected, nothing to do
}
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence,
signedTransaction: "",
error: errorMessage,
time: Math.floor(Date.now() / 1000),
};
await conn.client.relay(response);
}
// --- Private connection lifecycle ---
/** Immediately attempt to flush the notification queue (fire-and-forget). */
private flushNotificationQueue(conn: ActiveConnection): void {
this.processNotificationQueue(conn).catch((err) => {
console.error("[wizardconnect/wallet] Notification queue error:", err);
});
}
private onConnected(conn: ActiveConnection): void {
// New connection cycle: reset dedup flag so wallet_ready is sent fresh
conn.walletReadySentThisCycle = false;
// Start notification processor as fallback for retries after send errors
if (conn.notificationProcessor) {
clearInterval(conn.notificationProcessor);
}
conn.notificationProcessor = setInterval(() => {
this.processNotificationQueue(conn).catch((err) => {
console.error("[wizardconnect/wallet] Notification queue error:", err);
});
}, 1000);
// Wait for key exchange, then send wallet_ready
(async () => {
while (conn.client && !conn.client.isKeyExchangeComplete()) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (conn.client) {
this.pushWalletReady(conn).catch((err) => {
console.error(
"[wizardconnect/wallet] Failed to send wallet_ready:",
err,
);
});
}
})();
}
private async handleMessage(
conn: ActiveConnection,
message: ProtocolMessage,
): Promise<void> {
switch (message.action) {
case RelayMsgAction.DappReady:
await this.handleDappReady(conn, message as DappReadyMessage);
break;
case RelayMsgAction.SignTransactionRequest:
this.handleSignRequest(conn, message as SignTransactionRequest);
break;
case RelayMsgAction.WalletReady:
console.warn(
"[wizardconnect/wallet] Got wallet_ready as wallet, ignoring",
);
break;
case RelayMsgAction.Disconnect:
this.handleRemoteDisconnect(conn, message as DisconnectMessage);
break;
case RelayMsgAction.SignCancel:
this.handleSignCancel(conn, message as SignCancelMessage);
break;
default:
console.warn(
"[wizardconnect/wallet] Unknown message action:",
message.action,
);
}
}
private handleRemoteDisconnect(
conn: ActiveConnection,
msg: DisconnectMessage,
): void {
this.emit("remoteDisconnect", conn.id, msg.reason, msg.message);
this.doDisconnect(conn.id, false);
}
private handleSignCancel(
conn: ActiveConnection,
msg: SignCancelMessage,
): void {
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
}
private async handleDappReady(
conn: ActiveConnection,
msg: DappReadyMessage,
): Promise<void> {
// Log the dapp's protocol selection for diagnostics
if (msg.selected_protocol) {
console.debug(
"[wizardconnect/wallet] Dapp selected protocol:",
msg.selected_protocol,
);
}
// Update dapp metadata from the first dapp_ready that carries it
if (msg.dapp_name && !conn.dappName) {
conn.dappName = msg.dapp_name;
conn.dappIcon = msg.dapp_icon ?? null;
conn.label = msg.dapp_name;
this.emit("connectionStatusChanged", conn.id, conn.status);
}
if (!msg.wallet_discovered) {
// Dapp hasn't seen us yet (or has reset, e.g. browser refresh) — force
// re-introduction even if we already sent wallet_ready this cycle.
conn.walletReadySentThisCycle = false;
await this.pushWalletReady(conn);
return;
}
conn.dappDiscovered = true;
}
private async pushWalletReady(conn: ActiveConnection): Promise<void> {
if (conn.walletReadySentThisCycle) return;
conn.walletReadySentThisCycle = true;
const paths: PathXpub[] = [
{ name: "receive", xpub: this.adapter.getXpub(DerivationPath.Receive) },
{ name: "change", xpub: this.adapter.getXpub(DerivationPath.Change) },
{ name: "defi", xpub: this.adapter.getXpub(DerivationPath.Cauldron) },
];
const hdwv1Session: Hdwalletv1Session = {
paths,
};
const msg: WalletReadyMessage = {
action: RelayMsgAction.WalletReady,
supported_protocols: [PROTOCOL_NAME],
wallet_name: this.adapter.walletName,
wallet_icon: this.adapter.walletIcon,
time: Math.floor(Date.now() / 1000),
dapp_discovered: conn.dappDiscovered,
session: {
[PROTOCOL_NAME]: hdwv1Session,
},
public_key: conn.walletPublicKeyHex,
secret: conn.keyExchangeSecret,
};
conn.notificationQueue.push(msg);
this.flushNotificationQueue(conn);
}
private handleSignRequest(
conn: ActiveConnection,
msg: SignTransactionRequest,
): void {
// Emit to host app for queuing/approval
const pendingRequest: PendingSignRequest = {
connectionId: conn.id,
request: msg,
};
this.emit("pendingSignRequest", pendingRequest);
}
private async processNotificationQueue(
conn: ActiveConnection,
): Promise<void> {
if (!conn.client || conn.notificationQueue.length === 0) {
return;
}
const notifications = [...conn.notificationQueue];
conn.notificationQueue = [];
for (const notification of notifications) {
try {
await conn.client.relay(notification);
} catch (error) {
console.error(
"[wizardconnect/wallet] Failed to send notification:",
error,
);
conn.notificationQueue.push(notification);
}
}
}
}

View file

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"src/integration/**"
]
}

View file

@ -0,0 +1,12 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// Exclude integration tests from the normal fast test run
exclude: ["src/integration/**", "**/node_modules/**"],
},
});

View file

@ -0,0 +1,18 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/integration/**/*.test.ts"],
testTimeout: 60000,
hookTimeout: 60000,
// Run integration tests serially to avoid relay contention
pool: "forks",
poolOptions: {
forks: { singleFork: true },
},
},
});

18
tsconfig.base.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"declaration": true,
"target": "es2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"lib": ["dom", "dom.iterable", "esnext", "es2020", "esnext.bigint"],
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true
}
}

39
zensical.toml Normal file
View file

@ -0,0 +1,39 @@
[project]
site_name = "WizardConnect"
site_description = "Relay-based protocol for connecting dapps to HD wallets over encrypted Nostr channel."
nav = [
{ "Home" = "index.md" },
{ "Protocol" = "protocol.md" },
{ "Connection URI" = "connection-uri.md" },
{ "Transport" = "transport.md" },
{ "Wallet" = "wallet.md" },
{ "Dapp" = "dapp.md" },
{ "Pubkey Derivation" = "pubkey-derivation.md" },
]
[project.theme]
features = [
"content.code.copy",
"content.code.select",
"content.tooltips",
"navigation.footer",
"navigation.instant",
"navigation.instant.prefetch",
"navigation.path",
"navigation.sections",
"navigation.top",
"navigation.tracking",
"search.highlight",
]
[[project.theme.palette]]
scheme = "default"
toggle.icon = "lucide/sun"
toggle.name = "Switch to dark mode"
[[project.theme.palette]]
scheme = "slate"
toggle.icon = "lucide/moon"
toggle.name = "Switch to light mode"