Compare commits

..

1 commit

Author SHA1 Message Date
Dagur Valberg Johannsson
9ff77584fe
Add uri to relay private key generation
Making private key deterministic allows for easy session restoration
2026-03-09 16:03:21 +01:00
71 changed files with 1181 additions and 7342 deletions

View file

@ -21,7 +21,6 @@ test:
extends: .node-common
script:
- npm install
- npm run build
- npm run test
build:
@ -51,14 +50,12 @@ test-integration:
publish:
extends: .node-common
# npm trusted publishing via GitLab OIDC — no NPM_TOKEN needed.
# npm trusted publishing via GitLab OIDC — requires instance runner for OIDC support.
# Each @wizardconnect/* package must be linked to this GitLab project
# on npmjs.com: package Settings > Publishing access > Trusted publishers.
tags:
- saas-linux-small-amd64 # force gitlab instance runner (not self hosted)
- saas-linux-small-amd64 # force gitlab instance runner
id_tokens:
NPM_ID_TOKEN:
aud: "npm:registry.npmjs.org"
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
@ -91,10 +88,8 @@ pages:
force-publish:
extends: .node-common
tags:
- saas-linux-small-amd64 # force gitlab instance runner (not self hosted)
- saas-linux-small-amd64 # force gitlab instance runners
id_tokens:
NPM_ID_TOKEN:
aud: "npm:registry.npmjs.org"
SIGSTORE_ID_TOKEN:
aud: sigstore
script:

View file

@ -19,14 +19,10 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
- 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 relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
- Hit the real relay at `wss://relay.cauldron.quest:443`
- Test the full protocol handshake end-to-end
- Located in `src/integration/*.test.ts` (wallet is the only package with them today)
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially
(`singleFork`) to avoid relay contention, and with `retry: 2` — these hit third-party
relays, so a dropped connection is an environment failure rather than a regression
- A failure that reproduces locally is real; one that does not is usually the relay.
Check whether the same test passed on an earlier pipeline before assuming a regression
- 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
@ -64,23 +60,17 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
| File | Update when… |
|------|-------------|
| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change |
| `docs/extensions.md` | Extension system, `WalletAdapter` extension hooks, known extensions, or custom message conventions 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/react.md` | React components, hooks, or QR dialog API changes |
| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes |
| `docs/xpub-sharing.md` | xpub sharing rationale, security model, or comparison with other protocols changes |
| `docs/serialization.md` | Relay JSON encoding (`sourceOutputToRelay`, `toBigInt`, `toUint8Array`, etc.) 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/dapp` — dapp-side helpers (`DappConnectionManager`, `DappPubkeyStateManager`)
- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`)
- `@wizardconnect/react` — React components and hooks (`WizardConnectQRDialog`, `AlphanumericQRCode`, `useWizardConnect`)
- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes)
## Build
@ -91,13 +81,3 @@ npm run build # builds all packages in dependency order
```
Packages must be built before integration tests run (tests import from `dist/`).
## Releases
The `publish` CI job runs on `master` only, via `contrib/auto-publish.js`.
**The `version` in each `package.json` is a floor, not the shipped version** — all declare
`0.2.0` while npm carries higher patches. Use `npm view @wizardconnect/<pkg> version`.
The lockfile is not published, so clearing a dependency advisory means bumping the declared
range in `package.json`, not just `package-lock.json`.

192
README.md
View file

@ -1,192 +0,0 @@
# WizardConnect
A dapp-to-wallet protocol built for Bitcoin Cash.
WizardConnect lets a dapp scan a wallet's QR code and immediately start
deriving addresses, constructing transactions, and requesting signatures —
no seed phrases, no trusted servers, no round trips.
## Why not WalletConnect?
WalletConnect was built for Ethereum, where a wallet is a single address. BCH
wallets are HD wallets with thousands of addresses for privacy. Forcing an
Ethereum protocol onto BCH means:
- **One address per session.** WalletConnect gives the dapp a single address.
That breaks the HD wallet model and destroys the privacy that multiple
addresses provide.
- **Unreliable on BCH.** BCH support in WalletConnect is a second-class
citizen — a bridge on top of an Ethereum-native protocol. Connections drop,
sessions fail to restore, and there's no community maintaining it for BCH.
WizardConnect replaces all of this with a protocol designed from the ground up
for UTXO chains and HD wallets.
## How it works
```
Wallet Relay Dapp
│ │ │
│ ◄───── QR code scan ────── │ ◄──── shows QR (wiz://...) │
│ │ │
│ wallet_ready ──────────────►│──────────────────────────────►│
│ (xpubs + key exchange) │ │
│ │ derive addresses │
│ │ locally from xpubs │
│ │ (no more round trips)│
│ │ │
│ ◄──────────────────────────│◄──── sign_transaction_request │
│ user approves on wallet │ │
│ sign_transaction_response──►│──────────────────────────────►│
│ │ broadcast │
```
1. The dapp generates a `wiz://` URI and displays it as a QR code.
2. The wallet scans the QR, connects to the relay, and sends its xpubs.
3. The dapp derives all addresses locally — no further contact with the wallet
needed for address generation.
4. When the dapp needs a signature, it sends a request. The user approves on
the wallet. The signed transaction comes back.
All communication is end-to-end encrypted via Nostr NIP-17 gift wrap. The
relay sees only ciphertext.
## Key features
**Full HD wallet support.** The wallet shares BIP32 xpubs for specific
derivation paths (receive, change, DeFi). The dapp derives unlimited
addresses locally. No more single-address sessions.
**Zero round trips for addresses.** After the initial handshake, the dapp
never needs to ask the wallet for a public key. It derives them on demand from
the xpubs, in microseconds.
**Decentralized transport.** Built on Nostr — an open protocol with hundreds
of public relays. No proprietary bridge server. Users can point to any relay
for additional privacy or self-host their own.
**Reconnection-proof.** Both sides can disconnect and reconnect independently
(app switch, browser refresh, network drop) and converge back to a live
session without user action.
**Extensible.** The protocol negotiates capabilities during the handshake.
`hdwalletv1` is the first protocol; future protocols (multisig, post-quantum,
token-aware) can be added without breaking existing connections.
**Open source (LGPL-3.0).** Free to use in commercial and non-commercial
applications. Modifications to the library itself must be shared back.
## Packages
```
npm install @wizardconnect/core # transport, protocol types, key exchange
npm install @wizardconnect/dapp # dapp-side session + address derivation
npm install @wizardconnect/wallet # wallet-side connection + signing
```
| Package | Description |
|---------|-------------|
| `@wizardconnect/core` | Relay client, NIP-17 encryption, URI encoding, protocol message types |
| `@wizardconnect/dapp` | `DappConnectionManager`, `DappPubkeyStateManager` — session management and on-demand pubkey derivation |
| `@wizardconnect/wallet` | `WalletConnectionManager`, `WalletAdapter` interface — multi-connection management and sign request dispatch |
## Quick start: dapp
```typescript
import { initiateDappRelay } from "@wizardconnect/core";
import { DappConnectionManager } from "@wizardconnect/dapp";
const dapp = new DappConnectionManager("My Dapp", "https://example.com/icon.png");
const relay = initiateDappRelay(
(payload) => dapp.updateConnection(payload.client, payload.status),
{ explicitRelayUrls: ["wss://relay.riften.net:443"] },
);
// Display relay.uri as a QR code for the wallet to scan
dapp.on("walletready", () => {
// Connected! Derive addresses locally:
const receivePubkey = dapp.getPubkey(0, 0n); // receive path, index 0
const changePubkey = dapp.getPubkey(1, 0n); // change path, index 0
});
```
## Quick start: wallet
```typescript
import { WalletConnectionManager } from "@wizardconnect/wallet";
const manager = new WalletConnectionManager(myWalletAdapter);
// When the user scans a dapp's QR code:
const connectionId = manager.connect("wiz://?p=...&s=...");
// When a sign request arrives, show it to the user:
manager.on("pendingSignRequest", async ({ connectionId, request }) => {
const approved = await showApprovalUI(request);
if (approved) {
await manager.sendSignResponse(connectionId, request.sequence, signedTxHex);
} else {
await manager.sendSignError(connectionId, request.sequence, "User rejected");
}
});
```
See [wallet integration guide](docs/wallet.md) for `WalletAdapter` implementation details.
## Privacy model
WizardConnect shares xpubs at the **chain level**, not the account level.
A dapp receiving the receive xpub can derive receive addresses but cannot
derive change addresses or any other internal wallet activity. This is the
same level of key material used by watch-only wallets.
For wallets that want stronger isolation, the protocol supports per-session
xpub rotation — the dapp sees only the xpub node, never the derivation path.
See [xpub sharing: why it's safe](docs/xpub-sharing.md) for a full discussion.
## Security
- Private keys never leave the wallet. Signing happens on-device.
- All relay traffic is end-to-end encrypted (NIP-17 gift wrap). The relay
cannot read messages.
- Key exchange includes an 8-byte shared secret (embedded in the QR code) for
MITM prevention.
- No trusted intermediary. The relay is a dumb message broker — compromise it
and you get ciphertext.
## Documentation
| Topic | Link |
|-------|------|
| Protocol messages and handshake | [protocol.md](docs/protocol.md) |
| Connection URI and key exchange | [connection-uri.md](docs/connection-uri.md) |
| Relay transport and encryption | [transport.md](docs/transport.md) |
| Wallet integration guide | [wallet.md](docs/wallet.md) |
| Dapp integration guide | [dapp.md](docs/dapp.md) |
| xpub delivery and derivation | [pubkey-derivation.md](docs/pubkey-derivation.md) |
| xpub sharing: why it's safe | [xpub-sharing.md](docs/xpub-sharing.md) |
## Building
```bash
npm install
npm run build # builds all packages in dependency order
npm run test # unit tests (fast, no network)
```
Integration tests hit a live relay:
```bash
cd packages/wallet && npm run test:integration
```
## License
[LGPL-3.0-or-later](https://www.gnu.org/licenses/lgpl-3.0.html). Free for
commercial and non-commercial use. Modifications to the library must be
released under the same license.
Built by [Riften Labs](https://riftenlabs.com).

View file

@ -94,14 +94,6 @@ function getCurrentVersion(packagePath) {
return packageJson.version;
}
function compareVersions(a, b) {
const [aMajor, aMinor, aPatch] = a.split('.').map(Number);
const [bMajor, bMinor, bPatch] = b.split('.').map(Number);
if (aMajor !== bMajor) return aMajor - bMajor;
if (aMinor !== bMinor) return aMinor - bMinor;
return aPatch - bPatch;
}
function bumpVersion(currentVersion, latestNpmVersion) {
const [major, minor, patch] = currentVersion.split('.').map(Number);
@ -110,13 +102,12 @@ function bumpVersion(currentVersion, latestNpmVersion) {
}
const [npmMajor, npmMinor, npmPatch] = latestNpmVersion.split('.').map(Number);
const cmp = compareVersions(currentVersion, latestNpmVersion);
if (cmp > 0) {
if (currentVersion > latestNpmVersion) {
return `${major}.${minor}.${patch + 1}`;
}
if (cmp < 0) {
if (latestNpmVersion > currentVersion) {
return `${npmMajor}.${npmMinor}.${npmPatch + 1}`;
}
@ -139,7 +130,7 @@ function publishPackage(packagePath, packageName) {
log(`Publishing ${packageName}`);
try {
execCommand('npm publish --access public', packagePath);
execCommand('npm publish --provenance --access public', packagePath);
log(`Successfully published ${packageName}`);
} catch (error) {
log(`Failed to publish ${packageName}: ${error.message}`);

View file

@ -78,14 +78,6 @@ function getCurrentVersion(packagePath) {
return packageJson.version;
}
function compareVersions(a, b) {
const [aMajor, aMinor, aPatch] = a.split('.').map(Number);
const [bMajor, bMinor, bPatch] = b.split('.').map(Number);
if (aMajor !== bMajor) return aMajor - bMajor;
if (aMinor !== bMinor) return aMinor - bMinor;
return aPatch - bPatch;
}
function bumpVersion(currentVersion, latestNpmVersion) {
const [major, minor, patch] = currentVersion.split('.').map(Number);
@ -94,13 +86,12 @@ function bumpVersion(currentVersion, latestNpmVersion) {
}
const [npmMajor, npmMinor, npmPatch] = latestNpmVersion.split('.').map(Number);
const cmp = compareVersions(currentVersion, latestNpmVersion);
if (cmp > 0) {
if (currentVersion > latestNpmVersion) {
return `${major}.${minor}.${patch + 1}`;
}
if (cmp < 0) {
if (latestNpmVersion > currentVersion) {
return `${npmMajor}.${npmMinor}.${npmPatch + 1}`;
}
@ -123,7 +114,7 @@ function publishPackage(packagePath, packageName) {
log(`Publishing ${packageName}`);
try {
execCommand('npm publish --access public --loglevel verbose', packagePath);
execCommand('npm publish --provenance --access public', packagePath);
log(`Successfully published ${packageName}`);
} catch (error) {
log(`Failed to publish ${packageName}: ${error.message}`);

View file

@ -19,7 +19,7 @@ wiz://<hostname>:<port>?p=<pubkey_bech32>&s=<secret_bech32>&pr=<protocol>
|-----------|----------|------|---------|
| `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.riften.net`) |
| `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) |

View file

@ -17,47 +17,26 @@ class DappConnectionManager extends EventEmitter {
/** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
protocol: string | null;
constructor(dappName?: string, dappIcon?: string, options?: {
/** Session persistence config. Enabled by default (key: "wizardconnect-session",
* storage: localStorage). Pass `false` to disable. */
session?: DappSessionOptions | false;
})
constructor(dappName?: string, dappIcon?: string)
/** Call from the RelayStatusCallback each time the relay status changes. */
/** 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
/** Convenience: build a full SignTransactionRequest, send it, and optionally
* cancel via AbortSignal. See "Sending a sign request" below. */
signTransaction(
request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
options?: { signal?: AbortSignal },
): Promise<SignTransactionResponse>
/** Low-level: send a fully constructed sign request. */
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
/** Cancel an in-flight sign request by sequence number. */
sendSignCancel(sequence: number, reason?: string): Promise<void>
/** Get the next sequence number (for manual request construction). */
/** Get the next sequence number for a SignTransactionRequest. */
nextSequence(): number
/** Send a UserDisconnect courtesy message to the wallet. */
/** 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>
/** Get raw PathXpub[] received in wallet_ready (for caching). */
getSessionPaths(): PathXpub[]
/** Restore cached xpub paths — enables getPubkey() without wallet_ready. */
restoreSessionPaths(paths: PathXpub[]): void
// Session persistence (see "Session persistence" section below)
attachRelay(relay: DappRelayResult): void
loadStoredSession(): StoredSession | null
clearStoredSession(): void
// Events
on("walletready", (msg: WalletReadyMessage) => void)
on("messagesent", (msg: ProtocolMessage) => void)
@ -66,10 +45,6 @@ class DappConnectionManager extends EventEmitter {
}
```
The `"messagereceived"` event fires for **all** protocol messages, including extension-defined
actions. Use it to handle custom messages from wallet extensions. See
[extensions.md](extensions.md) for the extension system and graceful degradation patterns.
## Pubkey state — convenience delegation
`DappConnectionManager` delegates to `pubkeyState` for all pubkey operations. These methods
@ -97,17 +72,10 @@ removeFromChangeQueue(index: bigint): void
// Get the stored xpub node (after wallet_ready)
getXpubNode(childIndex: number): HdPublicNodeValid | undefined
// Get raw PathXpub[] from wallet_ready (for caching)
getSessionPaths(): PathXpub[]
// Restore cached PathXpub[] — populates pubkeyState so getPubkey works without wallet_ready
restoreSessionPaths(paths: PathXpub[]): void
```
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.
It returns `undefined` for extension path names — callers should skip those.
## Session lifecycle
@ -122,12 +90,11 @@ const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.p
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"] },
);
// Persist relay credentials and auto-save walletPublicKey on key exchange
dappMgr.attachRelay(relay);
// Show relay.uri as a QR code for the wallet to scan.
console.log("Scan this URI:", relay.uri);
```
@ -154,66 +121,36 @@ See [pubkey-derivation.md](pubkey-derivation.md) for full details.
### Sending a sign request
The `signTransaction` convenience method auto-fills `action`, `sequence`, and `time`:
```typescript
const response = await dappMgr.signTransaction({
transaction: {
transaction: txHex,
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
});
console.log("Signed tx:", response.signedTransaction);
```
#### Cancellation via AbortSignal
Pass an `AbortSignal` to automatically cancel the request when aborted. This sends
`sign_cancel` to the wallet and rejects the promise with an `AbortError`:
```typescript
const controller = new AbortController();
cancelButton.onclick = () => controller.abort("User cancelled");
try {
const response = await dappMgr.signTransaction(
{ transaction: { ... }, inputPaths: [...] },
{ signal: controller.signal },
);
} catch (err) {
if (err.name === "AbortError") {
console.log("User cancelled the signature request");
}
}
```
#### Low-level: sendSignRequest
For full control over the request, use `sendSignRequest` directly:
```typescript
const seq = dappMgr.nextSequence();
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: seq,
time: Math.floor(Date.now() / 1000),
transaction: { ... },
inputPaths: [[0, "receive", 0]],
transaction: {
transaction: { inputs, outputs, version: 2, locktime: 0 },
sourceOutputs,
userPrompt: "Confirm swap",
broadcast: true,
},
};
const response = await dappMgr.sendSignRequest(request);
// Cancel with: await dappMgr.sendSignCancel(seq, "reason");
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, clear session, then tear down the relay
dappMgr.clearStoredSession();
// Dapp-initiated: send courtesy message, then tear down the relay
await dappMgr.sendDisconnect("user closed the tab");
relay.cleanup();
@ -234,86 +171,6 @@ The `disconnect` event fires in two cases:
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
wallet before emitting the event.
### Session persistence
Session persistence is **enabled by default**. The manager automatically:
- **On construction**: restores xpub paths from storage so `getPubkey()` works immediately.
- **On `walletready`**: saves `walletName`, `walletIcon`, and xpub `paths` to storage.
The default storage key is `"wizardconnect-session"` and the default backend is `localStorage`.
#### Saving session data
Call `attachRelay()` after `initiateDappRelay()` to persist relay credentials and
automatically save the wallet public key when key exchange completes:
```typescript
const relay = initiateDappRelay(callback);
dappMgr.attachRelay(relay); // saves credentials + auto-saves walletPublicKey
// On disconnect:
dappMgr.clearStoredSession();
```
#### Loading for reconnection
Use `loadStoredSession()` on an existing manager, or the standalone `loadSession()` when
you need to read the session before creating the manager (e.g. to get relay credentials
for `initiateDappRelay`):
```typescript
import { loadSession } from "@wizardconnect/dapp";
const session = loadSession(); // uses default key and localStorage
if (session?.walletPublicKey) {
const relay = initiateDappRelay(callback, { existingCredentials: session });
}
```
#### Configuration
```typescript
// Default: session enabled, key "wizardconnect-session", localStorage
const mgr = new DappConnectionManager("My Dapp");
// Custom key:
const mgr = new DappConnectionManager("My Dapp", undefined, {
session: { key: "my-app-session" },
});
// Custom storage backend (e.g. for React Native or SSR):
const mgr = new DappConnectionManager("My Dapp", undefined, {
session: { storage: myCustomStorage },
});
// Disable session persistence:
const mgr = new DappConnectionManager("My Dapp", undefined, {
session: false,
});
```
The `SessionStorage` interface matches the Web Storage API:
```typescript
interface SessionStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
```
#### Manual path management
For advanced use cases, `getSessionPaths()` and `restoreSessionPaths()` are still available:
```typescript
const paths = dappMgr.getSessionPaths(); // raw PathXpub[] from wallet_ready
dappMgr.restoreSessionPaths(paths); // re-populate pubkeyState from cached paths
```
`restoreSessionPaths` throws if any xpub string is invalid.
### Reconnection
`updateConnection()` is called on every relay status change. When `status.status === "connected"`,
@ -321,12 +178,6 @@ it calls `onConnected()` which waits for key exchange and then sends a fresh `da
`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.
When `wallet_ready` is received and there are pending (unresponded) sign requests, the manager
automatically re-sends them. This handles the case where the user triggers a signature in the
dapp before the wallet app is open — the relay's time filter would otherwise discard the
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
valid across reconnects.
## Using initiateDappRelay without DappConnectionManager
If you need lower-level control (e.g., in the test-cli), you can work directly with the
@ -350,24 +201,14 @@ relay.events.on("keyexchangecomplete", async (walletPubkey) => {
});
```
## Using with React
## Cauldron (cauldron-beta) implementation notes
For React dapps, prefer the `useWizardConnect` hook from `@wizardconnect/react` over
managing the relay lifecycle manually. The hook handles session persistence, auto-reconnect,
and relay cleanup automatically. See [react.md](react.md).
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
Dapps that need a custom wallet adapter (e.g. Cauldron, Moria) can use the hook and wrap
the returned `manager` in their adapter:
```typescript
const wc = useWizardConnect({ dappName: "My Dapp" });
useEffect(() => {
if (!wc.manager) return;
const wallet = new MyWalletAdapter(wc.manager);
// dispatch wallet to your store
}, [wc.manager]);
```
The `DappConnectionManager` is created by the hook; the adapter receives it rather than
creating its own.
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.

View file

@ -1,213 +0,0 @@
# Extensions
This document covers `hdwalletv1` protocol-level extensions — optional capabilities that extend
the application protocol (extra path names, custom message actions, per-wallet features). For
transport-level capabilities that apply below the application protocol regardless of which
protocol is in use (chunking, future: compression), see
[transport.md § Transport-level extensions](transport.md#transport-level-extensions).
The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate
additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible:
existing wallets and dapps that don't know about extensions continue to work unchanged.
## How extensions work
Extensions use three mechanisms, all of which are optional and additive:
### 1. Session extensions field
Wallets advertise supported extensions in the `wallet_ready` handshake via an `extensions` field
on the `Hdwalletv1Session` object:
```typescript
interface Hdwalletv1Session {
paths: PathXpub[];
extensions?: Record<string, unknown>;
}
```
Each key in `extensions` is an extension name. Its **presence** indicates the wallet supports that
extension. The value carries extension-specific handshake data (derivation paths to the hardened
gate where the wallet exports xpubs), or `{}` if no data is needed.
Example:
```json
{
"paths": [
{ "name": "receive", "xpub": "xpub6..." },
{ "name": "change", "xpub": "xpub6..." },
{ "name": "defi", "xpub": "xpub6..." },
{ "name": "stealth_spend", "xpub": "xpub6..." },
{ "name": "stealth_scan", "xpub": "xpub6..." },
{ "name": "rpa_spend", "xpub": "xpub6..." },
{ "name": "rpa_scan", "xpub": "xpub6..." }
],
"extensions": {
"bch_stealth_bip352": {
"spend_path": "m/352'/145'/0'/0'",
"scan_path": "m/352'/145'/0'/1'"
},
"rpa_bip47": {
"spend_path": "m/47'/145'/0'/0'",
"scan_path": "m/47'/145'/0'/1'"
},
"decrypt": { "public_key": "02abc...", "scheme": "ecies" }
}
}
```
### 2. Additional path names
`PathName` is an open string type. Wallets may include paths beyond the well-known
`receive`/`change`/`defi` set. Extension-defined paths are carried in the standard `paths` array:
```json
{
"paths": [
{ "name": "receive", "xpub": "xpub6..." },
{ "name": "change", "xpub": "xpub6..." },
{ "name": "stealth_spend", "xpub": "xpub6..." },
{ "name": "stealth_scan", "xpub": "xpub6..." },
{ "name": "rpa_spend", "xpub": "xpub6..." },
{ "name": "rpa_scan", "xpub": "xpub6..." }
],
"extensions": {
"bch_stealth_bip352": {
"spend_path": "m/352'/145'/0'/0'",
"scan_path": "m/352'/145'/0'/1'"
},
"rpa_bip47": {
"spend_path": "m/47'/145'/0'/0'",
"scan_path": "m/47'/145'/0'/1'"
}
}
}
```
Dapps should ignore path names they do not recognize. The standard pubkey derivation logic
(`DappPubkeyStateManager`) automatically skips unknown paths.
### 3. Custom message actions
Extensions may define new message action strings beyond the well-known set. Custom messages follow
the standard `ProtocolMessage` shape (`action` + `time`) and use the existing relay transport.
Convention for request/response operations:
```typescript
// Request (dapp → wallet):
{ action: "<operation>_request", sequence: number, time: number, ...params }
// Response (wallet → dapp):
{ action: "<operation>_response", sequence: number, time: number, ...result }
```
The `sequence` field ties responses to requests, matching the pattern used by
`sign_transaction_request`/`sign_transaction_response`.
**Wallet side**: custom messages are emitted via the `"message"` event on
`WalletConnectionManager`:
```typescript
manager.on("message", (connectionId, msg) => {
if (msg.action === "decrypt_request") {
// handle decrypt request
}
});
```
**Dapp side**: all messages (including custom ones) are emitted via the `"messagereceived"` event
on `DappConnectionManager`:
```typescript
manager.on("messagereceived", (msg) => {
if (msg.action === "decrypt_response") {
// handle decrypt response
}
});
```
## Implementing an extension (wallet side)
Wallets advertise extensions by implementing optional methods on `WalletAdapter`:
```typescript
interface WalletAdapter {
// ... core methods ...
/** Additional paths to include in the session (e.g. stealth_scan). */
getAdditionalPaths?(): PathXpub[];
/** Extension data for the session handshake. */
getExtensions?(): Record<string, unknown>;
}
```
Example:
```typescript
const adapter: WalletAdapter = {
// ... core implementation ...
getAdditionalPaths() {
return [
{ name: "stealth_spend", xpub: deriveXpub("m/352'/145'/0'/0'") },
{ name: "stealth_scan", xpub: deriveXpub("m/352'/145'/0'/1'") },
{ name: "rpa_spend", xpub: deriveXpub("m/47'/145'/0'/0'") },
{ name: "rpa_scan", xpub: deriveXpub("m/47'/145'/0'/1'") },
];
},
getExtensions() {
return {
"bch_stealth_bip352": {
"spend_path": "m/352'/145'/0'/0'",
"scan_path": "m/352'/145'/0'/1'",
},
"rpa_bip47": {
"spend_path": "m/47'/145'/0'/0'",
"scan_path": "m/47'/145'/0'/1'",
},
};
},
};
```
Wallets that don't implement these methods produce the same session as before (receive/change/defi
paths only, no extensions field).
## Discovering extensions (dapp side)
Dapps check for extension support after receiving `wallet_ready`:
```typescript
manager.on("walletready", (msg) => {
const session = msg.session["hdwalletv1"] as Hdwalletv1Session;
if (session.extensions?.bch_stealth_bip352) {
const scanPath = session.paths.find(p => p.name === "stealth_scan");
const spendPath = session.paths.find(p => p.name === "stealth_spend");
// enable stealth address features
} else {
// show: "Stealth payments require a wallet that supports BCH Stealth (BIP352)"
}
if (session.extensions?.rpa_bip47) {
const rpaSpend = session.paths.find(p => p.name === "rpa_spend");
const rpaScan = session.paths.find(p => p.name === "rpa_scan");
// enable RPA features
}
});
```
Dapps should degrade gracefully when an extension is absent. If a feature requires an extension the
wallet doesn't support, inform the user rather than failing silently.
## Known extensions
| Extension name | Path names | Hardened gate paths | Purpose | Status |
|---------------|-----------|---------------------|---------|--------|
| `bch_stealth_bip352` | `stealth_spend`, `stealth_scan` | `m/352'/145'/0'/0'`, `m/352'/145'/0'/1'` | BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
| `rpa_bip47` | `rpa_spend`, `rpa_scan` | `m/47'/145'/0'/0'`, `m/47'/145'/0'/1'` | BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
| `decrypt` | — | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends `decrypt_request` messages when the data is needed. | Proposed |
See the discussions and specifications for each extension as they are formalized.

View file

@ -32,10 +32,6 @@ libwizardconnect/
│ DappConnectionManager, DappPubkeyStateManager.
│ Single-session dapp helper, on-demand xpub derivation.
├── packages/react — @wizardconnect/react
│ React components and hooks for dapp integration.
│ QR dialog, useWizardConnect hook.
└── packages/test-cli — @wizardconnect/test-cli (private)
CLI for manual and exploratory testing.
```
@ -76,11 +72,8 @@ future if needed. You will be prompted to sign the CLA when you open your first
| Topic | File |
|-------|------|
| Protocol messages and handshake | [protocol.md](protocol.md) |
| Protocol extensions | [extensions.md](extensions.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) |
| React components and hooks | [react.md](react.md) |
| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) |
| xpub sharing: why it's safe | [xpub-sharing.md](xpub-sharing.md) |

View file

@ -36,15 +36,8 @@ 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
chunk — either → either, transport-level. Carries one slice of a larger message
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
hdwalletv1 semantics. See transport.md.
```
The action names above are the well-known set. Extensions may define additional action strings
(e.g. `decrypt_request`, `decrypt_response`). Both sides should ignore unknown actions gracefully.
See [extensions.md](extensions.md) for conventions on defining extension messages.
---
## Base protocol
@ -124,11 +117,8 @@ a ready message from the other side in this runtime session."
"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`. Both `walletReadySentThisCycle` and `dappDiscovered` reset to
`false` on each new connect/reconnect. This ensures the wallet always sends
`wallet_ready(dapp_discovered=false)` at the start of a new connection cycle, matching the
"Wallet reconnects" scenario. Receiving `dapp_ready(wallet_discovered=false)` also resets
`walletReadySentThisCycle`.
via `walletReadySentThisCycle`. This resets to `false` on each new connect/reconnect.
Receiving `dapp_ready(wallet_discovered=false)` also resets this flag.
#### Scenarios
@ -177,17 +167,12 @@ the wallet.
```typescript
interface Hdwalletv1Session {
paths: PathXpub[]; // BIP32 xpubs for each named path
extensions?: Record<string, unknown>; // optional extension capabilities and data
paths: PathXpub[]; // BIP32 xpubs for receive/change/defi
}
```
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
The `extensions` field is optional. When present, each key is an extension name and its presence
indicates the wallet supports that extension. The value carries extension-specific handshake data,
or `{}` if no data is needed. See [extensions.md](extensions.md) for the full extension system.
See [pubkey-derivation.md](pubkey-derivation.md) for the full xpub story.
### PathXpub
@ -228,26 +213,15 @@ that only the wallet (and internal dapp state) need to know.
### PathName
```typescript
type PathName = string;
// Well-known path names:
const PATH_RECEIVE = "receive";
const PATH_CHANGE = "change";
const PATH_DEFI = "defi";
type PathName = "receive" | "change" | "defi";
```
`PathName` is an open string. The well-known values are:
| 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 |
Wallets may include additional paths via extensions (e.g. `stealth_scan`, `stealth_spend`, `rpa`).
Dapps should ignore path names they do not recognize. See [extensions.md](extensions.md) for
conventions on defining new path names.
### sign_transaction_request
```typescript
@ -255,7 +229,6 @@ interface SignTransactionRequest {
action: "sign_transaction_request";
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
sequence: number;
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
time: number;
}
```
@ -268,35 +241,6 @@ to match responses to requests.
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
wallet UI.
`inputPaths` is a sparse array of `[inputIndex, PathName, addressIndex]` tuples. Each entry identifies
the HD derivation path name and address index the dapp used to derive the locking script for the input
at position `inputIndex`. Only inputs that require wallet signing need an entry — contract inputs with
pre-set unlocking bytecode can be omitted. This allows the wallet to sign each input without scanning
or guessing which key was used.
#### SIGHASH requirement (security-critical)
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` and **MUST**
reject any request that would require different flags.
`SIGHASH_ALL` ensures the signature commits to the entire transaction (all inputs and all outputs).
Without it, an attacker could collect a valid signature and graft it onto a different transaction —
for example, using `SIGHASH_NONE` an attacker could replace every output to redirect funds.
Because `inputPaths` lets the dapp specify which key signs each input, the wallet no longer
independently verifies that the key matches the UTXO's locking bytecode. This is safe **only** when
`SIGHASH_ALL` is enforced: if the dapp provides a wrong path, the resulting signature is invalid
(public key hash mismatch) and the transaction cannot broadcast. Without `SIGHASH_ALL`, a
wrong-key signature could still be repurposed in a different transaction context.
Summary of the flags:
| Flag | Purpose |
|------|---------|
| `SIGHASH_ALL` | Commits to all inputs and outputs — prevents output substitution |
| `SIGHASH_FORKID` | Prevents cross-fork replay (BCH ↔ BTC) |
| `SIGHASH_UTXOS` | Commits to all input UTXOs — prevents input substitution after signing |
### sign_transaction_response
```typescript
@ -312,18 +256,6 @@ interface SignTransactionResponse {
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.
### Re-delivery on reconnect
If the wallet is not connected (or reconnects) while a `sign_transaction_request` is in flight,
the dapp automatically re-sends all pending requests when it receives `wallet_ready`. This
handles the common case where the user triggers a transaction in the dapp and then opens the
wallet app several seconds later.
The wallet deduplicates incoming requests by `sequence` number — if it has already emitted
`pendingSignRequest` for a given sequence and has not yet responded, the duplicate is silently
dropped. The dedup guard is cleared when the wallet sends a response (`sign_transaction_response`)
or receives a `sign_cancel`.
### sign_cancel
```typescript
@ -358,12 +290,6 @@ Either side may send a `disconnect` message before tearing down the relay connec
courtesy notification — the remote side treats the connection as closed immediately upon receipt
(no acknowledgement).
No acknowledgement does not mean fire-and-forget on the sender's side. `relay()` resolves only
after the publish has been settled against every configured relay, so the sender must keep the
relay connection open until then — closing it first kills the publish in flight and the peer
never learns of the disconnect. Sending a courtesy `disconnect` and immediately tearing the
transport down is the same as not sending one.
```typescript
enum DisconnectReason {
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
@ -379,9 +305,7 @@ interface DisconnectMessage {
```
**Wallet side** (`WalletConnectionManager`):
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish
settles — bounded, so an unreachable relay cannot hold the socket open. The connection leaves
the registry synchronously. See [wallet.md § Sending disconnect](wallet.md#sending-disconnect).
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
- Incoming `disconnect` emits a `remoteDisconnect` event
(`connectionId`, `reason`, `message`) and removes the connection.
@ -414,12 +338,10 @@ These are used internally to validate incoming messages before dispatch.
## Helper: childIndexOfPathName
```typescript
function childIndexOfPathName(name: PathName): number | undefined
// "receive" → 0, "change" → 1, "defi" → 7, unknown → undefined
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 for the well-known path names. It returns `undefined` for extension path names
(e.g. `"stealth_scan"`). Callers must handle the `undefined` case — typically by skipping the path.
It is not a protocol concern — the numeric indices never appear on the wire.
two representations. It is not a protocol concern — the numeric indices never appear on the wire.

View file

@ -1,184 +0,0 @@
# React integration
`@wizardconnect/react` provides React components and hooks for dapp developers who want to add
WizardConnect wallet connectivity without writing relay boilerplate.
## Quick start
```tsx
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
function App() {
const wc = useWizardConnect({ dappName: "My Dapp" });
return (
<>
<button onClick={wc.connect} disabled={wc.state !== "idle"}>
Connect Wallet
</button>
{wc.uri && wc.qrUri && (
<WizardConnectQRDialog
show={wc.state === "connecting"}
onClose={() => wc.disconnect()}
uri={wc.uri}
qrUri={wc.qrUri}
/>
)}
{wc.state === "connected" && (
<p>Connected to {wc.walletName}</p>
)}
</>
);
}
```
## useWizardConnect
The `useWizardConnect` hook manages the full relay lifecycle:
1. **`connect()`** — calls `initiateDappRelay()`, creates a `DappConnectionManager`, sets
`uri`/`qrUri` for QR display, and transitions to `"connecting"`.
2. **Key exchange** — persists the wallet's public key to session storage.
3. **`walletready`** — transitions to `"connected"` and populates `walletName`/`walletIcon`.
4. **Auto-reconnect** — on mount, checks session storage for a stored session with a
`walletPublicKey` and automatically reconnects. During auto-reconnect, `uri`/`qrUri` remain
`null` (no QR code to display — credentials are already known).
5. **`disconnect()`** — sends a courtesy disconnect, cleans up the relay, and clears session storage.
6. **Remote disconnect** — when the wallet sends a disconnect, the hook transitions to
`"disconnected"`, clears the session, and tears down the relay.
### Return value
```typescript
interface UseWizardConnectResult {
state: "idle" | "connecting" | "connected" | "disconnected";
manager: DappConnectionManager | null;
uri: string | null;
qrUri: string | null;
walletName: string | null;
walletIcon: string | null;
connect: () => boolean;
disconnect: () => Promise<void>;
error: string | null;
}
```
### Using the manager
Once `state === "connected"`, the `manager` is a live `DappConnectionManager` from
`@wizardconnect/dapp`. Use it to derive addresses and request signatures:
```typescript
// Derive a receive address pubkey
const pubkey = wc.manager.getPubkey(0, 0n);
// Send a sign request (auto-fills action, sequence, time)
const response = await wc.manager.signTransaction({
transaction: { transaction: txHex, sourceOutputs, broadcast: true },
inputPaths: [[0, "receive", 0]],
});
```
See the [dapp integration docs](dapp.md#sending-a-sign-request) for cancellation via
`AbortSignal` and the low-level `sendSignRequest` API.
For most dapps, you'll wrap the manager in an app-specific wallet adapter (like
`RelayWalletDapp` in the Cauldron and Moria codebases).
## WizardConnectQRDialog
A framework-independent modal dialog for displaying the connection QR code. Uses inline styles
(no Tailwind or CSS framework dependency) with a dark theme by default.
### Customization
Colors, text, and logos are all customizable via props:
```tsx
<WizardConnectQRDialog
show={true}
onClose={handleClose}
uri={connection.uri}
qrUri={connection.qrUri}
title="My Protocol"
subtitle="Scan to pair your wallet"
logoUrl="/my-logo.png"
theme={{
dialogBackground: "#0f172a",
headerBackground: "#0f172a",
borderColor: "#334155",
}}
onCopy={(uri) => {
navigator.clipboard.writeText(uri);
showToast("Copied!");
}}
/>
```
The `onCopy` callback replaces the default `navigator.clipboard.writeText` behavior, which is
useful when the dapp has its own toast/notification system.
## AlphanumericQRCode
A standalone canvas-based QR code component. Uses QR Alphanumeric mode with error correction
level H (30% recovery), which allows a center logo overlay without breaking the code.
```tsx
<AlphanumericQRCode
value="WIZ://..."
size={280}
foreground="#1e2a4a"
background="#ffffff"
logoUrl="/logo.png"
/>
```
## Session persistence
By default, `useWizardConnect` persists session data to localStorage under the
`wizardconnect-session` key. The stored session includes:
- **Relay credentials** (`privateKey`, `secret`) — saved on `connect()`
- **Wallet public key** (`walletPublicKey`) — saved after key exchange
- **Wallet identity** (`walletName`, `walletIcon`) — saved on `walletready`
- **Xpub paths** (`paths`) — saved on `walletready`
On page refresh, the hook auto-reconnects if a stored session with `walletPublicKey` exists.
Xpub paths and wallet name are restored immediately so `getPubkey()` works and the UI can
display the wallet name before the wallet app responds. During auto-reconnect, `uri` and
`qrUri` remain `null` — the QR dialog should not be shown. The typical pattern is:
```tsx
{wc.uri && wc.qrUri && (
<WizardConnectQRDialog
show={wc.state === "connecting"}
onClose={() => wc.disconnect()}
uri={wc.uri}
qrUri={wc.qrUri}
/>
)}
```
This naturally hides the dialog during auto-reconnect since `wc.uri` is `null`.
If the wallet sends a disconnect, the hook clears the session automatically and transitions
to `"disconnected"`. Dapps should listen for this state change to update their UI (e.g.
clear the wallet from their store).
### Customization
```typescript
useWizardConnect({
sessionKey: "my-app-session", // custom storage key (default: "wizardconnect-session")
persistSession: false, // disable persistence entirely
storage: myCustomStorage, // custom SessionStorage backend (default: localStorage)
});
```
The `storage` option accepts any object with `getItem`, `setItem`, and `removeItem` methods
(the standard Web Storage API). This is useful for React Native or server-side rendering
where `localStorage` is not available.
On disconnect, the stored session is cleared.

View file

@ -1,132 +0,0 @@
# Relay serialization
The WizardConnect relay transmits messages as JSON, which cannot represent `BigInt` or
`Uint8Array` natively. `@wizardconnect/core` provides canonical encoding helpers so dapps
and wallets always agree on the wire format.
The helpers are split into two layers:
- **Generic** (`serialize.ts`) — relay-level type coercion (`toUint8Array`, `toBigInt`, `parseExtendedJson`). Useful for any protocol.
- **hdwalletv1** (`protocols/hdwalletv1-serialize.ts`) — transaction-specific serialization (`sourceOutputToRelay`, `transactionToHex`). Tied to the sign-transaction flow.
## Encoding conventions
| Native type | Relay format | Example |
|---------------|-------------------------------------|---------------------------------|
| `Uint8Array` | hex string | `"76a914...88ac"` |
| `Uint8Array` | extended format (libauth stringify) | `"<Uint8Array: 0x76a914...>"` |
| `BigInt` | extended format | `"<bigint: 200000n>"` |
Both extended formats are accepted by the deserialization helpers. The serialization helpers
produce hex strings for `Uint8Array` and `<bigint: Xn>` for `BigInt`.
## hdwalletv1 serialization (dapp → relay)
### `sourceOutputToRelay(sourceOutput)`
Converts a source output with native types to relay-safe JSON:
```typescript
import { sourceOutputToRelay } from "@wizardconnect/core/hdwalletv1-serialize";
const relayOutput = sourceOutputToRelay({
outpointTransactionHash: txidBytes, // Uint8Array → hex string
outpointIndex: 0, // number (unchanged)
unlockingBytecode: new Uint8Array(0), // Uint8Array → hex string
sequenceNumber: 0xffffffff, // number (unchanged)
valueSatoshis: 200000n, // BigInt → "<bigint: 200000n>"
lockingBytecode: scriptBytes, // Uint8Array → hex string
token: { // optional
category: categoryBytes, // Uint8Array → hex string
amount: 1000n, // BigInt → "<bigint: 1000n>"
},
});
// relayOutput is JSON-serializable (no BigInt, no Uint8Array)
JSON.stringify(relayOutput); // works
```
### `transactionToHex(inputs, outputs, version?, locktime?)`
Encodes a transaction to hex using libauth's `encodeTransaction`:
```typescript
import { transactionToHex } from "@wizardconnect/core/hdwalletv1-serialize";
const txHex = transactionToHex(inputs, outputs);
// txHex is a hex string ready for the relay
```
Note: libauth's `encodeTransaction` reverses `outpointTransactionHash` to wire format
internally. Pass txids in **display order** (big-endian, as returned by electrum/explorers).
## Deserialization (relay → wallet)
### `toUint8Array(value)`
Converts hex strings, extended JSON format, or `Uint8Array` to `Uint8Array`:
```typescript
import { toUint8Array } from "@wizardconnect/core";
toUint8Array("76a914...88ac"); // hex string
toUint8Array("<Uint8Array: 0x76a914...88ac>"); // extended format
toUint8Array(existingBytes); // pass-through
```
### `toBigInt(value)`
Converts numeric strings, extended JSON format, numbers, or `bigint` to `bigint`:
```typescript
import { toBigInt } from "@wizardconnect/core";
toBigInt("<bigint: 200000n>"); // extended format
toBigInt("200000"); // numeric string
toBigInt(200000); // number
toBigInt(200000n); // pass-through
```
### `parseExtendedJson(jsonString)`
Parses a full JSON string, converting all extended-format values in one pass:
```typescript
import { parseExtendedJson } from "@wizardconnect/core";
const obj = parseExtendedJson('{"value":"<bigint: 200000n>","data":"<Uint8Array: 0xab>"}');
// obj.value === 200000n
// obj.data instanceof Uint8Array
```
### `isExtendedJsonFormat(str)`
Returns `true` if a string contains `<bigint: ...>` or `<Uint8Array: ...>` markers.
## Usage in sign requests
A typical dapp builds a sign request like this:
```typescript
import { RelayMsgAction } from "@wizardconnect/core";
import { sourceOutputToRelay, transactionToHex } from "@wizardconnect/core/hdwalletv1-serialize";
const txHex = transactionToHex(inputs, outputs);
const sourceOutputs = inputs.map((input, i) =>
sourceOutputToRelay({
...input,
valueSatoshis: utxos[i].value,
lockingBytecode: utxos[i].script,
})
);
const signReq = {
action: RelayMsgAction.SignTransactionRequest,
time: Math.floor(Date.now() / 1000),
sequence: manager.nextSequence(),
transaction: { transaction: txHex, sourceOutputs, broadcast: false },
inputPaths: [[0, "receive", 0]],
};
```
The wallet deserializes using `toUint8Array` and `toBigInt` on the received fields.

View file

@ -36,7 +36,7 @@ Application message handler
```typescript
new RelayClient({
explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.riften.net:443"]
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
@ -50,12 +50,10 @@ connect(): Promise<void>
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
disconnect(): Promise<void>
// Stop subscription, close the relay pool, mark queue not-ready, update
// lastProcessedTimestamp. Kills any in-flight publish — see below.
// 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.
// Resolves only once the publish has settled against every configured relay.
setPairedPublicKey(key: Uint8Array): void
// Called after key exchange. Enables outbound messages and incoming peer filtering.
@ -80,10 +78,6 @@ 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.
Because `disconnect()` closes the pool, it kills any publish still in flight — so anything
sending a final message before tearing down must await the `relay()` first. See
[wallet.md § Sending disconnect](wallet.md#sending-disconnect).
### Replay protection
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
@ -91,23 +85,6 @@ to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimes
dropped. This prevents the relay from re-delivering messages that were already handled before a
disconnect.
### Keepalive
`RelayClient` creates its `SimplePool` with `enablePing: true`. This enables nostr-tools'
built-in heartbeat: every 29 seconds the pool pings each connected relay and expects a
response within 20 seconds. In Node.js this uses native WebSocket ping/pong frames; in
browsers (where the WebSocket API doesn't expose ping) it falls back to sending a dummy
subscription request and waiting for EOSE.
If a relay fails to respond, nostr-tools closes the WebSocket, which fires the subscription
`onclose` callback, which emits `"disconnect"` on `RelayClient`, which triggers the
connection manager's existing reconnect loop. This detects "zombie" TCP connections where the
socket appears open but the relay is unreachable.
Additionally, if a `publishMessage()` call fails (all relays reject the event),
`RelayClient` emits `"disconnect"` alongside the thrown error. This ensures the reconnect
loop starts immediately rather than waiting for the next ping cycle.
### Sequence numbers
`nextSequence()` starts at a random offset in the safe integer range and increments by 2. This
@ -205,159 +182,12 @@ the same sender.
NDK handles all three layers in `giftWrap()` / `giftUnwrap()`.
## Default relays
## Default relay
```
wss://relay.riften.net:443 (primary)
wss://relay.cauldron.quest:443 (secondary)
wss://relay.cauldron.quest:443
```
Both relays are used by default on both dapp and wallet sides for redundancy. Since Nostr
relays do not federate (they don't forward events to each other), connecting to multiple relays
ensures messages are delivered even if one relay is temporarily unavailable.
The connection URI encodes only the primary relay; the secondary is added programmatically
by the library. When a custom relay is specified (via URI or `explicitRelayUrls`), only that
relay is used — default relays are not auto-added.
Nothing in the protocol prevents using any other standard Nostr relay.
### Duplicate message handling
When subscribed to multiple relays, the same event may arrive from more than one relay.
nostr-tools `SimplePool.subscribeMany()` deduplicates events by ID — it tracks seen event IDs
in a per-subscription `_knownIds` set and only fires `onevent` once per unique ID. Since
`pool.publish(urls, event)` sends the identical event (same ID) to all relays, the receiving
side's pool delivers it exactly once.
## Transport-level extensions
Transport-level extensions are capabilities of the relay/gift-wrap transport itself, independent
of any application protocol. They're distinct from the protocol-level extensions documented in
[extensions.md](extensions.md), which extend `hdwalletv1` specifically.
### Negotiation
Both sides advertise transport-level extensions in a base `extensions` field on their handshake
message (`dapp_ready` / `wallet_ready`). The shape is identical on both sides:
```typescript
interface DappReadyMessage {
// ...
extensions?: Record<string, unknown>;
}
interface WalletReadyMessage {
// ...
extensions?: Record<string, unknown>;
}
```
A capability is considered enabled only when **both** sides advertise it. `RelayClient` exposes
`setPeerCapabilities({...})` so the connection managers can inform it once they've parsed the
peer's `_ready` message.
Extension values are per-extension; today `chunk` uses `{ version: 1 }`. Unknown extension keys
are ignored by implementations that don't recognise them, so adding new capabilities is always
backward-compatible — an old peer simply doesn't advertise them and the new side falls back to
the non-extended behaviour.
### Known transport extensions
| Extension | Purpose |
|---|---|
| `chunk` | Split messages larger than NIP-44's 65,535-byte plaintext ceiling across multiple gift-wrapped events. Symmetric — enabled when both sides advertise. See below. |
## Chunking (`chunk` extension)
### Why it exists
NIP-44 caps plaintext at 65,535 bytes — the length is encoded as a U16BE prefix in the wire
format, so the limit is structural, not a guardrail that can be raised. NIP-17 gift-wrap
additionally encrypts twice (rumor inside seal inside wrap), so a single 50+ KB `ProtocolMessage`
will blow the outer wrap's plaintext budget even when the inner payload itself looks small enough.
Real scenarios that exceed the cap:
- Aggregated swap `sign_transaction_request` with many pool UTXOs (each with hex `lockingBytecode`
and `unlockingBytecode` in the per-input `sourceOutputs`).
- `sign_transaction_response` carrying a signed transaction hex string. Policy-max BCH transactions
are 100 KB (→ 200 KB hex); consensus-max is 1 MB (→ 2 MB hex).
### Wire format
```typescript
interface ChunkMessage extends ProtocolMessage {
action: "chunk";
time: number; // shared across all chunks of one logical message
msgId: string; // random identifier, shared across all chunks
index: number; // 0-based chunk index within [0, total)
total: number; // total number of chunks for this msgId
data: string; // base64 slice of the UTF-8 bytes of JSON.stringify(originalMessage)
}
```
To reconstruct the original message: concatenate the `data` strings in `index` order, base64-decode
to bytes, UTF-8-decode to a string, `JSON.parse`.
### Sender
`RelayClient.publishMessage` measures the UTF-8 byte length of the serialized `ProtocolMessage`.
If it exceeds the per-message threshold:
- If the peer's `chunk` capability is enabled, the message is split into `ChunkMessage`s and each
is published individually via the same `wrapEvent` path as any other message. No ACKs — see
"Failure modes" below.
- If the peer has not advertised `chunk`, `publishMessage` throws a structured error
(`"Cannot send <action>: message is larger than NIP-44's 65,535-byte ceiling and the peer does
not advertise the 'chunk' transport extension. Please update the connected wallet/dapp..."`).
This replaces the cryptic `invalid plaintext size` error from nostr-tools.
The per-chunk budget is sized conservatively. Each chunk's raw data is ≤ 30,000 bytes, which after
base64 expansion (~4/3×), JSON envelope overhead, and the two-layer NIP-17 gift-wrap encryption
stays well under NIP-44's 65,535-byte ceiling for the outer wrap's plaintext. See
`packages/core/src/transforms/chunk.ts` for the derivation.
### Receiver
`RelayClient` owns a `ChunkReassembler` instance. Chunks pass the same peer filter and
`lastProcessedTimestamp` dedup as any other message, then are routed to the reassembler. When all
chunks for a `msgId` have arrived (in any order), the bytes are concatenated, decoded, and the
resulting `ProtocolMessage` is handed to the application-level handler — indistinguishable to
the application from an unchunked message of equivalent size.
The reassembler holds two maps with TTL eviction:
- **in-flight buffers** keyed by `msgId` — collects chunks until complete; default TTL 120 s,
sized to comfortably fit a ~35-chunk 2 MB response under real relay latency.
- **completed** — tracks `msgId`s we've already delivered, for the same TTL, so late-arriving
duplicates (e.g. cross-subscription replay after reconnect) don't spawn a second reassembly and
double-deliver.
A background sweeper runs every 10 s while connected and evicts expired entries. Reassembly state
is not persisted — reconnects rely on the relay replaying events to complete any in-flight
transfer (see below).
### Failure modes
| Failure | Behaviour |
|---|---|
| Dapp reloads mid-send | Dapp on reload has no in-flight state. User retries → fresh `msgId`, all chunks re-sent. Wallet's partial buffer for the old `msgId` expires via TTL. |
| Wallet reloads mid-receive | Subscription filter has no `since` clause, so on reconnect the relay re-delivers all events addressed to the wallet. Chunks reassemble fresh. Works as long as the chunks are still within the relay's retention window. |
| Network blip / all relays reject a chunk | `Promise.allSettled` on publish treats each chunk identically to any other single message. If all relays reject, `publishMessage` throws and `emitDisconnect` fires — same posture as today's sign-request failure. |
| Relay prunes mid-reassembly | Receiver's partial times out via TTL. Application sees no response; user retries — same failure mode the protocol already has for any lost sign request. |
No persistence layer, no per-chunk ACKs, no new round trips. The assembled `ProtocolMessage`'s
own response (e.g. `sign_transaction_response`) is the effective end-to-end ACK.
### Backward compatibility
| Dapp | Wallet | Outcome |
|---|---|---|
| Old | Old | Unchanged. Oversized message fails at nostr-tools with the raw NIP-44 error. |
| **New** | Old | Dapp detects absent `chunk` capability and throws a clear upgrade-guidance error instead of the cryptic NIP-44 error. |
| Old | **New** | Wallet detects absent `chunk` capability and throws a clear error symmetrically. |
| **New** | **New** | Both advertise, both enable, oversized messages chunk-and-reassemble transparently. Application code is unchanged. |
Upgrading the library on both sides is sufficient — no adapter-interface changes, no new
configuration, no capability opt-in. The extension is always-on when supported.
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.

View file

@ -28,12 +28,6 @@ interface WalletAdapter {
/** 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>;
/** Optional: additional paths to include in the session (e.g. stealth_scan). */
getAdditionalPaths?(): PathXpub[];
/** Optional: extension data for the session handshake. */
getExtensions?(): Record<string, unknown>;
}
```
@ -69,11 +63,9 @@ class WalletConnectionManager extends EventEmitter {
connect(uri: string): string
// Tear down a specific connection, sending a UserDisconnect courtesy message.
// Returns as soon as the connection has left the registry; the relay socket
// closes once the courtesy message is published. See § Sending disconnect.
disconnect(connectionId: string): void
// Tear down all connections. Each is disconnected independently.
// Tear down all connections.
disconnectAll(): void
// Snapshot of all connections for UI rendering.
@ -90,7 +82,6 @@ class WalletConnectionManager extends EventEmitter {
on("pendingSignRequest", (req: PendingSignRequest) => void)
on("connectionsChanged", () => void)
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
}
```
@ -138,9 +129,7 @@ interface PendingSignRequest {
Sends `wallet_ready` with:
- `supported_protocols: ["hdwalletv1"]`
- `wallet_name`, `wallet_icon` from the adapter.
- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi),
plus any additional paths from `adapter.getAdditionalPaths()` and extension data from
`adapter.getExtensions()`. See [extensions.md](extensions.md).
- `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
@ -156,28 +145,6 @@ wallet_discovered=true → set dappDiscovered=true, no further action
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
### Sending disconnect
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
have opposite timing requirements.
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
connection left in the map during teardown would be handed back to a caller as if it were live.
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
published. `RelayClient.relay()` resolves after the publish settles against every configured
relay, which is a real round trip; closing the socket before that kills the publish in flight and
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
courtesy message that is never delivered.
Callers do not need to await anything. The observable contract is that state is correct
immediately and delivery is best-effort within the timeout.
### Receiving disconnect
When a `disconnect` message arrives from the dapp:
@ -196,19 +163,6 @@ application is responsible for:
The wallet library does not auto-sign or auto-reject anything.
**Deduplication:** The dapp re-sends pending sign requests when the wallet reconnects (see
[protocol docs](protocol.md#re-delivery-on-reconnect)). To prevent duplicate approval dialogs,
`WalletConnectionManager` tracks in-flight sequences and silently drops requests whose `sequence`
has already been emitted. The guard is cleared when a response is sent (`sendSignResponse` /
`sendSignError`) or a `sign_cancel` is received.
**SIGHASH enforcement:** The wallet **MUST** sign every input with
`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol
docs for the security rationale. Because the dapp specifies `inputPaths`, the wallet trusts the
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
invalid and cannot be repurposed).
## Minimal example
```typescript
@ -224,8 +178,7 @@ class MyAdapter implements WalletAdapter {
getXpub(path: DerivationPath) { /* ... */ }
async signTransaction(request) {
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
// show approval UI, sign, return hex
return { signedTransactionHex: "..." };
}
}

View file

@ -1,139 +0,0 @@
# xpub sharing: why it's safe and why we do it
When people hear "xpub sharing" they think of handing over the account-level
xpub — the master key that can derive every address in a wallet. That's not
what WizardConnect does. This page explains what we actually share, why it's
safe, and why the alternatives are worse.
## What we share
A BIP44 HD wallet has this tree structure:
```
m / 44' / 145' / 0' ← account xpub (we do NOT share this)
/ 0 ← receive chain xpub (we share this)
/ 0 ← address 0
/ 1 ← address 1
/ 2 ← address 2
/ ...
/ 1 ← change chain xpub (we share this)
/ 0
/ 1
/ ...
```
WizardConnect shares xpubs **one level below the account** — at the chain
level. The dapp gets the receive chain xpub and can derive receive addresses.
It cannot derive change addresses, and it cannot derive the account xpub.
This is the same level of key material that watch-only wallets, block
explorers, and payment processors have always worked with. Electron Cash's
"watching-only wallet" import works at exactly this level.
## What about privacy?
Sharing a chain xpub gives the dapp the ability to derive all addresses on
that chain. This means a dapp you connect to can see your receive addresses
and monitor incoming payments.
This is by design — the dapp needs addresses to construct transactions. The
question is how you deliver them.
### The three options
| Approach | Round trips | Privacy | UX |
|----------|-------------|---------|-----|
| Individual pubkeys | One per address | Best — dapp sees only what it asks for | Terrible on mobile — constant app switching |
| Chain xpub (WizardConnect) | Zero after handshake | Good — dapp sees one chain, not the whole wallet | Seamless — scan once, done |
| Account xpub | Zero after handshake | Poor — dapp sees everything | Seamless |
We started with individual pubkeys. On desktop it was tolerable. On mobile it
was unusable — every new address required switching to the wallet app,
approving, switching back. For a transaction with two inputs and a change
output, that's three round trips with three app switches. Users gave up.
Chain xpubs eliminate all round trips while limiting exposure to a single
purpose. A dapp connected for receive addresses cannot see your change
addresses or any other internal wallet activity.
### Per-session xpubs (advanced)
A wallet that wants maximum privacy can derive xpubs from non-standard paths
and rotate them each session:
```
Session 1: receive xpub from m/44'/145'/0'/1000/0
Session 2: receive xpub from m/44'/145'/0'/1001/0
```
The protocol carries only the xpub and a name ("receive") — the dapp never
learns the derivation path. Each session gets a fresh, isolated set of
addresses. The trade-off is that standard wallet recovery (which scans only
the BIP44 paths) won't find funds at these paths without extra metadata.
## Is this the same as sharing your full xpub?
No. The account xpub (`m/44'/145'/0'`) can derive keys for *all* chains —
receive, change, and any application-specific paths. Sharing it gives a dapp
complete visibility into your wallet's transaction graph, including internal
change outputs.
A chain xpub can only derive addresses on its own chain. It's one branch of
the tree, not the trunk.
```
Account xpub → can derive receive + change + defi + everything else
(this is what people worry about)
Receive chain xpub → can derive receive addresses only
(this is what we share)
```
## The security question: "can someone steal my funds?"
An xpub contains only public keys. Public keys cannot sign transactions.
Sharing an xpub — at any level — does not give anyone the ability to move
funds.
The theoretical concern with BIP32 unhardened derivation is: if an attacker
gets a child *private* key **and** the parent xpub, they can compute the
parent private key. But in WizardConnect, private keys never leave the wallet.
Signing happens on the wallet device and only the signed transaction is
returned. The attack requires compromising the wallet itself, at which point
the attacker already has the keys.
This is the same security model as every BIP44 wallet in existence. Electron
Cash, Bitcoin Core (with descriptors), and every hardware wallet that supports
watch-only mode all rely on the same property: xpub sharing is safe as long
as private keys stay private.
## Comparison with other protocols
**Electrum protocol**: shares individual pubkeys on demand. Maximum privacy,
but requires the wallet to be online for every new address. Poor mobile UX.
**WalletConnect v2 (Ethereum)**: shares account addresses, not xpubs. Works
for Ethereum's account model where one address is reused. Does not apply to
UTXO chains like BCH where each transaction should use a fresh address.
**BIP47 payment codes**: enables reusable payment addresses between two
parties using a shared secret derived from both parties' xpubs. Solves a
different problem (sender-receiver privacy for recurring payments) and
requires an on-chain notification transaction.
**WizardConnect**: shares chain-level xpubs by name. Zero round trips, chain
isolation, optional per-session rotation. Designed specifically for the UTXO
model where dapps need to derive many addresses.
## Summary
- We share chain xpubs, not account xpubs. A dapp sees one branch, not the
whole tree.
- Chain xpubs contain only public keys. They cannot sign transactions or move
funds.
- The security model is identical to watch-only wallets, which have been
standard practice in Bitcoin for over a decade.
- The alternative (individual pubkeys) was tried and produced an unusable
mobile experience.
- Wallets that want stronger privacy can rotate xpubs per session — the
protocol supports this without changes.

View file

@ -8,7 +8,7 @@ module.exports = [
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"],
},
{
files: ["**/*.ts", "**/*.tsx"],
files: ["**/*.ts"],
languageOptions: {
parser,
parserOptions: {

View file

@ -18,7 +18,7 @@ const REQUIRED_LINES = [
// 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", ".tsx", ".mjs"];
const EXTENSIONS = [".ts", ".mjs"];
const DIRECTORIES = ["packages", "linters"];
function walk(dir) {

1062
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
"packages/*"
],
"scripts": {
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/react -w packages/test-cli",
"build": "npm run build -w packages/core -w packages/dapp -w packages/wallet -w packages/test-cli",
"test": "npm run test --workspaces --if-present",
"test:integration": "npm run test:integration --workspaces --if-present",
"dapp": "npm run dapp --workspace @wizardconnect/test-cli",

View file

@ -1,23 +1,8 @@
{
"name": "@wizardconnect/core",
"version": "0.2.0",
"version": "0.1.2",
"type": "module",
"description": "Transport and protocol primitives for WizardConnect",
"repository": {
"type": "git",
"url": "https://gitlab.com/riftenlabs/lib/wizardconnect",
"directory": "packages/core"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./hdwalletv1-serialize": {
"types": "./dist/protocols/hdwalletv1-serialize.d.ts",
"default": "./dist/protocols/hdwalletv1-serialize.js"
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
@ -35,15 +20,15 @@
},
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"nostr-tools": "^2.23.0",
"@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.21.3"
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

@ -14,7 +14,9 @@ import {
generateKeyExchangeCredentials,
encodeKeyExchangeURI,
KeyExchangeCredentials,
DEFAULT_RELAY_URLS,
DEFAULT_RELAY_HOSTNAME,
DEFAULT_RELAY_PORT,
DEFAULT_RELAY_PROTOCOL,
} from "./key-exchange.js";
import { hexToBin, binToHex } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3";
@ -28,7 +30,6 @@ export interface DappRelayOptions {
existingCredentials?: {
privateKey: string;
secret: string;
walletPublicKey: string;
};
}
@ -103,13 +104,6 @@ export function initiateDappRelay(
let keyExchanged = false;
let walletPublicKeyNostr: Uint8Array | null = null;
if (options.existingCredentials) {
walletPublicKeyNostr = hexToBin(
options.existingCredentials.walletPublicKey,
);
keyExchanged = true;
}
const wrappedCallback: RelayStatusCallback = (
payload: RelayUpdatePayload,
) => {
@ -169,12 +163,14 @@ export function initiateDappRelay(
const relayUrls =
options.explicitRelayUrls && options.explicitRelayUrls.length > 0
? options.explicitRelayUrls
: [...DEFAULT_RELAY_URLS];
: [
`${DEFAULT_RELAY_PROTOCOL}://${DEFAULT_RELAY_HOSTNAME}:${DEFAULT_RELAY_PORT}`,
];
const cleanup = initiateRelay(
wrappedCallback,
dappPrivateKey,
walletPublicKeyNostr ?? new Uint8Array(33),
new Uint8Array(33),
{
explicitRelayUrls: relayUrls,
reconnectInterval: options.reconnectInterval,

View file

@ -4,7 +4,6 @@
export { RelayClient } from "./relay-client.js";
export type { RelayClientConfig } from "./relay-client.js";
export { SimplePool } from "nostr-tools/pool";
export { RelayStatus, initiateRelay } from "./relay-handler.js";
export type {
RelayUpdatePayload,
@ -21,7 +20,6 @@ export {
DEFAULT_RELAY_HOSTNAME,
DEFAULT_RELAY_PORT,
DEFAULT_RELAY_PROTOCOL,
DEFAULT_RELAY_URLS,
} from "./key-exchange.js";
export type {
KeyExchangeCredentials,
@ -31,22 +29,9 @@ export type {
} from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js";
export * from "./protocols/base.js";
export {
CHUNK_EXTENSION_NAME,
CHUNK_EXTENSION_VERSION,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "./transforms/chunk.js";
export {
binToHex,
hexToBin,
binToBech32Padded,
bech32PaddedToBin,
} from "@bitauth/libauth";
export {
parseExtendedJson,
parseExtendedJsonValue,
isExtendedJsonFormat,
toUint8Array,
toBigInt,
} from "./serialize.js";

View file

@ -7,8 +7,6 @@ import {
encodeKeyExchangeURI,
decodeKeyExchangeURI,
generateKeyExchangeCredentials,
DEFAULT_RELAY_URLS,
DEFAULT_RELAY_HOSTNAME,
} from "./key-exchange.js";
describe("key-exchange", () => {
@ -202,7 +200,7 @@ describe("key-exchange", () => {
});
it("should throw error for wrong scheme", () => {
const invalidUri = "wrong://relay.riften.net?p=abc&s=def";
const invalidUri = "wrong://relay.cauldron.quest?p=abc&s=def";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
"Invalid URI scheme",
@ -227,7 +225,7 @@ describe("key-exchange", () => {
});
it("should throw error for missing parameters", () => {
const invalidUri = "wiz://relay.riften.net?p=abc";
const invalidUri = "wiz://relay.cauldron.quest?p=abc";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
"Invalid URI format",
@ -237,7 +235,7 @@ describe("key-exchange", () => {
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.riften.net?p=invalid&s=chars";
const invalidUri = "wiz://relay.cauldron.quest?p=invalid&s=chars";
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
"Invalid bech32 encoding",
@ -252,7 +250,7 @@ describe("key-exchange", () => {
"b".repeat(16),
);
const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || "";
const invalidUri = `wiz://relay.riften.net?p=${shortBech32}&s=${secretPart}`;
const invalidUri = `wiz://relay.cauldron.quest?p=${shortBech32}&s=${secretPart}`;
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
});
@ -264,7 +262,7 @@ describe("key-exchange", () => {
);
const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || "";
const shortBech32 = "q";
const invalidUri = `wiz://relay.riften.net?p=${publicKeyPart}&s=${shortBech32}`;
const invalidUri = `wiz://relay.cauldron.quest?p=${publicKeyPart}&s=${shortBech32}`;
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
});
@ -373,18 +371,4 @@ describe("key-exchange", () => {
expect(decoded.secret).toBe(secret);
});
});
describe("DEFAULT_RELAY_URLS", () => {
it("has relay.riften.net as primary (first entry)", () => {
expect(DEFAULT_RELAY_URLS[0]).toBe("wss://relay.riften.net:443");
});
it("contains relay.cauldron.quest as secondary", () => {
expect(DEFAULT_RELAY_URLS).toContain("wss://relay.cauldron.quest:443");
});
it("primary URL matches DEFAULT_RELAY_HOSTNAME", () => {
expect(DEFAULT_RELAY_URLS[0]).toContain(DEFAULT_RELAY_HOSTNAME);
});
});
});

View file

@ -11,16 +11,10 @@ import {
} from "@bitauth/libauth";
import { deriveNostrPublicKey } from "./utilnostr.js";
export const DEFAULT_RELAY_HOSTNAME = "relay.riften.net";
export const DEFAULT_RELAY_HOSTNAME = "relay.cauldron.quest";
export const DEFAULT_RELAY_PORT = 443;
export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss";
/** All default relay URLs, primary first. */
export const DEFAULT_RELAY_URLS: readonly string[] = [
"wss://relay.riften.net:443",
"wss://relay.cauldron.quest:443",
];
export interface KeyExchangeCredentials {
privateKey: string;
publicKey: string;

View file

@ -29,10 +29,6 @@ export interface DappReadyMessage extends ProtocolMessage {
wallet_discovered: boolean;
dapp_name?: string;
dapp_icon?: string;
/// Transport-level extensions this dapp supports. Presence of a key = support.
/// Distinct from Hdwalletv1Session.extensions which is protocol-level.
/// See docs/transport.md.
extensions?: Record<string, unknown>;
}
export interface WalletReadyMessage extends ProtocolMessage {
@ -52,10 +48,6 @@ export interface WalletReadyMessage extends ProtocolMessage {
public_key: string;
/// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention.
secret: string;
/// Transport-level extensions this wallet supports. Presence of a key = support.
/// Distinct from Hdwalletv1Session.extensions which is protocol-level.
/// See docs/transport.md.
extensions?: Record<string, unknown>;
}
export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage {
@ -101,11 +93,3 @@ export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage {
typeof (msg as DisconnectMessage).reason === "string"
);
}
export interface PingMessage extends ProtocolMessage {
action: RelayMsgAction.Ping;
}
export interface PongMessage extends ProtocolMessage {
action: RelayMsgAction.Pong;
}

View file

@ -1,114 +0,0 @@
// 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 { hexToBin } from "@bitauth/libauth";
import {
sourceOutputToRelay,
transactionToHex,
} from "./hdwalletv1-serialize.js";
import { toUint8Array, toBigInt } from "../serialize.js";
describe("hdwalletv1-serialize", () => {
describe("sourceOutputToRelay", () => {
it("converts native types to relay-safe JSON", () => {
const so = {
outpointTransactionHash: hexToBin("ab".repeat(32)),
outpointIndex: 0,
unlockingBytecode: new Uint8Array(0),
sequenceNumber: 0xffffffff,
valueSatoshis: 200000n,
lockingBytecode: hexToBin("76a914" + "00".repeat(20) + "88ac"),
};
const result = sourceOutputToRelay(so);
expect(result.outpointTransactionHash).toBe("ab".repeat(32));
expect(result.outpointIndex).toBe(0);
expect(result.unlockingBytecode).toBe("");
expect(result.sequenceNumber).toBe(0xffffffff);
expect(result.valueSatoshis).toBe("<bigint: 200000n>");
expect(result.lockingBytecode).toBe("76a914" + "00".repeat(20) + "88ac");
});
it("is JSON-serializable", () => {
const so = {
outpointTransactionHash: new Uint8Array(32),
outpointIndex: 0,
unlockingBytecode: new Uint8Array(0),
sequenceNumber: 0xffffffff,
valueSatoshis: 100000000n,
lockingBytecode: new Uint8Array([0x76, 0xa9]),
};
const result = sourceOutputToRelay(so);
expect(() => JSON.stringify(result)).not.toThrow();
});
it("includes token data when present", () => {
const so = {
outpointTransactionHash: new Uint8Array(32),
outpointIndex: 0,
unlockingBytecode: new Uint8Array(0),
sequenceNumber: 0xffffffff,
valueSatoshis: 546n,
lockingBytecode: new Uint8Array([0x76, 0xa9]),
token: {
category: hexToBin("cc".repeat(32)),
amount: 1000n,
},
};
const result = sourceOutputToRelay(so);
expect(result.token).toBeDefined();
expect(result.token.category).toBe("cc".repeat(32));
expect(result.token.amount).toBe("<bigint: 1000n>");
});
it("round-trips through toBigInt/toUint8Array", () => {
const so = {
outpointTransactionHash: hexToBin("ab".repeat(32)),
outpointIndex: 2,
unlockingBytecode: new Uint8Array(0),
sequenceNumber: 0xffffffff,
valueSatoshis: 200000n,
lockingBytecode: hexToBin("76a914" + "00".repeat(20) + "88ac"),
};
const relay = sourceOutputToRelay(so);
// Simulate what the wallet does: parse the relay JSON
expect(toUint8Array(relay.outpointTransactionHash)).toEqual(
so.outpointTransactionHash,
);
expect(toBigInt(relay.valueSatoshis)).toBe(so.valueSatoshis);
expect(toUint8Array(relay.lockingBytecode)).toEqual(so.lockingBytecode);
});
});
describe("transactionToHex", () => {
it("encodes a simple transaction", () => {
const inputs = [
{
outpointTransactionHash: new Uint8Array(32),
outpointIndex: 0,
unlockingBytecode: new Uint8Array(0),
sequenceNumber: 0xffffffff,
},
];
const outputs = [
{
valueSatoshis: 0n,
lockingBytecode: new Uint8Array([0x6a]), // OP_RETURN
},
];
const hex = transactionToHex(inputs, outputs);
expect(typeof hex).toBe("string");
expect(hex.length).toBeGreaterThan(0);
// Should start with version 2 in little-endian
expect(hex.startsWith("02000000")).toBe(true);
});
});
});

View file

@ -1,90 +0,0 @@
// 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
/**
* Serialization helpers specific to the hdwalletv1 sign-transaction flow.
*
* These convert between native libauth transaction types and the relay-safe
* JSON used in SignTransactionRequest / SignTransactionResponse messages.
*/
import { binToHex, encodeTransaction } from "@bitauth/libauth";
// ---------------------------------------------------------------------------
// Interfaces (mirror libauth transaction shapes)
// ---------------------------------------------------------------------------
export interface SourceOutput {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
unlockingBytecode: Uint8Array;
sequenceNumber: number;
valueSatoshis: bigint;
lockingBytecode: Uint8Array;
token?: {
category: Uint8Array;
amount: bigint;
nft?: { capability?: string; commitment?: Uint8Array };
};
}
interface TxInput {
outpointTransactionHash: Uint8Array;
outpointIndex: number;
unlockingBytecode: Uint8Array;
sequenceNumber: number;
}
interface TxOutput {
valueSatoshis: bigint;
lockingBytecode: Uint8Array;
}
// ---------------------------------------------------------------------------
// Serialization (native types → relay JSON)
// ---------------------------------------------------------------------------
/**
* Convert a source output to relay-safe JSON format.
* Uint8Array fields become hex strings, BigInt becomes `<bigint: Xn>`.
*/
export function sourceOutputToRelay(so: SourceOutput): any {
const result: any = {
outpointTransactionHash: binToHex(so.outpointTransactionHash),
outpointIndex: so.outpointIndex,
unlockingBytecode: binToHex(so.unlockingBytecode),
sequenceNumber: so.sequenceNumber,
valueSatoshis: `<bigint: ${so.valueSatoshis}n>`,
lockingBytecode: binToHex(so.lockingBytecode),
};
if (so.token) {
result.token = {
category: binToHex(so.token.category),
amount: `<bigint: ${so.token.amount}n>`,
...(so.token.nft && {
nft: {
...(so.token.nft.capability !== undefined && {
capability: so.token.nft.capability,
}),
...(so.token.nft.commitment !== undefined && {
commitment: binToHex(so.token.nft.commitment),
}),
},
}),
};
}
return result;
}
/**
* Encode a transaction to hex for relay transport.
*/
export function transactionToHex(
inputs: TxInput[],
outputs: TxOutput[],
version = 2,
locktime = 0,
): string {
return binToHex(encodeTransaction({ inputs, outputs, version, locktime }));
}

View file

@ -1,72 +0,0 @@
// 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 { isSignTransactionRequest, RelayMsgAction } from "./hdwalletv1.js";
describe("isSignTransactionRequest", () => {
const valid = {
action: RelayMsgAction.SignTransactionRequest,
transaction: { transaction: {}, sourceOutputs: [] },
sequence: 1,
inputPaths: [
[0, "receive", 0],
[2, "change", 3],
],
time: 1000,
};
it("accepts valid message with inputPaths", () => {
expect(isSignTransactionRequest(valid)).toBe(true);
});
it("accepts empty inputPaths", () => {
expect(isSignTransactionRequest({ ...valid, inputPaths: [] })).toBe(true);
});
it("rejects missing inputPaths", () => {
const noInputPaths = { ...valid } as Record<string, unknown>;
delete noInputPaths.inputPaths;
expect(isSignTransactionRequest(noInputPaths)).toBe(false);
});
it("rejects non-array inputPaths", () => {
expect(isSignTransactionRequest({ ...valid, inputPaths: "bad" })).toBe(
false,
);
});
it("rejects tuple with wrong types", () => {
expect(
isSignTransactionRequest({
...valid,
inputPaths: [["receive", 0, 1]],
}),
).toBe(false);
});
it("rejects tuple with wrong length", () => {
expect(
isSignTransactionRequest({ ...valid, inputPaths: [[0, "receive"]] }),
).toBe(false);
});
it("rejects missing action", () => {
const noAction = { ...valid } as Record<string, unknown>;
delete noAction.action;
expect(isSignTransactionRequest(noAction)).toBe(false);
});
it("rejects missing transaction", () => {
const noTx = { ...valid } as Record<string, unknown>;
delete noTx.transaction;
expect(isSignTransactionRequest(noTx)).toBeFalsy();
});
it("rejects missing sequence", () => {
const noSeq = { ...valid } as Record<string, unknown>;
delete noSeq.sequence;
expect(isSignTransactionRequest(noSeq)).toBe(false);
});
});

View file

@ -25,14 +25,6 @@ export enum RelayMsgAction {
SignCancel = "sign_cancel",
/// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect",
/// Transport-level: carries one slice of a message that exceeds NIP-44's
/// 65,535-byte plaintext ceiling. See ChunkMessage and docs/transport.md.
/// Not tied to hdwalletv1 semantics — applies to any application protocol.
Chunk = "chunk",
/// Keepalive ping sent by the dapp every 10 s to prevent relay silence timeouts.
Ping = "ping",
/// Keepalive pong sent by the wallet in response to each ping.
Pong = "pong",
}
export interface ProtocolMessage {
@ -40,16 +32,10 @@ export interface ProtocolMessage {
time: number;
}
/// Well-known path names. PathName is an open string — wallets may include
/// additional paths (e.g. "stealth_scan", "stealth_spend", "rpa") via extensions.
export const PATH_RECEIVE = "receive" as const;
export const PATH_CHANGE = "change" as const;
export const PATH_DEFI = "defi" as const;
export type PathName = string;
export type PathName = "receive" | "change" | "defi";
export interface PathXpub {
name: PathName;
name: PathName; // "receive" | "change" | "defi"
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
}
@ -62,19 +48,16 @@ export function isPathXpub(obj: any): obj is PathXpub {
);
}
/** Returns the numeric BIP44 child index for the given well-known path name.
* Returns undefined for extension path names (e.g. "stealth_scan").
/** 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 | undefined {
export function childIndexOfPathName(name: PathName): number {
switch (name) {
case PATH_RECEIVE:
case "receive":
return 0;
case PATH_CHANGE:
case "change":
return 1;
case PATH_DEFI:
case "defi":
return 7;
default:
return undefined;
}
}
@ -112,21 +95,14 @@ export function childIndexOfPathName(name: PathName): number | undefined {
export interface Hdwalletv1Session {
/// BIP32 xpubs for each named derivation path.
paths: PathXpub[];
/// Optional extension data. Each key is an extension name; its presence
/// indicates the wallet supports that extension. The value carries any
/// extension-specific handshake data (or {} if none is needed).
extensions?: Record<string, unknown>;
}
export function isHdwalletv1Session(obj: unknown): obj is Hdwalletv1Session {
const s = obj as Hdwalletv1Session;
return (
obj !== null &&
typeof obj === "object" &&
Array.isArray(s.paths) &&
s.paths.every((p) => isPathXpub(p)) &&
(s.extensions === undefined ||
(typeof s.extensions === "object" && s.extensions !== null))
Array.isArray((obj as Hdwalletv1Session).paths) &&
(obj as Hdwalletv1Session).paths.every((p) => isPathXpub(p))
);
}
@ -138,7 +114,6 @@ export interface SignTransactionRequest extends ProtocolMessage {
action: RelayMsgAction.SignTransactionRequest;
transaction: WcSignTransactionRequest;
sequence: number;
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
}
export interface SignTransactionResponse extends ProtocolMessage {
@ -154,20 +129,6 @@ export interface SignCancelMessage extends ProtocolMessage {
reason?: string;
}
/// Transport-level message carrying one slice of a larger ProtocolMessage.
///
/// All chunks of one logical message share the same msgId and time.
/// `data` is a base64-encoded slice of the UTF-8 bytes of
/// JSON.stringify(originalMessage); concatenate slices in `index` order,
/// base64-decode, UTF-8-decode, then JSON.parse to reconstruct.
export interface ChunkMessage extends ProtocolMessage {
action: RelayMsgAction.Chunk;
msgId: string;
index: number;
total: number;
data: string;
}
// Type guard functions
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
@ -194,16 +155,7 @@ export function isSignTransactionRequest(
msg.action === RelayMsgAction.SignTransactionRequest &&
msg.transaction &&
typeof msg.transaction === "object" &&
typeof msg.sequence === "number" &&
Array.isArray(msg.inputPaths) &&
msg.inputPaths.every(
(p: any) =>
Array.isArray(p) &&
p.length === 3 &&
typeof p[0] === "number" &&
typeof p[1] === "string" &&
typeof p[2] === "number",
)
typeof msg.sequence === "number"
);
}
@ -215,20 +167,3 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
typeof msg.sequence === "number"
);
}
export function isChunkMessage(msg: any): msg is ChunkMessage {
return (
msg &&
typeof msg === "object" &&
msg.action === RelayMsgAction.Chunk &&
typeof msg.msgId === "string" &&
typeof msg.index === "number" &&
typeof msg.total === "number" &&
typeof msg.data === "string" &&
Number.isInteger(msg.index) &&
Number.isInteger(msg.total) &&
msg.total >= 1 &&
msg.index >= 0 &&
msg.index < msg.total
);
}

View file

@ -1,305 +0,0 @@
import { describe, it, expect, vi, afterEach, type Mock } from "vitest";
import { generateRandomBytes, secp256k1 } from "@bitauth/libauth";
// Mock nostr-tools and isomorphic-ws before importing RelayClient
vi.mock("nostr-tools/nip59", () => ({
wrapEvent: vi.fn(() => ({ kind: 1059, content: "wrapped" })),
unwrapEvent: vi.fn((_event: any, _key: any) => ({
kind: 14,
content: '{"action":"dapp_ready","time":9999999999}',
pubkey: "aa".repeat(32),
})),
}));
vi.mock("nostr-tools/pool", () => ({
SimplePool: vi.fn(),
useWebSocketImplementation: vi.fn(),
}));
vi.mock("isomorphic-ws", () => ({ default: vi.fn() }));
import { RelayClient } from "./relay-client.js";
import type { SimplePool, SubCloser } from "nostr-tools/pool";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makePrivateKey(): Uint8Array {
let key: Uint8Array;
do {
key = generateRandomBytes(32);
} while (typeof secp256k1.derivePublicKeyCompressed(key) === "string");
return key;
}
interface MockPoolHandles {
pool: SimplePool;
triggerEose: () => void;
triggerClose: (reasons: string[]) => void;
publishMock: Mock;
}
function makeMockPool(): MockPoolHandles {
let onEose: (() => void) | null = null;
let onClose: ((reasons: string[]) => void) | null = null;
const closeFn = vi.fn();
const publishMock = vi.fn(() => [Promise.resolve("")]);
const pool = {
subscribeMany: vi.fn(
(
_urls: string[],
_filter: any,
callbacks: {
onevent: (event: any) => void;
oneose: () => void;
onclose: (reasons: string[]) => void;
},
) => {
onEose = callbacks.oneose;
onClose = callbacks.onclose;
return { close: closeFn } as SubCloser;
},
),
publish: publishMock,
close: vi.fn(),
} as unknown as SimplePool;
return {
pool,
triggerEose: () => onEose?.(),
triggerClose: (reasons: string[]) => onClose?.(reasons),
publishMock,
};
}
function makeClient(pool: SimplePool) {
const privateKey = makePrivateKey();
const pairedKey = makePrivateKey();
return new RelayClient(
{
explicitRelayUrls: ["wss://test.relay:443"],
signerPrivateKey: privateKey,
pairedPublicKey: pairedKey,
logNetworkActivity: false,
},
pool,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("RelayClient — publish failure triggers disconnect", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("emits disconnect on publish failure", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
const disconnects: Error[] = [];
client.on("disconnect", (err: Error) => disconnects.push(err));
await client.connect();
triggerEose();
publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]);
await expect(
client.relay({
action: "dapp_ready" as any,
time: Math.floor(Date.now() / 1000),
}),
).rejects.toThrow(); // Promise.any wraps in AggregateError
expect(disconnects).toHaveLength(1);
});
it("still throws the error to the caller", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
client.on("disconnect", () => {}); // prevent unhandled
await client.connect();
triggerEose();
publishMock.mockReturnValueOnce([Promise.reject(new Error("relay down"))]);
await expect(
client.relay({
action: "dapp_ready" as any,
time: Math.floor(Date.now() / 1000),
}),
).rejects.toThrow();
});
it("does not double-emit disconnect from publish failure + onclose race", async () => {
const { pool, triggerEose, triggerClose, publishMock } = makeMockPool();
const client = makeClient(pool);
const disconnects: Error[] = [];
client.on("disconnect", (err: Error) => disconnects.push(err));
await client.connect();
triggerEose();
publishMock.mockReturnValueOnce([Promise.reject(new Error("send failed"))]);
await expect(
client.relay({
action: "dapp_ready" as any,
time: Math.floor(Date.now() / 1000),
}),
).rejects.toThrow();
// Subscription onclose also fires (race condition)
triggerClose(["relay gone"]);
expect(disconnects).toHaveLength(1);
});
it("reconnect resets the disconnect guard so future failures emit again", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
const disconnects: Error[] = [];
client.on("disconnect", (err: Error) => disconnects.push(err));
await client.connect();
triggerEose();
// First publish failure
publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 1"))]);
await client
.relay({ action: "dapp_ready" as any, time: 1 })
.catch(() => {});
expect(disconnects).toHaveLength(1);
// Reconnect
await client.connect();
triggerEose();
// Second publish failure — should emit again
publishMock.mockReturnValueOnce([Promise.reject(new Error("fail 2"))]);
await client
.relay({ action: "dapp_ready" as any, time: 2 })
.catch(() => {});
expect(disconnects).toHaveLength(2);
});
});
describe("RelayClient — isConnected", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns false before connect", () => {
const { pool } = makeMockPool();
const client = makeClient(pool);
expect(client.isConnected()).toBe(false);
});
it("returns true after connect", async () => {
const { pool } = makeMockPool();
const client = makeClient(pool);
await client.connect();
expect(client.isConnected()).toBe(true);
});
it("returns false after disconnect", async () => {
const { pool } = makeMockPool();
const client = makeClient(pool);
await client.connect();
await client.disconnect();
expect(client.isConnected()).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Chunking — sender path
// ---------------------------------------------------------------------------
describe("RelayClient — chunk sender path", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not chunk small messages (regression guard)", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
client.setPeerCapabilities({ chunk: true });
await client.connect();
triggerEose();
await client.relay({
action: "dapp_ready" as any,
time: Math.floor(Date.now() / 1000),
});
expect(publishMock).toHaveBeenCalledTimes(1);
});
it("throws a clear error for oversized messages when peer lacks chunk support", async () => {
const { pool, triggerEose } = makeMockPool();
const client = makeClient(pool);
// Intentionally NOT calling setPeerCapabilities({chunk: true})
client.on("disconnect", () => {}); // prevent unhandled
await client.connect();
triggerEose();
const huge = {
action: "sign_transaction_request" as any,
time: Math.floor(Date.now() / 1000),
payload: "x".repeat(200_000),
};
await expect(client.relay(huge)).rejects.toThrow(
/does not advertise the 'chunk' transport extension/,
);
});
it("splits oversized messages into multiple publish calls when peer supports chunking", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
client.setPeerCapabilities({ chunk: true });
await client.connect();
triggerEose();
const huge = {
action: "sign_transaction_request" as any,
time: Math.floor(Date.now() / 1000),
payload: "x".repeat(200_000),
};
await client.relay(huge);
// 200 KB payload should produce multiple chunks; each chunk is one publish.
expect(publishMock.mock.calls.length).toBeGreaterThan(1);
});
it("setPeerCapabilities only updates provided keys", async () => {
const { pool, triggerEose } = makeMockPool();
const client = makeClient(pool);
await client.connect();
triggerEose();
// Enable, then call with empty object — should not disable
client.setPeerCapabilities({ chunk: true });
client.setPeerCapabilities({});
const { publishMock } = makeMockPool(); // fresh count - not really needed
void publishMock;
const huge = {
action: "sign_transaction_request" as any,
time: Math.floor(Date.now() / 1000),
payload: "x".repeat(200_000),
};
// Should not throw, because chunk capability remains enabled
await expect(client.relay(huge)).resolves.toBeUndefined();
});
});

View file

@ -3,19 +3,18 @@
// 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 {
SimplePool,
useWebSocketImplementation,
type SubCloser,
} from "nostr-tools/pool";
import { wrapEvent, unwrapEvent } from "nostr-tools/nip59";
import type { NostrEvent } from "nostr-tools/core";
import WebSocket from "isomorphic-ws";
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 {
ChunkMessage,
isChunkMessage,
isProtocolMessage,
ProtocolMessage,
RelayMsgAction,
@ -23,16 +22,6 @@ import {
import { deriveNostrPublicKey } from "./utilnostr.js";
import { MessageQueue } from "./message-queue.js";
import { debug, error as logError, Scope } from "./log.js";
import {
ChunkReassembler,
needsChunking,
splitIntoChunks,
} from "./transforms/chunk.js";
useWebSocketImplementation(WebSocket);
const KIND_GIFT_WRAP = 1059;
const KIND_PRIVATE_DIRECT_MESSAGE = 14;
export interface RelayClientConfig {
explicitRelayUrls: string[];
@ -42,32 +31,14 @@ export interface RelayClientConfig {
}
export class RelayClient extends EventEmitter {
// Keyed by "walletPubkeyHex:dappPubkeyHex". Persists the high-water mark
// across RelayClient instance teardowns within the same JS session so that
// reconnects after an explicit disconnect()+connect() still filter
// relay-replayed messages from the prior session.
private static readonly sessionTimestamps = new Map<string, number>();
private pool: SimplePool;
private sharedPool: boolean;
private pairedPubkeyHex: string;
private ndk: NDK;
private paired: NDKUser;
private config: RelayClientConfig;
private subscription: SubCloser | null = null;
private messageSubscription: NDKSubscription | null = null;
private myPubkey: Uint8Array;
private myPubkeyHex: string;
private lastProcessedTimestamp: number = 0;
private messageQueue: MessageQueue;
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
private disconnecting: boolean = false;
/// Capability flag: peer advertised support for the `chunk` transport
/// extension in its dapp_ready / wallet_ready. Set via setPeerCapabilities.
private peerSupportsChunk: boolean = false;
/// Receiver-side reassembly buffer. Always active — if no chunks arrive it
/// stays empty. Started in connect(), stopped in disconnect().
private reassembler: ChunkReassembler;
private sequence: number = Math.floor(
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
@ -89,34 +60,23 @@ export class RelayClient extends EventEmitter {
}
>();
private get sessionKey(): string | null {
return this.pairedPubkeyHex
? `${this.myPubkeyHex}:${this.pairedPubkeyHex}`
: null;
}
constructor(config: RelayClientConfig, pool?: SimplePool) {
constructor(config: RelayClientConfig) {
super();
this.config = {
logNetworkActivity: true,
...config,
};
this.pool = pool ?? new SimplePool({ enablePing: true });
this.sharedPool = pool !== undefined;
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,
});
// Reassembled messages take the same post-decryption path as unchunked ones.
// Chunks themselves have already passed the peer filter and timestamp dedup
// on ingress (see routeIncoming), so the assembled message is handed directly
// to the application-level handler.
this.reassembler = new ChunkReassembler(
(msg) => this.handleRelayMessage(msg),
!!this.config.logNetworkActivity,
);
this.myPubkey = unwrap(
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
);
@ -127,17 +87,10 @@ export class RelayClient extends EventEmitter {
this.config.pairedPublicKey.length === 33
? this.config.pairedPublicKey.slice(1)
: this.config.pairedPublicKey;
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
} else {
this.pairedPubkeyHex = "";
this.paired = new NDKUser({ pubkey: "" });
}
// Restore persisted high-water mark for this wallet+dapp pair (survives
// instance recreation within the same JS session).
const saved = this.sessionKey
? RelayClient.sessionTimestamps.get(this.sessionKey)
: undefined;
if (saved) this.lastProcessedTimestamp = saved;
}
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
@ -146,27 +99,10 @@ export class RelayClient extends EventEmitter {
pairedPublicKey.length === 33
? pairedPublicKey.slice(1)
: pairedPublicKey;
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
// Now that we have the full key, restore any persisted timestamp.
const saved = RelayClient.sessionTimestamps.get(this.sessionKey!);
if (saved && saved > this.lastProcessedTimestamp) {
this.lastProcessedTimestamp = saved;
}
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
this.emit("paired");
}
/// Set transport-level capability flags based on the peer's advertisement in
/// its dapp_ready / wallet_ready `extensions` field. Called by the connection
/// manager after the handshake. New capability keys are additive — callers
/// may omit any they don't set.
setPeerCapabilities(caps: { chunk?: boolean }): void {
if (caps.chunk !== undefined) {
this.peerSupportsChunk = caps.chunk;
}
}
getPublicKey(): Uint8Array {
return this.myPubkey;
}
@ -182,68 +118,129 @@ export class RelayClient extends EventEmitter {
return !this.config.pairedPublicKey.every((byte) => byte === 0);
}
private emitDisconnect(error: Error): void {
if (this.disconnecting) return;
this.disconnecting = true;
this.emit("disconnect", error);
}
async connect(): Promise<void> {
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Connecting to relay...`);
}
this.disconnecting = false;
this.reassembler.start();
if (this.lastProcessedTimestamp === 0) {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
}
try {
this.subscription = this.pool.subscribeMany(
this.config.explicitRelayUrls,
{ kinds: [KIND_GIFT_WRAP], "#p": [this.myPubkeyHex] },
await this.ndk.connect();
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `NDK connect() resolved`);
}
this.messageSubscription = this.ndk.subscribe(
{
onevent: (event: NostrEvent) => this.handleWrappedEvent(event),
oneose: () => {
if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId);
this.readyTimeoutId = null;
}
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `EOSE received, relay connected`);
}
this.messageQueue.setReady((msg) => this.publishMessage(msg));
this.emit("connection");
},
onclose: (reasons: string[]) => {
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Subscription closed: ${reasons.join(", ")}`);
}
this.emitDisconnect(new Error("Subscription closed"));
},
kinds: [NDKKind.GiftWrap],
"#p": [this.myPubkeyHex],
},
{ closeOnEose: false },
);
// Fallback: if EOSE doesn't arrive within 5 seconds, assume ready
this.readyTimeoutId = setTimeout(() => {
if (!this.messageQueue.getReady()) {
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `EOSE timeout, assuming ready`);
this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => {
try {
const signer = this.ndk.signer;
if (!signer) {
throw new Error("No signer available");
}
this.messageQueue.setReady((msg) => this.publishMessage(msg));
this.emit("connection");
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.readyTimeoutId = null;
}, 5000);
});
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, waiting for relay connection`,
`Subscription created and handler set up, emitting connection event`,
);
}
this.emit("connection");
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, `Connection failed:`, error);
@ -254,24 +251,11 @@ export class RelayClient extends EventEmitter {
async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
this.messageQueue.setNotReady();
this.reassembler.stop();
if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId);
this.readyTimeoutId = null;
}
if (this.subscription) {
this.subscription.close();
this.subscription = null;
}
if (!this.sharedPool) {
this.pool.close(this.config.explicitRelayUrls);
if (this.messageSubscription) {
this.messageSubscription.stop();
this.messageSubscription = null;
}
}
@ -298,201 +282,82 @@ export class RelayClient extends EventEmitter {
}
private async publishMessage(message: ProtocolMessage): Promise<void> {
const serialized = JSON.stringify(message);
this.netlog("send", message.action);
if (!needsChunking(serialized)) {
return this.publishSerialized(message.action, serialized);
const signer = this.ndk.signer;
if (!signer) {
throw new Error("No signer available");
}
// Oversized. Chunk if the peer supports it; otherwise fail loudly with
// an actionable message (replacing nostr-tools' cryptic plaintext-size error).
if (!this.peerSupportsChunk) {
const err = new Error(
`Cannot send ${message.action}: message is larger than NIP-44's 65,535-byte ceiling ` +
`and the peer does not advertise the 'chunk' transport extension. ` +
`Please update the connected wallet/dapp to a version that supports chunked messages.`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, err.message);
}
throw err;
}
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 chunks = splitIntoChunks(serialized);
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Chunking ${message.action}: ${chunks.length} chunks (serialized ~${serialized.length} bytes)`,
);
}
for (const chunk of chunks) {
await this.publishSerialized(
`${message.action}[chunk ${chunk.index + 1}/${chunk.total}]`,
JSON.stringify(chunk),
);
}
}
const wrappedEvent = await giftWrap(rumor, this.paired, signer);
/// Wrap and publish one gift-wrap event. Used for both unchunked messages
/// and individual chunks. `displayAction` is only used for logs.
private async publishSerialized(
displayAction: string,
serialized: string,
): Promise<void> {
this.netlog("send", displayAction);
const wrapped = wrapEvent(
{
kind: KIND_PRIVATE_DIRECT_MESSAGE,
content: serialized,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.pairedPubkeyHex]],
},
this.config.signerPrivateKey,
this.pairedPubkeyHex,
);
const results = await Promise.allSettled(
this.pool.publish(this.config.explicitRelayUrls, wrapped),
);
const fulfilled = results.filter((r) => r.status === "fulfilled");
const rejected = results.filter((r) => r.status === "rejected");
if (rejected.length > 0 && this.config.logNetworkActivity) {
for (const r of rejected) {
logError(
Scope.Relay,
`Failed to publish ${displayAction} to a relay:`,
(r as PromiseRejectedResult).reason,
);
}
}
if (fulfilled.length === 0) {
const error = new Error(
`Failed to publish ${displayAction} to all relays`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, error.message);
}
this.emitDisconnect(error);
throw error;
}
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Published message ${displayAction} to ${fulfilled.length}/${results.length} relay(s)`,
);
}
}
private handleWrappedEvent(wrappedEvent: NostrEvent): void {
try {
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
if (rumor.kind !== KIND_PRIVATE_DIRECT_MESSAGE) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
);
}
return;
await wrappedEvent.publish();
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Published message ${message.action} to relay`);
}
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;
}
this.routeIncoming(payload, rumor.pubkey);
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Error handling incoming message:", error);
logError(
Scope.Relay,
`Failed to publish message ${message.action}:`,
error,
);
}
this.emitError(error as Error);
throw error;
}
}
/// Apply timestamp dedup + peer filter, then dispatch to chunk reassembly
/// or the application-level handler. Called from handleWrappedEvent (one
/// path: unwrap → route). Kept separate to keep handleWrappedEvent focused
/// on decryption and to allow future transport-layer transforms to invoke
/// this path with already-decoded payloads.
private routeIncoming(payload: ProtocolMessage, fromPubkey: string): void {
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})`,
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;
}
}
return;
}
// Advance the high-water mark so relay replays are filtered on reconnect.
// Also persist to the static session cache so a fresh RelayClient instance
// for the same wallet+dapp pair inherits this mark.
if (payload.time > this.lastProcessedTimestamp) {
this.lastProcessedTimestamp = payload.time;
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
}
// 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 (fromPubkey !== pairedNostrPubkey) {
if (Date.now() - startTime < maxWaitTime) {
setTimeout(checkRelays, checkInterval);
} else {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring '${payload.action}' message from unknown peer: ${fromPubkey} (expected: ${pairedNostrPubkey})`,
"Relay ready check timeout, assuming ready and processing queued messages",
);
}
return;
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
}
}
};
// Transport-level branch: a ChunkMessage is routed to the reassembler,
// which will emit a reassembled ProtocolMessage via handleRelayMessage
// once all pieces arrive.
if (isChunkMessage(payload)) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Received chunk ${payload.index + 1}/${payload.total} (msgId=${payload.msgId})`,
);
}
this.reassembler.ingest(payload as ChunkMessage);
return;
}
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Received message ${payload.action} from relay`);
}
this.handleRelayMessage(payload);
setTimeout(checkRelays, 200);
}
isConnected(): boolean {
return this.subscription !== null;
return true;
}
private netlog(

View file

@ -1,101 +0,0 @@
// 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 { binToHex } from "@bitauth/libauth";
import {
parseExtendedJson,
parseExtendedJsonValue,
isExtendedJsonFormat,
toUint8Array,
toBigInt,
} from "./serialize.js";
describe("wc-serialize", () => {
describe("parseExtendedJsonValue", () => {
it("parses bigint format", () => {
expect(parseExtendedJsonValue("<bigint: 546n>")).toBe(546n);
expect(parseExtendedJsonValue("<bigint: 0n>")).toBe(0n);
expect(parseExtendedJsonValue("<bigint: 100000000n>")).toBe(100000000n);
});
it("parses Uint8Array format", () => {
const result = parseExtendedJsonValue("<Uint8Array: 0xabcd>");
expect(result).toBeInstanceOf(Uint8Array);
expect(binToHex(result as Uint8Array)).toBe("abcd");
});
it("returns original string for non-extended values", () => {
expect(parseExtendedJsonValue("hello")).toBe("hello");
expect(parseExtendedJsonValue("123")).toBe("123");
});
});
describe("isExtendedJsonFormat", () => {
it("detects bigint format", () => {
expect(isExtendedJsonFormat("<bigint: 100n>")).toBe(true);
});
it("detects Uint8Array format", () => {
expect(isExtendedJsonFormat("<Uint8Array: 0xabcd>")).toBe(true);
});
it("rejects plain strings", () => {
expect(isExtendedJsonFormat("hello")).toBe(false);
expect(isExtendedJsonFormat("123")).toBe(false);
});
});
describe("parseExtendedJson", () => {
it("parses JSON with mixed extended values", () => {
const json = JSON.stringify({
value: "<bigint: 200000n>",
data: "<Uint8Array: 0x76a9>",
name: "test",
count: 42,
});
const result = parseExtendedJson(json);
expect(result.value).toBe(200000n);
expect(result.data).toBeInstanceOf(Uint8Array);
expect(binToHex(result.data)).toBe("76a9");
expect(result.name).toBe("test");
expect(result.count).toBe(42);
});
});
describe("toUint8Array", () => {
it("passes through Uint8Array", () => {
const bytes = new Uint8Array([1, 2, 3]);
expect(toUint8Array(bytes)).toBe(bytes);
});
it("converts hex string", () => {
const result = toUint8Array("abcd");
expect(binToHex(result)).toBe("abcd");
});
it("converts extended JSON format", () => {
const result = toUint8Array("<Uint8Array: 0xabcd>");
expect(binToHex(result)).toBe("abcd");
});
});
describe("toBigInt", () => {
it("passes through bigint", () => {
expect(toBigInt(546n)).toBe(546n);
});
it("converts number", () => {
expect(toBigInt(546)).toBe(546n);
});
it("converts numeric string", () => {
expect(toBigInt("546")).toBe(546n);
});
it("converts extended JSON format", () => {
expect(toBigInt("<bigint: 546n>")).toBe(546n);
});
});
});

View file

@ -1,92 +0,0 @@
// 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
/**
* Serialization helpers for converting between native libauth types and the
* relay-safe JSON format used by the WizardConnect protocol.
*
* The relay transmits messages as JSON, which cannot represent BigInt or
* Uint8Array natively. This module provides the canonical encoding:
*
* - Uint8Array hex string (or `<Uint8Array: 0x...>` extended format)
* - BigInt `<bigint: Xn>` string
*
* Both dapps and wallets should use these helpers to ensure interoperability.
*/
import { hexToBin } from "@bitauth/libauth";
// ---------------------------------------------------------------------------
// Deserialization (relay JSON → native types)
// ---------------------------------------------------------------------------
const BIGINT_RE = /^<bigint: (?<bigint>[0-9]*)n>$/;
const UINT8_RE = /^<Uint8Array: 0x(?<hex>[0-9a-f]*)>$/u;
/**
* Parse a full JSON string that may contain extended-format values.
* Handles both `<Uint8Array: 0x...>` and `<bigint: ...n>` formats.
*/
export function parseExtendedJson(jsonString: string): any {
return JSON.parse(jsonString, (_key, value) => {
if (typeof value === "string") {
const bigintMatch = value.match(BIGINT_RE);
if (bigintMatch) return BigInt(bigintMatch[1]);
const uint8Match = value.match(UINT8_RE);
if (uint8Match) return hexToBin(uint8Match[1]);
}
return value;
});
}
/**
* Check if a string contains extended JSON markers.
*/
export function isExtendedJsonFormat(str: string): boolean {
return UINT8_RE.test(str) || BIGINT_RE.test(str);
}
/**
* Parse a single extended JSON value string to its native type.
* Returns the original string if it doesn't match any known format.
*/
export function parseExtendedJsonValue(
value: string,
): Uint8Array | bigint | string {
const bigintMatch = value.match(BIGINT_RE);
if (bigintMatch) return BigInt(bigintMatch[1]);
const uint8Match = value.match(UINT8_RE);
if (uint8Match) return hexToBin(uint8Match[1]);
return value;
}
/**
* Convert a value to Uint8Array. Accepts:
* - Uint8Array (returned as-is)
* - hex string
* - extended JSON format string (`<Uint8Array: 0x...>`)
*/
export function toUint8Array(value: string | Uint8Array): Uint8Array {
if (value instanceof Uint8Array) return value;
if (isExtendedJsonFormat(value))
return parseExtendedJsonValue(value) as Uint8Array;
return hexToBin(value);
}
/**
* Convert a value to bigint. Accepts:
* - bigint (returned as-is)
* - number
* - numeric string
* - extended JSON format string (`<bigint: Xn>`)
*/
export function toBigInt(value: string | number | bigint): bigint {
if (typeof value === "bigint") return value;
if (typeof value === "string") {
if (isExtendedJsonFormat(value))
return parseExtendedJsonValue(value) as bigint;
return BigInt(value);
}
return BigInt(value);
}

View file

@ -1,322 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import {
ChunkReassembler,
CHUNK_REQUIRED_BYTES,
CHUNK_RAW_BYTES,
REASSEMBLY_TTL_MS,
chunkExtensionAdvertisement,
needsChunking,
peerSupportsChunk,
splitIntoChunks,
} from "./chunk.js";
import {
ChunkMessage,
ProtocolMessage,
RelayMsgAction,
} from "../protocols/hdwalletv1.js";
// ---------------------------------------------------------------------------
// Pure functions
// ---------------------------------------------------------------------------
describe("chunk extension advertisement helpers", () => {
it("chunkExtensionAdvertisement returns a version-tagged object", () => {
const adv = chunkExtensionAdvertisement();
expect(adv).toHaveProperty("version");
expect(typeof adv.version).toBe("number");
});
it("peerSupportsChunk true iff `chunk` key present", () => {
expect(peerSupportsChunk(undefined)).toBe(false);
expect(peerSupportsChunk({})).toBe(false);
expect(peerSupportsChunk({ chunk: { version: 1 } })).toBe(true);
expect(peerSupportsChunk({ chunk: {} })).toBe(true);
// presence of other keys doesn't imply chunk support
expect(peerSupportsChunk({ compress: {} })).toBe(false);
});
});
describe("needsChunking", () => {
it("returns false for small payloads", () => {
expect(needsChunking("hello")).toBe(false);
expect(needsChunking("a".repeat(1000))).toBe(false);
});
it("returns true when UTF-8 bytes exceed the ceiling-minus-overhead", () => {
expect(needsChunking("a".repeat(CHUNK_REQUIRED_BYTES + 1))).toBe(true);
});
it("returns false right at the threshold", () => {
// ASCII: 1 byte per char. CHUNK_REQUIRED_BYTES chars fits.
expect(needsChunking("a".repeat(CHUNK_REQUIRED_BYTES))).toBe(false);
});
it("accounts for multibyte UTF-8 (emoji)", () => {
// "🦀" = 4 UTF-8 bytes. 20,000 crabs = 80,000 bytes > CHUNK_REQUIRED_BYTES.
const s = "🦀".repeat(20_000);
expect(needsChunking(s)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Splitter
// ---------------------------------------------------------------------------
describe("splitIntoChunks", () => {
it("produces one chunk for tiny input", () => {
const chunks = splitIntoChunks(JSON.stringify({ hello: "world" }));
expect(chunks).toHaveLength(1);
expect(chunks[0].index).toBe(0);
expect(chunks[0].total).toBe(1);
});
it("shares one msgId and one time across chunks", () => {
const big = "x".repeat(CHUNK_RAW_BYTES * 5);
const chunks = splitIntoChunks(big);
expect(chunks.length).toBeGreaterThan(1);
const firstId = chunks[0].msgId;
const firstTime = chunks[0].time;
for (const c of chunks) {
expect(c.msgId).toBe(firstId);
expect(c.time).toBe(firstTime);
}
});
it("every chunk passes the NIP-17 gift-wrap round-trip without exceeding NIP-44", async () => {
// The real constraint isn't just that the chunk's JSON fits in 65,535
// plaintext bytes — it's that the OUTER gift-wrap's plaintext (which
// contains the seal, which contains the 4/3×-expanded encrypted rumor)
// also fits. This test exercises the full wrapEvent path.
const { wrapEvent } = await import("nostr-tools/nip59");
const { generateSecretKey, getPublicKey } =
await import("nostr-tools/pure");
const senderPriv = generateSecretKey();
const recipPub = getPublicKey(generateSecretKey());
const big = "x".repeat(CHUNK_RAW_BYTES * 10);
const chunks = splitIntoChunks(big);
for (const c of chunks) {
expect(() =>
wrapEvent(
{
kind: 14,
content: JSON.stringify(c),
created_at: Math.floor(Date.now() / 1000),
tags: [["p", recipPub]],
},
senderPriv,
recipPub,
),
).not.toThrow();
}
});
it("uses sequential indices starting at 0", () => {
const big = "x".repeat(CHUNK_RAW_BYTES * 4);
const chunks = splitIntoChunks(big);
chunks.forEach((c, i) => {
expect(c.index).toBe(i);
expect(c.total).toBe(chunks.length);
});
});
it("accepts caller-supplied msgId and time", () => {
const chunks = splitIntoChunks("hello", {
msgId: "fixed-id",
time: 12345,
});
expect(chunks[0].msgId).toBe("fixed-id");
expect(chunks[0].time).toBe(12345);
});
});
// ---------------------------------------------------------------------------
// Reassembler — round-trip
// ---------------------------------------------------------------------------
describe("ChunkReassembler round-trip", () => {
it("reassembles a small message", () => {
const original: ProtocolMessage = {
action: "sign_transaction_request" as any,
time: 1000,
};
const chunks = splitIntoChunks(JSON.stringify(original));
const received: ProtocolMessage[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (const c of chunks) r.ingest(c);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(original);
});
it("reassembles a ~2MB message (simulates signed tx hex response)", () => {
const original = {
action: "sign_transaction_response",
time: 1000,
sequence: 42,
signedTransaction: "ab".repeat(1_000_000), // 2 MB hex
};
const chunks = splitIntoChunks(JSON.stringify(original));
expect(chunks.length).toBeGreaterThan(30);
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (const c of chunks) r.ingest(c);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(original);
});
it("reassembles when chunks arrive in reverse order", () => {
const original = {
action: "sign_transaction_request",
time: 1,
payload: "x".repeat(CHUNK_RAW_BYTES * 3),
};
const chunks = splitIntoChunks(JSON.stringify(original));
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (let i = chunks.length - 1; i >= 0; i--) r.ingest(chunks[i]);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(original);
});
it("reassembles when chunks arrive in shuffled order", () => {
const original = {
action: "sign_transaction_request",
time: 1,
payload: "x".repeat(CHUNK_RAW_BYTES * 4),
};
const chunks = splitIntoChunks(JSON.stringify(original));
const shuffled = [...chunks].sort(() => Math.random() - 0.5);
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (const c of shuffled) r.ingest(c);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(original);
});
it("round-trips multibyte UTF-8 content", () => {
const original = {
action: "custom_action",
time: 1,
crab: "🦀".repeat(20_000), // 80 KB of 4-byte codepoints
japanese: "こんにちは世界".repeat(5_000),
};
const chunks = splitIntoChunks(JSON.stringify(original));
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (const c of chunks) r.ingest(c);
expect(received).toHaveLength(1);
expect(received[0]).toEqual(original);
});
});
// ---------------------------------------------------------------------------
// Reassembler — duplicate / malformed / TTL
// ---------------------------------------------------------------------------
describe("ChunkReassembler edge cases", () => {
it("duplicate chunks are idempotent (delivers exactly once)", () => {
const original = {
action: "a",
time: 1,
x: "y".repeat(CHUNK_RAW_BYTES * 2),
};
const chunks = splitIntoChunks(JSON.stringify(original));
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (const c of chunks) r.ingest(c);
// Replay everything a second time
for (const c of chunks) r.ingest(c);
expect(received).toHaveLength(1);
});
it("does not deliver if a chunk is missing", () => {
const original = {
action: "a",
time: 1,
x: "y".repeat(CHUNK_RAW_BYTES * 3),
};
const chunks = splitIntoChunks(JSON.stringify(original));
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (let i = 0; i < chunks.length - 1; i++) r.ingest(chunks[i]);
expect(received).toHaveLength(0);
});
it("drops a chunk whose total disagrees with the in-flight entry", () => {
const chunkA: ChunkMessage = {
action: RelayMsgAction.Chunk,
time: 1,
msgId: "m",
index: 0,
total: 3,
data: "AAAA",
};
const chunkBadTotal: ChunkMessage = { ...chunkA, index: 1, total: 99 };
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
r.ingest(chunkA);
r.ingest(chunkBadTotal);
expect(r.bufferCount).toBe(1);
expect(received).toHaveLength(0);
});
it("reassembled payload that is not a valid ProtocolMessage is dropped", () => {
// Craft a single chunk carrying junk JSON
const junk = JSON.stringify({ not: "a protocol message" });
const chunks = splitIntoChunks(junk);
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
for (const c of chunks) r.ingest(c);
expect(received).toHaveLength(0);
});
it("reassembled payload with invalid base64/UTF-8 is dropped without crashing", () => {
const chunk: ChunkMessage = {
action: RelayMsgAction.Chunk,
time: 1,
msgId: "bad",
index: 0,
total: 1,
data: "!!!not-base64!!!",
};
const received: any[] = [];
const r = new ChunkReassembler((m) => received.push(m), false);
expect(() => r.ingest(chunk)).not.toThrow();
expect(received).toHaveLength(0);
});
it("TTL sweeper evicts incomplete entries", () => {
let clock = 0;
const r = new ChunkReassembler(
() => {},
false,
() => clock,
);
const original = {
action: "a",
time: 1,
x: "y".repeat(CHUNK_RAW_BYTES * 3),
};
const chunks = splitIntoChunks(JSON.stringify(original));
r.ingest(chunks[0]);
expect(r.bufferCount).toBe(1);
// Advance clock past TTL
clock += REASSEMBLY_TTL_MS + 1;
r.sweep();
expect(r.bufferCount).toBe(0);
});
it("start() installs a periodic sweeper that stop() clears", () => {
vi.useFakeTimers();
const r = new ChunkReassembler(() => {}, false);
r.start();
// Shouldn't throw on repeat start
r.start();
vi.advanceTimersByTime(100_000);
r.stop();
// Shouldn't throw on repeat stop
r.stop();
vi.useRealTimers();
});
});

View file

@ -1,305 +0,0 @@
// 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
/**
* Transport-level chunking.
*
* NIP-44 caps plaintext at 65,535 bytes (the plaintext length is encoded as a
* U16BE prefix in the wire format structural, not a configurable guardrail).
* This module splits oversized ProtocolMessages into a sequence of
* ChunkMessages that each fit under the ceiling, and reassembles them on the
* receiver.
*
* Fire-and-forget: chunks are published individually through the same
* multi-relay path as any other message, with no per-chunk ACKs. Receiver
* buffers by msgId, applies a TTL, and delivers the assembled ProtocolMessage
* once complete.
*
* See docs/transport.md for wire format and failure modes.
*/
import {
ChunkMessage,
ProtocolMessage,
RelayMsgAction,
isProtocolMessage,
} from "../protocols/hdwalletv1.js";
import { debug, error as logError, Scope } from "../log.js";
/// Extension key advertised in base-level `extensions` on dapp_ready / wallet_ready.
export const CHUNK_EXTENSION_NAME = "chunk";
export const CHUNK_EXTENSION_VERSION = 1;
/// NIP-44 plaintext hard limit.
export const NIP44_MAX_PLAINTEXT = 65535;
/// NIP-17 gift-wrap applies NIP-44 encryption twice: once to the rumor
/// (inner) and once to the seal (outer). The 65,535-byte cap applies to the
/// plaintext of each layer. What we hand to wrapEvent becomes the rumor
/// content; that rumor is then JSON-serialized, padded (up to multiples of
/// 8192 for sizes in this range), encrypted, base64'd, and JSON-wrapped in
/// a seal — the outer wrap then encrypts that seal JSON, which must also
/// fit under 65,535 plaintext bytes.
///
/// For a rumor content of C bytes (ASCII) the outer plaintext is roughly:
/// (event_shell≈200) + ceil(4/3 × (32 + pad(C + 200) + 32)) + seal_shell≈300
/// With pad(~40 KB) = 40,960 this totals ≈55 KB — safely under the cap.
/// Above ~40,960 bytes of content the next pad multiple jumps to 49,152,
/// pushing the outer plaintext past the cap.
/// Raw content bytes we'll pack into a single chunk's `data` field (before
/// base64). Sized so that after base64 expansion, envelope overhead, and the
/// two-layer gift-wrap expansion, the outer plaintext stays well under
/// NIP-44's 65,535-byte ceiling. See derivation above.
export const CHUNK_RAW_BYTES = 30000;
/// When JSON.stringify(message)'s UTF-8 byte length exceeds this, chunking
/// is required. Derived from the same outer-wrap size analysis: anything
/// above ~40,760 bytes of content pushes the outer wrap plaintext over
/// 65,535. Conservative headroom built in.
export const CHUNK_REQUIRED_BYTES = 40000;
/// How long an incomplete reassembly buffer survives without progress.
/// Sized for a ~35-chunk transfer (2 MB tx-hex response) under congested
/// relay conditions.
export const REASSEMBLY_TTL_MS = 120_000;
/// How often the TTL sweeper runs.
export const SWEEP_INTERVAL_MS = 10_000;
/// Returns the extension advertisement value for the `chunk` key in the
/// base-level `extensions` field of dapp_ready / wallet_ready.
export function chunkExtensionAdvertisement(): { version: number } {
return { version: CHUNK_EXTENSION_VERSION };
}
/// True iff the peer's `extensions` object advertises support for chunking.
export function peerSupportsChunk(
extensions: Record<string, unknown> | undefined,
): boolean {
return !!extensions && extensions[CHUNK_EXTENSION_NAME] !== undefined;
}
/// Measure UTF-8 byte length without allocating the full encoded buffer
/// (a shallow optimisation for very large payloads).
export function utf8ByteLength(s: string): number {
return new TextEncoder().encode(s).length;
}
/// True iff a message of this serialized size requires chunking.
export function needsChunking(serialized: string): boolean {
return utf8ByteLength(serialized) > CHUNK_REQUIRED_BYTES;
}
/// Split a serialized ProtocolMessage into ChunkMessages. All chunks share
/// one msgId and one `time` so reassembly is deterministic and the
/// reassembled message keeps a coherent timestamp for existing dedup logic.
export function splitIntoChunks(
serialized: string,
opts?: { msgId?: string; time?: number },
): ChunkMessage[] {
const msgId = opts?.msgId ?? newMsgId();
const time = opts?.time ?? Math.floor(Date.now() / 1000);
const utf8 = new TextEncoder().encode(serialized);
const b64 = bytesToBase64(utf8);
// base64 chars per chunk equivalent to CHUNK_RAW_BYTES raw bytes
const sliceChars = Math.ceil((CHUNK_RAW_BYTES * 4) / 3);
const total = Math.max(1, Math.ceil(b64.length / sliceChars));
const chunks: ChunkMessage[] = [];
for (let i = 0; i < total; i++) {
chunks.push({
action: RelayMsgAction.Chunk,
time,
msgId,
index: i,
total,
data: b64.slice(i * sliceChars, (i + 1) * sliceChars),
});
}
return chunks;
}
interface ReassemblyEntry {
total: number;
chunks: (string | undefined)[];
received: number;
expiresAt: number;
}
/**
* Receiver-side chunk reassembler.
*
* Owns a msgId-keyed buffer of partial messages, TTL-evicted by a periodic
* sweeper. When all chunks for a msgId are present the assembled
* ProtocolMessage is handed to `onComplete`.
*
* Not concurrency-safe a single instance is owned by a single RelayClient.
*/
export class ChunkReassembler {
private buffers = new Map<string, ReassemblyEntry>();
/// msgIds that have already been delivered, kept for a short grace period
/// so late-arriving duplicate chunks (e.g. cross-subscription replay after
/// reconnect) don't spawn a second reassembly and double-deliver.
private completed = new Map<string, number /* expiresAt */>();
private sweeperId: ReturnType<typeof setInterval> | null = null;
constructor(
private readonly onComplete: (msg: ProtocolMessage) => void,
private readonly logActivity: boolean = true,
private readonly now: () => number = () => Date.now(),
) {}
start(): void {
if (this.sweeperId !== null) return;
this.sweeperId = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
}
stop(): void {
if (this.sweeperId !== null) {
clearInterval(this.sweeperId);
this.sweeperId = null;
}
this.buffers.clear();
this.completed.clear();
}
/// For tests. Otherwise start() schedules this automatically.
sweep(): void {
const now = this.now();
for (const [id, entry] of this.buffers) {
if (entry.expiresAt <= now) {
this.buffers.delete(id);
if (this.logActivity) {
debug(
Scope.Relay,
`Chunk reassembly timeout: ${id} (${entry.received}/${entry.total} received)`,
);
}
}
}
for (const [id, expiresAt] of this.completed) {
if (expiresAt <= now) this.completed.delete(id);
}
}
/// Ingest one chunk. If this completes the message, onComplete fires.
/// Duplicate chunks (same msgId/index) are idempotent. Malformed chunks
/// are dropped silently.
ingest(chunk: ChunkMessage): void {
if (this.completed.has(chunk.msgId)) {
// Already delivered this message; drop late duplicates.
return;
}
let entry = this.buffers.get(chunk.msgId);
if (!entry) {
entry = {
total: chunk.total,
chunks: new Array(chunk.total),
received: 0,
expiresAt: this.now() + REASSEMBLY_TTL_MS,
};
this.buffers.set(chunk.msgId, entry);
} else if (entry.total !== chunk.total) {
// Protocol invariant violation — peer changed total mid-stream. Drop.
if (this.logActivity) {
logError(
Scope.Relay,
`Chunk total mismatch for ${chunk.msgId}: ${chunk.total} vs expected ${entry.total}`,
);
}
return;
}
if (entry.chunks[chunk.index] !== undefined) {
// Duplicate — already have this slot. SimplePool dedupes by event id
// within a subscription; this guards against the cross-subscription
// replay case after a reconnect.
return;
}
entry.chunks[chunk.index] = chunk.data;
entry.received++;
if (entry.received !== entry.total) return;
// Assemble and deliver.
this.buffers.delete(chunk.msgId);
// Grace period matches reassembly TTL — sufficient to catch any laggard
// duplicates from a slow relay that were in flight when we completed.
this.completed.set(chunk.msgId, this.now() + REASSEMBLY_TTL_MS);
let reassembled: ProtocolMessage;
try {
const fullB64 = entry.chunks.join("");
const bytes = base64ToBytes(fullB64);
const json = new TextDecoder().decode(bytes);
const parsed = JSON.parse(json);
if (!isProtocolMessage(parsed)) {
if (this.logActivity) {
logError(
Scope.Relay,
`Reassembled chunk msgId=${chunk.msgId} is not a valid ProtocolMessage`,
);
}
return;
}
reassembled = parsed;
} catch (e) {
if (this.logActivity) {
logError(
Scope.Relay,
`Chunk reassembly failed for msgId=${chunk.msgId}:`,
e,
);
}
return;
}
if (this.logActivity) {
debug(
Scope.Relay,
`Reassembled chunked message: action=${reassembled.action} chunks=${entry.total}`,
);
}
this.onComplete(reassembled);
}
/// Test helper: number of in-flight partial messages.
get bufferCount(): number {
return this.buffers.size;
}
}
// ---------------------------------------------------------------------------
// Cross-platform base64 (no dependency on Buffer or DOM-specific APIs).
// ---------------------------------------------------------------------------
function bytesToBase64(bytes: Uint8Array): string {
const CHUNK = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK) {
const slice = bytes.subarray(i, i + CHUNK);
binary += String.fromCharCode.apply(null, slice as unknown as number[]);
}
return btoa(binary);
}
function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function newMsgId(): string {
const g = globalThis as { crypto?: { randomUUID?: () => string } };
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
const bytes = new Uint8Array(16);
const cryptoObj = (
globalThis as { crypto?: { getRandomValues?: (b: Uint8Array) => void } }
).crypto;
if (cryptoObj?.getRandomValues) cryptoObj.getRandomValues(bytes);
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

View file

@ -8,7 +8,7 @@ import {
RelayStatusCallback,
initiateRelay,
} from "./relay-handler.js";
import { decodeKeyExchangeURI, DEFAULT_RELAY_URLS } from "./key-exchange.js";
import { decodeKeyExchangeURI, DEFAULT_RELAY_PORT } from "./key-exchange.js";
import { hexToBin } from "@bitauth/libauth";
import { deriveNostrPublicKeyBytes } from "./utilnostr.js";
@ -48,7 +48,7 @@ export function initiateWalletRelay(
const hostname = decoded.hostname;
const protocol = decoded.protocol;
const port = decoded.port;
const relayUrl = `${protocol}://${hostname}:${port}`;
const relayUrl = `${protocol}://${hostname}${port === (protocol === "wss" ? DEFAULT_RELAY_PORT : 80) ? "" : `:${port}`}`;
const walletPublicKeyNostr = deriveNostrPublicKeyBytes(
options.walletPrivateKey,
@ -73,19 +73,6 @@ export function initiateWalletRelay(
};
const relayUrls: string[] = [relayUrl];
// Add remaining default relays for redundancy when the URI points to a
// default relay. Skip when using a custom/private relay — the user chose
// that relay deliberately and may not want traffic on public relays.
const isDefaultRelay = DEFAULT_RELAY_URLS.includes(relayUrl);
if (isDefaultRelay) {
for (const defaultUrl of DEFAULT_RELAY_URLS) {
if (!relayUrls.includes(defaultUrl)) {
relayUrls.push(defaultUrl);
}
}
}
if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) {
for (const explicitUrl of options.explicitRelayUrls) {
if (!relayUrls.includes(explicitUrl)) {

View file

@ -1,13 +1,8 @@
{
"name": "@wizardconnect/dapp",
"version": "0.2.0",
"version": "0.1.2",
"type": "module",
"description": "Dapp-side integration helpers for WizardConnect",
"repository": {
"type": "git",
"url": "https://gitlab.com/riftenlabs/lib/wizardconnect",
"directory": "packages/dapp"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
@ -29,6 +24,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

@ -1,656 +0,0 @@
// 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, vi } from "vitest";
import { encodeHdPublicKey } from "@bitauth/libauth";
import type { PathXpub } from "@wizardconnect/core";
import {
RelayMsgAction,
PROTOCOL_NAME,
DisconnectReason,
} from "@wizardconnect/core";
import type {
WalletReadyMessage,
SignTransactionRequest,
DisconnectMessage,
ProtocolMessage,
} from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
// Build a valid xpub string from a deterministic test node.
// Uses the secp256k1 generator point (a known valid public key).
function makeTestXpub(): string {
const node = {
chainCode: new Uint8Array(32).fill(0xbb),
// secp256k1 generator point (compressed) — always valid
publicKey: Uint8Array.from(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.match(/.{2}/g)!
.map((h) => parseInt(h, 16)),
),
childIndex: 0,
depth: 3,
parentFingerprint: new Uint8Array(4),
};
const result = encodeHdPublicKey({ node, network: "mainnet" });
if (typeof result === "string")
throw new Error("Failed to encode test xpub: " + result);
return result.hdPublicKey;
}
describe("DappConnectionManager", () => {
describe("getSessionPaths", () => {
it("returns empty array before wallet_ready", () => {
const mgr = new DappConnectionManager();
expect(mgr.getSessionPaths()).toEqual([]);
});
});
describe("restoreSessionPaths", () => {
it("restores xpubs so getPubkey works", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
const paths: PathXpub[] = [{ name: "receive", xpub }];
mgr.restoreSessionPaths(paths);
expect(mgr.hasPath(0)).toBe(true);
const pk = mgr.getPubkey(0, 0n);
expect(pk).toBeInstanceOf(Uint8Array);
expect(pk!.length).toBe(33);
});
it("makes paths available via getSessionPaths", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
const paths: PathXpub[] = [
{ name: "receive", xpub },
{ name: "change", xpub },
];
mgr.restoreSessionPaths(paths);
const stored = mgr.getSessionPaths();
expect(stored).toHaveLength(2);
expect(stored[0].name).toBe("receive");
expect(stored[1].name).toBe("change");
});
it("returns a copy, not a reference", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
const paths = mgr.getSessionPaths();
paths.push({ name: "change", xpub });
expect(mgr.getSessionPaths()).toHaveLength(1);
});
it("throws on invalid xpub strings", () => {
const mgr = new DappConnectionManager();
const paths: PathXpub[] = [{ name: "receive", xpub: "not-a-valid-xpub" }];
expect(() => mgr.restoreSessionPaths(paths)).toThrow("Invalid xpub");
});
it("can derive multiple child indices after restore", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
for (const idx of [0n, 1n, 5n, 10n]) {
const pk = mgr.getPubkey(0, idx);
expect(pk, `index ${idx}`).toBeInstanceOf(Uint8Array);
expect(pk!.length, `index ${idx}`).toBe(33);
}
});
it("does not affect paths not in the restore set", () => {
const mgr = new DappConnectionManager();
const xpub = makeTestXpub();
mgr.restoreSessionPaths([{ name: "receive", xpub }]);
expect(mgr.hasPath(0)).toBe(true); // receive = 0
expect(mgr.hasPath(1)).toBe(false); // change = 1
});
});
describe("re-sends pending sign requests on wallet_ready", () => {
function makeTestXpubForSession(): string {
const node = {
chainCode: new Uint8Array(32).fill(0xbb),
publicKey: Uint8Array.from(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.match(/.{2}/g)!
.map((h) => parseInt(h, 16)),
),
childIndex: 0,
depth: 3,
parentFingerprint: new Uint8Array(4),
};
const result = encodeHdPublicKey({ node, network: "mainnet" });
if (typeof result === "string") throw new Error(result);
return result.hdPublicKey;
}
/** Minimal mock of RelayClient — just enough to drive DappConnectionManager. */
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReady(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: {
[PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] },
},
time: Math.floor(Date.now() / 1000),
};
}
it("re-relays pending sign requests when wallet_ready arrives", async () => {
const mgr = new DappConnectionManager();
const client = makeMockClient();
const xpub = makeTestXpubForSession();
// Attach manager to mock client
mgr.updateConnection(client as any, { status: "connected" });
// Send a sign request (wallet is "connected" but hasn't sent wallet_ready yet)
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: client.nextSequence(),
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
const signPromise = mgr.sendSignRequest(request);
// The initial relay call
expect(client.relayed).toHaveLength(2); // dapp_ready + sign request
// Now simulate wallet_ready arriving (wallet just opened)
client.relayed.length = 0;
client.emit("message", makeWalletReady(xpub));
// Wait a tick for the async re-send
await new Promise((r) => setTimeout(r, 10));
// The sign request should have been re-sent
const resent = client.relayed.filter(
(m) => m.action === RelayMsgAction.SignTransactionRequest,
);
expect(resent).toHaveLength(1);
expect(resent[0].sequence).toBe(request.sequence);
// Clean up: resolve the pending promise so it doesn't leak
client.emit("message", {
action: RelayMsgAction.SignTransactionResponse,
sequence: request.sequence,
time: Math.floor(Date.now() / 1000),
transaction: "signed",
});
await signPromise;
});
it("does not re-send if there are no pending requests", async () => {
const mgr = new DappConnectionManager();
const client = makeMockClient();
const xpub = makeTestXpubForSession();
mgr.updateConnection(client as any, { status: "connected" });
client.relayed.length = 0;
client.emit("message", makeWalletReady(xpub));
await new Promise((r) => setTimeout(r, 10));
// Only dapp_ready should have been sent (reactive), no sign requests
const signMsgs = client.relayed.filter(
(m) => m.action === RelayMsgAction.SignTransactionRequest,
);
expect(signMsgs).toHaveLength(0);
});
});
describe("keepalive reconnect grace timer", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeDisconnectMsg(): DisconnectMessage {
return {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("suppresses disconnect when wallet_ready arrives within the grace window", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
// Wallet keepalive watchdog fires UserDisconnect
client.emit("message", makeDisconnectMsg());
vi.advanceTimersByTime(5_000);
expect(disconnectEvents).toHaveLength(0);
// Wallet reconnects and sends wallet_ready before the window expires
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(26_000); // past where the original 30s timer would have fired
expect(disconnectEvents).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it("fires disconnect after the grace window if wallet does not reconnect", () => {
vi.useFakeTimers();
try {
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: Array<{
reason: DisconnectReason;
message: string | undefined;
}> = [];
mgr.on("disconnect", (reason, message) =>
disconnectEvents.push({ reason, message }),
);
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeDisconnectMsg());
vi.advanceTimersByTime(7_999);
expect(disconnectEvents).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(disconnectEvents).toHaveLength(1);
expect(disconnectEvents[0].reason).toBe(
DisconnectReason.UserDisconnect,
);
} finally {
vi.useRealTimers();
}
});
});
describe("ping interval", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("sends a Ping after 10s following wallet_ready", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(9_999);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("stops sending pings once the grace timer fires on disconnect", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
client.emit("message", {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
});
// Grace timer fires at 30s and stops the ping interval
vi.advanceTimersByTime(30_000);
expect(disconnectEvents).toHaveLength(1);
// No further pings after the interval was stopped
client.relayed.length = 0;
vi.advanceTimersByTime(30_000);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it("resets the ping interval when wallet reconnects", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
mgr.updateConnection(client as any, { status: "connected" });
// First wallet_ready — ping interval starts (would fire at t=10s)
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(5_000); // t=5s, no ping yet
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
// Wallet reconnects — second wallet_ready resets the interval
client.emit("message", makeWalletReadyMsg(xpub));
client.relayed.length = 0;
// Old interval would have fired at t=15s (10s from now); new fires at t=25s
vi.advanceTimersByTime(9_000); // t=14s — still no ping
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
vi.advanceTimersByTime(1_000); // t=15s — new interval fires
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("enters reconnecting state after 75s with no pong or wallet_ready (does not disconnect)", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
const reconnectingEvents: number[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.on("reconnecting", () => reconnectingEvents.push(1));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Still alive just before the threshold
vi.advanceTimersByTime(74_999);
expect(disconnectEvents).toHaveLength(0);
expect(reconnectingEvents).toHaveLength(0);
// Next interval check pushes past 75s — liveness timeout fires reconnecting, not disconnect
vi.advanceTimersByTime(5_001); // advances to t=80s (next 10s interval)
expect(disconnectEvents).toHaveLength(0);
expect(reconnectingEvents).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("recovers to connected when wallet_ready arrives after liveness timeout", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
const walletReadyEvents: number[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.on("walletready", () => walletReadyEvents.push(1));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Liveness timeout fires at t=80s
vi.advanceTimersByTime(80_001);
// Wallet wakes up and sends wallet_ready — dapp should resume connected
client.emit("message", makeWalletReadyMsg(xpub));
expect(walletReadyEvents).toHaveLength(2); // initial + recovery
expect(disconnectEvents).toHaveLength(0);
// Ping interval restarts after recovery — next ping at t=90s
client.relayed.length = 0;
vi.advanceTimersByTime(10_000);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("does not disconnect when wallet_ready resets the liveness timer", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Simulate wallet_ready (keepalive reconnect) at t=60s, resetting the timer
vi.advanceTimersByTime(60_000);
client.emit("message", makeWalletReadyMsg(xpub));
// 74s after the reset — still below 75s threshold
vi.advanceTimersByTime(74_999);
expect(disconnectEvents).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});
describe("dapp_ready ordering on reconnect", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: false,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("sends dapp_ready before re-sending pending sign requests", async () => {
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const xpub = makeTestXpub();
mgr.updateConnection(client as any, { status: "connected" });
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: client.nextSequence(),
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
const signPromise = mgr.sendSignRequest(request);
client.relayed.length = 0;
// wallet_ready with dapp_discovered: false — dapp must await dapp_ready
// before re-sending pending sign requests
client.emit("message", makeWalletReadyMsg(xpub));
await new Promise((r) => setTimeout(r, 50));
const actions = client.relayed.map((m) => m.action);
const dappReadyIdx = actions.indexOf(RelayMsgAction.DappReady);
const signReqIdx = actions.indexOf(RelayMsgAction.SignTransactionRequest);
expect(dappReadyIdx).toBeGreaterThan(-1);
expect(signReqIdx).toBeGreaterThan(-1);
expect(dappReadyIdx).toBeLessThan(signReqIdx);
client.emit("message", {
action: RelayMsgAction.SignTransactionResponse,
sequence: request.sequence,
time: Math.floor(Date.now() / 1000),
signedTransaction: "signed",
});
await signPromise;
});
});
});

View file

@ -16,32 +16,13 @@ import {
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
PingMessage,
ProtocolMessage,
PROTOCOL_NAME,
PathName,
childIndexOfPathName,
isHdwalletv1Session,
binToHex,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core";
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
import {
type SessionStorage,
type StoredSession,
DEFAULT_SESSION_KEY,
loadSession,
saveSession,
clearSession,
} from "./session.js";
export interface DappSessionOptions {
/** Storage key for session persistence. Default: "wizardconnect-session" */
key?: string;
/** Storage backend. Defaults to localStorage if available. */
storage?: SessionStorage;
}
export interface DappConnectionManagerEvents {
/** Fired after wallet_ready is received and state is updated. */
@ -52,8 +33,6 @@ export interface DappConnectionManagerEvents {
messagereceived: [msg: ProtocolMessage];
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
disconnect: [reason: DisconnectReason, message: string | undefined];
/** Fired when the relay connection drops and auto-reconnect begins. */
reconnecting: [];
}
/**
@ -82,111 +61,26 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
private walletDiscovered = false;
private disconnectGraceTimer: ReturnType<typeof setTimeout> | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null;
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
private lastPongTime: number = 0;
private sessionPaths: PathXpub[] = [];
private pendingSignatureRequests = new Map<
number,
{
request: SignTransactionRequest;
resolve: (r: SignTransactionResponse) => void;
reject: (e: Error) => void;
}
>();
private sessionOptions: { key: string; storage?: SessionStorage } | null =
null;
/**
* @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).
* @param options Optional configuration. Session persistence is enabled by
* default (key: "wizardconnect-session", storage: localStorage).
* Pass `session: false` to disable.
*/
constructor(
private dappName?: string,
private dappIcon?: string,
options?: { session?: DappSessionOptions | false },
) {
super();
this.pubkeyState = new DappPubkeyStateManager();
if (options?.session !== false) {
const sessionConf = options?.session ?? {};
this.sessionOptions = {
key: sessionConf.key ?? DEFAULT_SESSION_KEY,
storage: sessionConf.storage,
};
// Auto-restore from stored session
const stored = loadSession(
this.sessionOptions.key,
this.sessionOptions.storage,
);
if (stored) {
if (stored.walletName) this.walletName = stored.walletName;
if (stored.walletIcon) this.walletIcon = stored.walletIcon;
if (stored.paths?.length) {
try {
this.restoreSessionPaths(stored.paths);
} catch {
// Corrupt cached paths — ignore, wallet_ready will repopulate
}
}
}
}
}
// --- Session persistence (public API) ----------------------------------------
/**
* Attach a relay result from `initiateDappRelay()`. Automatically:
* - Saves relay credentials (privateKey, secret) to the session
* - Listens for `keyexchangecomplete` and saves the wallet public key
*
* No-op if session persistence is disabled.
*/
attachRelay(relay: DappRelayResult): void {
if (!this.sessionOptions) return;
saveSession(
this.sessionOptions.key,
{
privateKey: relay.credentials.privateKey,
secret: relay.credentials.secret,
},
this.sessionOptions.storage,
);
relay.events.on("keyexchangecomplete", (walletPublicKey: Uint8Array) => {
if (!this.sessionOptions) return;
saveSession(
this.sessionOptions.key,
{ walletPublicKey: binToHex(walletPublicKey) },
this.sessionOptions.storage,
);
});
}
/**
* Load the stored session (e.g. for reconnection).
* Returns null if session persistence is disabled or no session exists.
*/
loadStoredSession(): StoredSession | null {
if (!this.sessionOptions) return null;
return loadSession(this.sessionOptions.key, this.sessionOptions.storage);
}
/**
* Clear the stored session. Call on disconnect.
* No-op if session persistence is disabled.
*/
clearStoredSession(): void {
if (!this.sessionOptions) return;
clearSession(this.sessionOptions.key, this.sessionOptions.storage);
}
// --- Relay connection -------------------------------------------------------
/**
* Call this from the RelayStatusCallback passed to `initiateDappRelay`.
* Attaches the message listener exactly once and re-sends dapp_ready
@ -207,14 +101,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
if (status.status === "connected" && this.conn) {
this.onConnected();
} else if (
status.status === "reconnecting" ||
status.status === "disconnected"
) {
this.stopPingInterval();
if (status.status === "reconnecting") {
this.emit("reconnecting");
}
}
}
@ -242,11 +128,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
return new Promise<SignTransactionResponse>((resolve, reject) => {
this.pendingSignatureRequests.set(request.sequence, {
request,
resolve,
reject,
});
this.pendingSignatureRequests.set(request.sequence, { resolve, reject });
this.conn!.relay(request)
.then(() => {
@ -297,57 +179,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagesent", msg);
}
/**
* Convenience method: build and send a sign transaction request.
* Auto-fills `action`, `sequence`, and `time`. Supports cancellation via
* AbortSignal when aborted, sendSignCancel is called automatically.
*/
async signTransaction(
request: Pick<SignTransactionRequest, "transaction" | "inputPaths">,
options?: { signal?: AbortSignal },
): Promise<SignTransactionResponse> {
const sequence = this.nextSequence();
const fullRequest: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
time: Math.floor(Date.now() / 1000),
sequence,
...request,
};
const signPromise = this.sendSignRequest(fullRequest);
if (!options?.signal) return signPromise;
// Suppress unhandled rejection — abort path rejects separately
signPromise.catch(() => {});
return new Promise<SignTransactionResponse>((resolve, reject) => {
const onAbort = () => {
const reason =
options.signal!.reason instanceof Error
? options.signal!.reason.message
: typeof options.signal!.reason === "string"
? options.signal!.reason
: "Sign request cancelled";
this.sendSignCancel(sequence, reason).catch(() => {});
reject(new DOMException(reason, "AbortError"));
};
if (options.signal!.aborted) {
onAbort();
return;
}
options.signal!.addEventListener("abort", onAbort, { once: true });
signPromise
.then(resolve)
.catch(reject)
.finally(() => {
options.signal!.removeEventListener("abort", onAbort);
});
});
}
// --- Pubkey state delegation ------------------------------------------------
// Convenience methods that forward to pubkeyState.
@ -368,93 +199,22 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return this.pubkeyState.getXpubNode(childIndex);
}
/**
* Returns the raw PathXpub entries received from the wallet.
* Available after wallet_ready is received.
*/
getSessionPaths(): PathXpub[] {
return [...this.sessionPaths];
}
/**
* Restore session paths from a previous session (e.g. from localStorage).
* Decodes xpub strings and populates pubkeyState so getPubkey() works
* without waiting for wallet_ready.
*/
restoreSessionPaths(paths: PathXpub[]): void {
this.sessionPaths = [...paths];
for (const pathInfo of paths) {
const decoded = decodeHdPublicKey(pathInfo.xpub);
if (typeof decoded === "string") {
throw new Error(
`[wizardconnect/dapp] Invalid xpub for path "${pathInfo.name}": ${decoded}`,
);
}
const ci = childIndexOfPathName(pathInfo.name);
if (ci !== undefined) {
this.pubkeyState.setXpubNode(ci, decoded.node);
}
}
}
// --- Private protocol handling -------------------------------------------
private onConnected(): void {
(async () => {
// Wait until key exchange is complete before sending dapp_ready
const deadline = Date.now() + 30_000;
while (this.conn && !this.conn.isKeyExchangeComplete()) {
if (Date.now() >= deadline) {
console.error("[wizardconnect/dapp] Key exchange timed out");
return;
}
await new Promise((r) => setTimeout(r, 100));
}
if (!this.conn) return;
await this.pushDappReady();
if (this.conn) {
await this.pushDappReady();
}
})().catch((e) =>
console.error("[wizardconnect/dapp] Error in onConnected:", e),
);
}
private startPingInterval(): void {
this.stopPingInterval();
// Treat connection as live at the moment the session is established.
this.lastPongTime = Date.now();
this.pingInterval = setInterval(() => {
if (!this.conn || !this.walletDiscovered) return;
// If the wallet hasn't responded (pong or wallet_ready) within 75s,
// it silently dropped the session — trigger reconnect.
if (Date.now() - this.lastPongTime > 75_000) {
this.stopPingInterval();
this.emit("reconnecting");
return;
}
const ping: PingMessage = {
action: RelayMsgAction.Ping,
time: Math.floor(Date.now() / 1000),
};
this.conn.relay(ping).catch(() => {});
}, 10_000);
}
private stopPingInterval(): void {
if (this.pingInterval !== null) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
destroy(): void {
this.stopPingInterval();
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
this.disconnectGraceTimer = null;
}
}
private async pushDappReady(): Promise<void> {
if (!this.conn) return;
@ -468,10 +228,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.protocol && { selected_protocol: this.protocol }),
...(this.dappName !== undefined && { dapp_name: this.dappName }),
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
// Transport-level: advertise chunking so the wallet can send large
// SignTransactionResponses (signed tx hex can reach ~2 MB) that exceed
// NIP-44's plaintext ceiling.
extensions: { chunk: chunkExtensionAdvertisement() },
};
await this.conn.relay(msg);
@ -482,9 +238,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagereceived", msg);
switch (msg.action) {
case RelayMsgAction.WalletReady:
this.handleWalletReady(msg as WalletReadyMessage).catch((e) =>
console.error("[wizardconnect/dapp] Error handling wallet_ready:", e),
);
this.handleWalletReady(msg as WalletReadyMessage);
break;
case RelayMsgAction.SignTransactionResponse:
this.handleSignTransactionResponse(msg as SignTransactionResponse);
@ -492,42 +246,22 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
case RelayMsgAction.Disconnect:
this.handleRemoteDisconnect(msg as DisconnectMessage);
break;
case RelayMsgAction.Pong:
this.lastPongTime = Date.now();
break;
case RelayMsgAction.DappReady:
// Not expected on dapp side — silently ignore
break;
default:
break;
console.warn(
"[wizardconnect/dapp] Unknown message action:",
msg.action,
);
}
}
private handleRemoteDisconnect(msg: DisconnectMessage): void {
// Give the wallet a short window to reconnect before propagating the
// disconnect. Wallets that use a keepalive watchdog send UserDisconnect
// and immediately reconnect; without this grace period the dapp would
// tear down the session before the wallet_ready arrives.
// 8s covers worst-case relay reconnect (WebSocket + 5s EOSE timeout + latency)
// while being fast enough that a real explicit disconnect is felt promptly.
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
}
this.disconnectGraceTimer = setTimeout(() => {
this.disconnectGraceTimer = null;
this.stopPingInterval();
this.emit("disconnect", msg.reason, msg.message);
}, 8_000);
this.emit("disconnect", msg.reason, msg.message);
}
private async handleWalletReady(msg: WalletReadyMessage): Promise<void> {
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
this.disconnectGraceTimer = null;
}
// Treat wallet_ready as a liveness signal — resets the pong stale timer.
// This covers keepalive reconnects where the wallet reconnects instead of ponging.
this.lastPongTime = Date.now();
private handleWalletReady(msg: WalletReadyMessage): void {
this.walletDiscovered = true;
this.walletName = msg.wallet_name;
this.walletIcon = msg.wallet_icon;
@ -545,7 +279,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
time: Math.floor(Date.now() / 1000),
};
this.conn?.relay(disconnectMsg).catch(() => {});
this.stopPingInterval();
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
return;
}
@ -561,17 +294,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return;
}
// Transport-level capability: if the wallet advertises chunking, enable
// chunked requests. Re-applied on every wallet_ready (cheap and idempotent),
// so reconnects pick up capability changes.
if (this.conn) {
this.conn.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
}
// Store raw paths for getSessionPaths() and xpub nodes for derivation
this.sessionPaths = [...sessionData.paths];
// Store xpub nodes — no eager derivation; consumer drives it
for (const pathInfo of sessionData.paths) {
const decoded = decodeHdPublicKey(pathInfo.xpub);
if (typeof decoded === "string") {
@ -582,54 +305,17 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
);
continue;
}
const ci = childIndexOfPathName(pathInfo.name);
if (ci !== undefined) {
this.pubkeyState.setXpubNode(ci, decoded.node);
}
const ci = childIndexOfPathName(pathInfo.name as PathName);
this.pubkeyState.setXpubNode(ci, decoded.node);
}
// Send dapp_ready first so the wallet's session state is confirmed before
// any sign requests arrive — await ensures correct ordering on the wire.
if (!msg.dapp_discovered) {
await this.pushDappReady().catch((e) =>
this.pushDappReady().catch((e) =>
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
);
}
this.emit("walletready", msg);
// Re-send any pending sign requests with a fresh timestamp so the wallet
// doesn't filter them as already-processed (the wallet timestamps messages
// at receive time and ignores anything older than its last disconnect).
if (this.pendingSignatureRequests.size > 0) {
const now = Math.floor(Date.now() / 1000);
for (const [, entry] of this.pendingSignatureRequests) {
const refreshed = { ...entry.request, time: now };
this.conn!.relay(refreshed)
.then(() => this.emit("messagesent", refreshed))
.catch((err) => {
this.pendingSignatureRequests.delete(entry.request.sequence);
entry.reject(err instanceof Error ? err : new Error(String(err)));
});
}
}
// Auto-persist wallet identity and xpub paths to session storage
if (this.sessionOptions) {
const sessionUpdate: Partial<StoredSession> = {
walletName: this.walletName ?? undefined,
walletIcon: this.walletIcon ?? undefined,
paths: this.getSessionPaths(),
};
saveSession(
this.sessionOptions.key,
sessionUpdate,
this.sessionOptions.storage,
);
}
// Start keepalive pings now that the session is fully established.
this.startPingInterval();
}
private handleSignTransactionResponse(

View file

@ -3,15 +3,5 @@
// 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,
DappSessionOptions,
} from "./dapp-connection-manager.js";
export type { DappConnectionManagerEvents } from "./dapp-connection-manager.js";
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
export {
DEFAULT_SESSION_KEY,
loadSession,
saveSession,
clearSession,
} from "./session.js";
export type { SessionStorage, StoredSession } from "./session.js";

View file

@ -1,112 +0,0 @@
// 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 } from "vitest";
import {
loadSession,
saveSession,
clearSession,
type SessionStorage,
type StoredSession,
} from "./session.js";
/** In-memory storage for testing (avoids depending on a DOM environment). */
function createMemoryStorage(): SessionStorage {
const store = new Map<string, string>();
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, value),
removeItem: (key) => store.delete(key),
};
}
describe("session utilities", () => {
let storage: SessionStorage;
const KEY = "test-session";
beforeEach(() => {
storage = createMemoryStorage();
});
describe("loadSession", () => {
it("returns null for missing key", () => {
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns null for malformed JSON", () => {
storage.setItem(KEY, "not-json{");
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns null when privateKey is missing", () => {
storage.setItem(KEY, JSON.stringify({ secret: "s" }));
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns null when secret is missing", () => {
storage.setItem(KEY, JSON.stringify({ privateKey: "pk" }));
expect(loadSession(KEY, storage)).toBeNull();
});
it("returns session with required fields", () => {
const session: StoredSession = { privateKey: "pk", secret: "s" };
storage.setItem(KEY, JSON.stringify(session));
expect(loadSession(KEY, storage)).toEqual(session);
});
it("returns session with all optional fields", () => {
const session: StoredSession = {
privateKey: "pk",
secret: "s",
walletPublicKey: "wpk",
walletName: "TestWallet",
walletIcon: "icon.png",
paths: [{ name: "receive", xpub: "xpub123" }],
};
storage.setItem(KEY, JSON.stringify(session));
expect(loadSession(KEY, storage)).toEqual(session);
});
});
describe("saveSession", () => {
it("saves a new session", () => {
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
const loaded = loadSession(KEY, storage);
expect(loaded).toEqual({ privateKey: "pk", secret: "s" });
});
it("merges with existing session", () => {
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
saveSession(KEY, { walletPublicKey: "wpk" }, storage);
const loaded = loadSession(KEY, storage);
expect(loaded).toEqual({
privateKey: "pk",
secret: "s",
walletPublicKey: "wpk",
});
});
it("overwrites fields on merge", () => {
saveSession(
KEY,
{ privateKey: "pk", secret: "s", walletName: "Old" },
storage,
);
saveSession(KEY, { walletName: "New" }, storage);
expect(loadSession(KEY, storage)?.walletName).toBe("New");
});
});
describe("clearSession", () => {
it("removes the stored session", () => {
saveSession(KEY, { privateKey: "pk", secret: "s" }, storage);
clearSession(KEY, storage);
expect(loadSession(KEY, storage)).toBeNull();
});
it("is a no-op for missing key", () => {
expect(() => clearSession(KEY, storage)).not.toThrow();
});
});
});

View file

@ -1,87 +0,0 @@
// 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 type { PathXpub } from "@wizardconnect/core";
/**
* Abstraction over localStorage for session persistence.
* Matches the Web Storage API subset, so `localStorage` can be passed directly.
*/
export interface SessionStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/** Persisted session data for a WizardConnect dapp connection. */
export interface StoredSession {
privateKey: string;
secret: string;
walletPublicKey?: string;
walletName?: string;
walletIcon?: string;
paths?: PathXpub[];
}
function defaultStorage(): SessionStorage | null {
if (
typeof localStorage !== "undefined" &&
typeof localStorage.getItem === "function"
)
return localStorage;
return null;
}
function resolveStorage(storage?: SessionStorage): SessionStorage | null {
return storage ?? defaultStorage();
}
export const DEFAULT_SESSION_KEY = "wizardconnect-session";
/**
* Load a stored session. Returns null if the key is missing, the data is
* malformed, or the required fields (privateKey, secret) are absent.
*/
export function loadSession(
key: string = DEFAULT_SESSION_KEY,
storage?: SessionStorage,
): StoredSession | null {
const s = resolveStorage(storage);
if (!s) return null;
const raw = s.getItem(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StoredSession;
if (!parsed.privateKey || !parsed.secret) return null;
return parsed;
} catch {
return null;
}
}
/**
* Save session data. Merges with any existing stored session so callers
* can save incrementally (e.g. credentials first, then walletPublicKey later).
*/
export function saveSession(
key: string = DEFAULT_SESSION_KEY,
data: Partial<StoredSession>,
storage?: SessionStorage,
): void {
const s = resolveStorage(storage);
if (!s) return;
const existing = loadSession(key, s);
const merged = { ...existing, ...data };
s.setItem(key, JSON.stringify(merged));
}
/** Remove a stored session. */
export function clearSession(
key: string = DEFAULT_SESSION_KEY,
storage?: SessionStorage,
): void {
const s = resolveStorage(storage);
if (!s) return;
s.removeItem(key);
}

View file

@ -1,155 +0,0 @@
# @wizardconnect/react
React components and hooks for integrating WizardConnect into dapps.
## Installation
```bash
npm install @wizardconnect/react @wizardconnect/core @wizardconnect/dapp
```
React 18+ is required as a peer dependency.
## Components
### WizardConnectQRDialog
A portal-based modal dialog that displays a WizardConnect QR code for wallet pairing. Uses inline styles for framework independence (no Tailwind or CSS framework required).
```tsx
import { WizardConnectQRDialog } from "@wizardconnect/react";
<WizardConnectQRDialog
show={showDialog}
onClose={() => setShowDialog(false)}
uri={connection.uri}
qrUri={connection.qrUri}
logoUrl="/my-logo.png"
theme={{
dialogBackground: "#1e293b",
headerBackground: "#1e293b",
}}
/>;
```
**Props:**
| Prop | Type | Default | Description |
| ----------- | ----------------------- | ------------------------------------ | --------------------------------------------------- |
| `show` | `boolean` | _required_ | Whether the dialog is visible |
| `onClose` | `() => void` | _required_ | Called when the user clicks close or the backdrop |
| `uri` | `string` | _required_ | Human-readable URI to display (`wiz://...`) |
| `qrUri` | `string` | _required_ | Alphanumeric-safe URI for QR encoding (`WIZ://...`) |
| `onCopy` | `(uri: string) => void` | `navigator.clipboard.writeText` | Called when copy button is clicked |
| `theme` | `WizardConnectQRTheme` | dark theme defaults | Color overrides |
| `title` | `string` | `"WizardConnect"` | Dialog title |
| `subtitle` | `string` | `"Scan with your wallet to connect"` | Subtitle text |
| `logoUrl` | `string` | none | Logo for the header |
| `className` | `string` | none | Additional CSS class on the outermost container |
### AlphanumericQRCode
A standalone canvas-based QR code renderer. Uses Alphanumeric mode with error correction level H (30% recovery) to tolerate a center logo overlay.
```tsx
import { AlphanumericQRCode } from "@wizardconnect/react";
<AlphanumericQRCode
value="WIZ://..."
size={280}
foreground="#1e2a4a"
background="#ffffff"
logoUrl="/logo.png"
/>;
```
## Hooks
### useWizardConnect
Encapsulates the full WizardConnect relay lifecycle: relay initiation, `DappConnectionManager` management, key exchange events, session persistence, and auto-reconnect.
```tsx
import { useWizardConnect, WizardConnectQRDialog } from "@wizardconnect/react";
function ConnectButton() {
const {
state, // "idle" | "connecting" | "connected" | "disconnected"
manager, // DappConnectionManager (null until connect())
uri, // connection URI (null until connect())
qrUri, // QR-safe URI (null until connect())
walletName, // wallet name (null until walletready)
walletIcon, // wallet icon (null until walletready)
connect, // () => boolean — initiate a new connection
disconnect, // () => Promise<void> — disconnect and clean up
error, // string | null — error message
} = useWizardConnect({
dappName: "My Dapp",
dappIcon: "https://example.com/icon.png",
});
return (
<>
{state === "idle" && <button onClick={connect}>Connect</button>}
{state === "connected" && <span>Connected to {walletName}</span>}
{uri && qrUri && (
<WizardConnectQRDialog
show={state === "connecting"}
onClose={disconnect}
uri={uri}
qrUri={qrUri}
/>
)}
</>
);
}
```
**Options:**
| Option | Type | Default | Description |
| ---------------- | ---------- | ------------------------- | ------------------------------------------ |
| `dappName` | `string` | none | Display name sent in `dapp_ready` |
| `dappIcon` | `string` | none | Icon URL sent in `dapp_ready` |
| `relayUrls` | `string[]` | default relay | Explicit relay WebSocket URLs |
| `sessionKey` | `string` | `"wizardconnect-session"` | localStorage key for session persistence |
| `persistSession` | `boolean` | `true` | Whether to save session for auto-reconnect |
**Using the `manager`:**
After `state` becomes `"connected"`, use `manager` to build your app-specific wallet adapter. The manager provides:
- `getPubkey(childIndex, addressIndex)` — derive pubkeys from xpubs
- `sendSignRequest(request)` — request transaction signatures
- `sendSignCancel(sequence)` — cancel an in-flight sign request
- `on("walletready", callback)` — listen for wallet handshake completion
See the [`@wizardconnect/dapp` documentation](../../docs/dapp.md) for the full `DappConnectionManager` API.
## Theme customization
All colors in `WizardConnectQRDialog` can be overridden via the `theme` prop:
```tsx
const myTheme: WizardConnectQRTheme = {
backdropColor: "rgba(0,0,0,0.5)",
dialogBackground: "#1a1f2e",
headerBackground: "#1a1f2e",
titleColor: "#ffffff",
subtitleColor: "#9ca3af",
qrForeground: "#1e2a4a",
qrBackground: "#ffffff",
uriRowBackground: "rgba(31,41,55,0.6)",
uriTextColor: "#9ca3af",
borderColor: "#374151",
closeButtonColor: "#9ca3af",
copyButtonColor: "#9ca3af",
logoUrl: "/my-qr-logo.png",
qrSize: 280,
};
```
## License
LGPL-3.0-or-later. See [LICENSE](../../LICENSE).

View file

@ -1,44 +0,0 @@
{
"name": "@wizardconnect/react",
"version": "0.2.0",
"type": "module",
"description": "React components and hooks for WizardConnect dapp integration",
"repository": {
"type": "git",
"url": "git+https://gitlab.com/riftenlabs/lib/wizardconnect.git",
"directory": "packages/react"
},
"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 --ignore-path ../../.gitignore . --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 --ignore-path ../../.gitignore . --write",
"fix:eslint": "npm run lint:eslint -- --fix"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
"qrcode-generator": "^1.4.4"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"happy-dom": "^20.8.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.7"
}
}

View file

@ -1,100 +0,0 @@
// 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 { useRef, useEffect } from "react";
import qrGenerator from "qrcode-generator";
import { WIZARDCONNECT_LOGO } from "./logo.js";
import type { AlphanumericQRCodeProps } from "../types.js";
const DEFAULT_SIZE = 280;
const DEFAULT_QUIET_ZONE = 4;
const DEFAULT_FG = "#1e2a4a";
const DEFAULT_BG = "#ffffff";
/**
* Renders a QR code on a canvas using explicit Alphanumeric mode.
* Optionally overlays a logo in the center (uses error correction H).
*/
export function AlphanumericQRCode({
value,
size = DEFAULT_SIZE,
foreground = DEFAULT_FG,
background = DEFAULT_BG,
quietZone = DEFAULT_QUIET_ZONE,
}: AlphanumericQRCodeProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Use error correction H (30% recovery) to tolerate the center logo
const qr = qrGenerator(0, "H");
qr.addData(value, "Alphanumeric");
qr.make();
const moduleCount = qr.getModuleCount();
const cellSize = size / moduleCount;
const canvasSize = size + 2 * quietZone;
const scale =
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
canvas.width = canvasSize * scale;
canvas.height = canvasSize * scale;
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
const ctx = canvas.getContext("2d")!;
ctx.scale(scale, scale);
// Background
ctx.fillStyle = background;
ctx.fillRect(0, 0, canvasSize, canvasSize);
// Draw QR modules
ctx.fillStyle = foreground;
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillRect(
Math.round(col * cellSize) + quietZone,
Math.round(row * cellSize) + quietZone,
Math.ceil(cellSize),
Math.ceil(cellSize),
);
}
}
}
// Overlay WizardConnect logo in the center
{
const logo = new Image();
logo.onload = () => {
const logoSize = size * 0.22;
const logoPadding = 4;
const dx = (canvasSize - logoSize) / 2;
const dy = (canvasSize - logoSize) / 2;
// Clear rectangular area behind logo
ctx.fillStyle = background;
ctx.fillRect(
dx - logoPadding,
dy - logoPadding,
logoSize + logoPadding * 2,
logoSize + logoPadding * 2,
);
ctx.drawImage(logo, dx, dy, logoSize, logoSize);
};
logo.src = WIZARDCONNECT_LOGO;
}
}, [value, size, foreground, background, quietZone]);
return (
<canvas
ref={canvasRef}
style={{ height: "auto", maxWidth: `${size}px`, width: "100%" }}
/>
);
}

View file

@ -1,157 +0,0 @@
// 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, vi, beforeEach, afterEach } from "vitest";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import { WizardConnectQRDialog } from "./WizardConnectQRDialog.js";
// Mock the QR component to avoid canvas issues in happy-dom
vi.mock("./AlphanumericQRCode.js", () => ({
AlphanumericQRCode: (props: { value: string }) =>
createElement("div", {
"data-testid": "qr-code",
"data-value": props.value,
}),
}));
describe("WizardConnectQRDialog", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
root.unmount();
container.remove();
// Clean up portals
const portals = document.querySelectorAll("[style*='position: fixed']");
portals.forEach((el) => el.remove());
});
function render(
props: Partial<Parameters<typeof WizardConnectQRDialog>[0]> = {},
) {
const defaultProps = {
show: true,
onClose: () => {},
uri: "wiz://test",
qrUri: "WIZ://TEST",
};
root.render(
createElement(WizardConnectQRDialog, { ...defaultProps, ...props }),
);
// Flush synchronous React work
return new Promise<void>((resolve) => setTimeout(resolve, 0));
}
it("renders nothing when show is false", async () => {
await render({ show: false });
const backdrops = document.querySelectorAll("[style*='position: fixed']");
expect(backdrops.length).toBe(0);
});
it("renders a portal modal when show is true", async () => {
await render({ show: true });
const backdrops = document.querySelectorAll("[style*='position: fixed']");
expect(backdrops.length).toBe(1);
});
it("displays the default title", async () => {
await render();
expect(document.body.textContent).toContain("WizardConnect");
});
it("displays a custom title", async () => {
await render({ title: "My Custom Title" });
expect(document.body.textContent).toContain("My Custom Title");
});
it("displays the URI text", async () => {
await render({ uri: "wiz://my-unique-uri" });
expect(document.body.textContent).toContain("wiz://my-unique-uri");
});
it("displays the default subtitle", async () => {
await render();
expect(document.body.textContent).toContain(
"Scan with your wallet to connect",
);
});
it("displays a custom subtitle", async () => {
await render({ subtitle: "Custom scan instructions" });
expect(document.body.textContent).toContain("Custom scan instructions");
});
it("calls onClose when close button is clicked", async () => {
const onClose = vi.fn();
await render({ onClose });
const closeButton = document.querySelector(
"button[aria-label='Close']",
) as HTMLButtonElement;
expect(closeButton).not.toBeNull();
closeButton.click();
expect(onClose).toHaveBeenCalledOnce();
});
it("calls onCopy with the URI when copy button is clicked", async () => {
const onCopy = vi.fn();
await render({ uri: "wiz://copy-test", onCopy });
const copyButton = document.querySelector(
"button[aria-label='Copy URI']",
) as HTMLButtonElement;
expect(copyButton).not.toBeNull();
copyButton.click();
expect(onCopy).toHaveBeenCalledOnce();
expect(onCopy).toHaveBeenCalledWith("wiz://copy-test");
});
it("applies custom theme colors", async () => {
await render({
theme: {
dialogBackground: "#ff0000",
headerBackground: "#00ff00",
},
});
const elements = document.querySelectorAll("[style*='background-color']");
const styles = Array.from(elements).map(
(el) => (el as HTMLElement).style.backgroundColor,
);
expect(styles).toContain("#00ff00");
expect(styles).toContain("#ff0000");
});
it("always renders the WizardConnect logo in the header", async () => {
await render();
const logo = document.querySelector("img") as HTMLImageElement;
expect(logo).not.toBeNull();
expect(logo.src).toContain("data:image/png;base64,");
});
it("passes qrUri to the QR code component", async () => {
await render({ qrUri: "WIZ://MY-QR-VALUE" });
const qrCode = document.querySelector("[data-testid='qr-code']");
expect(qrCode).not.toBeNull();
expect(qrCode?.getAttribute("data-value")).toBe("WIZ://MY-QR-VALUE");
});
});

View file

@ -1,278 +0,0 @@
// 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 React, { useCallback, useState, useRef } from "react";
import { createPortal } from "react-dom";
import { AlphanumericQRCode } from "./AlphanumericQRCode.js";
import { WIZARDCONNECT_LOGO } from "./logo.js";
import type {
WizardConnectQRDialogProps,
WizardConnectQRTheme,
} from "../types.js";
const defaults: Required<
Pick<
WizardConnectQRTheme,
| "backdropColor"
| "dialogBackground"
| "headerBackground"
| "titleColor"
| "subtitleColor"
| "qrForeground"
| "qrBackground"
| "uriRowBackground"
| "uriTextColor"
| "borderColor"
| "closeButtonColor"
| "copyButtonColor"
| "qrSize"
>
> = {
backdropColor: "rgba(0,0,0,0.5)",
dialogBackground: "#1a1f2e",
headerBackground: "#1a1f2e",
titleColor: "#ffffff",
subtitleColor: "#9ca3af",
qrForeground: "#1e2a4a",
qrBackground: "#ffffff",
uriRowBackground: "rgba(31,41,55,0.6)",
uriTextColor: "#9ca3af",
borderColor: "#374151",
closeButtonColor: "#9ca3af",
copyButtonColor: "#9ca3af",
qrSize: 280,
};
function CloseIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M5 5L15 15M15 5L5 15"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function CopyIcon({ color }: { color: string }) {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect
x="9"
y="9"
width="13"
height="13"
rx="2"
stroke={color}
strokeWidth="2"
/>
<path
d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"
stroke={color}
strokeWidth="2"
/>
</svg>
);
}
/**
* A portal-based modal dialog that displays a WizardConnect QR code
* for wallet pairing. Framework-independent (inline styles, no Tailwind).
*/
export function WizardConnectQRDialog({
show,
onClose,
uri,
qrUri,
onCopy,
theme,
className,
subtitle = "Scan with your wallet to connect",
title = "WizardConnect",
}: WizardConnectQRDialogProps) {
const t = { ...defaults, ...theme };
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = useCallback(() => {
if (onCopy) {
onCopy(uri);
} else if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(uri).catch(() => {});
}
setCopied(true);
if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, [uri, onCopy]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose],
);
if (!show) return null;
return createPortal(
<div
className={className}
onClick={handleBackdropClick}
style={{
position: "fixed",
inset: 0,
zIndex: 40,
display: "grid",
placeItems: "center",
padding: "16px",
backgroundColor: t.backdropColor,
backdropFilter: "blur(4px)",
}}
>
<div
style={{
width: "100%",
maxWidth: "384px",
borderRadius: "16px",
overflow: "hidden",
boxShadow: "0 25px 50px -12px rgba(0,0,0,0.5)",
border: `1px solid ${t.borderColor}`,
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
backgroundColor: t.headerBackground,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<img
src={WIZARDCONNECT_LOGO}
alt=""
style={{ width: "28px", height: "28px" }}
/>
<span
style={{
color: t.titleColor,
fontWeight: 600,
fontSize: "18px",
}}
>
{title}
</span>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
cursor: "pointer",
padding: "4px",
display: "flex",
alignItems: "center",
}}
aria-label="Close"
>
<CloseIcon color={t.closeButtonColor} />
</button>
</div>
{/* Body */}
<div
style={{
backgroundColor: t.dialogBackground,
padding: "0 20px 20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<p style={{ color: t.subtitleColor, fontSize: "14px", margin: 0 }}>
{subtitle}
</p>
{/* QR container */}
<div
style={{
backgroundColor: t.qrBackground,
padding: "12px",
borderRadius: "12px",
}}
>
<AlphanumericQRCode
value={qrUri}
size={t.qrSize}
foreground={t.qrForeground}
background={t.qrBackground}
/>
</div>
{/* Copy URI row — entire row is clickable */}
<button
onClick={handleCopy}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
backgroundColor: t.uriRowBackground,
borderRadius: "8px",
border: `1px solid ${t.borderColor}`,
cursor: "pointer",
textAlign: "left",
font: "inherit",
transition: "border-color 0.15s",
}}
aria-label="Copy URI"
>
<p
style={{
color: t.uriTextColor,
fontSize: "12px",
margin: 0,
flex: 1,
wordBreak: "break-all",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{uri}
</p>
{copied ? (
<span
style={{
color: "#0307fe",
fontSize: "12px",
fontWeight: 600,
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
Copied!
</span>
) : (
<span
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
<CopyIcon color={t.copyButtonColor} />
</span>
)}
</button>
</div>
</div>
</div>,
document.body,
);
}

File diff suppressed because one or more lines are too long

View file

@ -1,368 +0,0 @@
// 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, vi, beforeEach, afterEach } from "vitest";
import { createElement } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { UseWizardConnectResult } from "../types.js";
// Mock @wizardconnect/core before importing the hook
vi.mock("@wizardconnect/core", () => {
const EventEmitter = vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
}));
return {
initiateDappRelay: vi.fn(() => ({
client: {},
uri: "wiz://test-uri",
qrUri: "WIZ://TEST-URI",
credentials: {
privateKey: "a".repeat(64),
publicKey: "b".repeat(64),
secret: "c".repeat(16),
},
events: new (EventEmitter as unknown as {
new (): {
on: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
emit: ReturnType<typeof vi.fn>;
};
})(),
cleanup: vi.fn(),
})),
binToHex: vi.fn((bytes: Uint8Array) =>
Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
),
};
});
vi.mock("@wizardconnect/dapp", async () => {
const actual = await vi.importActual<typeof import("@wizardconnect/dapp")>(
"@wizardconnect/dapp",
);
return {
// Use real session utilities (they work with our mock localStorage)
loadSession: actual.loadSession,
saveSession: actual.saveSession,
clearSession: actual.clearSession,
DappConnectionManager: vi.fn(() => ({
on: vi.fn(),
off: vi.fn(),
walletName: null,
walletIcon: null,
updateConnection: vi.fn(),
sendDisconnect: vi.fn(() => Promise.resolve()),
getSessionPaths: vi.fn(() => []),
restoreSessionPaths: vi.fn(),
attachRelay: vi.fn(),
loadStoredSession: vi.fn(() => null),
clearStoredSession: vi.fn(),
})),
};
});
// Import AFTER mocks are set up
const { useWizardConnect } = await import("./useWizardConnect.js");
const { initiateDappRelay } = await import("@wizardconnect/core");
// Mock localStorage since happy-dom's implementation is incomplete
const storage = new Map<string, string>();
const mockLocalStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
get length() {
return storage.size;
},
key: (_index: number) => null,
};
Object.defineProperty(globalThis, "localStorage", {
value: mockLocalStorage,
writable: true,
});
describe("useWizardConnect", () => {
let container: HTMLDivElement;
let root: Root;
const SESSION_KEY = "wc-test-session";
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
storage.clear();
vi.clearAllMocks();
});
afterEach(() => {
root.unmount();
container.remove();
storage.clear();
});
/** Renders the hook inside a test component, returning a ref to the latest result. */
async function renderHook(
options?: Parameters<typeof useWizardConnect>[0],
): Promise<{ result: { current: UseWizardConnectResult } }> {
const result: { current: UseWizardConnectResult } = {
current: null as unknown as UseWizardConnectResult,
};
function TestComponent() {
const hookResult = useWizardConnect({
sessionKey: SESSION_KEY,
...options,
});
result.current = hookResult;
return null;
}
root.render(createElement(TestComponent));
// Wait for React to process the render
await new Promise<void>((r) => setTimeout(r, 0));
return { result };
}
it("starts in idle state", async () => {
const { result } = await renderHook({ persistSession: false });
expect(result.current.state).toBe("idle");
expect(result.current.manager).toBeNull();
expect(result.current.uri).toBeNull();
expect(result.current.qrUri).toBeNull();
expect(result.current.walletName).toBeNull();
expect(result.current.walletIcon).toBeNull();
expect(result.current.error).toBeNull();
});
it("transitions to connecting on connect()", async () => {
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(result.current.state).toBe("connecting");
expect(result.current.manager).not.toBeNull();
expect(result.current.uri).toBe("wiz://test-uri");
expect(result.current.qrUri).toBe("WIZ://TEST-URI");
});
it("connect() calls initiateDappRelay", async () => {
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).toHaveBeenCalledOnce();
});
it("connect() returns false when already connecting", async () => {
const { result } = await renderHook({ persistSession: false });
const firstResult = result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const secondResult = result.current.connect();
expect(firstResult).toBe(true);
expect(secondResult).toBe(false);
});
it("calls attachRelay on connect when persistSession is true", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
const { result } = await renderHook({ persistSession: true });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
expect(mgrInstance.attachRelay).toHaveBeenCalledOnce();
});
it("does not save credentials when persistSession is false", async () => {
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(localStorage.getItem(SESSION_KEY)).toBeNull();
});
it("disconnect() clears state and calls clearStoredSession", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
const { result } = await renderHook({ persistSession: true });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const mgrInstance = DappConnectionManager.mock.results[0]?.value;
await result.current.disconnect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(result.current.state).toBe("idle");
expect(result.current.manager).toBeNull();
expect(result.current.uri).toBeNull();
expect(result.current.qrUri).toBeNull();
expect(mgrInstance.clearStoredSession).toHaveBeenCalled();
});
it("attempts auto-reconnect when stored session has walletPublicKey", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
}),
);
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).toHaveBeenCalledOnce();
// Should pass existing credentials
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
expect(callArgs[1]).toEqual(
expect.objectContaining({
existingCredentials: {
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
},
}),
);
});
it("does not auto-reconnect when stored session lacks walletPublicKey", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
}),
);
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).not.toHaveBeenCalled();
});
it("does not auto-reconnect when persistSession is false", async () => {
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
}),
);
await renderHook({ persistSession: false });
await new Promise<void>((r) => setTimeout(r, 0));
expect(initiateDappRelay).not.toHaveBeenCalled();
});
it("passes dappName and dappIcon to DappConnectionManager", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
const { result } = await renderHook({
dappName: "My Dapp",
dappIcon: "https://example.com/icon.png",
persistSession: false,
});
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(DappConnectionManager).toHaveBeenCalledWith(
"My Dapp",
"https://example.com/icon.png",
expect.objectContaining({}),
);
});
it("passes session option with stored paths to DappConnectionManager", async () => {
const testPaths = [
{ name: "receive" as const, xpub: "xpub6test1" },
{ name: "change" as const, xpub: "xpub6test2" },
];
localStorage.setItem(
SESSION_KEY,
JSON.stringify({
privateKey: "d".repeat(64),
secret: "e".repeat(16),
walletPublicKey: "f".repeat(64),
paths: testPaths,
}),
);
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
await renderHook({ persistSession: true });
await new Promise<void>((r) => setTimeout(r, 0));
// Manager is constructed with session option so it can auto-restore paths
expect(DappConnectionManager).toHaveBeenCalledWith(
undefined,
undefined,
expect.objectContaining({
session: expect.objectContaining({ key: SESSION_KEY }),
}),
);
});
it("disables session when persistSession is false", async () => {
const { DappConnectionManager } = vi.mocked(
await import("@wizardconnect/dapp"),
);
const { result } = await renderHook({ persistSession: false });
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
expect(DappConnectionManager).toHaveBeenCalledWith(
undefined,
undefined,
expect.objectContaining({ session: false }),
);
});
it("passes relayUrls to initiateDappRelay", async () => {
const { result } = await renderHook({
relayUrls: ["wss://custom-relay:443"],
persistSession: false,
});
result.current.connect();
await new Promise<void>((r) => setTimeout(r, 0));
const callArgs = vi.mocked(initiateDappRelay).mock.calls[0];
expect(callArgs[1]).toEqual(
expect.objectContaining({
explicitRelayUrls: ["wss://custom-relay:443"],
}),
);
});
});

View file

@ -1,195 +0,0 @@
// 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 { useState, useRef, useEffect, useCallback } from "react";
import {
initiateDappRelay,
type DappRelayResult,
type RelayUpdatePayload,
} from "@wizardconnect/core";
import { DappConnectionManager, loadSession } from "@wizardconnect/dapp";
import type {
UseWizardConnectOptions,
UseWizardConnectResult,
WizardConnectState,
} from "../types.js";
const DEFAULT_SESSION_KEY = "wizardconnect-session";
/**
* React hook that encapsulates the WizardConnect relay lifecycle.
*
* Manages: relay initiation, DappConnectionManager, key exchange events,
* session persistence, and auto-reconnect on page refresh.
*
* The returned `manager` can be used to build an app-specific wallet adapter.
*/
export function useWizardConnect(
options: UseWizardConnectOptions = {},
): UseWizardConnectResult {
const {
dappName,
dappIcon,
relayUrls,
sessionKey = DEFAULT_SESSION_KEY,
persistSession = true,
storage,
} = options;
const [state, setState] = useState<WizardConnectState>("idle");
const [manager, setManager] = useState<DappConnectionManager | null>(null);
const [uri, setUri] = useState<string | null>(null);
const [qrUri, setQrUri] = useState<string | null>(null);
const [walletName, setWalletName] = useState<string | null>(null);
const [walletIcon, setWalletIcon] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const relayRef = useRef<DappRelayResult | null>(null);
const managerRef = useRef<DappConnectionManager | null>(null);
const autoReconnectAttempted = useRef(false);
const startRelay = useCallback(
(existingCredentials?: {
privateKey: string;
secret: string;
walletPublicKey: string;
}): boolean => {
if (state === "connecting" || state === "connected") return false;
setError(null);
setState("connecting");
const mgr = new DappConnectionManager(dappName, dappIcon, {
session: persistSession ? { key: sessionKey, storage } : false,
});
managerRef.current = mgr;
setManager(mgr);
mgr.on("walletready", () => {
setWalletName(mgr.walletName);
setWalletIcon(mgr.walletIcon);
setState("connected");
});
mgr.on("reconnecting", () => {
setState("reconnecting");
});
mgr.on("disconnect", () => {
setState("disconnected");
setWalletName(null);
setWalletIcon(null);
mgr.clearStoredSession();
relayRef.current?.cleanup();
relayRef.current = null;
});
try {
const relay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
mgr.updateConnection(payload.client, payload.status);
},
{
existingCredentials,
explicitRelayUrls: relayUrls,
},
);
relayRef.current = relay;
// Only expose URI for new connections (QR pairing), not reconnects
if (!existingCredentials) {
setUri(relay.uri);
setQrUri(relay.qrUri);
}
mgr.attachRelay(relay);
return true;
} catch (e) {
const message =
e instanceof Error ? e.message : "Failed to start relay";
setError(message);
setState("idle");
return false;
}
},
[state, dappName, dappIcon, relayUrls, sessionKey, persistSession, storage],
);
const connect = useCallback((): boolean => {
return startRelay();
}, [startRelay]);
const disconnect = useCallback(async (): Promise<void> => {
try {
if (managerRef.current) {
await managerRef.current.sendDisconnect().catch(() => {});
}
} finally {
relayRef.current?.cleanup();
relayRef.current = null;
managerRef.current?.clearStoredSession();
managerRef.current = null;
setManager(null);
setUri(null);
setQrUri(null);
setWalletName(null);
setWalletIcon(null);
setState("idle");
}
}, []);
// Auto-reconnect on mount if a stored session exists
useEffect(() => {
if (autoReconnectAttempted.current) return;
if (!persistSession) return;
autoReconnectAttempted.current = true;
// Read session before creating the manager (need credentials for startRelay)
const stored = loadSession(sessionKey, storage);
if (!stored || !stored.walletPublicKey) return;
if (stored.walletName) {
setWalletName(stored.walletName);
}
startRelay({
privateKey: stored.privateKey,
secret: stored.secret,
walletPublicKey: stored.walletPublicKey,
});
}, [persistSession, sessionKey, storage, startRelay]);
// Cleanup on unmount.
//
// Reset `autoReconnectAttempted` here so React 18 StrictMode's dev-mode
// mount→unmount→remount cycle doesn't leave the hook in a "attempted but
// torn down" state. Without the reset:
// - Mount A: auto-reconnect fires, creates relay, ref → true.
// - StrictMode cleanup: relay destroyed.
// - Mount B: auto-reconnect sees ref === true, skips. No new relay.
// Result: a stored session never reconnects in dev. Resetting the ref on
// cleanup lets Mount B re-fire the auto-reconnect path and rebuild the
// relay. In production (no StrictMode) this only runs at real unmount, so
// the reset is harmless there.
useEffect(() => {
return () => {
managerRef.current?.destroy();
relayRef.current?.cleanup();
relayRef.current = null;
autoReconnectAttempted.current = false;
};
}, []);
return {
state,
manager,
uri,
qrUri,
walletName,
walletIcon,
connect,
disconnect,
error,
};
}

View file

@ -1,16 +0,0 @@
// 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 { WizardConnectQRDialog } from "./components/WizardConnectQRDialog.js";
export { AlphanumericQRCode } from "./components/AlphanumericQRCode.js";
export { useWizardConnect } from "./hooks/useWizardConnect.js";
export type {
WizardConnectQRDialogProps,
WizardConnectQRTheme,
AlphanumericQRCodeProps,
UseWizardConnectOptions,
UseWizardConnectResult,
WizardConnectState,
} from "./types.js";
export type { SessionStorage } from "@wizardconnect/dapp";

View file

@ -1,121 +0,0 @@
// 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 type {
DappConnectionManager,
SessionStorage,
} from "@wizardconnect/dapp";
// ---- QR Code ----
export interface AlphanumericQRCodeProps {
/** The alphanumeric string to encode. */
value: string;
/** QR code pixel size. Default: 280 */
size?: number;
/** Module (dark cell) color. Default: "#1e2a4a" */
foreground?: string;
/** Background color. Default: "#ffffff" */
background?: string;
/** Quiet zone size in pixels. Default: 4 */
quietZone?: number;
}
// ---- QR Dialog ----
export interface WizardConnectQRTheme {
/** Backdrop overlay color. Default: "rgba(0,0,0,0.5)" */
backdropColor?: string;
/** Dialog body background. Default: "#1a1f2e" */
dialogBackground?: string;
/** Header bar background. Default: "#1a1f2e" */
headerBackground?: string;
/** Title text color. Default: "#ffffff" */
titleColor?: string;
/** Subtitle text color. Default: "#9ca3af" */
subtitleColor?: string;
/** QR foreground (module) color. Default: "#1e2a4a" */
qrForeground?: string;
/** QR background color. Default: "#ffffff" */
qrBackground?: string;
/** URI display row background. Default: "rgba(31,41,55,0.6)" */
uriRowBackground?: string;
/** URI text color. Default: "#9ca3af" */
uriTextColor?: string;
/** Border color. Default: "#374151" */
borderColor?: string;
/** Close button color. Default: "#9ca3af" */
closeButtonColor?: string;
/** Copy button color. Default: "#9ca3af" */
copyButtonColor?: string;
/** QR code size in pixels. Default: 280 */
qrSize?: number;
}
export interface WizardConnectQRDialogProps {
/** Whether the dialog is visible. */
show: boolean;
/** Called when the user clicks close or the backdrop. */
onClose: () => void;
/** The human-readable URI to display (lowercase wiz://...). */
uri: string;
/** The QR-alphanumeric-safe URI for encoding (uppercase WIZ://...). */
qrUri: string;
/** Called when the user clicks the copy button. Receives the uri string.
* If not provided, uses navigator.clipboard.writeText(). */
onCopy?: (uri: string) => void;
/** Theme overrides. */
theme?: WizardConnectQRTheme;
/** Additional CSS class on the outermost container. */
className?: string;
/** Subtitle text. Default: "Scan with your wallet to connect" */
subtitle?: string;
/** Title text. Default: "WizardConnect" */
title?: string;
}
// ---- Hook ----
export type WizardConnectState =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export interface UseWizardConnectOptions {
/** Display name of the dapp. */
dappName?: string;
/** Icon URL/data-URI of the dapp. */
dappIcon?: string;
/** Explicit relay URLs. */
relayUrls?: string[];
/** LocalStorage key for session persistence. Default: "wizardconnect-session" */
sessionKey?: string;
/** Whether to persist session for auto-reconnect. Default: true */
persistSession?: boolean;
/** Custom storage backend. Defaults to localStorage. */
storage?: SessionStorage;
}
export interface UseWizardConnectResult {
/** Current connection state. */
state: WizardConnectState;
/** The DappConnectionManager (null before connect()). */
manager: DappConnectionManager | null;
/** The connection URI (null before connect()). */
uri: string | null;
/** The QR-alphanumeric URI (null before connect()). */
qrUri: string | null;
/** Wallet name (null until walletready). */
walletName: string | null;
/** Wallet icon (null until walletready). */
walletIcon: string | null;
/** Initiate a new connection. Returns false if already connecting/connected. */
connect: () => boolean;
/** Disconnect and clean up. */
disconnect: () => Promise<void>;
/** Error message if connection failed. */
error: string | null;
}

View file

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

View file

@ -1,19 +0,0 @@
// 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 path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@wizardconnect/core": path.resolve(__dirname, "../core/src/index.ts"),
"@wizardconnect/dapp": path.resolve(__dirname, "../dapp/src/index.ts"),
},
},
test: {
environment: "happy-dom",
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
},
});

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/test-cli",
"version": "0.2.0",
"version": "0.1.0",
"description": "CLI for testing WizardConnect protocol",
"type": "module",
"private": true,

View file

@ -22,17 +22,13 @@ program
.option(
"-r, --relay <url>",
"Nostr relay WebSocket URL",
"wss://relay.riften.net:443",
"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(
"--wallet-public-key <hex>",
"Wallet's Nostr public key (hex) — for reconnection testing",
)
.option(
"--sign",
"Send a dummy sign request after wallet is ready (tests approval flow)",
@ -47,7 +43,7 @@ program
.option(
"-r, --relay <url>",
"Nostr relay WebSocket URL",
"wss://relay.riften.net:443",
"wss://relay.cauldron.quest:443",
)
.requiredOption("-u, --uri <uri>", "wiz:// URI from dapp")
.option(

View file

@ -86,7 +86,6 @@ async function sendSignRequest(
userPrompt: "Test sign request from wiz-test CLI",
broadcast: false,
},
inputPaths: [],
time: Math.floor(Date.now() / 1000),
};
@ -181,7 +180,6 @@ export async function runDappMode(options: {
relay: string;
privateKey?: string;
secret?: string;
walletPublicKey?: string;
sign?: boolean;
}): Promise<void> {
const state = makeState();
@ -219,12 +217,8 @@ export async function runDappMode(options: {
{
explicitRelayUrls: [options.relay],
existingCredentials:
options.privateKey && options.secret && options.walletPublicKey
? {
privateKey: options.privateKey,
secret: options.secret,
walletPublicKey: options.walletPublicKey,
}
options.privateKey && options.secret
? { privateKey: options.privateKey, secret: options.secret }
: undefined,
},
);
@ -240,11 +234,19 @@ export async function runDappMode(options: {
encodeURIComponent(dappRelay.uri),
),
);
console.log(
chalk.dim(
`\nReconnect (after key exchange):\n wiz-test dapp --private-key ${options.privateKey ?? dappRelay.credentials.privateKey} --secret ${dappRelay.credentials.secret} --wallet-public-key <WALLET_PUBKEY>`,
),
);
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();

View file

@ -1,13 +1,8 @@
{
"name": "@wizardconnect/wallet",
"version": "0.2.0",
"version": "0.1.2",
"type": "module",
"description": "Wallet-side integration helpers for WizardConnect",
"repository": {
"type": "git",
"url": "https://gitlab.com/riftenlabs/lib/wizardconnect",
"directory": "packages/wallet"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
@ -31,6 +26,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

@ -1,190 +0,0 @@
// 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
/**
* Chunk transport extension end-to-end tests against a real relay.
*
* Exercises the NIP-44 size-limit workaround: messages that exceed the
* 65,535-byte plaintext ceiling must be split on the sender and reassembled
* on the receiver, symmetric in both directions.
*
* These tests deliberately use payloads in the same size range as real-world
* swap transactions:
* - ~80 KB: typical pool-aggregated swap request
* - ~200 KB: large swap request
* - ~100 KB signed tx hex response (policy-max)
* - ~2 MB signed tx hex response (consensus-max)
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import {
RelayMsgAction,
type SignTransactionRequest,
type SignTransactionResponse,
} from "@wizardconnect/core";
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
import type { PendingSignRequest } from "../wallet-connection-manager.js";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
describe("chunk extension — dapp → wallet oversized requests", () => {
let conn: ConnectionHandles;
const pending: PendingSignRequest[] = [];
beforeAll(async () => {
conn = await setupConnection(TEST_RELAY_URL);
conn.wallet.manager.on("pendingSignRequest", (req) => pending.push(req));
}, 30000);
afterAll(() => {
conn?.cleanup();
});
it("wallet_ready advertises the chunk transport extension", () => {
const wr = conn.dapp.walletReadyMessages[0];
expect(wr.extensions?.chunk).toBeDefined();
});
it("reassembles an 80 KB sign_transaction_request", async () => {
const bigHex = "ab".repeat(40_000); // 80 KB hex string
const msg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 100,
transaction: {
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
sourceOutputs: [],
userPrompt: bigHex,
broadcast: false,
},
inputPaths: [],
time: Math.floor(Date.now() / 1000),
};
await conn.dapp.client()!.relay(msg);
await waitFor(() => pending.some((p) => p.request.sequence === 100), {
timeoutMs: 30000,
what: "80 KB sign request reassembled on wallet",
});
const got = pending.find((p) => p.request.sequence === 100)!;
expect(got.request.transaction.userPrompt).toBe(bigHex);
}, 60000);
it("reassembles a 200 KB sign_transaction_request", async () => {
const bigHex = "cd".repeat(100_000); // 200 KB hex
const msg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 101,
transaction: {
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
sourceOutputs: [],
userPrompt: bigHex,
broadcast: false,
},
inputPaths: [],
time: Math.floor(Date.now() / 1000),
};
await conn.dapp.client()!.relay(msg);
await waitFor(() => pending.some((p) => p.request.sequence === 101), {
timeoutMs: 45000,
what: "200 KB sign request reassembled on wallet",
});
const got = pending.find((p) => p.request.sequence === 101)!;
expect(got.request.transaction.userPrompt).toBe(bigHex);
expect(got.request.transaction.userPrompt!.length).toBe(200_000);
}, 60000);
});
describe("chunk extension — wallet → dapp oversized responses", () => {
let conn: ConnectionHandles;
beforeAll(async () => {
conn = await setupConnection(TEST_RELAY_URL);
}, 30000);
afterAll(() => {
conn?.cleanup();
});
async function walletSend(msg: SignTransactionResponse): Promise<void> {
// Reach through WalletConnectionManager internals to get the RelayClient
// directly and publish. A production app would return the response
// through its normal sign-approval flow; integration tests bypass that.
const m = conn.wallet.manager as unknown as {
connections: Map<
string,
{ client: { relay: (m: unknown) => Promise<void> } }
>;
};
const connEntry = m.connections.get(conn.wallet.connectionId);
if (!connEntry?.client) throw new Error("wallet relay client not ready");
await connEntry.client.relay(msg);
}
it("reassembles a ~100 KB signed tx hex response", async () => {
const hex = "ef".repeat(50_000); // 100 KB hex
const msg: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence: 200,
signedTransaction: hex,
time: Math.floor(Date.now() / 1000),
};
await walletSend(msg);
await waitFor(
() =>
conn.dapp.messages.some(
(m) =>
m.action === RelayMsgAction.SignTransactionResponse &&
(m as SignTransactionResponse).sequence === 200,
),
{
timeoutMs: 45000,
what: "100 KB sign response reassembled on dapp",
},
);
const got = conn.dapp.messages.find(
(m) =>
m.action === RelayMsgAction.SignTransactionResponse &&
(m as SignTransactionResponse).sequence === 200,
) as SignTransactionResponse;
expect(got.signedTransaction).toBe(hex);
}, 60000);
it("reassembles a ~2 MB signed tx hex response (consensus-max case)", async () => {
const hex = "f0".repeat(1_000_000); // 2 MB hex
const msg: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence: 201,
signedTransaction: hex,
time: Math.floor(Date.now() / 1000),
};
await walletSend(msg);
await waitFor(
() =>
conn.dapp.messages.some(
(m) =>
m.action === RelayMsgAction.SignTransactionResponse &&
(m as SignTransactionResponse).sequence === 201,
),
{
timeoutMs: 120000,
what: "2 MB sign response reassembled on dapp",
},
);
const got = conn.dapp.messages.find(
(m) =>
m.action === RelayMsgAction.SignTransactionResponse &&
(m as SignTransactionResponse).sequence === 201,
) as SignTransactionResponse;
expect(got.signedTransaction.length).toBe(2_000_000);
expect(got.signedTransaction).toBe(hex);
}, 180000);
});

View file

@ -1,98 +0,0 @@
// 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
/**
* Wallet-initiated disconnect must actually reach the dapp.
*
* disconnect.test.ts covers the other direction the dapp sends `disconnect`
* and the wallet reacts. Nothing covered wallet dapp, which is how this got
* shipped broken: doDisconnect fired the courtesy message without awaiting it and
* then tore the relay connection down, so the publish died mid-flight and the
* dapp went on believing the wallet was connected until its own liveness
* timeout. Downstream wallets were carrying a patch for it.
*
* These tests are worth their runtime because the failure is invisible locally
* the wallet's own state is correct either way, and only the peer notices.
*/
import { describe, it, expect, afterEach } from "vitest";
import { RelayMsgAction, DisconnectReason } from "@wizardconnect/core";
import type { DisconnectMessage, ProtocolMessage } from "@wizardconnect/core";
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
let handles: ConnectionHandles | null = null;
afterEach(() => {
handles?.cleanup();
handles = null;
});
/** Disconnect messages the dapp actually received off the relay. */
function disconnectsSeenByDapp(h: ConnectionHandles): DisconnectMessage[] {
return h.dapp.messages.filter(
(msg: ProtocolMessage) => msg.action === RelayMsgAction.Disconnect,
) as DisconnectMessage[];
}
describe("wallet-initiated disconnect", () => {
it("delivers the courtesy disconnect to the dapp", async () => {
handles = await setupConnection();
expect(disconnectsSeenByDapp(handles)).toHaveLength(0);
handles.wallet.manager.disconnect(handles.wallet.connectionId);
// The whole point: this arrives over a real relay, which it cannot do if the
// connection is torn down while the publish is still in flight.
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
timeoutMs: 20000,
what: "disconnect message received by the dapp",
});
expect(disconnectsSeenByDapp(handles)[0].reason).toBe(
DisconnectReason.UserDisconnect,
);
});
it("removes the connection immediately, without waiting for delivery", async () => {
// Teardown is deferred, but the registry must not be: a caller that
// disconnects and then inspects state should never see the dying connection.
handles = await setupConnection();
const { manager, connectionId } = handles.wallet;
expect(Object.keys(manager.getConnections())).toContain(connectionId);
manager.disconnect(connectionId);
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
});
it("delivers a disconnect for every connection in disconnectAll", async () => {
handles = await setupConnection();
handles.wallet.manager.disconnectAll();
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
timeoutMs: 20000,
what: "disconnect message from disconnectAll",
});
expect(Object.keys(handles.wallet.manager.getConnections())).toHaveLength(
0,
);
});
it("lets the dapp reconnect on the same URI afterwards", async () => {
// Deferring teardown must not leave the URI unusable — connect() returns an
// existing connection for a URI, so a stale entry would be handed back.
handles = await setupConnection();
const { manager, connectionId } = handles.wallet;
manager.disconnect(connectionId);
const reconnectedId = manager.connect(handles.dapp.uri);
expect(reconnectedId).not.toBe(connectionId);
expect(Object.keys(manager.getConnections())).toContain(reconnectedId);
manager.disconnect(reconnectedId);
});
});

View file

@ -25,7 +25,7 @@ import { WalletConnectionManager } from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
describe("WalletConnectionManager — disconnect", () => {
let dappCleanup: () => void;

View file

@ -22,8 +22,6 @@ import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
chunkExtensionAdvertisement,
peerSupportsChunk,
type RelayClient,
type RelayUpdatePayload,
type DappReadyMessage,
@ -112,10 +110,6 @@ export interface DappHandle {
cleanup: () => void;
/** All wallet_ready messages received. */
walletReadyMessages: WalletReadyMessage[];
/** RelayClient for direct message publishing (populated after key exchange). */
client: () => RelayClient | null;
/** All non-handshake messages received by the dapp. */
messages: ProtocolMessage[];
}
// ---- WalletHandle -----------------------------------------------------------
@ -145,7 +139,8 @@ export interface ConnectionHandles {
* 3. wallet_ready with paths
*/
export async function setupConnection(
relayUrl: string = process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443",
relayUrl: string = process.env.TEST_RELAY_URL ??
"wss://relay.cauldron.quest:443",
seed?: Uint8Array,
): Promise<ConnectionHandles> {
const adapter = makeTestAdapter(seed);
@ -153,7 +148,6 @@ export async function setupConnection(
// ---- Dapp side ----
const walletReadyMessages: WalletReadyMessage[] = [];
const allMessages: ProtocolMessage[] = [];
let dappClient: RelayClient | null = null;
let keyExchanged = false;
@ -170,9 +164,6 @@ export async function setupConnection(
supported_protocols: [PROTOCOL_NAME],
wallet_discovered: wd,
time: Math.floor(Date.now() / 1000),
// Advertise transport-level chunking so the wallet can return
// oversized sign_transaction_response messages.
extensions: { chunk: chunkExtensionAdvertisement() },
};
await dappClient!.relay(msg);
}
@ -192,51 +183,16 @@ export async function setupConnection(
if (message.action === RelayMsgAction.WalletReady) {
const msg = message as WalletReadyMessage;
walletReadyMessages.push(msg);
// Mirror DappConnectionManager behavior: enable chunked outbound if
// the wallet advertises support.
dappClient!.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
if (!msg.dapp_discovered) {
sendDappReady(true).catch(() => {});
}
} else {
allMessages.push(message);
}
});
// Prompt one more wallet_ready, now that the handler above is registered.
// The wallet_ready that completed key exchange was consumed by
// initiateDappRelay before this handler existed, so it is not in
// walletReadyMessages and the caller's "wallet_ready with paths" wait
// needs a fresh one. dapp_discovered=false is what makes the wallet
// re-send.
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
await sendDappReady(false);
});
// Re-announce until key exchange completes.
//
// The wallet sends exactly one wallet_ready per connection cycle, and it
// fires as soon as manager.connect() resolves — which can be before this
// dapp's relay subscription is live. If that single message is missed there
// is nothing to retry against: keyexchangecomplete never fires, so the
// handler above never registers and no dapp_ready is ever sent. The suite
// then sat until the 15s "key exchange" timeout. Under singleFork the
// previous file's teardown is still closing sockets while this runs, which
// is exactly when the race is won by the wrong side.
//
// dapp_ready(wallet_discovered=false) resets walletReadySentThisCycle on the
// wallet, so each retry earns another wallet_ready. This is the recovery path
// the protocol's mutual-discovery design already specifies — the harness
// simply was not using it.
const reannounce = setInterval(() => {
if (keyExchanged) {
clearInterval(reannounce);
return;
}
if (dappClient) sendDappReady(false).catch(() => {});
}, 2000);
// ---- Wallet side ----
const manager = new WalletConnectionManager(adapter);
@ -244,16 +200,7 @@ export async function setupConnection(
// ---- Wait for key exchange ----
try {
await waitFor(() => keyExchanged, {
timeoutMs: 15000,
what: "key exchange",
});
} finally {
// Must not outlive the wait: on timeout a leaked interval keeps publishing
// dapp_ready into later tests and holds the fork open.
clearInterval(reannounce);
}
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
// ---- Wait for wallet_ready with paths ----
@ -271,8 +218,6 @@ export async function setupConnection(
uri: dappRelay.uri,
cleanup: dappRelay.cleanup,
walletReadyMessages,
client: () => dappClient,
messages: allMessages,
};
const walletHandle: WalletHandle = { manager, connectionId, adapter };

View file

@ -17,7 +17,7 @@ import { DerivationPath } from "@wizardconnect/wallet";
import type { Hdwalletv1Session } from "@wizardconnect/core";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
const PATHS = [
{ childIndex: 0, name: "receive" },

View file

@ -1,117 +0,0 @@
// 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
/**
* Multi-relay tests verifies that when both dapp and wallet connect to
* both default relays, the handshake completes and messages are not duplicated.
*
* nostr-tools SimplePool deduplicates events by ID across relays in
* subscribeMany, so onevent fires at most once per event. This test exercises
* that path over the wire with real relay connections.
*/
import { describe, it, expect, afterAll } from "vitest";
import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
DEFAULT_RELAY_URLS,
type RelayUpdatePayload,
type RelayClient,
type DappReadyMessage,
type WalletReadyMessage,
type ProtocolMessage,
} from "@wizardconnect/core";
import { WalletConnectionManager } from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
const RELAY_URLS = [...DEFAULT_RELAY_URLS];
describe("Multi-relay redundancy", () => {
let dappCleanup: (() => void) | null = null;
let walletManager: WalletConnectionManager | null = null;
afterAll(() => {
dappCleanup?.();
walletManager?.disconnectAll();
});
it("handshake succeeds with both default relays and wallet_ready arrives once", async () => {
const adapter = makeTestAdapter();
// ---- Dapp side: use both relays ----
const walletReadyMessages: WalletReadyMessage[] = [];
let dappClient: RelayClient | null = null;
let keyExchanged = false;
const dappRelay = initiateDappRelay(
(payload: RelayUpdatePayload) => {
if (payload.client && !dappClient) dappClient = payload.client;
},
{ explicitRelayUrls: RELAY_URLS },
);
dappCleanup = dappRelay.cleanup;
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;
await new Promise((r) => setTimeout(r, 50));
while (!dappClient!.isKeyExchangeComplete()) {
await new Promise((r) => setTimeout(r, 50));
}
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(() => {});
}
}
});
await sendDappReady(false);
});
// ---- Wallet side: connect via URI (auto-adds second default relay) ----
walletManager = new WalletConnectionManager(adapter);
walletManager.connect(dappRelay.uri);
// ---- Wait for key exchange ----
await waitFor(() => keyExchanged, {
timeoutMs: 20000,
what: "key exchange",
});
// ---- Wait for wallet_ready with paths ----
await waitFor(
() =>
walletReadyMessages.length > 0 &&
(walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length >
0,
{ timeoutMs: 20000, what: "wallet_ready with paths" },
);
// ---- Verify single delivery ----
// Give extra time for any duplicate to arrive
await new Promise((r) => setTimeout(r, 3000));
// wallet_ready should arrive exactly once (not duplicated across relays)
expect(walletReadyMessages.length).toBe(1);
}, 60_000);
});

View file

@ -24,7 +24,7 @@ import { makeTestAdapter, waitFor } from "./helpers.js";
import { generateRandomBytes } from "@bitauth/libauth";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
describe("WalletConnectionManager — reconnection", () => {
// Shared dapp that stays alive across both wallet connections

View file

@ -23,7 +23,7 @@ import { WalletConnectionManager } from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
describe("WalletConnectionManager — sign_cancel", () => {
let dappCleanup: () => void;

View file

@ -1,161 +0,0 @@
// 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_transaction_request tests verifies that inputPaths sent by the dapp
* arrive intact on the wallet side via the pendingSignRequest 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 SignTransactionRequest,
} from "@wizardconnect/core";
import {
WalletConnectionManager,
type PendingSignRequest,
} from "@wizardconnect/wallet";
import { makeTestAdapter, waitFor } from "./helpers.js";
const TEST_RELAY_URL =
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
describe("WalletConnectionManager — sign_transaction_request with inputPaths", () => {
let dappCleanup: () => void;
let dappClient: RelayClient | null = null;
let manager: WalletConnectionManager;
let connectionId: string;
let walletReadyReceived = false;
const pendingRequests: PendingSignRequest[] = [];
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("pendingSignRequest", (req: PendingSignRequest) => {
pendingRequests.push(req);
});
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("wallet receives inputPaths from dapp sign request", async () => {
const signMsg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 1,
transaction: {
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
sourceOutputs: [],
userPrompt: "test",
broadcast: false,
},
inputPaths: [
[0, "receive", 0],
[1, "defi", 5],
[2, "change", 2],
],
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(signMsg);
await waitFor(() => pendingRequests.length > 0, {
timeoutMs: 5000,
what: "pendingSignRequest event on wallet",
});
expect(pendingRequests[0].connectionId).toBe(connectionId);
expect(pendingRequests[0].request.inputPaths).toEqual([
[0, "receive", 0],
[1, "defi", 5],
[2, "change", 2],
]);
}, 15000);
it("wallet receives empty inputPaths for zero-input transaction", async () => {
const signMsg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 2,
transaction: {
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
sourceOutputs: [],
broadcast: false,
},
inputPaths: [],
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(signMsg);
await waitFor(() => pendingRequests.length >= 2, {
timeoutMs: 5000,
what: "second pendingSignRequest event on wallet",
});
expect(pendingRequests[1].request.inputPaths).toEqual([]);
}, 15000);
});

View file

@ -3,7 +3,7 @@
// 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, PathXpub } from "@wizardconnect/core";
import { SignTransactionRequest } from "@wizardconnect/core";
export interface SignTransactionResult {
signedTransaction: string;
@ -45,18 +45,4 @@ export interface WalletAdapter {
signTransaction(
request: SignTransactionRequest,
): Promise<SignTransactionResult>;
/**
* Optional additional paths beyond receive/change/defi to include in the
* hdwalletv1 session handshake (e.g. stealth_scan, stealth_spend, rpa).
* See docs/extensions.md for conventions.
*/
getAdditionalPaths?(): PathXpub[];
/**
* Optional extension data to include in the hdwalletv1 session handshake.
* Each key is an extension name; its presence indicates wallet support.
* See docs/extensions.md for conventions.
*/
getExtensions?(): Record<string, unknown>;
}

View file

@ -1,267 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "eventemitter3";
import {
RelayMsgAction,
RelayStatus,
type SignTransactionRequest,
type RelayStatusCallback,
type RelayUpdatePayload,
} from "@wizardconnect/core";
import type { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
// --- Mock initiateWalletRelay so we can drive the connection from tests ------
let capturedCallback: RelayStatusCallback | null = null;
/** Minimal RelayClient mock — EventEmitter + relay spy. */
function makeMockClient() {
const emitter = new EventEmitter();
return {
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
relay: vi.fn(async () => {}),
isKeyExchangeComplete: () => true,
setPairedPublicKey: vi.fn(),
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
type MockClient = ReturnType<typeof makeMockClient>;
let mockClient: MockClient;
vi.mock("@wizardconnect/core", async () => {
const actual = await vi.importActual<typeof import("@wizardconnect/core")>(
"@wizardconnect/core",
);
return {
...actual,
initiateWalletRelay: (cb: RelayStatusCallback) => {
capturedCallback = cb;
return {
client: mockClient,
dappPublicKey: new Uint8Array(32),
walletPublicKey: new Uint8Array(33).fill(0x02),
secret: "aa".repeat(16),
cleanup: vi.fn(),
};
},
};
});
// Import after mock so the module picks up the mock
const { WalletConnectionManager } =
await import("./wallet-connection-manager.js");
// --- Helpers -----------------------------------------------------------------
function makeAdapter(): WalletAdapter {
return {
walletName: "Test Wallet",
walletIcon: "",
getRelayPrivateKey: () => new Uint8Array(32).fill(0x01),
getPublicKey: () => new Uint8Array(33).fill(0x02),
getXpub: (_path: DerivationPath) =>
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
signTransaction: vi.fn(),
};
}
function makeSignRequest(sequence: number): SignTransactionRequest {
return {
action: RelayMsgAction.SignTransactionRequest,
sequence,
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
}
/** Simulate connection: push status callback + emit messages via mock client. */
function simulateConnection(): MockClient {
if (!capturedCallback) throw new Error("connect() not called yet");
const payload: RelayUpdatePayload = {
client: mockClient as any,
status: RelayStatus.connected(),
};
capturedCallback(payload);
return mockClient;
}
// --- Tests -------------------------------------------------------------------
describe("WalletConnectionManager — sign request dedup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("does not emit duplicate pendingSignRequest for the same sequence", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const request = makeSignRequest(42);
// First delivery — wallet sees the sign request
client.emit("message", request);
// Second delivery — dapp re-sent after reconnect
client.emit("message", request);
await new Promise((r) => setTimeout(r, 10));
expect(emitted).toEqual([42]);
});
it("keeps dedup guard active after sign response to block relay re-delivery", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const request = makeSignRequest(42);
client.emit("message", request);
expect(emitted).toEqual([42]);
await mgr.sendSignResponse(connId, 42, "signed_hex");
// Nostr relay re-delivers the stored sign request after reconnect —
// must NOT prompt the user a second time
client.emit("message", request);
expect(emitted).toEqual([42]);
});
it("keeps dedup guard active after sign cancel to block relay re-delivery", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const request = makeSignRequest(42);
client.emit("message", request);
expect(emitted).toEqual([42]);
// Dapp cancels
client.emit("message", {
action: RelayMsgAction.SignCancel,
sequence: 42,
time: Math.floor(Date.now() / 1000),
});
// Relay re-delivers the old sign request — must still be filtered
client.emit("message", request);
expect(emitted).toEqual([42]);
});
});
describe("WalletConnectionManager — ping → pong", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("responds to a Ping message with a Pong", () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
client.relay.mockClear();
client.emit("message", {
action: RelayMsgAction.Ping,
time: Math.floor(Date.now() / 1000),
});
const pongCalls = client.relay.mock.calls.filter(
([msg]) => msg.action === RelayMsgAction.Pong,
);
expect(pongCalls).toHaveLength(1);
});
});
describe("WalletConnectionManager — per-connection signSequence cleanup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("blocks relay-replayed COMPLETED sequence on reconnect to same URI", async () => {
// A sequence that was fully signed must be blocked on relay replay even
// after an explicit disconnect()+connect() (e.g. Paytaca 65s watchdog).
const mgr = new WalletConnectionManager(makeAdapter());
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const connId1 = mgr.connect("wiz://test");
const firstClient = simulateConnection();
firstClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]);
// Complete the request — this is what persists seq 42 to the URI cache
await mgr.sendSignResponse(connId1, 42, "signed_hex");
mgr.disconnect(connId1);
// Relay replays seq=42 on the new connection — must be blocked
mockClient = makeMockClient();
mgr.connect("wiz://test");
const secondClient = simulateConnection();
secondClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]); // still just [42]
// A genuinely new sequence from the dapp is accepted
secondClient.emit("message", makeSignRequest(44));
expect(emitted).toEqual([42, 44]);
});
it("allows dapp to resend a PENDING sequence after watchdog disconnect", () => {
// If the Paytaca watchdog fires while the user is mid-signing, the dapp
// must be able to resend the request on the new connection. Pending sequences
// must NOT be stored in the URI cache until a response is sent.
const mgr = new WalletConnectionManager(makeAdapter());
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const connId1 = mgr.connect("wiz://test");
const firstClient = simulateConnection();
firstClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]);
// Watchdog fires BEFORE user signs — seq 42 is still pending, no response sent
mgr.disconnect(connId1);
// Dapp reconnects and resends the same pending request — must be accepted
mockClient = makeMockClient();
mgr.connect("wiz://test");
const secondClient = simulateConnection();
secondClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42, 42]); // resend accepted, user prompted again
});
});

View file

@ -6,7 +6,6 @@ import { EventEmitter } from "eventemitter3";
import {
RelayClient,
RelayStatus,
ProtocolMessage,
RelayUpdatePayload,
RelayStatusCallback,
initiateWalletRelay,
@ -18,14 +17,11 @@ import {
WalletReadyMessage,
DisconnectMessage,
DisconnectReason,
PingMessage,
PongMessage,
PathXpub,
Hdwalletv1Session,
ProtocolMessage,
PROTOCOL_NAME,
binToHex,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core";
import { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
@ -45,15 +41,6 @@ export interface PendingSignRequest {
request: SignTransactionRequest;
}
/**
* How long doDisconnect waits for the courtesy `disconnect` message to be
* published before tearing the relay connection down anyway.
*
* Generous enough for a slow relay, short enough that an unreachable one cannot
* hold the socket open indefinitely.
*/
const DISCONNECT_PUBLISH_TIMEOUT_MS = 5000;
interface ActiveConnection {
id: string;
uri: string;
@ -68,8 +55,6 @@ interface ActiveConnection {
/// 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;
/// Sign request sequences received on this connection, for cleanup on disconnect.
signSequences: Set<number>;
notificationQueue: ProtocolMessage[];
notificationProcessor: ReturnType<typeof setInterval> | null;
/// Key exchange data embedded in wallet_ready
@ -91,8 +76,6 @@ export type WalletConnectionManagerEvents = {
sequence: number,
reason: string | undefined,
];
/** Fired for protocol messages not handled by the core protocol (extension actions). */
message: [connectionId: string, message: ProtocolMessage];
};
/**
@ -101,12 +84,6 @@ export type WalletConnectionManagerEvents = {
*/
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
private connections: Map<string, ActiveConnection> = new Map();
private activeSignSequences = new Set<number>();
// Persists sign request sequences seen per URI across doDisconnect()+connect()
// within the same JS session. The relay replays stored sign requests after
// explicit reconnect; without this, clearing activeSignSequences in doDisconnect
// would let replayed sequences bypass the dedup guard.
private static readonly uriSignSequences = new Map<string, Set<number>>();
private adapter: WalletAdapter;
constructor(adapter: WalletAdapter) {
@ -140,7 +117,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
connectedAt: Date.now(),
dappDiscovered: false,
walletReadySentThisCycle: false,
signSequences: new Set(),
notificationQueue: [],
notificationProcessor: null,
walletPublicKeyHex: "",
@ -149,16 +125,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
this.connections.set(id, conn);
// Restore any sign sequences seen on this URI during this JS session so
// relay-replayed requests are blocked even after a full disconnect+reconnect.
const savedSeqs = WalletConnectionManager.uriSignSequences.get(uri);
if (savedSeqs) {
for (const seq of savedSeqs) {
this.activeSignSequences.add(seq);
conn.signSequences.add(seq);
}
}
const statusCallback: RelayStatusCallback = (
payload: RelayUpdatePayload,
) => {
@ -226,58 +192,19 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
const conn = this.connections.get(connectionId);
if (!conn) return;
// Drop the connection from the registry synchronously, before any awaiting.
// getConnections() must reflect the disconnect immediately, and connect()
// returns an existing connection for a URI — so leaving this one in the map
// while its teardown is pending would hand a caller a dying connection.
for (const seq of conn.signSequences) {
this.activeSignSequences.delete(seq);
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.notificationProcessor = null;
conn.cleanup();
this.connections.delete(connectionId);
this.emit("connectionsChanged");
if (!sendMessage || !conn.client) {
conn.cleanup();
return;
}
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
// Tear down only once the courtesy message has actually gone out.
//
// relay() resolves after `Promise.allSettled(pool.publish(...))` — a real
// round trip to every configured relay. Calling conn.cleanup() straight
// after firing it closed the pool underneath the in-flight publish, so the
// disconnect usually never reached the relay and the dapp went on believing
// the wallet was connected until its own liveness timeout fired. Downstream
// wallets were patching this out of the published package.
//
// Bounded, because "the publish never settles" is exactly the case where a
// relay is unreachable, and a socket that is never closed is worse than a
// courtesy message that is never delivered.
let torndown = false;
const teardown = () => {
if (torndown) return;
torndown = true;
conn.cleanup();
};
const timer = setTimeout(teardown, DISCONNECT_PUBLISH_TIMEOUT_MS);
conn.client
.relay(disconnectMsg)
.catch(() => {
// Nothing to do: we are disconnecting either way.
})
.finally(() => {
clearTimeout(timer);
teardown();
});
}
/**
@ -312,18 +239,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
throw new Error(`Connection ${connectionId} not found or not connected`);
}
// Intentionally keep sequence in activeSignSequences and conn.signSequences.
// Nostr relays replay stored events on reconnect; removing the guard here
// would let a re-delivered sign_transaction_request pass dedup and prompt
// the user a second time for an already-completed request.
// doDisconnect() is the sole cleanup point for these sets.
//
// Now that the request is complete, persist to the URI cache so a fresh
// connection created after a watchdog disconnect also blocks relay replays.
// Pending sequences are NOT stored until completion so the dapp can resend
// them if the watchdog fires while the user is still signing.
this.persistCompletedSequence(conn, sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence,
@ -347,10 +262,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
return; // Already disconnected, nothing to do
}
// Same reasoning as sendSignResponse: keep in dedup guard until doDisconnect.
// Persist completed sequence to URI cache so relay replays are blocked on reconnect.
this.persistCompletedSequence(conn, sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
sequence,
@ -362,27 +273,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
await conn.client.relay(response);
}
// --- Private helpers ---
/**
* Mark a sign sequence as completed in the URI-scoped cache.
* Called after a response is sent or the dapp cancels the request so that
* relay replays of the original sign_transaction_request are blocked on the
* next reconnect. Pending sequences are intentionally NOT stored here the
* dapp must be able to resend them if a keepalive reconnect fires mid-signing.
*/
private persistCompletedSequence(
conn: ActiveConnection,
sequence: number,
): void {
let seqSet = WalletConnectionManager.uriSignSequences.get(conn.uri);
if (!seqSet) {
seqSet = new Set();
WalletConnectionManager.uriSignSequences.set(conn.uri, seqSet);
}
seqSet.add(sequence);
}
// --- Private connection lifecycle ---
/** Immediately attempt to flush the notification queue (fire-and-forget). */
@ -393,11 +283,8 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
}
private onConnected(conn: ActiveConnection): void {
// New connection cycle: reset dedup flags so wallet_ready is sent fresh
// and dapp_discovered is false — the dapp must re-send dapp_ready to
// re-establish the session (matches "Wallet reconnects" scenario in protocol.md).
// New connection cycle: reset dedup flag so wallet_ready is sent fresh
conn.walletReadySentThisCycle = false;
conn.dappDiscovered = false;
// Start notification processor as fallback for retries after send errors
if (conn.notificationProcessor) {
@ -411,12 +298,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
// Wait for key exchange, then send wallet_ready
(async () => {
const deadline = Date.now() + 30_000;
while (conn.client && !conn.client.isKeyExchangeComplete()) {
if (Date.now() >= deadline) {
console.error("[wizardconnect/wallet] Key exchange timed out");
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (conn.client) {
@ -452,11 +334,11 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
case RelayMsgAction.SignCancel:
this.handleSignCancel(conn, message as SignCancelMessage);
break;
case RelayMsgAction.Ping:
this.handlePing(conn, message as PingMessage);
break;
default:
this.emit("message", conn.id, message);
console.warn(
"[wizardconnect/wallet] Unknown message action:",
message.action,
);
}
}
@ -472,22 +354,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn: ActiveConnection,
msg: SignCancelMessage,
): void {
// Keep in dedup guard: the relay may still re-deliver the original
// sign_transaction_request after the cancel. doDisconnect() cleans up.
// Persist to URI cache so the cancelled sequence is blocked on reconnect too.
this.persistCompletedSequence(conn, msg.sequence);
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
}
private handlePing(conn: ActiveConnection, _msg: PingMessage): void {
if (!conn.client) return;
const pong: PongMessage = {
action: RelayMsgAction.Pong,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(pong).catch(() => {});
}
private async handleDappReady(
conn: ActiveConnection,
msg: DappReadyMessage,
@ -508,15 +377,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
this.emit("connectionStatusChanged", conn.id, conn.status);
}
// Transport-level capability: if the dapp advertises chunking, enable
// chunked responses. Re-applied on every dapp_ready (cheap and idempotent),
// so reconnects pick up capability changes.
if (conn.client) {
conn.client.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
}
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.
@ -536,13 +396,10 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
{ name: "receive", xpub: this.adapter.getXpub(DerivationPath.Receive) },
{ name: "change", xpub: this.adapter.getXpub(DerivationPath.Change) },
{ name: "defi", xpub: this.adapter.getXpub(DerivationPath.Cauldron) },
...(this.adapter.getAdditionalPaths?.() ?? []),
];
const adapterExtensions = this.adapter.getExtensions?.();
const hdwv1Session: Hdwalletv1Session = {
paths,
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
};
const msg: WalletReadyMessage = {
@ -557,9 +414,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
},
public_key: conn.walletPublicKeyHex,
secret: conn.keyExchangeSecret,
// Transport-level: advertise chunking so the dapp can send large
// SignTransactionRequests that exceed NIP-44's plaintext ceiling.
extensions: { chunk: chunkExtensionAdvertisement() },
};
conn.notificationQueue.push(msg);
@ -570,15 +424,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn: ActiveConnection,
msg: SignTransactionRequest,
): void {
// Deduplicate: the dapp re-sends pending requests after reconnect, so the
// wallet may receive the same sequence twice while it's still awaiting
// user approval.
if (this.activeSignSequences.has(msg.sequence)) {
return;
}
this.activeSignSequences.add(msg.sequence);
conn.signSequences.add(msg.sequence);
// Emit to host app for queuing/approval
const pendingRequest: PendingSignRequest = {
connectionId: conn.id,

View file

@ -9,10 +9,6 @@ export default defineConfig({
include: ["src/integration/**/*.test.ts"],
testTimeout: 60000,
hookTimeout: 60000,
// These tests talk to live relays. A dropped connection or a slow publish
// is an environment failure, not a regression, and without a retry a single
// one reds the whole pipeline.
retry: 2,
// Run integration tests serially to avoid relay contention
pool: "forks",
poolOptions: {