First commit for WizardConnect
This commit is contained in:
commit
6fce9b47cb
61 changed files with 10955 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.js.map
|
||||
site/
|
||||
.venv/
|
||||
83
CLAUDE.md
Normal file
83
CLAUDE.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# wizardconnect
|
||||
|
||||
Relay-based protocol library for connecting dapps to HD wallets over WebSocket (Nostr NIP-17 gift-wrapped messages).
|
||||
|
||||
## Testing philosophy
|
||||
|
||||
Tests are more important than the code itself. A correct implementation that lacks tests is worse than a slightly imperfect implementation that is well-tested, because untested code will silently break and the breakage will only surface in production — often as subtle protocol bugs that are hard to reproduce.
|
||||
|
||||
This codebase communicates over a live relay with timing-sensitive handshakes and stateful reconnection logic. These bugs cannot be caught by reading the code. They only appear in integration tests that exercise the real relay.
|
||||
|
||||
**Always write tests before or alongside any non-trivial change.** If you add a feature, add a test. If you fix a bug, add a regression test that would have caught it.
|
||||
|
||||
### Test types
|
||||
|
||||
**Unit tests** (`npm test` in a package):
|
||||
- Fast, no network, run on every change
|
||||
- Test individual functions and classes in isolation
|
||||
- Use vitest, mock external deps
|
||||
- Should cover all edge cases in pure logic (state managers, message builders, URI encoding, etc.)
|
||||
|
||||
**Integration tests** (`npm run test:integration` in a package):
|
||||
- Hit the real relay at `wss://relay.cauldron.quest:443`
|
||||
- Test the full protocol handshake end-to-end
|
||||
- Located in `src/__tests__/*.integration.test.ts`
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
|
||||
- Must pass before any release
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
# All packages, unit tests only (fast):
|
||||
npm run test
|
||||
|
||||
# Wallet package integration tests (requires network):
|
||||
cd packages/wallet && npm run test:integration
|
||||
|
||||
# Custom relay:
|
||||
TEST_RELAY_URL=wss://your-relay:443 npm run test:integration
|
||||
```
|
||||
|
||||
### Test CLI (manual/exploratory testing)
|
||||
|
||||
```bash
|
||||
# Start a dapp session and watch the protocol:
|
||||
npm run dapp
|
||||
|
||||
# Connect a test wallet to a dapp URI:
|
||||
npm run wallet -- --uri wiz://...
|
||||
|
||||
# Test the approval flow:
|
||||
npm run dapp -- --sign
|
||||
```
|
||||
|
||||
The test CLI (`packages/test-cli/`) is not a substitute for automated tests — it is a debugging tool.
|
||||
|
||||
## Documentation
|
||||
|
||||
Protocol and architecture documentation lives in `docs/`. Keep it up to date when making changes:
|
||||
|
||||
| File | Update when… |
|
||||
|------|-------------|
|
||||
| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change |
|
||||
| `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes |
|
||||
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
|
||||
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |
|
||||
| `docs/dapp.md` | `DappConnectionManager` API or session lifecycle changes |
|
||||
| `docs/pubkey-derivation.md` | xpub delivery, `DappPubkeyStateManager`, or gap-fill logic changes |
|
||||
| `docs/index.md` | New top-level docs files are added |
|
||||
|
||||
## Packages
|
||||
|
||||
- `@wizardconnect/core` — transport + protocol primitives (relay client, key exchange, hdwalletv1 message types)
|
||||
- `@wizardconnect/wallet` — wallet-side helpers (`WalletConnectionManager`, `WalletAdapter`, `PubkeyStateManager`)
|
||||
- `@wizardconnect/test-cli` — manual test CLI (`dapp` and `wallet` modes)
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # builds all packages in dependency order
|
||||
```
|
||||
|
||||
Packages must be built before integration tests run (tests import from `dist/`).
|
||||
165
LICENSE.txt
Normal file
165
LICENSE.txt
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
174
docs/connection-uri.md
Normal file
174
docs/connection-uri.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Connection URI and key exchange
|
||||
|
||||
A WizardConnect session starts with the dapp generating a connection URI. The wallet scans
|
||||
this URI (typically as a QR code) and both sides use it to establish an encrypted channel.
|
||||
|
||||
## URI format
|
||||
|
||||
```
|
||||
wiz://?p=<pubkey_bech32>&s=<secret_bech32>
|
||||
```
|
||||
|
||||
When using a non-default relay:
|
||||
|
||||
```
|
||||
wiz://<hostname>:<port>?p=<pubkey_bech32>&s=<secret_bech32>&pr=<protocol>
|
||||
```
|
||||
|
||||
| Parameter | Encoding | Size | Meaning |
|
||||
|-----------|----------|------|---------|
|
||||
| `p` | bech32-padded | 32 bytes | Dapp's Nostr public key (x-only secp256k1) |
|
||||
| `s` | bech32-padded | 8 bytes | Shared secret for key exchange verification |
|
||||
| `hostname` | URL authority | — | Relay hostname (default: `relay.cauldron.quest`) |
|
||||
| `port` | URL authority | — | Relay port (default: `443`) |
|
||||
| `pr` | query param | — | `ws` or `wss` (default: `wss`, omitted when default) |
|
||||
|
||||
The URI omits the authority entirely when relay defaults are used, keeping QR codes compact.
|
||||
|
||||
### QR code encoding — alphanumeric mode
|
||||
|
||||
QR codes support several character encodings. **Alphanumeric mode** encodes each character in
|
||||
5.5 bits instead of 8 bits (byte mode), producing ~30 % smaller codes for the same data.
|
||||
|
||||
The alphanumeric charset covers: `A–Z`, `0–9`, and the symbols `$`, `%`, `*`, `+`, `-`, `.`,
|
||||
`/`, `:`, and space.
|
||||
|
||||
The bech32 alphabet (`a–z`, `2–7`) maps entirely into this charset when uppercased — every
|
||||
bech32 character is either a letter or a digit. The URI scheme characters (`WIZ`, `://`) and
|
||||
port numbers are also fully alphanumeric-safe.
|
||||
|
||||
However, the standard URI separators `?`, `=`, and `&` are **not** in the alphanumeric charset.
|
||||
Their percent-encoded forms are safe:
|
||||
|
||||
| Char | Encoding | All chars alphanumeric? |
|
||||
|------|----------|------------------------|
|
||||
| `?` | `%3F` | `%` ✅ `3` ✅ `F` ✅ |
|
||||
| `=` | `%3D` | `%` ✅ `3` ✅ `D` ✅ |
|
||||
| `&` | `%26` | `%` ✅ `2` ✅ `6` ✅ |
|
||||
|
||||
`encodeKeyExchangeURI()` returns both a standard URI and a QR-safe URI:
|
||||
|
||||
```typescript
|
||||
const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
// uri → "wiz://?p=qpzry9x8...&s=qpzry9x8" (standard, for copy-paste)
|
||||
// qrUri → "WIZ://%3FP%3DQPZRY9X8...%26S%3DQPZRY9X8" (QR alphanumeric-safe)
|
||||
```
|
||||
|
||||
Use `uri` for display and clipboard copy. Use `qrUri` for QR code generation.
|
||||
|
||||
`decodeKeyExchangeURI()` accepts both formats — it detects the QR encoding by looking for
|
||||
`%3f` without a `?` and reverses the substitutions before parsing. Both uppercase and
|
||||
lowercase variants are accepted.
|
||||
|
||||
### Example URIs
|
||||
|
||||
Default relay, standard format (as produced by `encodeKeyExchangeURI`):
|
||||
```
|
||||
wiz://?p=qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3jn54khce6mua7l&s=qpzry9x8
|
||||
```
|
||||
|
||||
QR-alphanumeric-safe format (use this for QR code generation):
|
||||
```
|
||||
WIZ://%3FP%3DQPZRY9X8GF2TVD...%26S%3DQPZRY9X8
|
||||
```
|
||||
|
||||
Custom relay:
|
||||
```
|
||||
wiz://my-relay.example.com:8443?p=qpzry9x8...&s=qpzry9x8&pr=wss
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
The dapp generates a fresh keypair and a random 8-byte secret for each new connection:
|
||||
|
||||
```typescript
|
||||
interface KeyExchangeCredentials {
|
||||
privateKey: string; // hex, 32 bytes — dapp's Nostr private key
|
||||
publicKey: string; // hex, 32 bytes — derived from privateKey (x-only)
|
||||
secret: string; // hex, 8 bytes — shared secret for MITM prevention
|
||||
}
|
||||
```
|
||||
|
||||
`generateKeyExchangeCredentials()` creates a fresh set. `encodeKeyExchangeURI(publicKey, secret)`
|
||||
produces the scannable URI from them.
|
||||
|
||||
### Reconnection
|
||||
|
||||
A dapp can reconnect to an existing session by passing `existingCredentials: { privateKey, secret }`
|
||||
to `initiateDappRelay()`. The wallet's public key is re-exchanged on reconnect.
|
||||
|
||||
## Key exchange flow
|
||||
|
||||
Once the wallet scans the URI, the following happens over the relay:
|
||||
|
||||
```
|
||||
1. Wallet sends: wallet_ready { ..., public_key: <wallet_nostr_pubkey_hex>,
|
||||
secret: <shared_secret_hex> }
|
||||
(wallet_ready carries both the application handshake data and the key exchange fields)
|
||||
|
||||
2. Dapp receives wallet_ready:
|
||||
- Verifies secret matches its own credentials.secret.
|
||||
- Calls client.setPairedPublicKey(walletPublicKey).
|
||||
- Fires keyexchangecomplete event.
|
||||
- Processes the session data (xpubs) as usual.
|
||||
- From this point, all outbound messages are encrypted to the wallet's pubkey.
|
||||
|
||||
3. Both sides now have each other's Nostr public keys.
|
||||
Subsequent messages are encrypted and filtered to the paired peer.
|
||||
```
|
||||
|
||||
The key exchange data (`public_key`, `secret`) is embedded directly in `wallet_ready`. The wallet
|
||||
sends a single message and the dapp learns the wallet's pubkey atomically with the session data.
|
||||
|
||||
### Why a shared secret?
|
||||
|
||||
The secret prevents a MITM or a curious third party from completing the key exchange with a
|
||||
wallet using a stolen URI. The wallet must echo back the correct 8-byte secret; if it doesn't
|
||||
match, the dapp ignores the `wallet_ready`. Eight bytes (64-bit) is enough entropy for
|
||||
a short-lived pairing code — it is not a long-term secret.
|
||||
|
||||
### Why Nostr keys?
|
||||
|
||||
Nostr keys are secp256k1 keys, the same curve used in Bitcoin/BCH. Using a Nostr relay and NIP-17
|
||||
gift wrap gets us:
|
||||
|
||||
- A widely-deployed, censorship-resistant message bus.
|
||||
- End-to-end encryption with a simple, audited encryption scheme.
|
||||
- No WizardConnect-operated server in the critical path — anyone can run a relay.
|
||||
|
||||
The "Nostr x-only pubkey" format is just the 32-byte x-coordinate of the secp256k1 public key
|
||||
(no 02/03 prefix). `deriveNostrPublicKey(privateKey)` computes this from a 32-byte private key.
|
||||
|
||||
## Peer filtering
|
||||
|
||||
After key exchange, `RelayClient` compares the `pubkey` field of every decrypted Nostr event
|
||||
against the paired public key. Messages from any other pubkey are dropped with a warning log.
|
||||
`wallet_ready` bypasses this filter because the dapp does not yet know the wallet's pubkey when
|
||||
it first arrives — it is the message that *establishes* the pairing.
|
||||
|
||||
## API surface
|
||||
|
||||
```typescript
|
||||
// Generate fresh credentials (called internally by initiateDappRelay)
|
||||
generateKeyExchangeCredentials(): KeyExchangeCredentials
|
||||
|
||||
// Encode a URI from an existing keypair — returns both standard and QR-safe forms
|
||||
encodeKeyExchangeURI(publicKey: string, secret: string, options?): KeyExchangeURIResult
|
||||
|
||||
interface KeyExchangeURIResult {
|
||||
uri: string; // standard URI for copy-paste: wiz://?p=...&s=...
|
||||
qrUri: string; // QR-alphanumeric-safe URI: WIZ://%3FP%3D...%26S%3D...
|
||||
}
|
||||
|
||||
// Decode and validate a wiz:// URI
|
||||
// Accepts standard format, QR format (%3F/%3D/%26), uppercase, and mixed-case
|
||||
decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI
|
||||
|
||||
interface DecodedKeyExchangeURI {
|
||||
publicKey: string; // hex, 32 bytes
|
||||
secret: string; // hex, 8 bytes
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: "ws" | "wss";
|
||||
}
|
||||
```
|
||||
214
docs/dapp.md
Normal file
214
docs/dapp.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# Dapp integration
|
||||
|
||||
Dapp integration uses `@wizardconnect/dapp` (for session management and pubkey state) together
|
||||
with `@wizardconnect/core` (for the relay connection and URI generation).
|
||||
|
||||
## DappConnectionManager
|
||||
|
||||
Manages a single dapp–wallet session. Handles the handshake, xpub storage, and sign request
|
||||
round-trips. Most dapps only ever have one active session at a time.
|
||||
|
||||
```typescript
|
||||
class DappConnectionManager extends EventEmitter {
|
||||
readonly pubkeyState: DappPubkeyStateManager;
|
||||
|
||||
walletName: string | null;
|
||||
walletIcon: string | null;
|
||||
/** The agreed protocol name after handshake, e.g. "hdwalletv1". Null until wallet_ready. */
|
||||
protocol: string | null;
|
||||
|
||||
constructor(dappName?: string, dappIcon?: string)
|
||||
|
||||
/** Call from the RelayStatusCallback each time the relay status changes.
|
||||
* Attaches the message listener exactly once (on first client seen).
|
||||
* Triggers onConnected() on each "connected" event. */
|
||||
updateConnection(client: RelayClient | null, status: RelayStatus): void
|
||||
|
||||
isWalletDiscovered(): boolean
|
||||
|
||||
/** Get the next sequence number for a SignTransactionRequest. */
|
||||
nextSequence(): number
|
||||
|
||||
/** Send a sign request and wait for the wallet's response.
|
||||
* Rejects if the wallet returns an error or if the connection drops. */
|
||||
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
|
||||
|
||||
/** Send a UserDisconnect courtesy message to the wallet.
|
||||
* Caller is responsible for calling dappRelay.cleanup() afterwards. */
|
||||
sendDisconnect(message?: string): Promise<void>
|
||||
|
||||
// Events
|
||||
on("walletready", (msg: WalletReadyMessage) => void)
|
||||
on("messagesent", (msg: ProtocolMessage) => void)
|
||||
on("messagereceived", (msg: ProtocolMessage) => void)
|
||||
on("disconnect", (reason: DisconnectReason, message: string | undefined) => void)
|
||||
}
|
||||
```
|
||||
|
||||
## Pubkey state — convenience delegation
|
||||
|
||||
`DappConnectionManager` delegates to `pubkeyState` for all pubkey operations. These methods
|
||||
are also available directly on the manager:
|
||||
|
||||
```typescript
|
||||
// Get a pubkey (derives on demand from xpub if not cached)
|
||||
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined
|
||||
|
||||
// Get all cached pubkeys for a path
|
||||
getPubkeys(childIndex: number): Map<bigint, Uint8Array>
|
||||
|
||||
// Get/set the current address index for a path
|
||||
getAddressIndex(childIndex: number): bigint
|
||||
setAddressIndex(childIndex: number, index: bigint): void
|
||||
|
||||
// Get a smart "next index to use" (see pubkey-derivation.md)
|
||||
getIndexToUse(childIndex: number, options?: { index?: bigint; reuseLast?: boolean }): bigint
|
||||
|
||||
// Get the min/max indices seen for a path
|
||||
getIndexRange(childIndex: number): { min?: bigint; max?: bigint }
|
||||
|
||||
// Remove a used change address from the gap-fill queue
|
||||
removeFromChangeQueue(index: bigint): void
|
||||
|
||||
// Get the stored xpub node (after wallet_ready)
|
||||
getXpubNode(childIndex: number): HdPublicNodeValid | undefined
|
||||
```
|
||||
|
||||
Child index values: `0` = receive, `1` = change, `7` = defi (Cauldron). These are
|
||||
internal to the dapp layer; use `childIndexOfPathName()` to convert from `PathName` if needed.
|
||||
|
||||
## Session lifecycle
|
||||
|
||||
### Initial connect
|
||||
|
||||
```typescript
|
||||
import { initiateDappRelay } from "@wizardconnect/core";
|
||||
import { DappConnectionManager } from "@wizardconnect/dapp";
|
||||
|
||||
const dappMgr = new DappConnectionManager("My Dapp", "https://example.com/icon.png");
|
||||
|
||||
const relay = initiateDappRelay(
|
||||
(payload) => {
|
||||
dappMgr.updateConnection(payload.client, payload.status);
|
||||
// also update your own UI state here (connected/disconnected indicator)
|
||||
},
|
||||
{ explicitRelayUrls: ["wss://relay.cauldron.quest:443"] },
|
||||
);
|
||||
|
||||
// Show relay.uri as a QR code for the wallet to scan.
|
||||
console.log("Scan this URI:", relay.uri);
|
||||
```
|
||||
|
||||
### After wallet connects
|
||||
|
||||
```typescript
|
||||
dappMgr.on("walletready", (msg) => {
|
||||
console.log("Wallet:", msg.wallet_name);
|
||||
// pubkeyState is now populated with xpub nodes.
|
||||
// You can start deriving addresses.
|
||||
});
|
||||
```
|
||||
|
||||
### Deriving addresses
|
||||
|
||||
```typescript
|
||||
// Get the first receive address pubkey:
|
||||
const RECEIVE = 0;
|
||||
const pubkey = dappMgr.getPubkey(RECEIVE, 0n); // derives from xpub if needed
|
||||
```
|
||||
|
||||
See [pubkey-derivation.md](pubkey-derivation.md) for full details.
|
||||
|
||||
### Sending a sign request
|
||||
|
||||
```typescript
|
||||
const seq = dappMgr.nextSequence();
|
||||
const request: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: seq,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
transaction: {
|
||||
transaction: { inputs, outputs, version: 2, locktime: 0 },
|
||||
sourceOutputs,
|
||||
userPrompt: "Confirm swap",
|
||||
broadcast: true,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await dappMgr.sendSignRequest(request);
|
||||
console.log("Signed tx:", response.signedTransaction);
|
||||
} catch (err) {
|
||||
console.error("Signing failed:", err.message);
|
||||
}
|
||||
```
|
||||
|
||||
`sendSignRequest` returns a Promise that resolves when the wallet sends back a
|
||||
`sign_transaction_response` with the matching `sequence`. It rejects if the wallet sends an
|
||||
error response.
|
||||
|
||||
### Disconnecting
|
||||
|
||||
```typescript
|
||||
// Dapp-initiated: send courtesy message, then tear down the relay
|
||||
await dappMgr.sendDisconnect("user closed the tab");
|
||||
relay.cleanup();
|
||||
|
||||
// Listen for wallet-initiated disconnect or protocol mismatch:
|
||||
dappMgr.on("disconnect", (reason, message) => {
|
||||
if (reason === DisconnectReason.ProtocolMismatch) {
|
||||
console.error("Protocol mismatch:", message);
|
||||
} else {
|
||||
console.log("Wallet disconnected:", reason, message);
|
||||
}
|
||||
relay.cleanup();
|
||||
});
|
||||
```
|
||||
|
||||
The `disconnect` event fires in two cases:
|
||||
1. **Remote disconnect**: the wallet sent a `disconnect` message (any reason).
|
||||
2. **Protocol mismatch**: `handleWalletReady` found no overlap between the dapp's and wallet's
|
||||
`supported_protocols`. The dapp automatically sends a `ProtocolMismatch` disconnect to the
|
||||
wallet before emitting the event.
|
||||
|
||||
### Reconnection
|
||||
|
||||
`updateConnection()` is called on every relay status change. When `status.status === "connected"`,
|
||||
it calls `onConnected()` which waits for key exchange and then sends a fresh `dapp_ready`. The
|
||||
`walletDiscovered` flag carries over reconnects (it is only reset by creating a new manager),
|
||||
so the correct `wallet_discovered` value is sent on each reconnect.
|
||||
|
||||
## Using initiateDappRelay without DappConnectionManager
|
||||
|
||||
If you need lower-level control (e.g., in the test-cli), you can work directly with the
|
||||
`RelayClient` and handle `wallet_ready` manually:
|
||||
|
||||
```typescript
|
||||
const relay = initiateDappRelay(statusCallback, options);
|
||||
|
||||
relay.events.on("keyexchangecomplete", async (walletPubkey) => {
|
||||
// Key exchange done — wait for relay client to be fully ready
|
||||
while (!relay.client.isKeyExchangeComplete()) {
|
||||
await sleep(50);
|
||||
}
|
||||
|
||||
relay.client.on("message", (msg) => {
|
||||
if (isDappReadyMessage(msg)) { /* ... */ }
|
||||
if (isWalletReadyMessage(msg)) { /* ... */ }
|
||||
});
|
||||
|
||||
await relay.client.relay({ action: RelayMsgAction.DappReady, ... });
|
||||
});
|
||||
```
|
||||
|
||||
## Cauldron (cauldron-beta) implementation notes
|
||||
|
||||
Cauldron uses `DappConnectionManager` via a vendored adapter in `src/relay/RelayWalletDapp.ts`.
|
||||
This adapter wraps `DappConnectionManager` to implement Cauldron's internal `Wallet` interface.
|
||||
|
||||
Key design points:
|
||||
- `DappConnectionManager` is created per connection session (not a singleton).
|
||||
- `updateConnection()` is called from the relay status callback.
|
||||
- `getXpubNode(childIndex)` and `getPubkey(childIndex, index)` are the primary access patterns.
|
||||
- Child indices are used internally (0/1/7); `childIndexOfPathName()` converts from PathName
|
||||
when processing `wallet_ready` data.
|
||||
79
docs/index.md
Normal file
79
docs/index.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# WizardConnect
|
||||
|
||||
WizardConnect is a protocol and library for connecting dapps to HD cryptocurrency wallets over
|
||||
an encrypted relay. A wallet shows a QR code; the dapp scans it. After that, the dapp can derive
|
||||
any number of the wallet's public keys locally and request transaction signatures — no seed phrase
|
||||
exposure, no trusted intermediary, minimal round-trips.
|
||||
|
||||
## Design goals
|
||||
|
||||
- **Private**: all relay messages are end-to-end encrypted (Nostr NIP-17 gift wrap). The relay sees
|
||||
only ciphertext addressed to the recipient's Nostr public key.
|
||||
- **Reconnection-proof**: both sides can disconnect and reconnect independently (mobile app switch,
|
||||
browser refresh, relay drop) and converge back to a live session without user action.
|
||||
- **Low-latency pubkey delivery**: the wallet sends BIP32 xpubs in the handshake; the dapp derives
|
||||
all addresses locally with no further round-trips.
|
||||
- **Protocol-agnostic paths**: the protocol identifies derivation paths by name (`receive`, `change`,
|
||||
`defi`) rather than numeric child indices. The wallet chooses how it actually derives those xpubs.
|
||||
|
||||
## Package structure
|
||||
|
||||
```
|
||||
libwizardconnect/
|
||||
├── packages/core — @wizardconnect/core
|
||||
│ Transport (Nostr relay, key exchange, encryption),
|
||||
│ protocol message types and type guards.
|
||||
│
|
||||
├── packages/wallet — @wizardconnect/wallet
|
||||
│ WalletConnectionManager, WalletAdapter interface.
|
||||
│ Multi-connection management, sign-request queuing.
|
||||
│
|
||||
├── packages/dapp — @wizardconnect/dapp
|
||||
│ DappConnectionManager, DappPubkeyStateManager.
|
||||
│ Single-session dapp helper, on-demand xpub derivation.
|
||||
│
|
||||
└── packages/test-cli — @wizardconnect/test-cli (private)
|
||||
CLI for manual and exploratory testing.
|
||||
```
|
||||
|
||||
The dependency graph is strictly one-directional:
|
||||
|
||||
```
|
||||
test-cli → wallet, dapp → core
|
||||
```
|
||||
|
||||
`core` has no knowledge of wallets or dapps; `wallet` and `dapp` have no knowledge of each other.
|
||||
|
||||
## License
|
||||
|
||||
WizardConnect is licensed under the [GNU Lesser General Public License v3.0](https://www.gnu.org/licenses/lgpl-3.0.html) (LGPL-3.0-or-later).
|
||||
|
||||
You may use, link against, and distribute WizardConnect in both open-source and proprietary
|
||||
applications. Modifications to the library itself must be released under the same license.
|
||||
|
||||
Every source file must carry the copyright header:
|
||||
|
||||
```ts
|
||||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
```
|
||||
|
||||
A linter enforces this: `npm run lint:copyright`.
|
||||
|
||||
### Contributing
|
||||
|
||||
All contributors must sign a Contributor License Agreement (CLA) before their contributions can be
|
||||
accepted. The CLA assigns copyright to Whiterun LLC so that the project can be relicensed in the
|
||||
future if needed. You will be prompted to sign the CLA when you open your first pull request.
|
||||
|
||||
## Quick navigation
|
||||
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| Protocol messages and handshake | [protocol.md](protocol.md) |
|
||||
| Connection URI and key exchange | [connection-uri.md](connection-uri.md) |
|
||||
| Relay transport and encryption | [transport.md](transport.md) |
|
||||
| Wallet integration guide | [wallet.md](wallet.md) |
|
||||
| Dapp integration guide | [dapp.md](dapp.md) |
|
||||
| xpub delivery and pubkey derivation | [pubkey-derivation.md](pubkey-derivation.md) |
|
||||
347
docs/protocol.md
Normal file
347
docs/protocol.md
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
# Protocol
|
||||
|
||||
The application-level protocol has two layers:
|
||||
|
||||
- **Base protocol** — the handshake messages (`dapp_ready`, `wallet_ready`, `disconnect`) that are
|
||||
shared across all application protocols and live in `@wizardconnect/core/protocols/base.ts`.
|
||||
- **hdwalletv1** — the BCH HD-wallet protocol, carrying session data (xpubs) and
|
||||
sign request round-trips. Defined in `@wizardconnect/core/protocols/hdwalletv1.ts`.
|
||||
|
||||
Both layers use the same encrypted relay channel (see [transport.md](transport.md)).
|
||||
|
||||
Protocol selection happens during the handshake via `supported_protocols` lists, not via a
|
||||
hard-coded field. This allows forward-compatible negotiation when future protocol versions are added.
|
||||
|
||||
## Message envelope
|
||||
|
||||
Every message shares a base shape:
|
||||
|
||||
```typescript
|
||||
interface ProtocolMessage {
|
||||
action: string; // one of the RelayMsgAction values below
|
||||
time: number; // Unix timestamp (seconds). Used for replay filtering.
|
||||
}
|
||||
```
|
||||
|
||||
The `time` field is checked by the relay client: messages older than the last-processed timestamp
|
||||
are silently dropped. This prevents stale messages buffered at the relay from being re-delivered
|
||||
after a reconnect.
|
||||
|
||||
## Actions
|
||||
|
||||
```
|
||||
dapp_ready — dapp → wallet, signals dapp is alive + lists supported protocols
|
||||
wallet_ready — wallet → dapp, signals wallet is alive + delivers session data + key exchange
|
||||
sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
|
||||
sign_transaction_response — wallet → dapp, returns signed tx or error
|
||||
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
|
||||
disconnect — either → either, courtesy notification before tearing down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base protocol
|
||||
|
||||
### dapp_ready
|
||||
|
||||
```typescript
|
||||
interface DappReadyMessage {
|
||||
action: "dapp_ready";
|
||||
supported_protocols: string[]; // protocols this dapp supports, in preference order
|
||||
selected_protocol?: string; // set only on the reactive dapp_ready (after seeing wallet_ready)
|
||||
wallet_discovered: boolean; // true if dapp already saw this wallet this session
|
||||
dapp_name?: string; // optional, sent on first message for wallet UI
|
||||
dapp_icon?: string; // optional icon URL or data-URI
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
||||
`dapp_name` and `dapp_icon` are captured by the wallet on the first `dapp_ready` that includes
|
||||
them. The wallet shows these in its connections list.
|
||||
|
||||
`selected_protocol` is absent on the proactive `dapp_ready` (sent before the dapp has seen
|
||||
the wallet). Once the dapp receives `wallet_ready` and picks a protocol, the reactive `dapp_ready`
|
||||
carries `selected_protocol` so the wallet can confirm the agreed protocol.
|
||||
|
||||
### wallet_ready
|
||||
|
||||
```typescript
|
||||
interface WalletReadyMessage {
|
||||
action: "wallet_ready";
|
||||
supported_protocols: string[]; // protocols this wallet supports
|
||||
wallet_name: string;
|
||||
wallet_icon: string;
|
||||
dapp_discovered: boolean; // true if wallet already saw this dapp this session
|
||||
session: Record<string, unknown>; // keyed by protocol name; each value is protocol-specific
|
||||
public_key: string; // wallet's Nostr x-only pubkey (hex, 32 bytes) — key exchange
|
||||
secret: string; // echo of the shared secret from the URI — MITM prevention
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
||||
`wallet_ready` is the most important message in the protocol. It serves two purposes:
|
||||
|
||||
1. **Key exchange** — `public_key` is the wallet's Nostr pubkey; `secret` is echoed from the
|
||||
connection URI for MITM prevention. The dapp verifies the secret and calls
|
||||
`setPairedPublicKey(public_key)` before processing the rest of the message. This is why
|
||||
`wallet_ready` bypasses the relay-client peer filter.
|
||||
|
||||
2. **Application handshake** — the wallet populates `session` for every protocol it supports.
|
||||
The dapp picks the first protocol from its own `supported_protocols` list that also appears
|
||||
in the wallet's list, then reads `session[selectedProtocol]` for the protocol-specific data.
|
||||
|
||||
### Protocol negotiation
|
||||
|
||||
1. The dapp sends its `supported_protocols` list in the proactive `dapp_ready`.
|
||||
2. The wallet replies with its own `supported_protocols` and the `session` map.
|
||||
3. The dapp selects `agreed = dapp.supported_protocols.find(p => wallet.supported_protocols.includes(p))`.
|
||||
4. If no overlap: the dapp sends `disconnect(reason: "protocol_mismatch")` and emits a `disconnect`
|
||||
event. No further communication happens.
|
||||
5. If agreed: the dapp reads `session[agreed]` and sends a reactive `dapp_ready` with
|
||||
`selected_protocol = agreed`.
|
||||
|
||||
### Handshake
|
||||
|
||||
The handshake uses a **mutual-discovery** pattern. The goal is for both sides to converge to a
|
||||
live session regardless of who reconnects first. "Discovered" means "I have received and processed
|
||||
a ready message from the other side in this runtime session."
|
||||
|
||||
#### Rules
|
||||
|
||||
1. On every connect/reconnect, each side sends its own "ready" message proactively. The wallet
|
||||
sends `wallet_ready` immediately (it already knows the dapp's pubkey from the URI). The dapp
|
||||
sends `dapp_ready` once the relay is connected and key exchange resolves.
|
||||
2. Each "ready" message carries a boolean indicating whether the sender has already seen the
|
||||
other party (`wallet_discovered` in `dapp_ready`, `dapp_discovered` in `wallet_ready`).
|
||||
3. On receiving a "ready" with the discovery flag `false`, the receiver must send back its own
|
||||
"ready" — *even if it already sent one* — because the other side has lost state and needs
|
||||
a fresh delivery.
|
||||
4. The wallet guards against duplicate `wallet_ready` messages within a single connection cycle
|
||||
via `walletReadySentThisCycle`. This resets to `false` on each new connect/reconnect.
|
||||
Receiving `dapp_ready(wallet_discovered=false)` also resets this flag.
|
||||
|
||||
#### Scenarios
|
||||
|
||||
**Initial connect (neither has seen the other):**
|
||||
|
||||
```
|
||||
Dapp ──dapp_ready(supported=["hdwalletv1"], wallet_discovered=false)──▶ Wallet (proactive)
|
||||
Dapp ◀──wallet_ready(supported=["hdwalletv1"], session={...}, dapp_discovered=false)── Wallet
|
||||
Dapp ──dapp_ready(supported=["hdwalletv1"], selected="hdwalletv1", wallet_discovered=true)──▶ Wallet
|
||||
```
|
||||
|
||||
After step 3 the wallet sets `dappDiscovered = true`. No more ready messages unless a reconnect.
|
||||
|
||||
**Wallet reconnects (dapp still running, walletDiscovered=true):**
|
||||
|
||||
```
|
||||
Dapp ──dapp_ready(wallet_discovered=true)──────────────────────────────▶ Wallet (proactive)
|
||||
Dapp ◀──wallet_ready(dapp_discovered=false, session={...})────────────── Wallet (proactive)
|
||||
Dapp ──dapp_ready(selected="hdwalletv1", wallet_discovered=true)────────▶ Wallet (reactive)
|
||||
```
|
||||
|
||||
**Dapp reconnects (browser refresh, wallet still running):**
|
||||
|
||||
```
|
||||
Dapp ──dapp_ready(wallet_discovered=false)──────────────────────────────▶ Wallet (proactive)
|
||||
Dapp ◀──wallet_ready(dapp_discovered=true, session={...})─────────────── Wallet (reactive)
|
||||
```
|
||||
(No third message: `dapp_discovered=true` means the dapp does not need to send a reactive reply.)
|
||||
|
||||
#### Design decision: why mutual discovery?
|
||||
|
||||
An alternative is a fixed initiator/responder role (only the dapp initiates). That breaks when the
|
||||
wallet reconnects while the dapp is still alive — the wallet would wait for a dapp message that
|
||||
never comes because the dapp thinks the session is live. Mutual discovery means each side sends a
|
||||
"hello" on reconnect without depending on the other side's state.
|
||||
|
||||
---
|
||||
|
||||
## hdwalletv1 protocol
|
||||
|
||||
The `hdwalletv1` session data is carried in `wallet_ready.session["hdwalletv1"]`. It delivers
|
||||
everything the dapp needs to derive an unlimited number of addresses without further contact with
|
||||
the wallet.
|
||||
|
||||
### Hdwalletv1Session
|
||||
|
||||
```typescript
|
||||
interface Hdwalletv1Session {
|
||||
paths: PathXpub[]; // BIP32 xpubs for receive/change/defi
|
||||
}
|
||||
```
|
||||
|
||||
Carried as `wallet_ready.session["hdwalletv1"]`. The dapp validates it with `isHdwalletv1Session()`.
|
||||
|
||||
See [pubkey-derivation.md](pubkey-derivation.md) for the full xpub story.
|
||||
|
||||
### PathXpub
|
||||
|
||||
```typescript
|
||||
interface PathXpub {
|
||||
name: PathName; // "receive" | "change" | "defi"
|
||||
xpub: string; // BIP32 base58-encoded extended public key
|
||||
}
|
||||
```
|
||||
|
||||
`name` is the protocol-level identifier. The dapp uses the name to know what kind of addresses to
|
||||
derive from the xpub; it does not need to know (or care) where the wallet derived the xpub from.
|
||||
|
||||
**Highly recommended derivation paths.** To ensure addresses are recognised by other wallets and
|
||||
blockchain explorers, wallets should derive xpubs from the standard BIP44 paths for BCH:
|
||||
|
||||
| Name | Recommended derivation path | Purpose |
|
||||
|------|-----------------------------|---------|
|
||||
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
|
||||
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
|
||||
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
|
||||
|
||||
Using these paths means the same addresses will appear in any BIP44-compatible wallet that holds
|
||||
the same seed, making fund recovery straightforward.
|
||||
|
||||
**Privacy-first alternative: any path per session.** The protocol does not enforce the recommended
|
||||
paths. A wallet that prioritises privacy may derive xpubs from non-standard or randomly-chosen
|
||||
paths, and may even rotate them each session. The dapp derives addresses correctly regardless —
|
||||
it never sees the path, only the xpub. The trade-off is that funds sent to session-specific paths
|
||||
will not be found by standard wallet recovery tools without additional metadata.
|
||||
|
||||
**Design decision: names instead of child indices.** The protocol uses human-readable names rather
|
||||
than numeric child indices because the derivation path is a wallet-internal detail. A name like
|
||||
`"receive"` is stable and meaningful; the corresponding BIP44 index is an implementation concern
|
||||
that only the wallet (and internal dapp state) need to know.
|
||||
|
||||
### PathName
|
||||
|
||||
```typescript
|
||||
type PathName = "receive" | "change" | "defi";
|
||||
```
|
||||
|
||||
| Name | Recommended BIP44 path | Purpose |
|
||||
|------|------------------------|---------|
|
||||
| `receive` | `m/44'/145'/0'/0` | External receive addresses |
|
||||
| `change` | `m/44'/145'/0'/1` | Internal change addresses |
|
||||
| `defi` | `m/44'/145'/0'/7` | DeFi / Cauldron addresses |
|
||||
|
||||
### sign_transaction_request
|
||||
|
||||
```typescript
|
||||
interface SignTransactionRequest {
|
||||
action: "sign_transaction_request";
|
||||
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
|
||||
sequence: number;
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
||||
`sequence` is a unique number generated by `RelayClient.nextSequence()`. It starts at a random
|
||||
offset (to avoid collisions across sessions) and increments by 2 per call. The dapp uses `sequence`
|
||||
to match responses to requests.
|
||||
|
||||
`WcSignTransactionRequest` describes a Bitcoin Cash transaction: inputs, outputs, source outputs
|
||||
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
|
||||
wallet UI.
|
||||
|
||||
### sign_transaction_response
|
||||
|
||||
```typescript
|
||||
interface SignTransactionResponse {
|
||||
action: "sign_transaction_response";
|
||||
sequence: number;
|
||||
signedTransaction: string; // hex-encoded fully signed transaction
|
||||
error?: string; // if present, signing failed; signedTransaction is ""
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
||||
The wallet either returns the signed transaction hex or an error string. The dapp rejects the
|
||||
pending Promise associated with the `sequence` in the error case.
|
||||
|
||||
### sign_cancel
|
||||
|
||||
```typescript
|
||||
interface SignCancelMessage {
|
||||
action: "sign_cancel";
|
||||
sequence: number; // must match the sequence of the sign_transaction_request being cancelled
|
||||
reason?: string; // optional human-readable explanation
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
||||
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should
|
||||
dismiss the corresponding sign dialog immediately upon receipt.
|
||||
|
||||
Use cases:
|
||||
- User presses cancel on the dapp side while waiting for the wallet to sign.
|
||||
- Dapp replaces a stale request with a new one (e.g., trade price has changed).
|
||||
|
||||
**Dapp side** (`DappConnectionManager`):
|
||||
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
|
||||
then sends `sign_cancel` to the wallet.
|
||||
|
||||
**Wallet side** (`WalletConnectionManager`):
|
||||
- Incoming `sign_cancel` emits a `signCancelled` event (`connectionId`, `sequence`, `reason`).
|
||||
The host app is responsible for dismissing the sign dialog.
|
||||
|
||||
---
|
||||
|
||||
## disconnect
|
||||
|
||||
Either side may send a `disconnect` message before tearing down the relay connection. This is a
|
||||
courtesy notification — the remote side treats the connection as closed immediately upon receipt
|
||||
(no acknowledgement).
|
||||
|
||||
```typescript
|
||||
enum DisconnectReason {
|
||||
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
|
||||
UserDisconnect = "user_disconnect", // explicit user or application action
|
||||
}
|
||||
|
||||
interface DisconnectMessage {
|
||||
action: "disconnect";
|
||||
reason: DisconnectReason;
|
||||
message?: string; // optional human-readable detail
|
||||
time: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Wallet side** (`WalletConnectionManager`):
|
||||
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
|
||||
- Incoming `disconnect` emits a `remoteDisconnect` event
|
||||
(`connectionId`, `reason`, `message`) and removes the connection.
|
||||
|
||||
**Dapp side** (`DappConnectionManager`):
|
||||
- `sendDisconnect(message?)` sends `UserDisconnect`. Caller then calls `dappRelay.cleanup()`.
|
||||
- Protocol mismatch during `handleWalletReady` sends `ProtocolMismatch` and emits a `disconnect`
|
||||
event (`reason`, `message`).
|
||||
- Incoming `disconnect` emits a `disconnect` event.
|
||||
|
||||
---
|
||||
|
||||
## Type guards
|
||||
|
||||
`@wizardconnect/core` exports runtime type guards for all protocol messages:
|
||||
|
||||
```typescript
|
||||
isProtocolMessage(obj) → ProtocolMessage
|
||||
isDappReadyMessage(obj) → DappReadyMessage
|
||||
isWalletReadyMessage(obj) → WalletReadyMessage
|
||||
isDisconnectMessage(obj) → DisconnectMessage
|
||||
isHdwalletv1Session(obj) → Hdwalletv1Session
|
||||
isPathXpub(obj) → PathXpub
|
||||
isErrorMessage(obj) → ErrorMessage
|
||||
isSignTransactionRequest(obj) → SignTransactionRequest
|
||||
isSignCancelMessage(obj) → SignCancelMessage
|
||||
```
|
||||
|
||||
These are used internally to validate incoming messages before dispatch.
|
||||
|
||||
## Helper: childIndexOfPathName
|
||||
|
||||
```typescript
|
||||
function childIndexOfPathName(name: PathName): number
|
||||
// "receive" → 0, "change" → 1, "defi" → 7
|
||||
```
|
||||
|
||||
The protocol uses string names for paths, but code that manages key state internally (such as
|
||||
`DappPubkeyStateManager`) keys its maps by numeric child index. This helper converts between the
|
||||
two representations. It is not a protocol concern — the numeric indices never appear on the wire.
|
||||
142
docs/pubkey-derivation.md
Normal file
142
docs/pubkey-derivation.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# xpub delivery and pubkey derivation
|
||||
|
||||
One of the main design choices in `hdwalletv1` is that the wallet delivers BIP32 extended
|
||||
public keys (xpubs) in `wallet_ready` and the dapp derives all addresses locally. This page
|
||||
explains the design, the on-demand derivation model, and the change-address gap-fill logic.
|
||||
|
||||
## Why xpubs instead of individual pubkeys
|
||||
|
||||
### The old model (pre-xpub)
|
||||
|
||||
Earlier relay protocols required the dapp to ask the wallet for pubkeys by sending `pubkey_batch`
|
||||
requests, waiting for `pubkey_batch_response`, and tracking "subscribe_paths" subscriptions. This:
|
||||
|
||||
- Added round-trips before the dapp could construct a transaction.
|
||||
- Required the wallet to be online and responsive during transaction construction.
|
||||
- Created complex state management for "have I received pubkeys for indices N…M?".
|
||||
|
||||
### The xpub model
|
||||
|
||||
The wallet sends one xpub per named path in `wallet_ready`. The dapp can then derive any child
|
||||
pubkey `m/<xpub>/n` locally for any index `n` in O(1) with no network. BIP32 public-child
|
||||
derivation is deterministic and requires no private key material.
|
||||
|
||||
**Design decision: wallet chooses derivation path.** The xpub is delivered with a name
|
||||
(`receive`, `change`, `defi`) but no information about the wallet's internal derivation path.
|
||||
The dapp derives addresses correctly regardless — it never sees the path, only the xpub node.
|
||||
|
||||
**Highly recommended: standard BIP44 paths.** For maximum interoperability — so that funds are
|
||||
found by any BIP44-compatible wallet during seed recovery — wallets should use:
|
||||
|
||||
| Name | Recommended path |
|
||||
|------|-----------------|
|
||||
| `receive` | `m/44'/145'/0'/0` |
|
||||
| `change` | `m/44'/145'/0'/1` |
|
||||
| `defi` | `m/44'/145'/0'/7` |
|
||||
|
||||
**Privacy alternative: non-standard or rotating paths.** A wallet that prioritises privacy may
|
||||
derive xpubs from arbitrary paths, or choose a fresh random derivation path each session. The
|
||||
protocol carries only the xpub, so the dapp is unaffected. The trade-off is that standard
|
||||
recovery tools won't find funds at non-standard paths without extra metadata.
|
||||
|
||||
## DappPubkeyStateManager
|
||||
|
||||
`DappPubkeyStateManager` (in `@wizardconnect/dapp`) is the dapp's local address book.
|
||||
|
||||
### Storage
|
||||
|
||||
```
|
||||
xpubNodes: Map<childIndex, HdPublicNodeValid> — decoded xpub node per path
|
||||
pubkeys: Map<childIndex, Map<index, Uint8Array>> — cached 33-byte compressed pubkeys
|
||||
addressIndices: Map<childIndex, bigint> — current "next index" per path
|
||||
changeAddressQueue: Set<bigint> — gap-fill queue for change path
|
||||
```
|
||||
|
||||
### On-demand derivation
|
||||
|
||||
`getPubkey(childIndex, index)` checks the cache first. On a miss it derives the child key from
|
||||
the xpub node:
|
||||
|
||||
```typescript
|
||||
const child = deriveHdPublicNodeChild(xpubNode, Number(index));
|
||||
// child.publicKey is the 33-byte compressed secp256k1 public key
|
||||
```
|
||||
|
||||
The derived key is added to the cache so subsequent calls are instant. This means the dapp
|
||||
never pre-fetches — it derives exactly the indices it needs, when it needs them.
|
||||
|
||||
### getIndexToUse
|
||||
|
||||
```typescript
|
||||
getIndexToUse(childIndex, options?): bigint
|
||||
```
|
||||
|
||||
Returns the "right" index for the next address on a path, with three cases in priority order:
|
||||
|
||||
1. If `options.index` is provided, use it directly (caller knows exactly what they want).
|
||||
2. If the path is change (`childIndex === 1`) and there are gap-fill entries, return the lowest
|
||||
available gap address (see below).
|
||||
3. Otherwise return `addressIndex + 1` (the next fresh index).
|
||||
|
||||
With `options.reuseLast = true`, returns `addressIndex` instead of `+1` (for reusing the last
|
||||
issued address, e.g., when re-displaying an invoice).
|
||||
|
||||
### getIndexRange
|
||||
|
||||
Returns the min and max indices currently cached for a path. Useful for knowing the spread of
|
||||
addresses the dapp has worked with.
|
||||
|
||||
## Change address gap-filling
|
||||
|
||||
Change addresses are spend-once by convention. When a transaction is constructed but not yet
|
||||
broadcast, the dapp allocates a change address. If the transaction is later abandoned, that
|
||||
change index becomes a "gap" — it was never actually used on-chain, but the dapp has already
|
||||
handed it out. Requesting a new fresh index from `addressIndex + 1` would skip the gap, wasting
|
||||
an address and potentially causing the wallet to over-scan.
|
||||
|
||||
### How it works
|
||||
|
||||
`addPubkey(1, index, pubkey)` checks whether `index < currentAddressIndex`. If so, the address
|
||||
is in the "past" — it was issued earlier but not yet used (a gap). It gets added to
|
||||
`changeAddressQueue`.
|
||||
|
||||
`getIndexToUse(1)` checks `changeAddressQueue` first and returns a gap address if one exists
|
||||
and its pubkey is cached. The caller calls `removeFromChangeQueue(index)` once the address is
|
||||
actually used in a broadcast transaction.
|
||||
|
||||
This is primarily useful for dapps that optimistically allocate change addresses before confirming
|
||||
broadcast. If the user cancels mid-flow, the gap address goes back into the queue.
|
||||
|
||||
## BIP32 derivation depth
|
||||
|
||||
The xpub delivered in `wallet_ready` is the path node — for example, the node at
|
||||
`m/44'/145'/0'/0` for the receive path. Deriving child index `n` from it gives the same key as
|
||||
deriving `m/44'/145'/0'/0/n` from the master key. The `/n` level is the address level (unhardened).
|
||||
|
||||
BIP32 requires unhardened derivation for xpub child derivation (hardened derivation requires
|
||||
the private key). Standard BIP44 address indices are always unhardened, so this works correctly.
|
||||
|
||||
## Example: deriving receive addresses
|
||||
|
||||
```typescript
|
||||
// After wallet_ready:
|
||||
const RECEIVE = 0; // childIndex for receive
|
||||
|
||||
// Derive address at index 0:
|
||||
const pubkey = dappMgr.getPubkey(RECEIVE, 0n);
|
||||
// pubkey is a 33-byte Uint8Array (compressed secp256k1 public key)
|
||||
|
||||
// Derive several addresses for display (no network needed):
|
||||
for (let i = 0n; i < 5n; i++) {
|
||||
const pk = dappMgr.getPubkey(RECEIVE, i);
|
||||
// convert to cashaddr, P2PKH, etc.
|
||||
}
|
||||
```
|
||||
|
||||
## Integration test coverage
|
||||
|
||||
`integration.test.ts` and `pubkeybatch.test.ts` (in `packages/wallet/src/integration/`) verify:
|
||||
|
||||
- `wallet_ready` carries paths for all three named paths (receive, change, defi).
|
||||
- Each xpub is valid base58 and decodes as a valid BIP32 node.
|
||||
- Deriving child indices from the xpub matches `adapter.getPublicKey(path, index)` exactly.
|
||||
193
docs/transport.md
Normal file
193
docs/transport.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Relay transport
|
||||
|
||||
The relay layer handles everything below the application protocol: WebSocket connectivity,
|
||||
message encryption, reconnection, and message queuing. Application code (wallet, dapp) should
|
||||
never touch NDK or Nostr directly.
|
||||
|
||||
## Stack
|
||||
|
||||
```
|
||||
Application messages (hdwalletv1 JSON)
|
||||
↓ JSON.stringify
|
||||
NDK PrivateDirectMessage (kind 14, rumor)
|
||||
↓ NIP-17 gift wrap (kind 1059, encrypted to recipient)
|
||||
NDK → Nostr relay (WebSocket)
|
||||
↓ stored as kind 1059 event, tagged with recipient pubkey
|
||||
NDK ← Nostr relay subscription (filter: kind=1059, #p=<our_pubkey>)
|
||||
↓ giftUnwrap → PrivateDirectMessage → JSON.parse
|
||||
Application message handler
|
||||
```
|
||||
|
||||
## RelayClient
|
||||
|
||||
`RelayClient` is the low-level building block. One instance per peer connection.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- Owns an NDK instance configured with explicit relay URLs.
|
||||
- Subscribes to `kind:1059` (GiftWrap) events tagged with its own pubkey.
|
||||
- Decrypts and unwraps incoming events using NIP-17 `giftUnwrap`.
|
||||
- Publishes outbound messages using `giftWrap` addressed to the paired peer.
|
||||
- Filters incoming messages by peer pubkey (rejects messages from unknown senders).
|
||||
- Discards messages older than `lastProcessedTimestamp` (replay protection).
|
||||
- Queues outbound messages until at least one relay is ready.
|
||||
|
||||
### Construction
|
||||
|
||||
```typescript
|
||||
new RelayClient({
|
||||
explicitRelayUrls: string[]; // WebSocket URLs, e.g. ["wss://relay.cauldron.quest:443"]
|
||||
signerPrivateKey: Uint8Array; // 32-byte secp256k1 private key (this client's identity)
|
||||
pairedPublicKey?: Uint8Array; // 32-byte x-only pubkey of the peer (set after key exchange)
|
||||
logNetworkActivity?: boolean; // default true
|
||||
})
|
||||
```
|
||||
|
||||
### Key methods
|
||||
|
||||
```typescript
|
||||
connect(): Promise<void>
|
||||
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
|
||||
|
||||
disconnect(): Promise<void>
|
||||
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
|
||||
|
||||
relay(message: ProtocolMessage): Promise<void>
|
||||
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
|
||||
|
||||
setPairedPublicKey(key: Uint8Array): void
|
||||
// Called after key exchange. Enables outbound messages and incoming peer filtering.
|
||||
|
||||
isKeyExchangeComplete(): boolean
|
||||
// true once pairedPublicKey is set and non-zero.
|
||||
|
||||
nextSequence(): number
|
||||
// Returns a unique sequence number (starts at random offset, increments by 2).
|
||||
|
||||
getLastProcessedTimestamp() / setLastProcessedTimestamp(ts): void
|
||||
// Persist/restore across reconnects to avoid re-delivering buffered messages.
|
||||
```
|
||||
|
||||
### Message queue
|
||||
|
||||
Before any relay is confirmed as connected, outbound `relay()` calls are enqueued in a
|
||||
`MessageQueue`. The client polls every 100 ms for up to 5 seconds for at least one relay with
|
||||
status 1 (connected). After that timeout it assumes the relay is ready and flushes the queue
|
||||
regardless (avoiding silent message loss on slow connections).
|
||||
|
||||
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
|
||||
held rather than dropped.
|
||||
|
||||
### Replay protection
|
||||
|
||||
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
|
||||
to `now` in `disconnect()`. Any incoming message with `time < lastProcessedTimestamp` is silently
|
||||
dropped. This prevents the relay from re-delivering messages that were already handled before a
|
||||
disconnect.
|
||||
|
||||
### Sequence numbers
|
||||
|
||||
`nextSequence()` starts at a random offset in the safe integer range and increments by 2. This
|
||||
means two instances are unlikely to collide (they start at different offsets), and the step of 2
|
||||
leaves room for error responses at odd offsets if needed in the future.
|
||||
|
||||
## initiateRelay
|
||||
|
||||
`initiateRelay()` (in `relay-handler.ts`) wraps `RelayClient` with a reconnect loop:
|
||||
|
||||
```typescript
|
||||
initiateRelay(
|
||||
statusCallback: RelayStatusCallback,
|
||||
privateKey: Uint8Array,
|
||||
initialPairedKey: Uint8Array,
|
||||
options: { explicitRelayUrls, reconnectInterval?, maxReconnectAttempts? }
|
||||
): () => void // returns cleanup function
|
||||
```
|
||||
|
||||
It calls `statusCallback` with a `RelayUpdatePayload` on every state change:
|
||||
|
||||
```typescript
|
||||
interface RelayUpdatePayload {
|
||||
client: RelayClient;
|
||||
status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" }
|
||||
}
|
||||
```
|
||||
|
||||
The reconnect loop is simple: on a disconnect event from the client it waits
|
||||
`reconnectInterval` (default 5000 ms) and calls `client.connect()` again. No exponential backoff
|
||||
currently — connections are expected to be stable (relay and mobile network) and fast to re-establish.
|
||||
|
||||
## initiateDappRelay
|
||||
|
||||
Higher-level helper that bundles credential generation, URI encoding, key exchange handling,
|
||||
and reconnection into one call:
|
||||
|
||||
```typescript
|
||||
initiateDappRelay(
|
||||
statusCallback: RelayStatusCallback,
|
||||
options?: {
|
||||
explicitRelayUrls?: string[];
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
existingCredentials?: { privateKey: string; secret: string };
|
||||
}
|
||||
): DappRelayResult
|
||||
|
||||
interface DappRelayResult {
|
||||
client: RelayClient;
|
||||
uri: string; // wiz:// URI to encode into QR
|
||||
credentials: KeyExchangeCredentials;
|
||||
events: EventEmitter<{ keyexchangecomplete: [walletPublicKey: Uint8Array] }>;
|
||||
cleanup: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Internally it wraps the caller's `statusCallback` to intercept `wallet_ready` messages,
|
||||
verify the secret, and call `client.setPairedPublicKey()` before the message is processed by
|
||||
`DappConnectionManager`. On reconnect, if `walletPublicKeyNostr` is already known it immediately
|
||||
restores the paired key so outbound messages can be sent before the next `wallet_ready` arrives.
|
||||
|
||||
## initiateWalletRelay
|
||||
|
||||
```typescript
|
||||
initiateWalletRelay(
|
||||
statusCallback: RelayStatusCallback,
|
||||
options: {
|
||||
uri: string; // wiz:// URI from QR scan
|
||||
walletPrivateKey: Uint8Array; // wallet's relay identity key (may be ephemeral)
|
||||
}
|
||||
): { cleanup: () => void }
|
||||
```
|
||||
|
||||
Decodes the URI, sets the dapp's public key as the initial paired key, and starts the relay.
|
||||
On each connect/reconnect, the wallet sends `wallet_ready` which carries the key exchange fields
|
||||
(`public_key`, `secret`) alongside the session data. This gives the dapp the wallet's pubkey
|
||||
atomically with the application handshake data.
|
||||
|
||||
## Encryption: NIP-17 gift wrap
|
||||
|
||||
NIP-17 gift wrap works like a sealed envelope:
|
||||
|
||||
1. **Rumor** (kind 14, `PrivateDirectMessage`): the plaintext message, signed by the sender.
|
||||
Content is the JSON payload. Tags include `["p", recipient_pubkey_hex]`.
|
||||
2. **Seal** (kind 13): the rumor encrypted with a shared ECDH secret derived from sender's
|
||||
private key and recipient's public key. No `p` tag — unlinkable to sender/recipient.
|
||||
3. **Wrap** (kind 1059, `GiftWrap`): the seal encrypted to the recipient's public key using a
|
||||
freshly generated throwaway key. Has a `["p", recipient_pubkey_hex]` tag so the relay can
|
||||
route it to the right subscription.
|
||||
|
||||
The relay sees only kind 1059 with a recipient tag. It cannot read content or link messages to
|
||||
senders. The throwaway outer key means even the relay cannot correlate multiple messages from
|
||||
the same sender.
|
||||
|
||||
NDK handles all three layers in `giftWrap()` / `giftUnwrap()`.
|
||||
|
||||
## Default relay
|
||||
|
||||
```
|
||||
wss://relay.cauldron.quest:443
|
||||
```
|
||||
|
||||
This is a Cauldron-operated Nostr relay. Nothing in the protocol prevents using any other
|
||||
standard Nostr relay. The relay is specified in the connection URI, so wallet and dapp always
|
||||
use the same relay without out-of-band coordination.
|
||||
212
docs/wallet.md
Normal file
212
docs/wallet.md
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# Wallet integration
|
||||
|
||||
Wallet integration uses `@wizardconnect/wallet`. The wallet implements the `WalletAdapter`
|
||||
interface and hands it to `WalletConnectionManager`, which handles everything else.
|
||||
|
||||
## WalletAdapter
|
||||
|
||||
```typescript
|
||||
interface WalletAdapter {
|
||||
walletName: string; // shown in the dapp's connection UI
|
||||
walletIcon: string; // URL or data-URI, shown in the dapp's connection UI
|
||||
|
||||
/**
|
||||
* Return the relay identity private key for this session.
|
||||
* May be ephemeral (random per session) or stable (HD-derived) — both work.
|
||||
* The dapp learns the wallet's public key from the wallet_ready message,
|
||||
* so stability across restarts is not required.
|
||||
*/
|
||||
getRelayPrivateKey(): Uint8Array;
|
||||
|
||||
/** Returns the compressed 33-byte secp256k1 public key at path/index. */
|
||||
getPublicKey(path: DerivationPath, index: bigint): Uint8Array;
|
||||
|
||||
/** Returns the BIP32 base58-encoded xpub for the given derivation path.
|
||||
* The dapp derives all addresses from this — no further pubkey requests needed. */
|
||||
getXpub(path: DerivationPath): string;
|
||||
|
||||
/** Sign the transaction. May show approval UI to the user.
|
||||
* Called when the wallet has received and validated a sign_transaction_request. */
|
||||
signTransaction(request: SignTransactionRequest): Promise<SignTransactionResult>;
|
||||
}
|
||||
```
|
||||
|
||||
### DerivationPath
|
||||
|
||||
```typescript
|
||||
enum DerivationPath {
|
||||
Receive = 0, // m/44'/145'/0'/0 — external (receive) addresses
|
||||
Change = 1, // m/44'/145'/0'/1 — internal (change) addresses
|
||||
Cauldron = 7, // m/44'/145'/0'/7 — DeFi/Cauldron addresses
|
||||
}
|
||||
```
|
||||
|
||||
The numeric values are wallet-internal; the protocol uses names (`receive`, `change`, `defi`).
|
||||
`childIndexOfPath(path)` and `pathOfChildIndex(index)` convert between them.
|
||||
|
||||
### SignTransactionResult
|
||||
|
||||
```typescript
|
||||
interface SignTransactionResult {
|
||||
signedTransactionHex: string;
|
||||
}
|
||||
```
|
||||
|
||||
## WalletConnectionManager
|
||||
|
||||
```typescript
|
||||
class WalletConnectionManager extends EventEmitter {
|
||||
constructor(adapter: WalletAdapter)
|
||||
|
||||
// Connect to a dapp. Returns a stable connection ID.
|
||||
// If a connection for this URI already exists, returns the existing ID.
|
||||
connect(uri: string): string
|
||||
|
||||
// Tear down a specific connection, sending a UserDisconnect courtesy message.
|
||||
disconnect(connectionId: string): void
|
||||
|
||||
// Tear down all connections.
|
||||
disconnectAll(): void
|
||||
|
||||
// Snapshot of all connections for UI rendering.
|
||||
getConnections(): Record<string, RelayConnectionState>
|
||||
|
||||
// Send the signed transaction back to the dapp.
|
||||
sendSignResponse(connectionId: string, sequence: number, signedTx: string): Promise<void>
|
||||
|
||||
// Send an error back to the dapp (user rejected, signing failed, etc.)
|
||||
sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
|
||||
|
||||
// Events
|
||||
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
|
||||
on("pendingSignRequest", (req: PendingSignRequest) => void)
|
||||
on("connectionsChanged", () => void)
|
||||
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
|
||||
}
|
||||
```
|
||||
|
||||
### RelayConnectionState
|
||||
|
||||
```typescript
|
||||
interface RelayConnectionState {
|
||||
id: string;
|
||||
uri: string;
|
||||
status: RelayStatus; // { status: "connected" | "reconnecting" | "disconnected" }
|
||||
label: string; // dapp name once known, otherwise "Connecting..."
|
||||
dappName: string | null;
|
||||
dappIcon: string | null;
|
||||
connectedAt: number; // Unix ms
|
||||
}
|
||||
```
|
||||
|
||||
### PendingSignRequest
|
||||
|
||||
```typescript
|
||||
interface PendingSignRequest {
|
||||
connectionId: string;
|
||||
request: SignTransactionRequest;
|
||||
}
|
||||
```
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
### connect()
|
||||
|
||||
1. A unique `connectionId` is generated.
|
||||
2. `initiateWalletRelay(statusCallback, { uri, walletPrivateKey })` is called.
|
||||
3. The relay decodes the URI, extracts the dapp's public key and secret, and connects.
|
||||
4. On the first `"connected"` status, `onConnected()` is called.
|
||||
|
||||
### onConnected()
|
||||
|
||||
1. `walletReadySentThisCycle` is reset to `false`.
|
||||
2. A notification processor interval is started (1 second, for retry on send errors).
|
||||
3. The wallet polls until `client.isKeyExchangeComplete()` (key exchange with dapp done).
|
||||
4. `pushWalletReady()` is called.
|
||||
|
||||
### pushWalletReady()
|
||||
|
||||
Sends `wallet_ready` with:
|
||||
- `supported_protocols: ["hdwalletv1"]`
|
||||
- `wallet_name`, `wallet_icon` from the adapter.
|
||||
- `session["hdwalletv1"]`: one `{ name, xpub }` per `DerivationPath` (receive/change/defi).
|
||||
- `dapp_discovered`: whether the dapp was seen in this runtime session.
|
||||
|
||||
The message is pushed to a per-connection `notificationQueue` and flushed immediately. Retry
|
||||
is handled by the interval processor — if `relay()` throws (e.g. network drop), the message
|
||||
stays in the queue and is retried on the next tick.
|
||||
|
||||
### Receiving dapp_ready
|
||||
|
||||
```
|
||||
wallet_discovered=false → reset walletReadySentThisCycle, call pushWalletReady() again
|
||||
wallet_discovered=true → set dappDiscovered=true, no further action
|
||||
```
|
||||
|
||||
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
|
||||
|
||||
### Receiving disconnect
|
||||
|
||||
When a `disconnect` message arrives from the dapp:
|
||||
1. The `remoteDisconnect` event is emitted with `(connectionId, reason, message)`.
|
||||
2. The connection is cleaned up without sending a reply disconnect.
|
||||
|
||||
### Receiving sign_transaction_request
|
||||
|
||||
The wallet emits `pendingSignRequest` with the `connectionId` and the full request. The host
|
||||
application is responsible for:
|
||||
|
||||
1. Queueing or displaying the request.
|
||||
2. Getting user approval.
|
||||
3. Calling `sendSignResponse(connectionId, sequence, signedTxHex)` or
|
||||
`sendSignError(connectionId, sequence, errorMessage)`.
|
||||
|
||||
The wallet library does not auto-sign or auto-reject anything.
|
||||
|
||||
## Minimal example
|
||||
|
||||
```typescript
|
||||
import { WalletConnectionManager } from "@wizardconnect/wallet";
|
||||
import type { WalletAdapter, DerivationPath } from "@wizardconnect/wallet";
|
||||
|
||||
class MyAdapter implements WalletAdapter {
|
||||
walletName = "My Wallet";
|
||||
walletIcon = "";
|
||||
|
||||
getRelayPrivateKey() { return crypto.getRandomValues(new Uint8Array(32)); }
|
||||
getPublicKey(path: DerivationPath, index: bigint) { /* ... */ }
|
||||
getXpub(path: DerivationPath) { /* ... */ }
|
||||
|
||||
async signTransaction(request) {
|
||||
// show approval UI, sign, return hex
|
||||
return { signedTransactionHex: "..." };
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new WalletConnectionManager(new MyAdapter());
|
||||
|
||||
// When user scans a QR code:
|
||||
const connId = manager.connect("wiz://?p=...&s=...");
|
||||
|
||||
// When a sign request arrives:
|
||||
manager.on("pendingSignRequest", async ({ connectionId, request }) => {
|
||||
try {
|
||||
const result = await showApprovalUI(request);
|
||||
await manager.sendSignResponse(connectionId, request.sequence, result.signedTransactionHex);
|
||||
} catch {
|
||||
await manager.sendSignError(connectionId, request.sequence, "User rejected");
|
||||
}
|
||||
});
|
||||
|
||||
// When the dapp disconnects:
|
||||
manager.on("remoteDisconnect", (id, reason, message) => {
|
||||
console.log(`Dapp disconnected: ${reason}`, message);
|
||||
store.dispatch(setConnections(manager.getConnections()));
|
||||
});
|
||||
|
||||
// For Redux / UI updates:
|
||||
manager.on("connectionsChanged", () => {
|
||||
store.dispatch(setConnections(manager.getConnections()));
|
||||
});
|
||||
```
|
||||
|
||||
37
eslint.config.cjs
Normal file
37
eslint.config.cjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const js = require("@eslint/js");
|
||||
const globals = require("globals");
|
||||
const parser = require("@typescript-eslint/parser");
|
||||
const plugin = require("@typescript-eslint/eslint-plugin");
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.cjs"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
languageOptions: {
|
||||
parser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.es2022,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": plugin,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"no-console": "off",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
59
linters/copyright_check.mjs
Normal file
59
linters/copyright_check.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env node
|
||||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, dirname, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const OUR_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(OUR_DIR, "..");
|
||||
|
||||
const REQUIRED_LINES = [
|
||||
"This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.",
|
||||
"A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html",
|
||||
];
|
||||
|
||||
// Matches: "Copyright (C) 2026 Whiterun LLC" or "Copyright (C) 2024-2026 Whiterun LLC"
|
||||
const COPYRIGHT_PATTERN = /Copyright \(C\) (\d{4}-)?\d{4} Whiterun LLC/;
|
||||
|
||||
const EXTENSIONS = [".ts", ".mjs"];
|
||||
const DIRECTORIES = ["packages", "linters"];
|
||||
|
||||
function walk(dir) {
|
||||
const results = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "node_modules" || entry === "dist") continue;
|
||||
results.push(...walk(full));
|
||||
} else if (EXTENSIONS.some((ext) => entry.endsWith(ext))) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
let missing = 0;
|
||||
|
||||
for (const directory of DIRECTORIES) {
|
||||
const dirPath = join(ROOT, directory);
|
||||
for (const filePath of walk(dirPath)) {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const hasRequired = REQUIRED_LINES.every((line) => content.includes(line));
|
||||
const hasCopyright = COPYRIGHT_PATTERN.test(content);
|
||||
if (!hasRequired || !hasCopyright) {
|
||||
console.log(`${relative(ROOT, filePath)}: Missing`);
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
console.log(`${missing} file(s) are missing copyright headers`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("OK");
|
||||
}
|
||||
4164
package-lock.json
generated
Normal file
4164
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "wizardconnect",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"test:integration": "npm run test:integration --workspaces --if-present",
|
||||
"dapp": "npm run dapp --workspace @wizardconnect/test-cli",
|
||||
"wallet": "npm run wallet --workspace @wizardconnect/test-cli",
|
||||
"docs:serve": "zensical serve",
|
||||
"docs:build": "zensical build",
|
||||
"lint:copyright": "node linters/copyright_check.mjs",
|
||||
"lint:prettier": "npm run lint:prettier --workspaces --if-present",
|
||||
"lint:eslint": "npm run lint:eslint --workspaces --if-present",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix --workspaces --if-present",
|
||||
"fix:prettier": "npm run fix:prettier --workspaces --if-present",
|
||||
"fix:eslint": "npm run fix:eslint --workspaces --if-present"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"eslint": "^10.0.1",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.2.5",
|
||||
"typescript-eslint": "^8.56.0"
|
||||
}
|
||||
}
|
||||
34
packages/core/package.json
Normal file
34
packages/core/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@wizardconnect/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Transport and protocol primitives for WizardConnect",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
||||
"lint:prettier": "prettier . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:prettier": "prettier . --write",
|
||||
"fix:eslint": "npm run lint:eslint -- --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bch-wc2/interfaces": "^0.0.8",
|
||||
"@nostr-dev-kit/ndk": "^2.18.1",
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
228
packages/core/src/connection-manager.ts
Normal file
228
packages/core/src/connection-manager.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { debug, Scope, LogScope } from "./log.js";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export interface VisibilityChangeContext<TClient> {
|
||||
client: TClient;
|
||||
state: "hidden" | "visible";
|
||||
setPaused: (_value: boolean) => void;
|
||||
isPaused: () => boolean;
|
||||
startConnectionLoop: () => void;
|
||||
}
|
||||
|
||||
export interface ConnectionManagerOptions<
|
||||
TClient extends ConnectionClient = ConnectionClient,
|
||||
> {
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
enableVisibilityHandling?: boolean;
|
||||
scope?: LogScope;
|
||||
onVisibilityChange?: (
|
||||
_context: VisibilityChangeContext<TClient>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ConnectionClient extends EventEmitter {
|
||||
connect(): Promise<void>;
|
||||
disconnect(..._args: any[]): Promise<void | boolean>;
|
||||
}
|
||||
|
||||
export interface ConnectionManagerResult {
|
||||
cleanup: () => Promise<void>;
|
||||
startConnectionLoop: () => void;
|
||||
}
|
||||
|
||||
export const createConnectionManager = <TClient extends ConnectionClient>(
|
||||
client: TClient,
|
||||
callbacks: {
|
||||
onConnected: (_client: TClient) => void;
|
||||
onReconnecting: (_client: TClient, _reason: string | null) => void;
|
||||
onDisconnected: (_client: TClient) => void;
|
||||
onError?: (_client: TClient, _error: any) => void;
|
||||
},
|
||||
events: {
|
||||
connected: string;
|
||||
disconnected: string;
|
||||
error?: string;
|
||||
},
|
||||
options: ConnectionManagerOptions<TClient> = {},
|
||||
): ConnectionManagerResult => {
|
||||
const {
|
||||
reconnectInterval = 5000,
|
||||
maxReconnectAttempts = Infinity,
|
||||
enableVisibilityHandling = true,
|
||||
scope = Scope.Network,
|
||||
onVisibilityChange,
|
||||
} = options;
|
||||
|
||||
let isPaused = false;
|
||||
let reconnectLoop: Promise<void> | null = null;
|
||||
let visibilityChangeHandler: (() => void) | null = null;
|
||||
|
||||
const triggerReconnect = (reason: string | null) => {
|
||||
if (!isPaused) {
|
||||
callbacks.onReconnecting(client, reason);
|
||||
reconnectLoop = null;
|
||||
startConnectionLoop();
|
||||
}
|
||||
};
|
||||
|
||||
const onConnected = () => callbacks.onConnected(client);
|
||||
client.on(events.connected, onConnected);
|
||||
|
||||
const onDisconnected = (...args: any[]) => {
|
||||
const err = args[0] instanceof Error ? args[0].message : null;
|
||||
debug(scope, "Disconnected event received", err);
|
||||
triggerReconnect(err);
|
||||
};
|
||||
|
||||
const onError = (err: any) => {
|
||||
const errorMsg = err?.message || String(err);
|
||||
debug(scope, "Error event received", errorMsg);
|
||||
triggerReconnect(errorMsg);
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(client, err);
|
||||
}
|
||||
};
|
||||
|
||||
client.on(events.disconnected, onDisconnected);
|
||||
if (events.error) {
|
||||
client.on(events.error, onError);
|
||||
}
|
||||
|
||||
const setupVisibilityHandling = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
visibilityChangeHandler = () => {
|
||||
const state = document.visibilityState as "hidden" | "visible";
|
||||
|
||||
if (onVisibilityChange) {
|
||||
const context: VisibilityChangeContext<TClient> = {
|
||||
client,
|
||||
state,
|
||||
setPaused: (value: boolean) => {
|
||||
isPaused = value;
|
||||
},
|
||||
isPaused: () => isPaused,
|
||||
startConnectionLoop,
|
||||
};
|
||||
(async () => {
|
||||
try {
|
||||
await onVisibilityChange(context);
|
||||
} catch (err) {
|
||||
debug(scope, "Error in custom visibility change handler:", err);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "hidden") {
|
||||
isPaused = true;
|
||||
(async () => {
|
||||
try {
|
||||
await client.disconnect();
|
||||
callbacks.onDisconnected(client);
|
||||
} catch (err) {
|
||||
debug(scope, "Error disconnecting on visibility change:", err);
|
||||
}
|
||||
})();
|
||||
} else if (state === "visible") {
|
||||
if (isPaused) {
|
||||
isPaused = false;
|
||||
startConnectionLoop();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", visibilityChangeHandler);
|
||||
};
|
||||
|
||||
const startConnectionLoop = () => {
|
||||
if (reconnectLoop) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectLoop = (async () => {
|
||||
let reconnectAttempts = 0;
|
||||
let wasConnected = false;
|
||||
|
||||
while (true) {
|
||||
if (isPaused) {
|
||||
await sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
// onConnected is fired via the "connection" event emitted inside client.connect()
|
||||
// — do NOT call callbacks.onConnected here too, that would double-fire it.
|
||||
reconnectAttempts = 0;
|
||||
wasConnected = true;
|
||||
reconnectLoop = null;
|
||||
return;
|
||||
} catch (e) {
|
||||
reconnectAttempts++;
|
||||
|
||||
if (wasConnected || reconnectAttempts === 1) {
|
||||
callbacks.onReconnecting(client, `${e}`);
|
||||
wasConnected = false;
|
||||
}
|
||||
|
||||
if (reconnectAttempts > maxReconnectAttempts) {
|
||||
callbacks.onDisconnected(client);
|
||||
reconnectLoop = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch (disconnectError) {
|
||||
debug(scope, "Failed to disconnect client", disconnectError);
|
||||
}
|
||||
|
||||
await sleep(reconnectInterval);
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
if (enableVisibilityHandling) {
|
||||
setupVisibilityHandling();
|
||||
}
|
||||
|
||||
const cleanup = async () => {
|
||||
isPaused = true;
|
||||
|
||||
if (typeof document !== "undefined" && visibilityChangeHandler !== null) {
|
||||
document.removeEventListener("visibilitychange", visibilityChangeHandler);
|
||||
}
|
||||
|
||||
client.off(events.connected, onConnected);
|
||||
client.off(events.disconnected, onDisconnected);
|
||||
if (events.error) {
|
||||
client.off(events.error, onError);
|
||||
}
|
||||
|
||||
callbacks.onDisconnected(client);
|
||||
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch (e) {
|
||||
debug(scope, "Failed to disconnect client during cleanup", e);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
startConnectionLoop,
|
||||
};
|
||||
};
|
||||
189
packages/core/src/dapp-relay.ts
Normal file
189
packages/core/src/dapp-relay.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { RelayClient } from "./relay-client.js";
|
||||
import {
|
||||
RelayUpdatePayload,
|
||||
RelayStatusCallback,
|
||||
initiateRelay,
|
||||
} from "./relay-handler.js";
|
||||
import { RelayMsgAction, ProtocolMessage } from "./protocols/hdwalletv1.js";
|
||||
import { WalletReadyMessage } from "./protocols/base.js";
|
||||
import {
|
||||
generateKeyExchangeCredentials,
|
||||
encodeKeyExchangeURI,
|
||||
KeyExchangeCredentials,
|
||||
DEFAULT_RELAY_HOSTNAME,
|
||||
DEFAULT_RELAY_PORT,
|
||||
DEFAULT_RELAY_PROTOCOL,
|
||||
} from "./key-exchange.js";
|
||||
import { hexToBin, binToHex } from "@bitauth/libauth";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
import { error as logError, warn, Scope } from "./log.js";
|
||||
|
||||
export interface DappRelayOptions {
|
||||
explicitRelayUrls?: string[];
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
existingCredentials?: {
|
||||
privateKey: string;
|
||||
secret: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DappRelayResult {
|
||||
client: RelayClient;
|
||||
uri: string;
|
||||
qrUri: string;
|
||||
credentials: KeyExchangeCredentials;
|
||||
events: EventEmitter<{
|
||||
keyexchangecomplete: [walletPublicKey: Uint8Array];
|
||||
}>;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
export function initiateDappRelay(
|
||||
statusCallback: RelayStatusCallback,
|
||||
options: DappRelayOptions = {},
|
||||
): DappRelayResult {
|
||||
const events = new EventEmitter<{
|
||||
keyexchangecomplete: [walletPublicKey: Uint8Array];
|
||||
}>();
|
||||
|
||||
let credentials: KeyExchangeCredentials;
|
||||
let dappPrivateKey: Uint8Array;
|
||||
|
||||
if (options.existingCredentials) {
|
||||
const privateKeyHex = options.existingCredentials.privateKey;
|
||||
if (privateKeyHex.length !== 64) {
|
||||
throw new Error("Private key must be 64 hex characters (32 bytes)");
|
||||
}
|
||||
dappPrivateKey = hexToBin(privateKeyHex);
|
||||
const dappPublicKeyHex = deriveNostrPublicKey(dappPrivateKey);
|
||||
|
||||
credentials = {
|
||||
privateKey: privateKeyHex,
|
||||
publicKey: dappPublicKeyHex,
|
||||
secret: options.existingCredentials.secret,
|
||||
};
|
||||
} else {
|
||||
credentials = generateKeyExchangeCredentials();
|
||||
dappPrivateKey = hexToBin(credentials.privateKey);
|
||||
}
|
||||
|
||||
let uriOptions: {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
protocol?: "ws" | "wss";
|
||||
} = {};
|
||||
if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) {
|
||||
const relayUrl = options.explicitRelayUrls[0];
|
||||
const hostMatch = relayUrl.match(/^wss?:\/\/([^:/]+)/);
|
||||
const portMatch = relayUrl.match(/:(\d+)/);
|
||||
if (hostMatch) {
|
||||
uriOptions.hostname = hostMatch[1];
|
||||
}
|
||||
if (portMatch) {
|
||||
uriOptions.port = parseInt(portMatch[1], 10);
|
||||
}
|
||||
if (relayUrl.startsWith("wss://")) {
|
||||
uriOptions.protocol = "wss";
|
||||
} else if (relayUrl.startsWith("ws://")) {
|
||||
uriOptions.protocol = "ws";
|
||||
}
|
||||
}
|
||||
const { uri, qrUri } = encodeKeyExchangeURI(
|
||||
credentials.publicKey,
|
||||
credentials.secret,
|
||||
uriOptions,
|
||||
);
|
||||
|
||||
let relayClient: RelayClient | null = null;
|
||||
let keyExchanged = false;
|
||||
let walletPublicKeyNostr: Uint8Array | null = null;
|
||||
|
||||
const wrappedCallback: RelayStatusCallback = (
|
||||
payload: RelayUpdatePayload,
|
||||
) => {
|
||||
if (!relayClient) {
|
||||
relayClient = payload.client;
|
||||
|
||||
relayClient.on("message", async (message: ProtocolMessage) => {
|
||||
if (message.action === RelayMsgAction.WalletReady) {
|
||||
const walletReady = message as WalletReadyMessage;
|
||||
|
||||
if (walletReady.secret !== credentials.secret) {
|
||||
if (!keyExchanged) {
|
||||
logError(Scope.Relay, "Key exchange failed: secret mismatch");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const receivedWalletKey = hexToBin(walletReady.public_key);
|
||||
if (receivedWalletKey.length !== 32) {
|
||||
logError(Scope.Relay, "Invalid wallet public key length");
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyExchanged && walletPublicKeyNostr) {
|
||||
if (
|
||||
binToHex(receivedWalletKey) !== binToHex(walletPublicKeyNostr)
|
||||
) {
|
||||
warn(
|
||||
Scope.Relay,
|
||||
"Different wallet connected (different public key)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
walletPublicKeyNostr = receivedWalletKey;
|
||||
relayClient!.setPairedPublicKey(receivedWalletKey);
|
||||
|
||||
if (!keyExchanged) {
|
||||
keyExchanged = true;
|
||||
events.emit("keyexchangecomplete", receivedWalletKey);
|
||||
}
|
||||
// NOTE: do NOT return — message also propagates to DappConnectionManager's listener
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.status.status === "connected") {
|
||||
if (walletPublicKeyNostr && relayClient) {
|
||||
relayClient.setPairedPublicKey(walletPublicKeyNostr);
|
||||
keyExchanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
statusCallback(payload);
|
||||
};
|
||||
|
||||
const relayUrls =
|
||||
options.explicitRelayUrls && options.explicitRelayUrls.length > 0
|
||||
? options.explicitRelayUrls
|
||||
: [
|
||||
`${DEFAULT_RELAY_PROTOCOL}://${DEFAULT_RELAY_HOSTNAME}:${DEFAULT_RELAY_PORT}`,
|
||||
];
|
||||
|
||||
const cleanup = initiateRelay(
|
||||
wrappedCallback,
|
||||
dappPrivateKey,
|
||||
new Uint8Array(33),
|
||||
{
|
||||
explicitRelayUrls: relayUrls,
|
||||
reconnectInterval: options.reconnectInterval,
|
||||
maxReconnectAttempts: options.maxReconnectAttempts,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
client: relayClient!,
|
||||
uri,
|
||||
qrUri,
|
||||
credentials,
|
||||
events,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
37
packages/core/src/index.ts
Normal file
37
packages/core/src/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
export { RelayClient } from "./relay-client.js";
|
||||
export type { RelayClientConfig } from "./relay-client.js";
|
||||
export { RelayStatus, initiateRelay } from "./relay-handler.js";
|
||||
export type {
|
||||
RelayUpdatePayload,
|
||||
RelayStatusCallback,
|
||||
} from "./relay-handler.js";
|
||||
export { initiateDappRelay } from "./dapp-relay.js";
|
||||
export type { DappRelayOptions, DappRelayResult } from "./dapp-relay.js";
|
||||
export { initiateWalletRelay } from "./wallet-relay.js";
|
||||
export type { WalletRelayOptions, WalletRelayResult } from "./wallet-relay.js";
|
||||
export {
|
||||
generateKeyExchangeCredentials,
|
||||
encodeKeyExchangeURI,
|
||||
decodeKeyExchangeURI,
|
||||
DEFAULT_RELAY_HOSTNAME,
|
||||
DEFAULT_RELAY_PORT,
|
||||
DEFAULT_RELAY_PROTOCOL,
|
||||
} from "./key-exchange.js";
|
||||
export type {
|
||||
KeyExchangeCredentials,
|
||||
DecodedKeyExchangeURI,
|
||||
KeyExchangeURIOptions,
|
||||
KeyExchangeURIResult,
|
||||
} from "./key-exchange.js";
|
||||
export * from "./protocols/hdwalletv1.js";
|
||||
export * from "./protocols/base.js";
|
||||
export {
|
||||
binToHex,
|
||||
hexToBin,
|
||||
binToBech32Padded,
|
||||
bech32PaddedToBin,
|
||||
} from "@bitauth/libauth";
|
||||
374
packages/core/src/key-exchange.test.ts
Normal file
374
packages/core/src/key-exchange.test.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
encodeKeyExchangeURI,
|
||||
decodeKeyExchangeURI,
|
||||
generateKeyExchangeCredentials,
|
||||
} from "./key-exchange.js";
|
||||
|
||||
describe("key-exchange", () => {
|
||||
describe("generateKeyExchangeCredentials", () => {
|
||||
it("should generate valid credentials", () => {
|
||||
const credentials = generateKeyExchangeCredentials();
|
||||
|
||||
expect(credentials.privateKey).toHaveLength(64); // 32 bytes in hex
|
||||
expect(credentials.publicKey).toHaveLength(64); // 32 bytes in hex
|
||||
expect(credentials.secret).toHaveLength(16); // 8 bytes in hex
|
||||
|
||||
// Verify all are valid hex strings
|
||||
expect(/^[0-9a-f]+$/.test(credentials.privateKey)).toBe(true);
|
||||
expect(/^[0-9a-f]+$/.test(credentials.publicKey)).toBe(true);
|
||||
expect(/^[0-9a-f]+$/.test(credentials.secret)).toBe(true);
|
||||
});
|
||||
|
||||
it("should generate different credentials on each call", () => {
|
||||
const cred1 = generateKeyExchangeCredentials();
|
||||
const cred2 = generateKeyExchangeCredentials();
|
||||
|
||||
expect(cred1.privateKey).not.toBe(cred2.privateKey);
|
||||
expect(cred1.publicKey).not.toBe(cred2.publicKey);
|
||||
expect(cred1.secret).not.toBe(cred2.secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeKeyExchangeURI", () => {
|
||||
it("should encode valid public key and secret to URI", () => {
|
||||
const publicKey = "a".repeat(64); // 32 bytes in hex
|
||||
const secret = "b".repeat(16); // 8 bytes in hex
|
||||
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
// With defaults, should use minimal URI format
|
||||
expect(uri).toMatch(
|
||||
/^wiz:\/\/\?p=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+&s=[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+$/,
|
||||
);
|
||||
expect(uri.startsWith("wiz://?")).toBe(true);
|
||||
expect(uri.includes("?p=")).toBe(true);
|
||||
expect(uri.includes("&s=")).toBe(true);
|
||||
});
|
||||
|
||||
it("should always produce lowercase bech32 encoding", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
// Extract the bech32 parts (minimal URI format)
|
||||
const parts = uri.match(/^wiz:\/\/\?p=([^&]+)&s=(.+)$/);
|
||||
expect(parts).not.toBeNull();
|
||||
if (parts) {
|
||||
expect(parts[1]).toBe(parts[1].toLowerCase());
|
||||
expect(parts[2]).toBe(parts[2].toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw error for invalid public key length", () => {
|
||||
// Use a hex string that decodes to wrong length (odd length hex = invalid)
|
||||
const invalidPublicKey = "a".repeat(62); // 31 bytes when decoded
|
||||
const secret = "b".repeat(16);
|
||||
|
||||
expect(() => encodeKeyExchangeURI(invalidPublicKey, secret)).toThrow(
|
||||
"Invalid public key length",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid secret length", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
// Use a hex string that decodes to wrong length (odd length hex = invalid)
|
||||
const invalidSecret = "b".repeat(14); // 7 bytes when decoded
|
||||
|
||||
expect(() => encodeKeyExchangeURI(publicKey, invalidSecret)).toThrow(
|
||||
"Invalid secret length",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle invalid hex gracefully", () => {
|
||||
// hexToBin from libauth might be lenient with invalid hex
|
||||
// This test verifies the function doesn't crash, but the actual behavior
|
||||
// depends on libauth's hexToBin implementation
|
||||
const invalidPublicKey = "g".repeat(64); // 'g' is not valid hex
|
||||
const secret = "b".repeat(16);
|
||||
|
||||
// The function should either throw or produce a result
|
||||
// If it doesn't throw, it means libauth's hexToBin is lenient
|
||||
// In that case, it will likely fail at length validation
|
||||
try {
|
||||
encodeKeyExchangeURI(invalidPublicKey, secret);
|
||||
// If it doesn't throw, that's also acceptable behavior
|
||||
} catch (error) {
|
||||
// If it throws, that's expected
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeKeyExchangeURI — qrUri", () => {
|
||||
it("qrUri is fully uppercase", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
expect(qrUri).toBe(qrUri.toUpperCase());
|
||||
});
|
||||
|
||||
it("qrUri contains no ?, =, or &", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
expect(qrUri).not.toContain("?");
|
||||
expect(qrUri).not.toContain("=");
|
||||
expect(qrUri).not.toContain("&");
|
||||
});
|
||||
|
||||
it("qrUri passes QR alphanumeric charset regex", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true);
|
||||
});
|
||||
|
||||
it("qrUri contains %3F, %3D, %26 for default relay", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
expect(qrUri).toContain("%3F");
|
||||
expect(qrUri).toContain("%3D");
|
||||
expect(qrUri).toContain("%26");
|
||||
});
|
||||
|
||||
it("custom relay encodes all separators", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { qrUri } = encodeKeyExchangeURI(publicKey, secret, {
|
||||
hostname: "example.com",
|
||||
port: 8080,
|
||||
protocol: "ws",
|
||||
});
|
||||
expect(qrUri).not.toContain("?");
|
||||
expect(qrUri).not.toContain("=");
|
||||
expect(qrUri).not.toContain("&");
|
||||
expect(/^[A-Z0-9 $%*+\-./:]+$/.test(qrUri)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeKeyExchangeURI", () => {
|
||||
it("should decode valid URI to public key and secret", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
const decoded = decodeKeyExchangeURI(uri);
|
||||
|
||||
expect(decoded.publicKey).toBe(publicKey);
|
||||
expect(decoded.secret).toBe(secret);
|
||||
});
|
||||
|
||||
it("should handle case-insensitive URIs", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
// Test uppercase URI (but keep scheme lowercase as URL parsing requires it)
|
||||
const upperUri = uri.replace(/wiz:\/\/([^?]+)/, (match, host) => {
|
||||
return `wiz://${host.toUpperCase()}`;
|
||||
});
|
||||
const decoded = decodeKeyExchangeURI(upperUri);
|
||||
|
||||
expect(decoded.publicKey).toBe(publicKey);
|
||||
expect(decoded.secret).toBe(secret);
|
||||
});
|
||||
|
||||
it("should handle mixed case URIs", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
// Mix case
|
||||
const mixedUri = uri.replace(/r/i, "R").replace(/c/i, "C");
|
||||
const decoded = decodeKeyExchangeURI(mixedUri);
|
||||
|
||||
expect(decoded.publicKey).toBe(publicKey);
|
||||
expect(decoded.secret).toBe(secret);
|
||||
});
|
||||
|
||||
it("should throw error for invalid URI format", () => {
|
||||
const invalidUri = "invalid://uri";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
|
||||
});
|
||||
|
||||
it("should throw error for wrong scheme", () => {
|
||||
const invalidUri = "wrong://relay.cauldron.quest?p=abc&s=def";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
|
||||
"Invalid URI scheme",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle URIs with custom hostname and port", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret, {
|
||||
hostname: "example.com",
|
||||
port: 8080,
|
||||
protocol: "ws",
|
||||
});
|
||||
|
||||
expect(uri).toMatch(/^wiz:\/\/example\.com:8080\?p=/);
|
||||
const decoded = decodeKeyExchangeURI(uri);
|
||||
expect(decoded.hostname).toBe("example.com");
|
||||
expect(decoded.port).toBe(8080);
|
||||
expect(decoded.protocol).toBe("ws");
|
||||
});
|
||||
|
||||
it("should throw error for missing parameters", () => {
|
||||
const invalidUri = "wiz://relay.cauldron.quest?p=abc";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
|
||||
"Invalid URI format",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for invalid bech32 encoding", () => {
|
||||
// Use invalid bech32 characters (bech32 only uses: qpzry9x8gf2tvdw0s3jn54khce6mua7l)
|
||||
// This will fail at bech32 decoding
|
||||
const invalidUri = "wiz://relay.cauldron.quest?p=invalid&s=chars";
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow(
|
||||
"Invalid bech32 encoding",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error for wrong public key length after decoding", () => {
|
||||
// Create a bech32 string that decodes to wrong length
|
||||
const shortBech32 = "q";
|
||||
const { uri: validSecret } = encodeKeyExchangeURI(
|
||||
"a".repeat(64),
|
||||
"b".repeat(16),
|
||||
);
|
||||
const secretPart = validSecret.match(/&s=(.+)$/)?.[1] || "";
|
||||
const invalidUri = `wiz://relay.cauldron.quest?p=${shortBech32}&s=${secretPart}`;
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
|
||||
});
|
||||
|
||||
it("should throw error for wrong secret length after decoding", () => {
|
||||
const { uri: validPublicKey } = encodeKeyExchangeURI(
|
||||
"a".repeat(64),
|
||||
"b".repeat(16),
|
||||
);
|
||||
const publicKeyPart = validPublicKey.match(/p=([^&]+)/)?.[1] || "";
|
||||
const shortBech32 = "q";
|
||||
const invalidUri = `wiz://relay.cauldron.quest?p=${publicKeyPart}&s=${shortBech32}`;
|
||||
|
||||
expect(() => decodeKeyExchangeURI(invalidUri)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeKeyExchangeURI — QR format", () => {
|
||||
it("decodes qrUri to same values as standard uri", () => {
|
||||
const publicKey = "0123456789abcdef".repeat(4);
|
||||
const secret = "fedcba9876543210";
|
||||
const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
const fromUri = decodeKeyExchangeURI(uri);
|
||||
const fromQr = decodeKeyExchangeURI(qrUri);
|
||||
|
||||
expect(fromQr.publicKey).toBe(fromUri.publicKey);
|
||||
expect(fromQr.secret).toBe(fromUri.secret);
|
||||
expect(fromQr.hostname).toBe(fromUri.hostname);
|
||||
expect(fromQr.port).toBe(fromUri.port);
|
||||
expect(fromQr.protocol).toBe(fromUri.protocol);
|
||||
});
|
||||
|
||||
it("accepts lowercase version of qrUri", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { uri, qrUri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
|
||||
const fromUri = decodeKeyExchangeURI(uri);
|
||||
const fromLowerQr = decodeKeyExchangeURI(qrUri.toLowerCase());
|
||||
|
||||
expect(fromLowerQr.publicKey).toBe(fromUri.publicKey);
|
||||
expect(fromLowerQr.secret).toBe(fromUri.secret);
|
||||
});
|
||||
|
||||
it("round-trip: encodeKeyExchangeURI → qrUri → decodeKeyExchangeURI", () => {
|
||||
const credentials = generateKeyExchangeCredentials();
|
||||
const { qrUri } = encodeKeyExchangeURI(
|
||||
credentials.publicKey,
|
||||
credentials.secret,
|
||||
);
|
||||
const decoded = decodeKeyExchangeURI(qrUri);
|
||||
|
||||
expect(decoded.publicKey).toBe(credentials.publicKey);
|
||||
expect(decoded.secret).toBe(credentials.secret);
|
||||
});
|
||||
|
||||
it("round-trip with custom relay via qrUri", () => {
|
||||
const publicKey = "a".repeat(64);
|
||||
const secret = "b".repeat(16);
|
||||
const { qrUri } = encodeKeyExchangeURI(publicKey, secret, {
|
||||
hostname: "example.com",
|
||||
port: 8080,
|
||||
protocol: "ws",
|
||||
});
|
||||
const decoded = decodeKeyExchangeURI(qrUri);
|
||||
|
||||
expect(decoded.publicKey).toBe(publicKey);
|
||||
expect(decoded.secret).toBe(secret);
|
||||
expect(decoded.hostname).toBe("example.com");
|
||||
expect(decoded.port).toBe(8080);
|
||||
expect(decoded.protocol).toBe("ws");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip encoding/decoding", () => {
|
||||
it("should round-trip encode and decode correctly", () => {
|
||||
const publicKey = "0123456789abcdef".repeat(4); // 32 bytes
|
||||
const secret = "fedcba9876543210"; // 8 bytes
|
||||
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
const decoded = decodeKeyExchangeURI(uri);
|
||||
|
||||
expect(decoded.publicKey).toBe(publicKey);
|
||||
expect(decoded.secret).toBe(secret);
|
||||
});
|
||||
|
||||
it("should round-trip with generated credentials", () => {
|
||||
const credentials = generateKeyExchangeCredentials();
|
||||
|
||||
const { uri } = encodeKeyExchangeURI(
|
||||
credentials.publicKey,
|
||||
credentials.secret,
|
||||
);
|
||||
const decoded = decodeKeyExchangeURI(uri);
|
||||
|
||||
expect(decoded.publicKey).toBe(credentials.publicKey);
|
||||
expect(decoded.secret).toBe(credentials.secret);
|
||||
});
|
||||
|
||||
it("should round-trip with random hex values", () => {
|
||||
const randomHex = (length: number) => {
|
||||
const chars = "0123456789abcdef";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const publicKey = randomHex(64);
|
||||
const secret = randomHex(16);
|
||||
|
||||
const { uri } = encodeKeyExchangeURI(publicKey, secret);
|
||||
const decoded = decodeKeyExchangeURI(uri);
|
||||
|
||||
expect(decoded.publicKey).toBe(publicKey);
|
||||
expect(decoded.secret).toBe(secret);
|
||||
});
|
||||
});
|
||||
});
|
||||
216
packages/core/src/key-exchange.ts
Normal file
216
packages/core/src/key-exchange.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import {
|
||||
generatePrivateKey,
|
||||
binToHex,
|
||||
hexToBin,
|
||||
binToBech32Padded,
|
||||
bech32PaddedToBin,
|
||||
} from "@bitauth/libauth";
|
||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
|
||||
export const DEFAULT_RELAY_HOSTNAME = "relay.cauldron.quest";
|
||||
export const DEFAULT_RELAY_PORT = 443;
|
||||
export const DEFAULT_RELAY_PROTOCOL: "wss" = "wss";
|
||||
|
||||
export interface KeyExchangeCredentials {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export function generateKeyExchangeCredentials(): KeyExchangeCredentials {
|
||||
const privateKey = generatePrivateKey();
|
||||
const privateKeyHex = binToHex(privateKey);
|
||||
const publicKeyHex = deriveNostrPublicKey(privateKey);
|
||||
|
||||
const secretBytes = generatePrivateKey();
|
||||
const secretShort = secretBytes.slice(0, 8);
|
||||
const secret = binToHex(secretShort);
|
||||
|
||||
return {
|
||||
privateKey: privateKeyHex,
|
||||
publicKey: publicKeyHex,
|
||||
secret: secret,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DecodedKeyExchangeURI {
|
||||
publicKey: string;
|
||||
secret: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: "ws" | "wss";
|
||||
}
|
||||
|
||||
export interface KeyExchangeURIOptions {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
protocol?: "ws" | "wss";
|
||||
}
|
||||
|
||||
export interface KeyExchangeURIResult {
|
||||
/** Standard URI for copy-paste and display: wiz://?p=...&s=... */
|
||||
uri: string;
|
||||
/** Fully QR-alphanumeric-safe URI for QR code generation: WIZ://%3FP%3D... */
|
||||
qrUri: string;
|
||||
}
|
||||
|
||||
export function encodeKeyExchangeURI(
|
||||
publicKey: string,
|
||||
secret: string,
|
||||
options: KeyExchangeURIOptions = {},
|
||||
): KeyExchangeURIResult {
|
||||
const publicKeyBin = hexToBin(publicKey);
|
||||
const secretBin = hexToBin(secret);
|
||||
|
||||
if (publicKeyBin.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`,
|
||||
);
|
||||
}
|
||||
if (secretBin.length !== 8) {
|
||||
throw new Error(
|
||||
`Invalid secret length: expected 8 bytes, got ${secretBin.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const publicKeyBech32 = binToBech32Padded(publicKeyBin).toLowerCase();
|
||||
const secretBech32 = binToBech32Padded(secretBin).toLowerCase();
|
||||
|
||||
const hostname = options.hostname || DEFAULT_RELAY_HOSTNAME;
|
||||
const port = options.port ?? DEFAULT_RELAY_PORT;
|
||||
const protocol = options.protocol || DEFAULT_RELAY_PROTOCOL;
|
||||
|
||||
const isDefaultHostname = hostname === DEFAULT_RELAY_HOSTNAME;
|
||||
const defaultPort = protocol === "wss" ? 443 : 80;
|
||||
const isDefaultPort = port === defaultPort;
|
||||
const isDefaultProtocol = protocol === DEFAULT_RELAY_PROTOCOL;
|
||||
|
||||
let uri: string;
|
||||
if (isDefaultHostname && isDefaultPort && isDefaultProtocol) {
|
||||
uri = `wiz://?p=${publicKeyBech32}&s=${secretBech32}`;
|
||||
} else {
|
||||
const portPart = isDefaultPort ? "" : `:${port}`;
|
||||
const authority = `${hostname}${portPart}`;
|
||||
|
||||
uri = `wiz://${authority}?p=${publicKeyBech32}&s=${secretBech32}`;
|
||||
|
||||
if (!isDefaultProtocol) {
|
||||
uri += `&pr=${protocol}`;
|
||||
}
|
||||
}
|
||||
|
||||
const qrUri = uri
|
||||
.toUpperCase()
|
||||
.replace("?", "%3F")
|
||||
.replace(/=/g, "%3D")
|
||||
.replace(/&/g, "%26");
|
||||
|
||||
return { uri, qrUri };
|
||||
}
|
||||
|
||||
export function decodeKeyExchangeURI(uri: string): DecodedKeyExchangeURI {
|
||||
let url: URL;
|
||||
try {
|
||||
const lower = uri.toLowerCase();
|
||||
const isQr = lower.includes("%3f") && !lower.includes("?");
|
||||
const toParse = isQr
|
||||
? lower.replace("%3f", "?").replace(/%3d/g, "=").replace(/%26/g, "&")
|
||||
: lower;
|
||||
url = new URL(toParse);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid URI format: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (url.protocol !== "wiz:") {
|
||||
throw new Error("Invalid URI scheme. Expected: wiz://");
|
||||
}
|
||||
|
||||
let hostname = url.hostname || DEFAULT_RELAY_HOSTNAME;
|
||||
let port: number;
|
||||
if (url.port) {
|
||||
const parsedPort = parseInt(url.port, 10);
|
||||
if (isNaN(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
|
||||
throw new Error(`Invalid port number: ${url.port}`);
|
||||
}
|
||||
port = parsedPort;
|
||||
} else {
|
||||
port = DEFAULT_RELAY_PORT;
|
||||
}
|
||||
|
||||
const publicKeyBech32 = url.searchParams.get("p");
|
||||
const secretBech32 = url.searchParams.get("s");
|
||||
|
||||
if (!publicKeyBech32 || !secretBech32) {
|
||||
throw new Error(
|
||||
"Invalid URI format. Missing required parameters: p (public key) or s (secret)",
|
||||
);
|
||||
}
|
||||
|
||||
const protocol = url.searchParams.get("pr") as "ws" | "wss" | null;
|
||||
|
||||
const publicKeyBech32Normalized = publicKeyBech32.toLowerCase();
|
||||
const secretBech32Normalized = secretBech32.toLowerCase();
|
||||
|
||||
let publicKeyBin: Uint8Array;
|
||||
let secretBin: Uint8Array;
|
||||
|
||||
try {
|
||||
const pubkeyResult = bech32PaddedToBin(publicKeyBech32Normalized);
|
||||
const secretResult = bech32PaddedToBin(secretBech32Normalized);
|
||||
|
||||
if (
|
||||
pubkeyResult instanceof Uint8Array &&
|
||||
secretResult instanceof Uint8Array
|
||||
) {
|
||||
publicKeyBin = pubkeyResult;
|
||||
secretBin = secretResult;
|
||||
} else {
|
||||
const pubkeyError =
|
||||
typeof pubkeyResult === "string" ? pubkeyResult : "Unknown error";
|
||||
const secretError =
|
||||
typeof secretResult === "string" ? secretResult : "Unknown error";
|
||||
throw new Error(
|
||||
`Bech32 decoding failed: pubkey=${pubkeyError}, secret=${secretError}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Invalid bech32 encoding: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (publicKeyBin.length !== 32) {
|
||||
throw new Error(
|
||||
`Invalid public key length: expected 32 bytes, got ${publicKeyBin.length}`,
|
||||
);
|
||||
}
|
||||
if (secretBin.length !== 8) {
|
||||
throw new Error(
|
||||
`Invalid secret length: expected 8 bytes, got ${secretBin.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (protocol && protocol !== "ws" && protocol !== "wss") {
|
||||
throw new Error(`Invalid protocol: ${protocol}. Must be 'ws' or 'wss'`);
|
||||
}
|
||||
|
||||
if (!url.port && protocol === "ws") {
|
||||
port = 80;
|
||||
}
|
||||
|
||||
return {
|
||||
publicKey: binToHex(publicKeyBin),
|
||||
secret: binToHex(secretBin),
|
||||
hostname: hostname,
|
||||
port: port,
|
||||
protocol: protocol || DEFAULT_RELAY_PROTOCOL,
|
||||
};
|
||||
}
|
||||
32
packages/core/src/log.ts
Normal file
32
packages/core/src/log.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Simple console-based logger shim.
|
||||
* Replaces @riftenlabs/log without any external dependencies.
|
||||
*/
|
||||
|
||||
export enum Scope {
|
||||
Relay = "relay",
|
||||
Network = "network",
|
||||
Misc = "misc",
|
||||
}
|
||||
|
||||
export type LogScope = Scope | string;
|
||||
|
||||
export function debug(scope: LogScope, ...args: unknown[]): void {
|
||||
console.log(`[${scope}]`, ...args);
|
||||
}
|
||||
|
||||
export function info(scope: LogScope, ...args: unknown[]): void {
|
||||
console.info(`[${scope}]`, ...args);
|
||||
}
|
||||
|
||||
export function warn(scope: LogScope, ...args: unknown[]): void {
|
||||
console.warn(`[${scope}]`, ...args);
|
||||
}
|
||||
|
||||
export function error(scope: LogScope, ...args: unknown[]): void {
|
||||
console.error(`[${scope}]`, ...args);
|
||||
}
|
||||
221
packages/core/src/message-queue.test.ts
Normal file
221
packages/core/src/message-queue.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { MessageQueue } from "./message-queue.js";
|
||||
import { ProtocolMessage, RelayMsgAction } from "./protocols/hdwalletv1.js";
|
||||
|
||||
describe("MessageQueue", () => {
|
||||
let queue: MessageQueue;
|
||||
let publishFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new MessageQueue({ logActivity: false });
|
||||
publishFn = vi.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should start with ready state false", () => {
|
||||
expect(queue.getReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("should start with empty queue", () => {
|
||||
expect(queue.getQueueLength()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueue", () => {
|
||||
it("should queue messages when not ready", async () => {
|
||||
const message: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const promise = queue.enqueue(message);
|
||||
|
||||
expect(queue.getQueueLength()).toBe(1);
|
||||
expect(queue.getReady()).toBe(false);
|
||||
|
||||
// Message should be queued, promise should not resolve yet
|
||||
await expect(
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise((resolve) => setTimeout(() => resolve("timeout"), 10)),
|
||||
]),
|
||||
).resolves.toBe("timeout");
|
||||
});
|
||||
|
||||
it("should return resolved promise when already ready", async () => {
|
||||
queue.setReady(publishFn);
|
||||
|
||||
const message: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
await expect(queue.enqueue(message)).resolves.toBeUndefined();
|
||||
expect(queue.getQueueLength()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setReady", () => {
|
||||
it("should mark queue as ready", () => {
|
||||
queue.setReady(publishFn);
|
||||
expect(queue.getReady()).toBe(true);
|
||||
});
|
||||
|
||||
it("should process queued messages when set to ready", async () => {
|
||||
const message1: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const message2: ProtocolMessage = {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const promise1 = queue.enqueue(message1);
|
||||
const promise2 = queue.enqueue(message2);
|
||||
|
||||
expect(queue.getQueueLength()).toBe(2);
|
||||
|
||||
await queue.setReady(publishFn);
|
||||
|
||||
expect(publishFn).toHaveBeenCalledTimes(2);
|
||||
expect(publishFn).toHaveBeenCalledWith(message1);
|
||||
expect(publishFn).toHaveBeenCalledWith(message2);
|
||||
expect(queue.getQueueLength()).toBe(0);
|
||||
|
||||
// Promises should resolve
|
||||
await expect(promise1).resolves.toBeUndefined();
|
||||
await expect(promise2).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should reject queued messages if publish fails", async () => {
|
||||
const error = new Error("Publish failed");
|
||||
publishFn.mockRejectedValueOnce(error);
|
||||
|
||||
const message: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const promise = queue.enqueue(message);
|
||||
await queue.setReady(publishFn);
|
||||
|
||||
await expect(promise).rejects.toThrow("Publish failed");
|
||||
});
|
||||
|
||||
it("should process messages in order", async () => {
|
||||
const messages: ProtocolMessage[] = [];
|
||||
const callOrder: number[] = [];
|
||||
|
||||
publishFn.mockImplementation(async (msg: ProtocolMessage) => {
|
||||
const index = messages.length;
|
||||
messages.push(msg);
|
||||
callOrder.push(index);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const message: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
queue.enqueue(message);
|
||||
}
|
||||
|
||||
await queue.setReady(publishFn);
|
||||
|
||||
expect(callOrder).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(messages).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setNotReady", () => {
|
||||
it("should mark queue as not ready", () => {
|
||||
queue.setReady(publishFn);
|
||||
expect(queue.getReady()).toBe(true);
|
||||
|
||||
queue.setNotReady();
|
||||
expect(queue.getReady()).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject all queued messages", async () => {
|
||||
const message1: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const message2: ProtocolMessage = {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const promise1 = queue.enqueue(message1);
|
||||
const promise2 = queue.enqueue(message2);
|
||||
|
||||
queue.setNotReady("Custom error message");
|
||||
|
||||
await expect(promise1).rejects.toThrow("Custom error message");
|
||||
await expect(promise2).rejects.toThrow("Custom error message");
|
||||
expect(queue.getQueueLength()).toBe(0);
|
||||
});
|
||||
|
||||
it("should use default error message if not provided", async () => {
|
||||
const message: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const promise = queue.enqueue(message);
|
||||
queue.setNotReady();
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Connection closed before message could be sent",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear all queued messages", async () => {
|
||||
const message: ProtocolMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const promise = queue.enqueue(message);
|
||||
expect(queue.getQueueLength()).toBe(1);
|
||||
|
||||
queue.clear();
|
||||
|
||||
expect(queue.getQueueLength()).toBe(0);
|
||||
await expect(promise).rejects.toThrow("Queue cleared");
|
||||
});
|
||||
|
||||
it("should not affect ready state", () => {
|
||||
queue.setReady(publishFn);
|
||||
expect(queue.getReady()).toBe(true);
|
||||
|
||||
queue.clear();
|
||||
expect(queue.getReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQueueLength", () => {
|
||||
it("should return correct queue length", () => {
|
||||
expect(queue.getQueueLength()).toBe(0);
|
||||
|
||||
queue.enqueue({
|
||||
action: RelayMsgAction.DappReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
expect(queue.getQueueLength()).toBe(1);
|
||||
|
||||
queue.enqueue({
|
||||
action: RelayMsgAction.WalletReady,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
expect(queue.getQueueLength()).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
packages/core/src/message-queue.ts
Normal file
91
packages/core/src/message-queue.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { ProtocolMessage } from "./protocols/hdwalletv1.js";
|
||||
import { debug, Scope } from "./log.js";
|
||||
|
||||
interface QueuedMessage {
|
||||
message: ProtocolMessage;
|
||||
resolve: () => void;
|
||||
reject: (_error: Error) => void;
|
||||
}
|
||||
|
||||
export class MessageQueue {
|
||||
private queue: QueuedMessage[] = [];
|
||||
private isReady: boolean = false;
|
||||
private logActivity: boolean = false;
|
||||
|
||||
constructor(options?: { logActivity?: boolean }) {
|
||||
this.logActivity = options?.logActivity ?? false;
|
||||
}
|
||||
|
||||
getReady(): boolean {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
getQueueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
enqueue(message: ProtocolMessage): Promise<void> {
|
||||
if (this.isReady) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.logActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`net: Relays not ready, queuing message ${message.action}`,
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.queue.push({ message, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
async setReady(
|
||||
publishFn: (_message: ProtocolMessage) => Promise<void>,
|
||||
): Promise<void> {
|
||||
this.isReady = true;
|
||||
|
||||
const queuedMessages = [...this.queue];
|
||||
this.queue = [];
|
||||
|
||||
if (queuedMessages.length > 0 && this.logActivity) {
|
||||
debug(Scope.Relay, `Processing ${queuedMessages.length} queued messages`);
|
||||
}
|
||||
|
||||
for (const queued of queuedMessages) {
|
||||
try {
|
||||
await publishFn(queued.message);
|
||||
queued.resolve();
|
||||
} catch (error) {
|
||||
queued.reject(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setNotReady(
|
||||
errorMessage: string = "Connection closed before message could be sent",
|
||||
): void {
|
||||
this.isReady = false;
|
||||
|
||||
const queuedMessages = [...this.queue];
|
||||
this.queue = [];
|
||||
|
||||
for (const queued of queuedMessages) {
|
||||
queued.reject(new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const queuedMessages = [...this.queue];
|
||||
this.queue = [];
|
||||
|
||||
for (const queued of queuedMessages) {
|
||||
queued.reject(new Error("Queue cleared"));
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/core/src/primitives.ts
Normal file
23
packages/core/src/primitives.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Minimal primitives shim — replaces @riftenlabs/primitives.
|
||||
*/
|
||||
|
||||
export const throwUnless = (x: boolean, what: string): void => {
|
||||
if (!x) {
|
||||
throw Error(`Internal application error: ${what}`);
|
||||
}
|
||||
};
|
||||
|
||||
export function unwrap<T>(value: string | Error | T): T {
|
||||
if (typeof value === "string") {
|
||||
throw new Error(`unwrap: ${value}`);
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
throw value;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
95
packages/core/src/protocols/base.ts
Normal file
95
packages/core/src/protocols/base.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/// Base protocol — DappReadyMessage and WalletReadyMessage
|
||||
///
|
||||
/// These are protocol-agnostic handshake messages shared across all
|
||||
/// application-level protocols (hdwalletv1, future hdwalletv2, etc.).
|
||||
///
|
||||
/// The wallet populates `session` for every protocol it supports, keyed by
|
||||
/// protocol name. The dapp selects the first protocol from its own
|
||||
/// `supported_protocols` list that is also present in the wallet's list,
|
||||
/// then reads `session[selectedProtocol]` for protocol-specific data.
|
||||
|
||||
import {
|
||||
RelayMsgAction,
|
||||
ProtocolMessage,
|
||||
isProtocolMessage,
|
||||
} from "./hdwalletv1.js";
|
||||
|
||||
export interface DappReadyMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.DappReady;
|
||||
/// Protocols this dapp supports, in preference order.
|
||||
supported_protocols: string[];
|
||||
/// Set on the reactive dapp_ready (after seeing wallet_ready).
|
||||
/// Absent on the proactive send (before the dapp has seen the wallet).
|
||||
selected_protocol?: string;
|
||||
/// True if the dapp has already seen this wallet in the current runtime session.
|
||||
wallet_discovered: boolean;
|
||||
dapp_name?: string;
|
||||
dapp_icon?: string;
|
||||
}
|
||||
|
||||
export interface WalletReadyMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.WalletReady;
|
||||
wallet_name: string;
|
||||
wallet_icon: string;
|
||||
/// True if the wallet has already seen this dapp in the current runtime session.
|
||||
/// The dapp uses this to skip sending a reactive dapp_ready when already known.
|
||||
dapp_discovered: boolean;
|
||||
/// Protocols this wallet supports.
|
||||
supported_protocols: string[];
|
||||
/// Session data keyed by protocol name.
|
||||
/// e.g. { "hdwalletv1": { paths: PathXpub[], next_indices: NextIndex[] } }
|
||||
/// The dapp picks the first compatible protocol and reads session[selectedProtocol].
|
||||
session: Record<string, unknown>;
|
||||
/// Wallet's Nostr x-only public key (hex, 32 bytes). Replaces the old key_exchange_response.
|
||||
public_key: string;
|
||||
/// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention.
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage {
|
||||
return (
|
||||
isProtocolMessage(msg) &&
|
||||
msg.action === RelayMsgAction.DappReady &&
|
||||
Array.isArray((msg as DappReadyMessage).supported_protocols) &&
|
||||
typeof (msg as DappReadyMessage).wallet_discovered === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
export function isWalletReadyMessage(msg: unknown): msg is WalletReadyMessage {
|
||||
const m = msg as WalletReadyMessage;
|
||||
return (
|
||||
isProtocolMessage(msg) &&
|
||||
msg.action === RelayMsgAction.WalletReady &&
|
||||
typeof m.wallet_name === "string" &&
|
||||
typeof m.wallet_icon === "string" &&
|
||||
typeof m.dapp_discovered === "boolean" &&
|
||||
Array.isArray(m.supported_protocols) &&
|
||||
m.session !== null &&
|
||||
typeof m.session === "object" &&
|
||||
typeof m.public_key === "string" &&
|
||||
typeof m.secret === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export enum DisconnectReason {
|
||||
ProtocolMismatch = "protocol_mismatch",
|
||||
UserDisconnect = "user_disconnect",
|
||||
}
|
||||
|
||||
export interface DisconnectMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Disconnect;
|
||||
reason: DisconnectReason;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage {
|
||||
return (
|
||||
isProtocolMessage(msg) &&
|
||||
(msg as DisconnectMessage).action === RelayMsgAction.Disconnect &&
|
||||
typeof (msg as DisconnectMessage).reason === "string"
|
||||
);
|
||||
}
|
||||
169
packages/core/src/protocols/hdwalletv1.ts
Normal file
169
packages/core/src/protocols/hdwalletv1.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/// This is a protocol within the relayed messages (which is protocol agnostic).
|
||||
|
||||
import { WcSignTransactionRequest } from "@bch-wc2/interfaces";
|
||||
|
||||
/// Dapp <-> Wallet messages
|
||||
|
||||
export const PROTOCOL_NAME = "hdwalletv1" as const;
|
||||
|
||||
/// What this message is about
|
||||
|
||||
export enum RelayMsgAction {
|
||||
/// Notification: Dapp has joined the session and is ready to receive relayed messages.
|
||||
DappReady = "dapp_ready",
|
||||
/// Notification: Wallet has joined the session and is ready to send relayed messages.
|
||||
/// Also carries key exchange data (public_key, secret) embedded in the message.
|
||||
WalletReady = "wallet_ready",
|
||||
/// Request: The dapp wants wallet to sign a transaction.
|
||||
SignTransactionRequest = "sign_transaction_request",
|
||||
SignTransactionResponse = "sign_transaction_response",
|
||||
/// Dapp-only: cancels an in-flight sign_transaction_request.
|
||||
SignCancel = "sign_cancel",
|
||||
/// Courtesy notification: one side is closing the connection.
|
||||
Disconnect = "disconnect",
|
||||
}
|
||||
|
||||
export interface ProtocolMessage {
|
||||
action: RelayMsgAction;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export type PathName = "receive" | "change" | "defi";
|
||||
|
||||
export interface PathXpub {
|
||||
name: PathName; // "receive" | "change" | "defi"
|
||||
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
|
||||
}
|
||||
|
||||
export function isPathXpub(obj: any): obj is PathXpub {
|
||||
return (
|
||||
obj &&
|
||||
typeof obj === "object" &&
|
||||
typeof obj.name === "string" &&
|
||||
typeof obj.xpub === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the numeric BIP44 child index for the given named path.
|
||||
* Use only for internal HD derivation — not a protocol concern. */
|
||||
export function childIndexOfPathName(name: PathName): number {
|
||||
switch (name) {
|
||||
case "receive":
|
||||
return 0;
|
||||
case "change":
|
||||
return 1;
|
||||
case "defi":
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handshake Protocol
|
||||
///
|
||||
/// Either side may connect or reconnect at any time (mobile app switch, browser
|
||||
/// refresh, relay drop). The handshake uses a **mutual-discovery** pattern so
|
||||
/// both sides converge regardless of who connects first:
|
||||
///
|
||||
/// 1. On every connect/reconnect, both sides independently send their "ready"
|
||||
/// message once key exchange completes.
|
||||
/// 2. Each "ready" carries a boolean indicating whether the sender has already
|
||||
/// seen the other party before (in this runtime session).
|
||||
/// 3. On receiving a "ready" with `_discovered=false`, the receiver sends back
|
||||
/// their own "ready" — **only if they have not already sent one this cycle**.
|
||||
/// 4. Once both sides have exchanged wallet_ready/dapp_ready, the wallet sends
|
||||
/// its xpubs and the dapp derives all needed pubkeys locally.
|
||||
///
|
||||
/// INITIAL CONNECT (neither has seen the other):
|
||||
///
|
||||
/// Dapp --dapp_ready(supported=["v1"])--> Wallet (proactive)
|
||||
/// Dapp <--wallet_ready(supported=["v1"], session={v1:{paths,...}})-- Wallet
|
||||
/// Dapp --dapp_ready(supported=["v1"], selected="hdwalletv1")--> Wallet (reactive)
|
||||
///
|
||||
/// WALLET RECONNECTS (dapp still running, has walletDiscovered=true):
|
||||
///
|
||||
/// Dapp --dapp_ready(supported=["v1"], wallet_discovered=true)--> Wallet
|
||||
/// Dapp <--wallet_ready(supported=["v1"], session={...})-- Wallet
|
||||
/// Dapp --dapp_ready(selected="hdwalletv1", wallet_discovered=true)--> Wallet
|
||||
///
|
||||
/// See base.ts for DappReadyMessage / WalletReadyMessage definitions.
|
||||
|
||||
/// Session data for the hdwalletv1 protocol.
|
||||
/// Carried inside WalletReadyMessage.session["hdwalletv1"].
|
||||
export interface Hdwalletv1Session {
|
||||
/// BIP32 xpubs for each named derivation path.
|
||||
paths: PathXpub[];
|
||||
}
|
||||
|
||||
export function isHdwalletv1Session(obj: unknown): obj is Hdwalletv1Session {
|
||||
return (
|
||||
obj !== null &&
|
||||
typeof obj === "object" &&
|
||||
Array.isArray((obj as Hdwalletv1Session).paths) &&
|
||||
(obj as Hdwalletv1Session).paths.every((p) => isPathXpub(p))
|
||||
);
|
||||
}
|
||||
|
||||
export interface ErrorMessage extends ProtocolMessage {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SignTransactionRequest extends ProtocolMessage {
|
||||
action: RelayMsgAction.SignTransactionRequest;
|
||||
transaction: WcSignTransactionRequest;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface SignTransactionResponse extends ProtocolMessage {
|
||||
action: RelayMsgAction.SignTransactionResponse;
|
||||
sequence: number;
|
||||
signedTransaction: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SignCancelMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.SignCancel;
|
||||
sequence: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Type guard functions
|
||||
|
||||
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
|
||||
return (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
typeof payload.action === "string" &&
|
||||
typeof payload.time === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isErrorMessage(payload: any): payload is ErrorMessage {
|
||||
return (
|
||||
payload && typeof payload === "object" && typeof payload.error === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function isSignTransactionRequest(
|
||||
msg: any,
|
||||
): msg is SignTransactionRequest {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === "object" &&
|
||||
msg.action === RelayMsgAction.SignTransactionRequest &&
|
||||
msg.transaction &&
|
||||
typeof msg.transaction === "object" &&
|
||||
typeof msg.sequence === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === "object" &&
|
||||
msg.action === RelayMsgAction.SignCancel &&
|
||||
typeof msg.sequence === "number"
|
||||
);
|
||||
}
|
||||
406
packages/core/src/relay-client.ts
Normal file
406
packages/core/src/relay-client.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { throwUnless, unwrap } from "./primitives.js";
|
||||
import NDK, {
|
||||
NDKPrivateKeySigner,
|
||||
NDKUser,
|
||||
NDKEvent,
|
||||
NDKKind,
|
||||
giftWrap,
|
||||
giftUnwrap,
|
||||
NDKSubscription,
|
||||
} from "@nostr-dev-kit/ndk";
|
||||
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import {
|
||||
isProtocolMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
} from "./protocols/hdwalletv1.js";
|
||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
import { MessageQueue } from "./message-queue.js";
|
||||
import { debug, error as logError, Scope } from "./log.js";
|
||||
|
||||
export interface RelayClientConfig {
|
||||
explicitRelayUrls: string[];
|
||||
signerPrivateKey: Uint8Array;
|
||||
pairedPublicKey?: Uint8Array;
|
||||
logNetworkActivity?: boolean;
|
||||
}
|
||||
|
||||
export class RelayClient extends EventEmitter {
|
||||
private ndk: NDK;
|
||||
private paired: NDKUser;
|
||||
private config: RelayClientConfig;
|
||||
private messageSubscription: NDKSubscription | null = null;
|
||||
private myPubkey: Uint8Array;
|
||||
private myPubkeyHex: string;
|
||||
private lastProcessedTimestamp: number = 0;
|
||||
private messageQueue: MessageQueue;
|
||||
|
||||
private sequence: number = Math.floor(
|
||||
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
|
||||
);
|
||||
|
||||
private pendingCalls = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (_value: any) => void;
|
||||
reject: (_error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
private pendingDeliveries = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (_value: any) => void;
|
||||
reject: (_error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(config: RelayClientConfig) {
|
||||
super();
|
||||
this.config = {
|
||||
logNetworkActivity: true,
|
||||
...config,
|
||||
};
|
||||
this.ndk = new NDK({
|
||||
explicitRelayUrls: this.config.explicitRelayUrls,
|
||||
signer: new NDKPrivateKeySigner(this.config.signerPrivateKey),
|
||||
enableOutboxModel: false,
|
||||
autoConnectUserRelays: false,
|
||||
});
|
||||
|
||||
this.messageQueue = new MessageQueue({
|
||||
logActivity: this.config.logNetworkActivity,
|
||||
});
|
||||
|
||||
this.myPubkey = unwrap(
|
||||
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
|
||||
);
|
||||
this.myPubkeyHex = deriveNostrPublicKey(this.config.signerPrivateKey);
|
||||
|
||||
if (this.config.pairedPublicKey) {
|
||||
const pairedNostrPubkey =
|
||||
this.config.pairedPublicKey.length === 33
|
||||
? this.config.pairedPublicKey.slice(1)
|
||||
: this.config.pairedPublicKey;
|
||||
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
|
||||
} else {
|
||||
this.paired = new NDKUser({ pubkey: "" });
|
||||
}
|
||||
}
|
||||
|
||||
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
|
||||
this.config.pairedPublicKey = pairedPublicKey;
|
||||
const pairedNostrPubkey =
|
||||
pairedPublicKey.length === 33
|
||||
? pairedPublicKey.slice(1)
|
||||
: pairedPublicKey;
|
||||
this.paired = new NDKUser({ pubkey: binToHex(pairedNostrPubkey) });
|
||||
this.emit("paired");
|
||||
}
|
||||
|
||||
getPublicKey(): Uint8Array {
|
||||
return this.myPubkey;
|
||||
}
|
||||
|
||||
getPublicKeyHex(): string {
|
||||
return this.myPubkeyHex;
|
||||
}
|
||||
|
||||
isKeyExchangeComplete(): boolean {
|
||||
if (!this.config.pairedPublicKey) {
|
||||
return false;
|
||||
}
|
||||
return !this.config.pairedPublicKey.every((byte) => byte === 0);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Connecting to relay...`);
|
||||
}
|
||||
|
||||
if (this.lastProcessedTimestamp === 0) {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ndk.connect();
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `NDK connect() resolved`);
|
||||
}
|
||||
|
||||
this.messageSubscription = this.ndk.subscribe(
|
||||
{
|
||||
kinds: [NDKKind.GiftWrap],
|
||||
"#p": [this.myPubkeyHex],
|
||||
},
|
||||
{ closeOnEose: false },
|
||||
);
|
||||
|
||||
this.messageSubscription.on("event", async (wrappedEvent: NDKEvent) => {
|
||||
try {
|
||||
const signer = this.ndk.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
|
||||
const rumor = await giftUnwrap(wrappedEvent, undefined, signer);
|
||||
|
||||
if (rumor.kind === NDKKind.PrivateDirectMessage) {
|
||||
let payload: ProtocolMessage;
|
||||
try {
|
||||
payload = JSON.parse(rumor.content);
|
||||
} catch (e) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
"Failed to parse message content as JSON:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!payload.time ||
|
||||
(this.lastProcessedTimestamp > 0 &&
|
||||
payload.time < this.lastProcessedTimestamp)
|
||||
) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// wallet_ready carries the key exchange data (public_key + secret) so it
|
||||
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
|
||||
const isKeyExchangeMessage =
|
||||
payload.action === RelayMsgAction.WalletReady;
|
||||
|
||||
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
|
||||
const pairedNostrPubkey =
|
||||
this.config.pairedPublicKey.length === 33
|
||||
? binToHex(this.config.pairedPublicKey.slice(1))
|
||||
: binToHex(this.config.pairedPublicKey);
|
||||
if (rumor.pubkey !== pairedNostrPubkey) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Received message ${payload.action} from relay`,
|
||||
);
|
||||
}
|
||||
this.handleRelayMessage(payload);
|
||||
} else {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, "Error handling incoming message:", error);
|
||||
}
|
||||
this.emitError(error as Error);
|
||||
}
|
||||
});
|
||||
|
||||
this.messageSubscription.on("close", () => {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, "Subscription closed, connection may be stale");
|
||||
}
|
||||
this.emit("disconnect", new Error("Subscription closed"));
|
||||
});
|
||||
|
||||
this.waitForRelaysReady();
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Subscription created and handler set up, emitting connection event`,
|
||||
);
|
||||
}
|
||||
|
||||
this.emit("connection");
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, `Connection failed:`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
|
||||
this.messageQueue.setNotReady();
|
||||
|
||||
if (this.messageSubscription) {
|
||||
this.messageSubscription.stop();
|
||||
this.messageSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
getLastProcessedTimestamp(): number {
|
||||
return this.lastProcessedTimestamp;
|
||||
}
|
||||
|
||||
setLastProcessedTimestamp(timestamp: number): void {
|
||||
this.lastProcessedTimestamp = timestamp;
|
||||
}
|
||||
|
||||
async relay(message: ProtocolMessage): Promise<void> {
|
||||
if (!this.config.pairedPublicKey) {
|
||||
throw new Error(
|
||||
"Cannot relay message: paired public key not set. Call setPairedPublicKey() first.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.messageQueue.getReady()) {
|
||||
return this.messageQueue.enqueue(message);
|
||||
}
|
||||
|
||||
return this.publishMessage(message);
|
||||
}
|
||||
|
||||
private async publishMessage(message: ProtocolMessage): Promise<void> {
|
||||
this.netlog("send", message.action);
|
||||
|
||||
const signer = this.ndk.signer;
|
||||
if (!signer) {
|
||||
throw new Error("No signer available");
|
||||
}
|
||||
|
||||
const rumor = new NDKEvent(this.ndk);
|
||||
rumor.kind = NDKKind.PrivateDirectMessage;
|
||||
rumor.content = JSON.stringify(message);
|
||||
rumor.created_at = Math.floor(Date.now() / 1000);
|
||||
rumor.tags = [["p", this.paired.pubkey]];
|
||||
|
||||
const wrappedEvent = await giftWrap(rumor, this.paired, signer);
|
||||
|
||||
try {
|
||||
await wrappedEvent.publish();
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Published message ${message.action} to relay`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Failed to publish message ${message.action}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForRelaysReady(): Promise<void> {
|
||||
const maxWaitTime = 5000;
|
||||
const checkInterval = 100;
|
||||
const startTime = Date.now();
|
||||
|
||||
const checkRelays = async (): Promise<void> => {
|
||||
const pool = (this.ndk as any).pool;
|
||||
if (pool) {
|
||||
const relays = pool.relays || [];
|
||||
// NDKRelayStatus: DISCONNECTED=1, CONNECTED=5, AUTHENTICATED=8
|
||||
const connectedRelays = Array.from(relays.values()).filter(
|
||||
(relay: any) => relay.status >= 5,
|
||||
);
|
||||
|
||||
if (connectedRelays.length > 0) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Relays ready (${connectedRelays.length} connected), processing queued messages`,
|
||||
);
|
||||
}
|
||||
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - startTime < maxWaitTime) {
|
||||
setTimeout(checkRelays, checkInterval);
|
||||
} else {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
"Relay ready check timeout, assuming ready and processing queued messages",
|
||||
);
|
||||
}
|
||||
await this.messageQueue.setReady((msg) => this.publishMessage(msg));
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(checkRelays, 200);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
private netlog(
|
||||
direction: "recv" | "send",
|
||||
what: string,
|
||||
sequence?: number,
|
||||
): void {
|
||||
if (!this.config.logNetworkActivity) {
|
||||
return;
|
||||
}
|
||||
const us = binToHex(hash256(this.config.signerPrivateKey)).slice(-6);
|
||||
const them = this.config.pairedPublicKey
|
||||
? binToHex(this.config.pairedPublicKey).slice(-6)
|
||||
: "??????";
|
||||
const pending = `c${this.pendingCalls.size} d${this.pendingDeliveries.size}`;
|
||||
if (direction === "send") {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`net [${sequence ?? "?"} ${pending}] ${us} -> ${them}: ${what}`,
|
||||
);
|
||||
} else {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`net [${sequence ?? "?"} ${pending}] ${us} <- ${them}: ${what}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRelayMessage(message: ProtocolMessage): Promise<void> {
|
||||
throwUnless(
|
||||
isProtocolMessage(message),
|
||||
`Invalid protocol message: ${message}`,
|
||||
);
|
||||
this.emit("message", message);
|
||||
}
|
||||
|
||||
public nextSequence(): number {
|
||||
const current = this.sequence;
|
||||
this.sequence += 2;
|
||||
return current;
|
||||
}
|
||||
|
||||
private emitError(error: Error): void {
|
||||
this.emit("error", error);
|
||||
}
|
||||
}
|
||||
167
packages/core/src/relay-handler.ts
Normal file
167
packages/core/src/relay-handler.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { RelayClient } from "./relay-client.js";
|
||||
import {
|
||||
createConnectionManager,
|
||||
VisibilityChangeContext,
|
||||
} from "./connection-manager.js";
|
||||
import { debug, error as logError, Scope } from "./log.js";
|
||||
|
||||
type RelayStatusCode =
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "session_deleted";
|
||||
|
||||
export class RelayStatus {
|
||||
status: RelayStatusCode;
|
||||
error: string | null;
|
||||
sessionId: string | null;
|
||||
|
||||
private constructor(
|
||||
status: RelayStatusCode,
|
||||
error: string | null,
|
||||
sessionId: string | null = null,
|
||||
) {
|
||||
this.status = status;
|
||||
this.error = error;
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
static connected(sessionId?: string): RelayStatus {
|
||||
return new RelayStatus("connected", null, sessionId || null);
|
||||
}
|
||||
|
||||
static reconnecting(reason: string | null, sessionId?: string): RelayStatus {
|
||||
return new RelayStatus("reconnecting", reason, sessionId || null);
|
||||
}
|
||||
|
||||
static disconnected(): RelayStatus {
|
||||
return new RelayStatus("disconnected", null, null);
|
||||
}
|
||||
|
||||
static sessionDeleted(): RelayStatus {
|
||||
return new RelayStatus("session_deleted", null, null);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RelayUpdatePayload {
|
||||
client: RelayClient;
|
||||
status: RelayStatus;
|
||||
}
|
||||
|
||||
export type RelayStatusCallback = (_payload: RelayUpdatePayload) => void;
|
||||
|
||||
export const initiateRelay = (
|
||||
dispatchCallback: RelayStatusCallback,
|
||||
signerPrivateKey: Uint8Array,
|
||||
pairPublicKey: Uint8Array,
|
||||
options?: {
|
||||
explicitRelayUrls: string[];
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
enableVisibilityHandling?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = new RelayClient({
|
||||
explicitRelayUrls: options?.explicitRelayUrls ?? [],
|
||||
signerPrivateKey: signerPrivateKey,
|
||||
pairedPublicKey: pairPublicKey,
|
||||
});
|
||||
|
||||
let lastProcessedTimestamp: number = 0;
|
||||
|
||||
const connectionManager = createConnectionManager(
|
||||
client,
|
||||
{
|
||||
onConnected: () => {
|
||||
if (lastProcessedTimestamp > 0) {
|
||||
client.setLastProcessedTimestamp(lastProcessedTimestamp);
|
||||
} else {
|
||||
lastProcessedTimestamp = client.getLastProcessedTimestamp();
|
||||
}
|
||||
dispatchCallback({
|
||||
client,
|
||||
status: RelayStatus.connected(),
|
||||
});
|
||||
},
|
||||
onReconnecting: (_client: RelayClient, reason: string | null) => {
|
||||
dispatchCallback({
|
||||
client,
|
||||
status: RelayStatus.reconnecting(reason),
|
||||
});
|
||||
},
|
||||
onDisconnected: () => {
|
||||
dispatchCallback({
|
||||
client,
|
||||
status: RelayStatus.disconnected(),
|
||||
});
|
||||
},
|
||||
onError: (_client: RelayClient, _error: any) => {
|
||||
// Error handling is already done in onReconnecting
|
||||
},
|
||||
},
|
||||
{
|
||||
connected: "connection",
|
||||
disconnected: "disconnect",
|
||||
error: "error",
|
||||
},
|
||||
{
|
||||
reconnectInterval: options?.reconnectInterval,
|
||||
maxReconnectAttempts: options?.maxReconnectAttempts,
|
||||
enableVisibilityHandling: options?.enableVisibilityHandling ?? true,
|
||||
scope: Scope.Relay,
|
||||
onVisibilityChange: async (
|
||||
context: VisibilityChangeContext<RelayClient>,
|
||||
) => {
|
||||
if (context.state === "hidden") {
|
||||
debug(Scope.Relay, "Page hidden, disconnecting relay");
|
||||
context.setPaused(true);
|
||||
try {
|
||||
await client.disconnect();
|
||||
lastProcessedTimestamp = client.getLastProcessedTimestamp();
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Disconnected, last processed timestamp: ${lastProcessedTimestamp}`,
|
||||
);
|
||||
dispatchCallback({
|
||||
client,
|
||||
status: RelayStatus.disconnected(),
|
||||
});
|
||||
} catch (error) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
"Error disconnecting on visibility change:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else if (context.state === "visible") {
|
||||
if (context.isPaused()) {
|
||||
debug(Scope.Relay, "Page visible, reconnecting relay");
|
||||
context.setPaused(false);
|
||||
context.startConnectionLoop();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
connectionManager.startConnectionLoop();
|
||||
|
||||
return () => {
|
||||
dispatchCallback({
|
||||
client,
|
||||
status: RelayStatus.disconnected(),
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await connectionManager.cleanup();
|
||||
} catch {
|
||||
// Ignore disconnect errors during cleanup
|
||||
}
|
||||
})();
|
||||
};
|
||||
};
|
||||
104
packages/core/src/utilnostr.test.ts
Normal file
104
packages/core/src/utilnostr.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveNostrPublicKey,
|
||||
deriveNostrPublicKeyBytes,
|
||||
} from "./utilnostr.js";
|
||||
import {
|
||||
generatePrivateKey,
|
||||
hexToBin,
|
||||
binToHex,
|
||||
secp256k1,
|
||||
} from "@bitauth/libauth";
|
||||
|
||||
describe("utilnostr", () => {
|
||||
describe("deriveNostrPublicKey", () => {
|
||||
it("should derive a valid Nostr public key from a private key", () => {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKey = deriveNostrPublicKey(privateKey);
|
||||
|
||||
// Nostr public key should be 64 hex characters (32 bytes)
|
||||
expect(publicKey).toHaveLength(64);
|
||||
expect(/^[0-9a-f]+$/.test(publicKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("should produce the same public key for the same private key", () => {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKey1 = deriveNostrPublicKey(privateKey);
|
||||
const publicKey2 = deriveNostrPublicKey(privateKey);
|
||||
|
||||
expect(publicKey1).toBe(publicKey2);
|
||||
});
|
||||
|
||||
it("should produce different public keys for different private keys", () => {
|
||||
const privateKey1 = generatePrivateKey();
|
||||
const privateKey2 = generatePrivateKey();
|
||||
const publicKey1 = deriveNostrPublicKey(privateKey1);
|
||||
const publicKey2 = deriveNostrPublicKey(privateKey2);
|
||||
|
||||
expect(publicKey1).not.toBe(publicKey2);
|
||||
});
|
||||
|
||||
it("should handle private key from hex string", () => {
|
||||
const privateKeyHex =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const privateKey = hexToBin(privateKeyHex);
|
||||
const publicKey = deriveNostrPublicKey(privateKey);
|
||||
|
||||
expect(publicKey).toHaveLength(64);
|
||||
expect(/^[0-9a-f]+$/.test(publicKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("should match the pattern used in key-exchange", () => {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKey = deriveNostrPublicKey(privateKey);
|
||||
|
||||
// Verify it's the same as the manual pattern
|
||||
const result = secp256k1.derivePublicKeyCompressed(privateKey);
|
||||
if (typeof result === "string") throw new Error(result);
|
||||
const publicKeyNostr = result.slice(1);
|
||||
const publicKeyHex = binToHex(publicKeyNostr);
|
||||
|
||||
expect(publicKey).toBe(publicKeyHex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveNostrPublicKeyBytes", () => {
|
||||
it("should derive a valid Nostr public key as bytes", () => {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKeyBytes = deriveNostrPublicKeyBytes(privateKey);
|
||||
|
||||
// Nostr public key should be 32 bytes
|
||||
expect(publicKeyBytes).toHaveLength(32);
|
||||
expect(publicKeyBytes).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("should match the hex version when converted", () => {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKeyHex = deriveNostrPublicKey(privateKey);
|
||||
const publicKeyBytes = deriveNostrPublicKeyBytes(privateKey);
|
||||
|
||||
expect(binToHex(publicKeyBytes)).toBe(publicKeyHex);
|
||||
});
|
||||
|
||||
it("should produce the same bytes for the same private key", () => {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKeyBytes1 = deriveNostrPublicKeyBytes(privateKey);
|
||||
const publicKeyBytes2 = deriveNostrPublicKeyBytes(privateKey);
|
||||
|
||||
expect(publicKeyBytes1).toEqual(publicKeyBytes2);
|
||||
});
|
||||
|
||||
it("should produce different bytes for different private keys", () => {
|
||||
const privateKey1 = generatePrivateKey();
|
||||
const privateKey2 = generatePrivateKey();
|
||||
const publicKeyBytes1 = deriveNostrPublicKeyBytes(privateKey1);
|
||||
const publicKeyBytes2 = deriveNostrPublicKeyBytes(privateKey2);
|
||||
|
||||
expect(publicKeyBytes1).not.toEqual(publicKeyBytes2);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/core/src/utilnostr.ts
Normal file
21
packages/core/src/utilnostr.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { binToHex, secp256k1 } from "@bitauth/libauth";
|
||||
import { unwrap } from "./primitives.js";
|
||||
|
||||
export function deriveNostrPublicKey(privateKey: Uint8Array): string {
|
||||
const publicKeyCompressed = unwrap(
|
||||
secp256k1.derivePublicKeyCompressed(privateKey),
|
||||
);
|
||||
const publicKeyNostr = publicKeyCompressed.slice(1);
|
||||
return binToHex(publicKeyNostr);
|
||||
}
|
||||
|
||||
export function deriveNostrPublicKeyBytes(privateKey: Uint8Array): Uint8Array {
|
||||
const publicKeyCompressed = unwrap(
|
||||
secp256k1.derivePublicKeyCompressed(privateKey),
|
||||
);
|
||||
return publicKeyCompressed.slice(1);
|
||||
}
|
||||
111
packages/core/src/wallet-relay.ts
Normal file
111
packages/core/src/wallet-relay.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { RelayClient } from "./relay-client.js";
|
||||
import {
|
||||
RelayUpdatePayload,
|
||||
RelayStatusCallback,
|
||||
initiateRelay,
|
||||
} from "./relay-handler.js";
|
||||
import { decodeKeyExchangeURI, DEFAULT_RELAY_PORT } from "./key-exchange.js";
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
import { deriveNostrPublicKeyBytes } from "./utilnostr.js";
|
||||
|
||||
export interface WalletRelayOptions {
|
||||
uri: string;
|
||||
walletPrivateKey: Uint8Array;
|
||||
explicitRelayUrls?: string[];
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
}
|
||||
|
||||
export interface WalletRelayResult {
|
||||
client: RelayClient;
|
||||
dappPublicKey: Uint8Array;
|
||||
walletPublicKey: Uint8Array;
|
||||
secret: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
export function initiateWalletRelay(
|
||||
statusCallback: RelayStatusCallback,
|
||||
options: WalletRelayOptions,
|
||||
): WalletRelayResult {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeKeyExchangeURI(options.uri);
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to decode connection URI: ${err.message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const dappPublicKeyHex = decoded.publicKey;
|
||||
const secret = decoded.secret;
|
||||
const dappPublicKeyNostr = hexToBin(dappPublicKeyHex);
|
||||
|
||||
const hostname = decoded.hostname;
|
||||
const protocol = decoded.protocol;
|
||||
const port = decoded.port;
|
||||
const relayUrl = `${protocol}://${hostname}${port === (protocol === "wss" ? DEFAULT_RELAY_PORT : 80) ? "" : `:${port}`}`;
|
||||
|
||||
const walletPublicKeyNostr = deriveNostrPublicKeyBytes(
|
||||
options.walletPrivateKey,
|
||||
);
|
||||
|
||||
let relayClient: RelayClient | null = null;
|
||||
|
||||
const wrappedCallback: RelayStatusCallback = (
|
||||
payload: RelayUpdatePayload,
|
||||
) => {
|
||||
if (!relayClient) {
|
||||
relayClient = payload.client;
|
||||
}
|
||||
|
||||
if (payload.status.status === "connected") {
|
||||
// Set dapp's pubkey so outbound messages are addressed correctly.
|
||||
// The wallet's own pubkey + secret are delivered via wallet_ready.
|
||||
relayClient!.setPairedPublicKey(dappPublicKeyNostr);
|
||||
}
|
||||
|
||||
statusCallback(payload);
|
||||
};
|
||||
|
||||
const relayUrls: string[] = [relayUrl];
|
||||
if (options.explicitRelayUrls && options.explicitRelayUrls.length > 0) {
|
||||
for (const explicitUrl of options.explicitRelayUrls) {
|
||||
if (!relayUrls.includes(explicitUrl)) {
|
||||
relayUrls.push(explicitUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = initiateRelay(
|
||||
wrappedCallback,
|
||||
options.walletPrivateKey,
|
||||
dappPublicKeyNostr,
|
||||
{
|
||||
explicitRelayUrls: relayUrls,
|
||||
reconnectInterval: options.reconnectInterval,
|
||||
maxReconnectAttempts: options.maxReconnectAttempts,
|
||||
},
|
||||
);
|
||||
|
||||
const result: WalletRelayResult = {
|
||||
get client() {
|
||||
if (!relayClient) {
|
||||
throw new Error(
|
||||
"Relay client not yet initialized. Wait for connection status callback.",
|
||||
);
|
||||
}
|
||||
return relayClient;
|
||||
},
|
||||
dappPublicKey: dappPublicKeyNostr,
|
||||
walletPublicKey: walletPublicKeyNostr,
|
||||
secret,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
9
packages/core/tsconfig.json
Normal file
9
packages/core/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
11
packages/core/vitest.config.ts
Normal file
11
packages/core/vitest.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
|
||||
},
|
||||
});
|
||||
29
packages/dapp/package.json
Normal file
29
packages/dapp/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@wizardconnect/dapp",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Dapp-side integration helpers for WizardConnect",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
||||
"lint:prettier": "prettier . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:prettier": "prettier . --write",
|
||||
"fix:eslint": "npm run lint:eslint -- --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
BIN
packages/dapp/src/.pubkey-state-manager.ts.swp
Normal file
BIN
packages/dapp/src/.pubkey-state-manager.ts.swp
Normal file
Binary file not shown.
340
packages/dapp/src/dapp-connection-manager.ts
Normal file
340
packages/dapp/src/dapp-connection-manager.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import { decodeHdPublicKey } from "@bitauth/libauth";
|
||||
import type { HdPublicNodeValid } from "@bitauth/libauth";
|
||||
import {
|
||||
RelayClient,
|
||||
RelayStatus,
|
||||
RelayMsgAction,
|
||||
DappReadyMessage,
|
||||
WalletReadyMessage,
|
||||
DisconnectMessage,
|
||||
DisconnectReason,
|
||||
SignTransactionRequest,
|
||||
SignTransactionResponse,
|
||||
SignCancelMessage,
|
||||
ProtocolMessage,
|
||||
PROTOCOL_NAME,
|
||||
PathName,
|
||||
childIndexOfPathName,
|
||||
isHdwalletv1Session,
|
||||
} from "@wizardconnect/core";
|
||||
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
|
||||
export interface DappConnectionManagerEvents {
|
||||
/** Fired after wallet_ready is received and state is updated. */
|
||||
walletready: [msg: WalletReadyMessage];
|
||||
/** Fired for every protocol message sent by the dapp. */
|
||||
messagesent: [msg: ProtocolMessage];
|
||||
/** Fired for every protocol message received from the wallet. */
|
||||
messagereceived: [msg: ProtocolMessage];
|
||||
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
|
||||
disconnect: [reason: DisconnectReason, message: string | undefined];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the dapp side of a single WizardConnect session.
|
||||
*
|
||||
* Protocol responsibilities:
|
||||
* - Sends dapp_ready after key exchange completes (and on reconnect)
|
||||
* - Receives wallet_ready, sign_transaction_response
|
||||
* - Tracks pubkeys and address indices (via xpub on-demand derivation)
|
||||
*
|
||||
* No chain-specific logic — child indices are plain numbers:
|
||||
* 0 = Receive, 1 = Change, 7 = Cauldron (BCH convention)
|
||||
*/
|
||||
export class DappConnectionManager extends EventEmitter<DappConnectionManagerEvents> {
|
||||
private conn: RelayClient | null = null;
|
||||
private listenerAttached = false;
|
||||
|
||||
/** Pubkey state — exposed for callers that need to query by index. */
|
||||
readonly pubkeyState: DappPubkeyStateManager;
|
||||
|
||||
walletName: string | null = null;
|
||||
walletIcon: string | null = null;
|
||||
protocol: string | null = null;
|
||||
|
||||
/** Protocols this dapp supports, in preference order. */
|
||||
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
|
||||
|
||||
private walletDiscovered = false;
|
||||
private pendingSignatureRequests = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (r: SignTransactionResponse) => void;
|
||||
reject: (e: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
/**
|
||||
* @param dappName Optional display name of the dapp (sent in dapp_ready).
|
||||
* @param dappIcon Optional icon URL/data-URI of the dapp (sent in dapp_ready).
|
||||
*/
|
||||
constructor(
|
||||
private dappName?: string,
|
||||
private dappIcon?: string,
|
||||
) {
|
||||
super();
|
||||
this.pubkeyState = new DappPubkeyStateManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this from the RelayStatusCallback passed to `initiateDappRelay`.
|
||||
* Attaches the message listener exactly once and re-sends dapp_ready
|
||||
* each time the connection is established (handles reconnects).
|
||||
*/
|
||||
updateConnection(
|
||||
client: RelayClient | null | undefined,
|
||||
status: RelayStatus,
|
||||
): void {
|
||||
if (client) {
|
||||
// Attach message listener once (same RelayClient object is reused across reconnects)
|
||||
if (!this.listenerAttached) {
|
||||
this.listenerAttached = true;
|
||||
client.on("message", (msg: ProtocolMessage) => this.handleMessage(msg));
|
||||
}
|
||||
this.conn = client;
|
||||
}
|
||||
|
||||
if (status.status === "connected" && this.conn) {
|
||||
this.onConnected();
|
||||
}
|
||||
}
|
||||
|
||||
isWalletDiscovered(): boolean {
|
||||
return this.walletDiscovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a sequence number from the relay client.
|
||||
* Use this to populate the `sequence` field of a SignTransactionRequest.
|
||||
*/
|
||||
nextSequence(): number {
|
||||
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
||||
return this.conn.nextSequence();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sign transaction request and wait for the wallet's response.
|
||||
* The caller is responsible for creating the full SignTransactionRequest
|
||||
* (including sequence from `nextSequence()`).
|
||||
*/
|
||||
async sendSignRequest(
|
||||
request: SignTransactionRequest,
|
||||
): Promise<SignTransactionResponse> {
|
||||
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
|
||||
|
||||
return new Promise<SignTransactionResponse>((resolve, reject) => {
|
||||
this.pendingSignatureRequests.set(request.sequence, { resolve, reject });
|
||||
|
||||
this.conn!.relay(request)
|
||||
.then(() => {
|
||||
this.emit("messagesent", request);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.pendingSignatureRequests.delete(request.sequence);
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight sign request.
|
||||
* Immediately rejects the pending Promise and sends sign_cancel to the wallet.
|
||||
*/
|
||||
async sendSignCancel(sequence: number, reason?: string): Promise<void> {
|
||||
// Reject pending promise immediately — no response will come
|
||||
const handlers = this.pendingSignatureRequests.get(sequence);
|
||||
if (handlers) {
|
||||
this.pendingSignatureRequests.delete(sequence);
|
||||
handlers.reject(new Error(reason ?? "Sign request cancelled"));
|
||||
}
|
||||
if (!this.conn) return;
|
||||
const msg: SignCancelMessage = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence,
|
||||
...(reason !== undefined && { reason }),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await this.conn.relay(msg);
|
||||
this.emit("messagesent", msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a disconnect message to the wallet (courtesy notification).
|
||||
* The caller is responsible for calling dappRelay.cleanup() afterwards.
|
||||
*/
|
||||
async sendDisconnect(message?: string): Promise<void> {
|
||||
if (!this.conn) return;
|
||||
const msg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
...(message !== undefined && { message }),
|
||||
};
|
||||
await this.conn.relay(msg);
|
||||
this.emit("messagesent", msg);
|
||||
}
|
||||
|
||||
// --- Pubkey state delegation ------------------------------------------------
|
||||
// Convenience methods that forward to pubkeyState.
|
||||
|
||||
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined {
|
||||
return this.pubkeyState.getPubkey(childIndex, index);
|
||||
}
|
||||
|
||||
/** Returns true if an xpub node is available for this child index. */
|
||||
hasPath(childIndex: number): boolean {
|
||||
return this.pubkeyState.hasPath(childIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored xpub node for the given child index.
|
||||
* Available after wallet_ready is received.
|
||||
*/
|
||||
getXpubNode(childIndex: number): HdPublicNodeValid | undefined {
|
||||
return this.pubkeyState.getXpubNode(childIndex);
|
||||
}
|
||||
|
||||
// --- Private protocol handling -------------------------------------------
|
||||
|
||||
private onConnected(): void {
|
||||
(async () => {
|
||||
// Wait until key exchange is complete before sending dapp_ready
|
||||
while (this.conn && !this.conn.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (this.conn) {
|
||||
await this.pushDappReady();
|
||||
}
|
||||
})().catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error in onConnected:", e),
|
||||
);
|
||||
}
|
||||
|
||||
private async pushDappReady(): Promise<void> {
|
||||
if (!this.conn) return;
|
||||
|
||||
const msg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: this.supportedProtocols,
|
||||
wallet_discovered: this.walletDiscovered,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
// Include selected_protocol on the reactive send (after the dapp has seen the wallet)
|
||||
...(this.walletDiscovered &&
|
||||
this.protocol && { selected_protocol: this.protocol }),
|
||||
...(this.dappName !== undefined && { dapp_name: this.dappName }),
|
||||
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
|
||||
};
|
||||
|
||||
await this.conn.relay(msg);
|
||||
this.emit("messagesent", msg);
|
||||
}
|
||||
|
||||
private handleMessage(msg: ProtocolMessage): void {
|
||||
this.emit("messagereceived", msg);
|
||||
switch (msg.action) {
|
||||
case RelayMsgAction.WalletReady:
|
||||
this.handleWalletReady(msg as WalletReadyMessage);
|
||||
break;
|
||||
case RelayMsgAction.SignTransactionResponse:
|
||||
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
||||
break;
|
||||
case RelayMsgAction.Disconnect:
|
||||
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
||||
break;
|
||||
case RelayMsgAction.DappReady:
|
||||
// Not expected on dapp side — silently ignore
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
"[wizardconnect/dapp] Unknown message action:",
|
||||
msg.action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteDisconnect(msg: DisconnectMessage): void {
|
||||
this.emit("disconnect", msg.reason, msg.message);
|
||||
}
|
||||
|
||||
private handleWalletReady(msg: WalletReadyMessage): void {
|
||||
this.walletDiscovered = true;
|
||||
this.walletName = msg.wallet_name;
|
||||
this.walletIcon = msg.wallet_icon;
|
||||
|
||||
// Protocol selection: first match in dapp's preference order
|
||||
const agreed = this.supportedProtocols.find((p) =>
|
||||
msg.supported_protocols.includes(p),
|
||||
);
|
||||
if (!agreed) {
|
||||
const detail = `No protocol overlap. Wallet: [${msg.supported_protocols}], Dapp: [${this.supportedProtocols}]`;
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.ProtocolMismatch,
|
||||
message: detail,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.conn?.relay(disconnectMsg).catch(() => {});
|
||||
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
|
||||
return;
|
||||
}
|
||||
this.protocol = agreed;
|
||||
|
||||
// Extract and validate protocol-specific session data
|
||||
const sessionData = msg.session[agreed];
|
||||
if (!isHdwalletv1Session(sessionData)) {
|
||||
console.error(
|
||||
"[wizardconnect/dapp] Invalid hdwalletv1 session data:",
|
||||
sessionData,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store xpub nodes — no eager derivation; consumer drives it
|
||||
for (const pathInfo of sessionData.paths) {
|
||||
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
||||
if (typeof decoded === "string") {
|
||||
console.warn(
|
||||
"[wizardconnect/dapp] Bad xpub for path",
|
||||
pathInfo.name,
|
||||
decoded,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ci = childIndexOfPathName(pathInfo.name as PathName);
|
||||
this.pubkeyState.setXpubNode(ci, decoded.node);
|
||||
}
|
||||
|
||||
if (!msg.dapp_discovered) {
|
||||
this.pushDappReady().catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
|
||||
);
|
||||
}
|
||||
|
||||
this.emit("walletready", msg);
|
||||
}
|
||||
|
||||
private handleSignTransactionResponse(
|
||||
response: SignTransactionResponse,
|
||||
): void {
|
||||
const handlers = this.pendingSignatureRequests.get(response.sequence);
|
||||
if (!handlers) {
|
||||
console.warn(
|
||||
"[wizardconnect/dapp] No pending request for sequence:",
|
||||
response.sequence,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.pendingSignatureRequests.delete(response.sequence);
|
||||
|
||||
if (response.error) {
|
||||
handlers.reject(new Error(response.error));
|
||||
} else {
|
||||
handlers.resolve(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/dapp/src/index.ts
Normal file
7
packages/dapp/src/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
export { DappConnectionManager } from "./dapp-connection-manager.js";
|
||||
export type { DappConnectionManagerEvents } from "./dapp-connection-manager.js";
|
||||
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
27
packages/dapp/src/pubkey-state-manager.test.ts
Normal file
27
packages/dapp/src/pubkey-state-manager.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Dapp-side PubkeyStateManager tests.
|
||||
* Uses plain number child indices (0=Receive, 1=Change, 7=Cauldron)
|
||||
* instead of DerivationPath enum.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
|
||||
const Receive = 0;
|
||||
|
||||
describe("DappPubkeyStateManager", () => {
|
||||
let manager: DappPubkeyStateManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new DappPubkeyStateManager();
|
||||
});
|
||||
|
||||
describe("hasPath", () => {
|
||||
it("should return false when no xpub node is set", () => {
|
||||
expect(manager.hasPath(Receive)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
packages/dapp/src/pubkey-state-manager.ts
Normal file
43
packages/dapp/src/pubkey-state-manager.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Dapp-side pubkey state manager.
|
||||
*
|
||||
* Stores xpub nodes received from the wallet and derives pubkeys on demand.
|
||||
*
|
||||
* Uses plain `number` for child indices (0=Receive, 1=Change, 7=Cauldron)
|
||||
* so this package has no dependency on chain-specific types.
|
||||
*/
|
||||
|
||||
import { deriveHdPublicNodeChild } from "@bitauth/libauth";
|
||||
import type { HdPublicNodeValid } from "@bitauth/libauth";
|
||||
|
||||
export class DappPubkeyStateManager {
|
||||
// xpub nodes for on-demand pubkey derivation
|
||||
private xpubNodes: Map<number, HdPublicNodeValid> = new Map();
|
||||
|
||||
/** Derive a pubkey on demand from the stored xpub node. */
|
||||
getPubkey(childIndex: number, index: bigint): Uint8Array | undefined {
|
||||
const xpubNode = this.xpubNodes.get(childIndex);
|
||||
if (!xpubNode) return undefined;
|
||||
|
||||
const child = deriveHdPublicNodeChild(xpubNode, Number(index));
|
||||
if (typeof child === "string") return undefined;
|
||||
return child.publicKey;
|
||||
}
|
||||
|
||||
/** Returns true if an xpub node is available for this child index. */
|
||||
hasPath(childIndex: number): boolean {
|
||||
return this.xpubNodes.has(childIndex);
|
||||
}
|
||||
|
||||
setXpubNode(childIndex: number, node: HdPublicNodeValid): void {
|
||||
this.xpubNodes.set(childIndex, node);
|
||||
}
|
||||
|
||||
getXpubNode(childIndex: number): HdPublicNodeValid | undefined {
|
||||
return this.xpubNodes.get(childIndex);
|
||||
}
|
||||
}
|
||||
9
packages/dapp/tsconfig.json
Normal file
9
packages/dapp/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
11
packages/dapp/vitest.config.ts
Normal file
11
packages/dapp/vitest.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
|
||||
},
|
||||
});
|
||||
35
packages/test-cli/package.json
Normal file
35
packages/test-cli/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI for testing WizardConnect protocol",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"wiz-test": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"dapp": "tsx src/cli.ts dapp",
|
||||
"wallet": "tsx src/cli.ts wallet",
|
||||
"lint:prettier": "prettier . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:prettier": "prettier . --write",
|
||||
"fix:eslint": "npm run lint:eslint -- --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
"@wizardconnect/wallet": "*",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"ora": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
60
packages/test-cli/src/cli.ts
Normal file
60
packages/test-cli/src/cli.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env node
|
||||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { Command } from "commander";
|
||||
import { runDappMode } from "./dapp.js";
|
||||
import { runWalletMode } from "./wallet.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("wiz-test")
|
||||
.description("WizardConnect protocol test CLI")
|
||||
.version("0.1.0");
|
||||
|
||||
program
|
||||
.command("dapp")
|
||||
.description(
|
||||
"Run as a dapp — generates URI, waits for wallet connection, logs all messages",
|
||||
)
|
||||
.option(
|
||||
"-r, --relay <url>",
|
||||
"Nostr relay WebSocket URL",
|
||||
"wss://relay.cauldron.quest:443",
|
||||
)
|
||||
.option(
|
||||
"-k, --private-key <hex>",
|
||||
"Existing dapp private key (64 hex chars) — for reconnection testing",
|
||||
)
|
||||
.option("--secret <hex>", "Existing secret (hex) — for reconnection testing")
|
||||
.option(
|
||||
"--sign",
|
||||
"Send a dummy sign request after wallet is ready (tests approval flow)",
|
||||
)
|
||||
.action(async (options) => {
|
||||
await runDappMode(options);
|
||||
});
|
||||
|
||||
program
|
||||
.command("wallet")
|
||||
.description("Run as a wallet — connects to a dapp URI, pushes test pubkeys")
|
||||
.option(
|
||||
"-r, --relay <url>",
|
||||
"Nostr relay WebSocket URL",
|
||||
"wss://relay.cauldron.quest:443",
|
||||
)
|
||||
.requiredOption("-u, --uri <uri>", "wiz:// URI from dapp")
|
||||
.option(
|
||||
"-k, --private-key <hex>",
|
||||
"Wallet private key (64 hex chars) — random if omitted",
|
||||
)
|
||||
.action(async (options) => {
|
||||
await runWalletMode(options);
|
||||
});
|
||||
|
||||
// Strip bare "--" that npm inserts when forwarding arguments through
|
||||
// multiple script layers (e.g. `npm run wallet -- --uri ...`).
|
||||
const argv = process.argv.filter((arg) => arg !== "--");
|
||||
program.parse(argv);
|
||||
297
packages/test-cli/src/dapp.ts
Normal file
297
packages/test-cli/src/dapp.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
type RelayUpdatePayload,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type Hdwalletv1Session,
|
||||
type SignTransactionResponse,
|
||||
type ProtocolMessage,
|
||||
type RelayClient,
|
||||
} from "@wizardconnect/core";
|
||||
|
||||
// ---- State ----
|
||||
|
||||
interface DappState {
|
||||
walletDiscovered: boolean;
|
||||
walletName: string | null;
|
||||
walletIcon: string | null;
|
||||
// Whether we've received at least one wallet_ready
|
||||
walletReady: boolean;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
function makeState(): DappState {
|
||||
return {
|
||||
walletDiscovered: false,
|
||||
walletName: null,
|
||||
walletIcon: null,
|
||||
walletReady: false,
|
||||
sequence: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Send dapp_ready ----
|
||||
|
||||
async function sendDappReady(
|
||||
client: RelayClient,
|
||||
state: DappState,
|
||||
): Promise<void> {
|
||||
const msg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: state.walletDiscovered,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
console.log(
|
||||
chalk.blue(`→ dapp_ready`) +
|
||||
chalk.dim(` wallet_discovered=${msg.wallet_discovered}`),
|
||||
);
|
||||
|
||||
try {
|
||||
await client.relay(msg);
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(" send error:"), err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Send dummy sign request ----
|
||||
|
||||
async function sendSignRequest(
|
||||
client: RelayClient,
|
||||
state: DappState,
|
||||
): Promise<void> {
|
||||
const sequence = state.sequence++;
|
||||
|
||||
// Minimal dummy transaction for approval flow testing
|
||||
const msg = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence,
|
||||
transaction: {
|
||||
transaction: {
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
},
|
||||
sourceOutputs: [],
|
||||
userPrompt: "Test sign request from wiz-test CLI",
|
||||
broadcast: false,
|
||||
},
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
console.log(
|
||||
chalk.yellow(`→ sign_transaction_request`) + chalk.dim(` seq=${sequence}`),
|
||||
);
|
||||
|
||||
try {
|
||||
await (client as any).relay(msg);
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(" send error:"), err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Handle incoming messages ----
|
||||
|
||||
function handleMessage(
|
||||
message: ProtocolMessage,
|
||||
client: RelayClient,
|
||||
state: DappState,
|
||||
sendSign: boolean,
|
||||
): void {
|
||||
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
||||
|
||||
switch (message.action) {
|
||||
case RelayMsgAction.WalletReady: {
|
||||
const msg = message as WalletReadyMessage;
|
||||
state.walletDiscovered = true;
|
||||
state.walletName = msg.wallet_name;
|
||||
state.walletIcon = msg.wallet_icon;
|
||||
state.walletReady = true;
|
||||
|
||||
const hdwv1 = msg.session?.[PROTOCOL_NAME] as
|
||||
| Hdwalletv1Session
|
||||
| undefined;
|
||||
const pathsSummary = hdwv1?.paths?.map((p) => p.name).join(",") ?? "none";
|
||||
|
||||
console.log(
|
||||
`${now} ` +
|
||||
chalk.green("← wallet_ready") +
|
||||
` wallet="${chalk.bold(msg.wallet_name)}"` +
|
||||
` dapp_discovered=${msg.dapp_discovered}` +
|
||||
` protocols=[${msg.supported_protocols.join(",")}]` +
|
||||
` paths=[${pathsSummary}]`,
|
||||
);
|
||||
|
||||
if (!msg.dapp_discovered) {
|
||||
// Wallet hasn't seen us — reply with dapp_ready(wallet_discovered: true)
|
||||
sendDappReady(client, state).catch(() => {});
|
||||
}
|
||||
|
||||
// If --sign flag set, schedule a sign request after wallet_ready
|
||||
if (sendSign && state.walletReady) {
|
||||
console.log(
|
||||
chalk.dim(" (--sign: scheduling sign request in 500ms...)"),
|
||||
);
|
||||
setTimeout(() => {
|
||||
sendSignRequest(client, state).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RelayMsgAction.SignTransactionResponse: {
|
||||
const msg = message as SignTransactionResponse;
|
||||
if (msg.error) {
|
||||
console.log(
|
||||
chalk.red("← sign_response") +
|
||||
` seq=${msg.sequence} error="${msg.error}"`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
chalk.green("← sign_response") +
|
||||
` seq=${msg.sequence} tx=${msg.signedTransaction.slice(0, 32)}...`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RelayMsgAction.DappReady:
|
||||
console.log(chalk.dim("← dapp_ready (unexpected as dapp, ignoring)"));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(chalk.dim(`← unknown action: ${message.action}`));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
|
||||
export async function runDappMode(options: {
|
||||
relay: string;
|
||||
privateKey?: string;
|
||||
secret?: string;
|
||||
sign?: boolean;
|
||||
}): Promise<void> {
|
||||
const state = makeState();
|
||||
|
||||
console.log(chalk.bold("\nwiz-test dapp mode"));
|
||||
console.log(chalk.dim(`relay: ${options.relay}`));
|
||||
if (options.sign)
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"--sign: will send dummy sign request after first pubkey batch",
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
|
||||
let connected = false;
|
||||
let capturedClient: RelayClient | null = null;
|
||||
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
// Capture the client reference here — dappRelay.client is null at creation time
|
||||
if (payload.client) capturedClient = payload.client;
|
||||
|
||||
const s = payload.status.status;
|
||||
if (s === "connected" && !connected) {
|
||||
connected = true;
|
||||
console.log(chalk.green("relay: connected"));
|
||||
} else if (s === "reconnecting") {
|
||||
console.log(chalk.yellow("relay: reconnecting..."));
|
||||
connected = false;
|
||||
} else if (s === "disconnected") {
|
||||
console.log(chalk.red("relay: disconnected"));
|
||||
connected = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
explicitRelayUrls: [options.relay],
|
||||
existingCredentials:
|
||||
options.privateKey && options.secret
|
||||
? { privateKey: options.privateKey, secret: options.secret }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// ---- Print URI ----
|
||||
console.log(chalk.bgCyan(chalk.black(" CONNECTION URI ")));
|
||||
console.log();
|
||||
console.log(" " + chalk.cyan(chalk.bold(dappRelay.uri)));
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"Dev shortcut:\n npm run dev -- --wiz=" +
|
||||
encodeURIComponent(dappRelay.uri),
|
||||
),
|
||||
);
|
||||
if (options.privateKey) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\nReconnect:\n wiz-test dapp --private-key ${options.privateKey} --secret ${dappRelay.credentials.secret}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`\nReconnect:\n wiz-test dapp --private-key ${dappRelay.credentials.privateKey} --secret ${dappRelay.credentials.secret}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
|
||||
const keySpinner = ora("Waiting for wallet to connect...").start();
|
||||
|
||||
// ---- Key exchange ----
|
||||
dappRelay.events.on("keyexchangecomplete", async (walletPubkey) => {
|
||||
const hex = Buffer.from(walletPubkey).toString("hex");
|
||||
keySpinner.succeed(
|
||||
chalk.green("Key exchange complete!") +
|
||||
chalk.dim(` wallet nostr key: ${hex.slice(0, 16)}...`),
|
||||
);
|
||||
|
||||
// Wait for client to be fully ready
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const client = capturedClient;
|
||||
if (!client) {
|
||||
console.error(chalk.red("No relay client after key exchange"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for key exchange to complete on the client side
|
||||
while (!client.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
// Register message handler
|
||||
client.on("message", (message: ProtocolMessage) => {
|
||||
handleMessage(message, client, state, options.sign ?? false);
|
||||
});
|
||||
|
||||
// Send initial dapp_ready
|
||||
await sendDappReady(client, state);
|
||||
});
|
||||
|
||||
// ---- Keep alive ----
|
||||
console.log(chalk.dim("Press Ctrl+C to quit\n"));
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
keySpinner.stop();
|
||||
console.log(chalk.yellow("\nShutting down..."));
|
||||
dappRelay.cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
}
|
||||
151
packages/test-cli/src/wallet.ts
Normal file
151
packages/test-cli/src/wallet.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import {
|
||||
WalletConnectionManager,
|
||||
type WalletAdapter,
|
||||
DerivationPath,
|
||||
} from "@wizardconnect/wallet";
|
||||
import { generateKeyExchangeCredentials, hexToBin } from "@wizardconnect/core";
|
||||
import {
|
||||
deriveHdPrivateNodeFromSeed,
|
||||
deriveHdPrivateNodeChild,
|
||||
deriveHdPath,
|
||||
deriveHdPublicNode,
|
||||
encodeHdPublicKey,
|
||||
secp256k1,
|
||||
} from "@bitauth/libauth";
|
||||
|
||||
// ---- Build WalletAdapter ----
|
||||
|
||||
async function buildAdapter(
|
||||
relayPrivateKey: Uint8Array,
|
||||
): Promise<WalletAdapter> {
|
||||
// Use relay private key as HD seed for deterministic derivation
|
||||
const hdMaster = deriveHdPrivateNodeFromSeed(relayPrivateKey);
|
||||
|
||||
const hdMain = deriveHdPath(hdMaster, "m/0") as any;
|
||||
|
||||
const hdChange = deriveHdPath(hdMaster, "m/1") as any;
|
||||
|
||||
const hdDefi = deriveHdPath(hdMaster, "m/7") as any;
|
||||
|
||||
return {
|
||||
walletName: "wiz-test CLI wallet",
|
||||
walletIcon: "",
|
||||
|
||||
getRelayPrivateKey(): Uint8Array {
|
||||
return relayPrivateKey;
|
||||
},
|
||||
|
||||
getPublicKey(path: DerivationPath, index: bigint): Uint8Array {
|
||||
const hdChain =
|
||||
(path as number) === 1
|
||||
? hdChange
|
||||
: (path as number) === 7
|
||||
? hdDefi
|
||||
: hdMain;
|
||||
const child = deriveHdPrivateNodeChild(hdChain, Number(index));
|
||||
const pubKey = secp256k1.derivePublicKeyCompressed(child.privateKey);
|
||||
if (typeof pubKey === "string") throw new Error(`secp256k1: ${pubKey}`);
|
||||
return pubKey;
|
||||
},
|
||||
|
||||
getXpub(path: DerivationPath): string {
|
||||
const hdChain =
|
||||
(path as number) === 1
|
||||
? hdChange
|
||||
: (path as number) === 7
|
||||
? hdDefi
|
||||
: hdMain;
|
||||
const result = encodeHdPublicKey({
|
||||
node: deriveHdPublicNode(hdChain),
|
||||
network: "mainnet",
|
||||
});
|
||||
if (typeof result === "string")
|
||||
throw new Error(`encodeHdPublicKey: ${result}`);
|
||||
return result.hdPublicKey;
|
||||
},
|
||||
|
||||
async signTransaction(_request): Promise<any> {
|
||||
throw new Error("signTransaction not implemented in test wallet");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
|
||||
export async function runWalletMode(options: {
|
||||
relay: string;
|
||||
uri: string;
|
||||
privateKey?: string;
|
||||
}): Promise<void> {
|
||||
console.log(chalk.bold("\nwiz-test wallet mode"));
|
||||
console.log(chalk.dim(`relay: ${options.relay}`));
|
||||
console.log(chalk.dim(`uri: ${options.uri}`));
|
||||
console.log();
|
||||
|
||||
// Derive relay private key
|
||||
let relayPrivKey: Uint8Array;
|
||||
if (options.privateKey) {
|
||||
if (options.privateKey.length !== 64) {
|
||||
console.error(chalk.red("--private-key must be 64 hex chars"));
|
||||
process.exit(1);
|
||||
}
|
||||
relayPrivKey = hexToBin(options.privateKey);
|
||||
} else {
|
||||
const creds = generateKeyExchangeCredentials();
|
||||
relayPrivKey = hexToBin(creds.privateKey);
|
||||
console.log(chalk.dim(`generated relay key: ${creds.privateKey}`));
|
||||
}
|
||||
|
||||
const buildSpinner = ora("Building test wallet adapter...").start();
|
||||
const adapter = await buildAdapter(relayPrivKey);
|
||||
buildSpinner.succeed("Wallet adapter ready");
|
||||
|
||||
const manager = new WalletConnectionManager(adapter);
|
||||
|
||||
manager.on("connectionStatusChanged", (connectionId, status) => {
|
||||
const s = status.status;
|
||||
if (s === "connected") {
|
||||
console.log(chalk.green(`connection ${connectionId}: connected`));
|
||||
} else if (s === "reconnecting") {
|
||||
console.log(chalk.yellow(`connection ${connectionId}: reconnecting...`));
|
||||
} else if (s === "disconnected") {
|
||||
console.log(chalk.red(`connection ${connectionId}: disconnected`));
|
||||
}
|
||||
});
|
||||
|
||||
manager.on("pendingSignRequest", (request) => {
|
||||
console.log(
|
||||
chalk.yellow("← sign_request") +
|
||||
chalk.dim(
|
||||
` conn=${request.connectionId} seq=${request.request.sequence}`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)"));
|
||||
manager
|
||||
.sendSignError(
|
||||
request.connectionId,
|
||||
request.request.sequence,
|
||||
"Test wallet cannot sign",
|
||||
)
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
console.log(chalk.dim("\nConnecting to dapp..."));
|
||||
const connectionId = manager.connect(options.uri);
|
||||
console.log(chalk.dim(`connection id: ${connectionId}`));
|
||||
console.log(chalk.dim("Press Ctrl+C to quit\n"));
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log(chalk.yellow("\nShutting down..."));
|
||||
manager.disconnectAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
await new Promise(() => {});
|
||||
}
|
||||
8
packages/test-cli/tsconfig.json
Normal file
8
packages/test-cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
31
packages/wallet/package.json
Normal file
31
packages/wallet/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@wizardconnect/wallet",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Wallet-side integration helpers for WizardConnect",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
|
||||
"test:integration": "vitest --config vitest.integration.config.ts --run",
|
||||
"lint:prettier": "prettier . --list-different",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint": "npm run lint:eslint && npm run lint:prettier",
|
||||
"fix": "npm run fix:eslint && npm run fix:prettier",
|
||||
"fix:prettier": "prettier . --write",
|
||||
"fix:eslint": "npm run lint:eslint -- --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
33
packages/wallet/src/derivation-path.ts
Normal file
33
packages/wallet/src/derivation-path.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* BCH HD wallet derivation paths.
|
||||
* child_index values as used in hdwalletv1 protocol:
|
||||
* 0 = Receive (m/44'/145'/0'/0)
|
||||
* 1 = Change (m/44'/145'/0'/1)
|
||||
* 7 = Cauldron (m/44'/145'/0'/7)
|
||||
*/
|
||||
export enum DerivationPath {
|
||||
Receive = 0,
|
||||
Change = 1,
|
||||
Cauldron = 7,
|
||||
}
|
||||
|
||||
export function childIndexOfPath(path: DerivationPath): number {
|
||||
return path as number;
|
||||
}
|
||||
|
||||
export function pathOfChildIndex(child: number): DerivationPath | undefined {
|
||||
switch (child) {
|
||||
case 0:
|
||||
return DerivationPath.Receive;
|
||||
case 1:
|
||||
return DerivationPath.Change;
|
||||
case 7:
|
||||
return DerivationPath.Cauldron;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
16
packages/wallet/src/index.ts
Normal file
16
packages/wallet/src/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
export {
|
||||
DerivationPath,
|
||||
childIndexOfPath,
|
||||
pathOfChildIndex,
|
||||
} from "./derivation-path.js";
|
||||
export type { WalletAdapter, SignTransactionResult } from "./wallet-adapter.js";
|
||||
export { WalletConnectionManager } from "./wallet-connection-manager.js";
|
||||
export type {
|
||||
RelayConnectionState,
|
||||
PendingSignRequest,
|
||||
WalletConnectionManagerEvents,
|
||||
} from "./wallet-connection-manager.js";
|
||||
148
packages/wallet/src/integration/disconnect.test.ts
Normal file
148
packages/wallet/src/integration/disconnect.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Disconnect tests — verifies the disconnect message flow.
|
||||
*
|
||||
* Test: user_disconnect — dapp sends a raw DisconnectMessage; wallet emits remoteDisconnect.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
DisconnectReason,
|
||||
type RelayUpdatePayload,
|
||||
type RelayClient,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type DisconnectMessage,
|
||||
type ProtocolMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletConnectionManager } from "@wizardconnect/wallet";
|
||||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
describe("WalletConnectionManager — disconnect", () => {
|
||||
let dappCleanup: () => void;
|
||||
let dappClient: RelayClient | null = null;
|
||||
let dappUri: string;
|
||||
|
||||
let manager: WalletConnectionManager;
|
||||
let connectionId: string;
|
||||
|
||||
let walletReadyReceived = false;
|
||||
|
||||
const remoteDisconnectEvents: Array<{
|
||||
connectionId: string;
|
||||
reason: DisconnectReason;
|
||||
message: string | undefined;
|
||||
}> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// ---- Dapp setup ----
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
if (payload.client && !dappClient) dappClient = payload.client;
|
||||
},
|
||||
{ explicitRelayUrls: [TEST_RELAY_URL] },
|
||||
);
|
||||
|
||||
dappUri = dappRelay.uri;
|
||||
dappCleanup = dappRelay.cleanup;
|
||||
|
||||
// Wait for the dapp relay client to be available
|
||||
await waitFor(() => dappClient !== null, {
|
||||
timeoutMs: 10000,
|
||||
what: "dapp relay client",
|
||||
});
|
||||
|
||||
// Register keyexchange handler BEFORE wallet connects to avoid race condition
|
||||
dappRelay.events.on("keyexchangecomplete", async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (!dappClient!.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
dappClient!.on("message", (msg: ProtocolMessage) => {
|
||||
if (msg.action === RelayMsgAction.WalletReady) {
|
||||
walletReadyReceived = true;
|
||||
const wr = msg as WalletReadyMessage;
|
||||
if (!wr.dapp_discovered) {
|
||||
const reply: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: true,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
dappClient!.relay(reply).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const initMsg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: false,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(initMsg);
|
||||
});
|
||||
|
||||
// ---- Wallet side (connect AFTER registering dapp handlers) ----
|
||||
const adapter = makeTestAdapter();
|
||||
manager = new WalletConnectionManager(adapter);
|
||||
|
||||
manager.on("remoteDisconnect", (id, reason, message) => {
|
||||
remoteDisconnectEvents.push({ connectionId: id, reason, message });
|
||||
});
|
||||
|
||||
connectionId = manager.connect(dappUri);
|
||||
|
||||
// Wait for full handshake (wallet_ready received on dapp side)
|
||||
await waitFor(() => walletReadyReceived, {
|
||||
timeoutMs: 15000,
|
||||
what: "wallet_ready received on dapp side",
|
||||
});
|
||||
|
||||
// Give a moment for the reactive dapp_ready to be processed
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
dappCleanup?.();
|
||||
manager?.disconnectAll();
|
||||
});
|
||||
|
||||
it("user_disconnect: dapp sends disconnect, wallet emits remoteDisconnect and removes connection", async () => {
|
||||
expect(Object.keys(manager.getConnections())).toContain(connectionId);
|
||||
|
||||
// Dapp sends a raw disconnect message
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
message: "test disconnect",
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(disconnectMsg);
|
||||
|
||||
// Wallet should emit remoteDisconnect
|
||||
await waitFor(() => remoteDisconnectEvents.length > 0, {
|
||||
timeoutMs: 5000,
|
||||
what: "remoteDisconnect event on wallet",
|
||||
});
|
||||
|
||||
expect(remoteDisconnectEvents[0].connectionId).toBe(connectionId);
|
||||
expect(remoteDisconnectEvents[0].reason).toBe(
|
||||
DisconnectReason.UserDisconnect,
|
||||
);
|
||||
expect(remoteDisconnectEvents[0].message).toBe("test disconnect");
|
||||
|
||||
// Connection should be removed from manager
|
||||
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
|
||||
}, 15000);
|
||||
});
|
||||
233
packages/wallet/src/integration/helpers.ts
Normal file
233
packages/wallet/src/integration/helpers.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Shared test helpers for integration tests.
|
||||
*
|
||||
* Architecture: the wallet side uses WalletConnectionManager (our package).
|
||||
* The dapp side is implemented manually using initiateDappRelay from core.
|
||||
*/
|
||||
|
||||
import {
|
||||
generateRandomBytes,
|
||||
deriveHdPrivateNodeFromSeed,
|
||||
deriveHdPrivateNodeChild,
|
||||
deriveHdPath,
|
||||
deriveHdPublicNode,
|
||||
encodeHdPublicKey,
|
||||
secp256k1,
|
||||
} from "@bitauth/libauth";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
type RelayClient,
|
||||
type RelayUpdatePayload,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type ProtocolMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import {
|
||||
WalletConnectionManager,
|
||||
DerivationPath,
|
||||
type WalletAdapter,
|
||||
} from "@wizardconnect/wallet";
|
||||
|
||||
// ---- waitFor ----------------------------------------------------------------
|
||||
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
options: { timeoutMs?: number; intervalMs?: number; what?: string } = {},
|
||||
): Promise<void> {
|
||||
const { timeoutMs = 15000, intervalMs = 100, what = "condition" } = options;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await condition()) return;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error(`Timed out (${timeoutMs}ms) waiting for: ${what}`);
|
||||
}
|
||||
|
||||
// ---- TestWalletAdapter ------------------------------------------------------
|
||||
|
||||
export function makeTestAdapter(seed?: Uint8Array): WalletAdapter {
|
||||
const seedBytes = seed ?? generateRandomBytes(32);
|
||||
const hdMaster = deriveHdPrivateNodeFromSeed(seedBytes);
|
||||
|
||||
const hdMain = deriveHdPath(hdMaster, "m/0") as any;
|
||||
|
||||
const hdChange = deriveHdPath(hdMaster, "m/1") as any;
|
||||
|
||||
const hdDefi = deriveHdPath(hdMaster, "m/7") as any;
|
||||
|
||||
return {
|
||||
walletName: "Test Wallet",
|
||||
walletIcon: "",
|
||||
|
||||
getRelayPrivateKey(): Uint8Array {
|
||||
return hdMaster.privateKey;
|
||||
},
|
||||
|
||||
getPublicKey(path: DerivationPath, index: bigint): Uint8Array {
|
||||
const hdChain =
|
||||
(path as number) === 1
|
||||
? hdChange
|
||||
: (path as number) === 7
|
||||
? hdDefi
|
||||
: hdMain;
|
||||
const child = deriveHdPrivateNodeChild(hdChain, Number(index));
|
||||
const pubKey = secp256k1.derivePublicKeyCompressed(child.privateKey);
|
||||
if (typeof pubKey === "string") throw new Error(pubKey);
|
||||
return pubKey;
|
||||
},
|
||||
|
||||
getXpub(path: DerivationPath): string {
|
||||
const hdChain =
|
||||
(path as number) === 1
|
||||
? hdChange
|
||||
: (path as number) === 7
|
||||
? hdDefi
|
||||
: hdMain;
|
||||
const result = encodeHdPublicKey({
|
||||
node: deriveHdPublicNode(hdChain),
|
||||
network: "mainnet",
|
||||
});
|
||||
if (typeof result === "string") throw new Error(result);
|
||||
return result.hdPublicKey;
|
||||
},
|
||||
|
||||
async signTransaction() {
|
||||
throw new Error("signTransaction not implemented in test adapter");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- DappHandle -------------------------------------------------------------
|
||||
|
||||
export interface DappHandle {
|
||||
uri: string;
|
||||
cleanup: () => void;
|
||||
/** All wallet_ready messages received. */
|
||||
walletReadyMessages: WalletReadyMessage[];
|
||||
}
|
||||
|
||||
// ---- WalletHandle -----------------------------------------------------------
|
||||
|
||||
export interface WalletHandle {
|
||||
manager: WalletConnectionManager;
|
||||
connectionId: string;
|
||||
adapter: WalletAdapter;
|
||||
}
|
||||
|
||||
// ---- ConnectionHandles ------------------------------------------------------
|
||||
|
||||
export interface ConnectionHandles {
|
||||
dapp: DappHandle;
|
||||
wallet: WalletHandle;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
// ---- setupConnection --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sets up a full dapp + wallet connection.
|
||||
*
|
||||
* Waits for:
|
||||
* 1. Key exchange to complete
|
||||
* 2. The initial dapp_ready / wallet_ready handshake
|
||||
* 3. wallet_ready with paths
|
||||
*/
|
||||
export async function setupConnection(
|
||||
relayUrl: string = process.env.TEST_RELAY_URL ??
|
||||
"wss://relay.cauldron.quest:443",
|
||||
seed?: Uint8Array,
|
||||
): Promise<ConnectionHandles> {
|
||||
const adapter = makeTestAdapter(seed);
|
||||
|
||||
// ---- Dapp side ----
|
||||
|
||||
const walletReadyMessages: WalletReadyMessage[] = [];
|
||||
let dappClient: RelayClient | null = null;
|
||||
let keyExchanged = false;
|
||||
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
if (payload.client && !dappClient) dappClient = payload.client;
|
||||
},
|
||||
{ explicitRelayUrls: [relayUrl] },
|
||||
);
|
||||
|
||||
async function sendDappReady(wd: boolean): Promise<void> {
|
||||
const msg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: wd,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(msg);
|
||||
}
|
||||
|
||||
dappRelay.events.on("keyexchangecomplete", async () => {
|
||||
keyExchanged = true;
|
||||
|
||||
// Wait for client to be ready
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
while (!dappClient!.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
// Register message handler
|
||||
dappClient!.on("message", (message: ProtocolMessage) => {
|
||||
if (message.action === RelayMsgAction.WalletReady) {
|
||||
const msg = message as WalletReadyMessage;
|
||||
walletReadyMessages.push(msg);
|
||||
if (!msg.dapp_discovered) {
|
||||
sendDappReady(true).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
|
||||
await sendDappReady(false);
|
||||
});
|
||||
|
||||
// ---- Wallet side ----
|
||||
|
||||
const manager = new WalletConnectionManager(adapter);
|
||||
const connectionId = manager.connect(dappRelay.uri);
|
||||
|
||||
// ---- Wait for key exchange ----
|
||||
|
||||
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
|
||||
|
||||
// ---- Wait for wallet_ready with paths ----
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
walletReadyMessages.length > 0 &&
|
||||
(walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length >
|
||||
0,
|
||||
{ timeoutMs: 15000, what: "wallet_ready with paths" },
|
||||
);
|
||||
|
||||
// ---- Build handle ----
|
||||
|
||||
const dappHandle: DappHandle = {
|
||||
uri: dappRelay.uri,
|
||||
cleanup: dappRelay.cleanup,
|
||||
walletReadyMessages,
|
||||
};
|
||||
|
||||
const walletHandle: WalletHandle = { manager, connectionId, adapter };
|
||||
|
||||
return {
|
||||
dapp: dappHandle,
|
||||
wallet: walletHandle,
|
||||
cleanup() {
|
||||
dappRelay.cleanup();
|
||||
manager.disconnectAll();
|
||||
},
|
||||
};
|
||||
}
|
||||
126
packages/wallet/src/integration/integration.test.ts
Normal file
126
packages/wallet/src/integration/integration.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Integration tests — basic connection, key exchange, and xpub delivery.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { decodeHdPublicKey, deriveHdPublicNodeChild } from "@bitauth/libauth";
|
||||
import {
|
||||
setupConnection,
|
||||
makeTestAdapter,
|
||||
type ConnectionHandles,
|
||||
} from "./helpers.js";
|
||||
import { DerivationPath } from "@wizardconnect/wallet";
|
||||
import type { Hdwalletv1Session } from "@wizardconnect/core";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
const PATHS = [
|
||||
{ childIndex: 0, name: "receive" },
|
||||
{ childIndex: 1, name: "change" },
|
||||
{ childIndex: 7, name: "defi" },
|
||||
];
|
||||
|
||||
// Fixed seed for deterministic tests
|
||||
const TEST_SEED = new Uint8Array(32).fill(0xab);
|
||||
|
||||
describe("WalletConnectionManager — integration", () => {
|
||||
let conn: ConnectionHandles;
|
||||
|
||||
beforeAll(async () => {
|
||||
conn = await setupConnection(TEST_RELAY_URL, TEST_SEED);
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
conn?.cleanup();
|
||||
});
|
||||
|
||||
it("should complete key exchange between dapp and wallet", () => {
|
||||
expect(conn.dapp.walletReadyMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have received wallet name in wallet_ready", () => {
|
||||
const msg = conn.dapp.walletReadyMessages[0];
|
||||
expect(msg.wallet_name).toBe("Test Wallet");
|
||||
expect(msg.supported_protocols).toContain("hdwalletv1");
|
||||
});
|
||||
|
||||
it("wallet_ready session.hdwalletv1 contains paths", () => {
|
||||
const msg = conn.dapp.walletReadyMessages[0];
|
||||
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
||||
expect(hdwv1).toBeDefined();
|
||||
expect(Array.isArray(hdwv1.paths)).toBe(true);
|
||||
expect(hdwv1.paths.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("wallet_ready contains paths for all three named paths", () => {
|
||||
const msg = conn.dapp.walletReadyMessages[0];
|
||||
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
||||
expect(Array.isArray(hdwv1.paths)).toBe(true);
|
||||
const names = hdwv1.paths.map((p) => p.name);
|
||||
for (const { name } of PATHS) {
|
||||
expect(names, `Expected path for ${name}`).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("paths have all named paths", () => {
|
||||
const msg = conn.dapp.walletReadyMessages[0];
|
||||
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
||||
for (const { name } of PATHS) {
|
||||
const pathInfo = hdwv1.paths.find((p) => p.name === name);
|
||||
expect(pathInfo, `Expected path info for ${name}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("xpubs are valid BIP32 base58", () => {
|
||||
const msg = conn.dapp.walletReadyMessages[0];
|
||||
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
||||
const xpubRe = /^[xt]pub[1-9A-HJ-NP-Za-km-z]{107,108}$/;
|
||||
for (const pathInfo of hdwv1.paths) {
|
||||
expect(pathInfo.xpub, `xpub for ${pathInfo.name}`).toMatch(xpubRe);
|
||||
}
|
||||
});
|
||||
|
||||
it("xpubs decode and derive pubkeys consistent with adapter", () => {
|
||||
const adapter = makeTestAdapter(TEST_SEED);
|
||||
const msg = conn.dapp.walletReadyMessages[0];
|
||||
const hdwv1 = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
||||
|
||||
for (const { childIndex, name } of PATHS) {
|
||||
const pathInfo = hdwv1.paths.find((p) => p.name === name)!;
|
||||
expect(pathInfo, `Expected path info for ${name}`).toBeDefined();
|
||||
|
||||
const decoded = decodeHdPublicKey(pathInfo.xpub);
|
||||
expect(
|
||||
typeof decoded,
|
||||
`xpub decode failed for ${name}: ${decoded}`,
|
||||
).not.toBe("string");
|
||||
if (typeof decoded === "string") continue;
|
||||
|
||||
// Derive index 0 and 3 from xpub, compare with adapter
|
||||
for (const i of [0, 3]) {
|
||||
const child = deriveHdPublicNodeChild(decoded.node, i);
|
||||
expect(
|
||||
typeof child,
|
||||
`child derivation failed for ${name}[${i}]`,
|
||||
).not.toBe("string");
|
||||
if (typeof child === "string") continue;
|
||||
|
||||
const fromXpub = child.publicKey;
|
||||
const fromAdapter = adapter.getPublicKey(
|
||||
childIndex as DerivationPath,
|
||||
BigInt(i),
|
||||
);
|
||||
|
||||
expect(
|
||||
Array.from(fromXpub),
|
||||
`${name}[${i}]: xpub-derived pubkey should match adapter`,
|
||||
).toEqual(Array.from(fromAdapter));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
156
packages/wallet/src/integration/reconnection.test.ts
Normal file
156
packages/wallet/src/integration/reconnection.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Reconnection tests — verifies that a second wallet with the same private key
|
||||
* can reconnect to the same dapp session and receive wallet_ready without interference
|
||||
* from old messages.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
type RelayUpdatePayload,
|
||||
type RelayClient,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type ProtocolMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletConnectionManager } from "@wizardconnect/wallet";
|
||||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
import { generateRandomBytes } from "@bitauth/libauth";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
describe("WalletConnectionManager — reconnection", () => {
|
||||
// Shared dapp that stays alive across both wallet connections
|
||||
let dappCleanup: () => void;
|
||||
let dappClient: RelayClient | null = null;
|
||||
let dappUri: string;
|
||||
|
||||
let walletReadyMessages: WalletReadyMessage[] = [];
|
||||
let walletDiscovered = false;
|
||||
|
||||
// Shared wallet relay private key — same key used for both connections
|
||||
const walletSeed = generateRandomBytes(32);
|
||||
|
||||
beforeAll(async () => {
|
||||
// ---- Dapp setup ----
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
if (payload.client && !dappClient) dappClient = payload.client;
|
||||
},
|
||||
{ explicitRelayUrls: [TEST_RELAY_URL] },
|
||||
);
|
||||
|
||||
dappUri = dappRelay.uri;
|
||||
dappCleanup = dappRelay.cleanup;
|
||||
|
||||
// Wait for dapp relay to connect
|
||||
await waitFor(() => dappClient !== null, {
|
||||
timeoutMs: 10000,
|
||||
what: "dapp relay connection",
|
||||
});
|
||||
|
||||
// Register message handler — stays alive for both wallet connections
|
||||
dappClient!.on("message", (message: ProtocolMessage) => {
|
||||
if (message.action === RelayMsgAction.WalletReady) {
|
||||
const msg = message as WalletReadyMessage;
|
||||
walletDiscovered = true;
|
||||
walletReadyMessages.push(msg);
|
||||
if (!msg.dapp_discovered) {
|
||||
sendDappReady(true).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dappRelay.events.on("keyexchangecomplete", async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (!dappClient!.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await sendDappReady(false);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
afterAll(() => {
|
||||
dappCleanup?.();
|
||||
});
|
||||
|
||||
async function sendDappReady(wd: boolean): Promise<void> {
|
||||
const msg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: wd,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(msg);
|
||||
}
|
||||
|
||||
it("first wallet connects and sends wallet_ready with paths", async () => {
|
||||
const adapter1 = makeTestAdapter(walletSeed);
|
||||
const manager1 = new WalletConnectionManager(adapter1);
|
||||
manager1.connect(dappUri);
|
||||
|
||||
// Wait for first wallet_ready with paths
|
||||
await waitFor(
|
||||
() =>
|
||||
walletReadyMessages.length > 0 &&
|
||||
(walletReadyMessages[0].session?.["hdwalletv1"] as any)?.paths?.length >
|
||||
0,
|
||||
{
|
||||
timeoutMs: 15000,
|
||||
what: "wallet_ready with paths from first connection",
|
||||
},
|
||||
);
|
||||
|
||||
expect(walletReadyMessages.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
(walletReadyMessages[0].session["hdwalletv1"] as any).paths.length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(walletDiscovered).toBe(true);
|
||||
|
||||
// Disconnect first wallet
|
||||
manager1.disconnectAll();
|
||||
|
||||
// Short pause to let disconnect propagate
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
|
||||
// Store count for second connection comparison
|
||||
(globalThis as any).__walletReadyCountAfterFirst =
|
||||
walletReadyMessages.length;
|
||||
}, 30000);
|
||||
|
||||
it("second wallet with same private key reconnects and sends wallet_ready", async () => {
|
||||
const countBefore: number =
|
||||
(globalThis as any).__walletReadyCountAfterFirst ?? 0;
|
||||
|
||||
// Reset walletDiscovered to verify second handshake completes
|
||||
walletDiscovered = false;
|
||||
|
||||
const adapter2 = makeTestAdapter(walletSeed); // same seed = same relay private key
|
||||
const manager2 = new WalletConnectionManager(adapter2);
|
||||
manager2.connect(dappUri);
|
||||
|
||||
// Wait for second connection to deliver wallet_ready
|
||||
await waitFor(() => walletReadyMessages.length > countBefore, {
|
||||
timeoutMs: 15000,
|
||||
what: "wallet_ready from second connection",
|
||||
});
|
||||
|
||||
expect(walletReadyMessages.length).toBeGreaterThan(countBefore);
|
||||
expect(walletDiscovered).toBe(true);
|
||||
|
||||
// Verify the new wallet_ready has paths
|
||||
const newMsg = walletReadyMessages[walletReadyMessages.length - 1];
|
||||
expect((newMsg.session["hdwalletv1"] as any).paths.length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
|
||||
manager2.disconnectAll();
|
||||
}, 30000);
|
||||
});
|
||||
144
packages/wallet/src/integration/sign-cancel.test.ts
Normal file
144
packages/wallet/src/integration/sign-cancel.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* sign_cancel tests — verifies that an incoming sign_cancel from the dapp
|
||||
* causes the WalletConnectionManager to emit a signCancelled event.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
type RelayUpdatePayload,
|
||||
type RelayClient,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
type ProtocolMessage,
|
||||
type SignCancelMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletConnectionManager } from "@wizardconnect/wallet";
|
||||
import { makeTestAdapter, waitFor } from "./helpers.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.cauldron.quest:443";
|
||||
|
||||
describe("WalletConnectionManager — sign_cancel", () => {
|
||||
let dappCleanup: () => void;
|
||||
let dappClient: RelayClient | null = null;
|
||||
|
||||
let manager: WalletConnectionManager;
|
||||
let connectionId: string;
|
||||
|
||||
let walletReadyReceived = false;
|
||||
|
||||
const signCancelledEvents: Array<{
|
||||
connectionId: string;
|
||||
sequence: number;
|
||||
reason: string | undefined;
|
||||
}> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
// ---- Dapp setup ----
|
||||
const dappRelay = initiateDappRelay(
|
||||
(payload: RelayUpdatePayload) => {
|
||||
if (payload.client && !dappClient) dappClient = payload.client;
|
||||
},
|
||||
{ explicitRelayUrls: [TEST_RELAY_URL] },
|
||||
);
|
||||
|
||||
dappCleanup = dappRelay.cleanup;
|
||||
|
||||
dappRelay.events.on("keyexchangecomplete", async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (!dappClient!.isKeyExchangeComplete()) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
dappClient!.on("message", (msg: ProtocolMessage) => {
|
||||
if (msg.action === RelayMsgAction.WalletReady) {
|
||||
walletReadyReceived = true;
|
||||
const wr = msg as WalletReadyMessage;
|
||||
if (!wr.dapp_discovered) {
|
||||
const reply: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: true,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
dappClient!.relay(reply).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const initMsg: DappReadyMessage = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: false,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(initMsg);
|
||||
});
|
||||
|
||||
// ---- Wallet side ----
|
||||
const adapter = makeTestAdapter();
|
||||
manager = new WalletConnectionManager(adapter);
|
||||
|
||||
manager.on("signCancelled", (connId, sequence, reason) => {
|
||||
signCancelledEvents.push({ connectionId: connId, sequence, reason });
|
||||
});
|
||||
|
||||
connectionId = manager.connect(dappRelay.uri);
|
||||
|
||||
await waitFor(() => walletReadyReceived, {
|
||||
timeoutMs: 15000,
|
||||
what: "wallet_ready received on dapp side",
|
||||
});
|
||||
|
||||
// Give time for reactive dapp_ready to be processed
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
dappCleanup?.();
|
||||
manager?.disconnectAll();
|
||||
});
|
||||
|
||||
it("sign_cancel from dapp emits signCancelled on wallet with correct fields", async () => {
|
||||
const cancelMsg: SignCancelMessage = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 42,
|
||||
reason: "user cancelled on dapp",
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(cancelMsg);
|
||||
|
||||
await waitFor(() => signCancelledEvents.length > 0, {
|
||||
timeoutMs: 5000,
|
||||
what: "signCancelled event on wallet",
|
||||
});
|
||||
|
||||
expect(signCancelledEvents[0].connectionId).toBe(connectionId);
|
||||
expect(signCancelledEvents[0].sequence).toBe(42);
|
||||
expect(signCancelledEvents[0].reason).toBe("user cancelled on dapp");
|
||||
}, 15000);
|
||||
|
||||
it("sign_cancel without reason emits signCancelled with undefined reason", async () => {
|
||||
const cancelMsg: SignCancelMessage = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 99,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await dappClient!.relay(cancelMsg);
|
||||
|
||||
await waitFor(() => signCancelledEvents.length >= 2, {
|
||||
timeoutMs: 5000,
|
||||
what: "second signCancelled event on wallet",
|
||||
});
|
||||
|
||||
expect(signCancelledEvents[1].sequence).toBe(99);
|
||||
expect(signCancelledEvents[1].reason).toBeUndefined();
|
||||
}, 15000);
|
||||
});
|
||||
48
packages/wallet/src/wallet-adapter.ts
Normal file
48
packages/wallet/src/wallet-adapter.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
import { SignTransactionRequest } from "@wizardconnect/core";
|
||||
|
||||
export interface SignTransactionResult {
|
||||
signedTransaction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract interface that wallets implement to integrate with WizardConnect.
|
||||
*/
|
||||
export interface WalletAdapter {
|
||||
/** Human-readable wallet name shown to dapps */
|
||||
walletName: string;
|
||||
/** URL to wallet icon (used in wallet_ready handshake) */
|
||||
walletIcon: string;
|
||||
|
||||
/**
|
||||
* Return the relay identity private key for this session.
|
||||
* May be ephemeral (random per session) or stable (HD-derived) — both work.
|
||||
* The dapp learns the wallet's public key dynamically via wallet_ready
|
||||
* so stability across restarts is not required.
|
||||
*/
|
||||
getRelayPrivateKey(): Uint8Array;
|
||||
|
||||
/**
|
||||
* Derive the compressed public key (33 bytes) for a given HD path and index.
|
||||
*/
|
||||
getPublicKey(path: DerivationPath, index: bigint): Uint8Array;
|
||||
|
||||
/**
|
||||
* BIP32 xpub string for the given derivation path.
|
||||
* Used to allow dapps to derive all needed pubkeys locally without round-trips.
|
||||
*/
|
||||
getXpub(path: DerivationPath): string;
|
||||
|
||||
/**
|
||||
* Sign a transaction request from a dapp.
|
||||
* The wallet should verify the request and return the signed transaction hex,
|
||||
* or throw if the user rejects or signing fails.
|
||||
*/
|
||||
signTransaction(
|
||||
request: SignTransactionRequest,
|
||||
): Promise<SignTransactionResult>;
|
||||
}
|
||||
457
packages/wallet/src/wallet-connection-manager.ts
Normal file
457
packages/wallet/src/wallet-connection-manager.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import {
|
||||
RelayClient,
|
||||
RelayStatus,
|
||||
RelayUpdatePayload,
|
||||
RelayStatusCallback,
|
||||
initiateWalletRelay,
|
||||
SignTransactionRequest,
|
||||
SignTransactionResponse,
|
||||
SignCancelMessage,
|
||||
RelayMsgAction,
|
||||
DappReadyMessage,
|
||||
WalletReadyMessage,
|
||||
DisconnectMessage,
|
||||
DisconnectReason,
|
||||
PathXpub,
|
||||
Hdwalletv1Session,
|
||||
ProtocolMessage,
|
||||
PROTOCOL_NAME,
|
||||
binToHex,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
|
||||
export interface RelayConnectionState {
|
||||
id: string;
|
||||
uri: string;
|
||||
status: RelayStatus;
|
||||
label: string;
|
||||
dappName: string | null;
|
||||
dappIcon: string | null;
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
export interface PendingSignRequest {
|
||||
connectionId: string;
|
||||
request: SignTransactionRequest;
|
||||
}
|
||||
|
||||
interface ActiveConnection {
|
||||
id: string;
|
||||
uri: string;
|
||||
cleanup: () => void;
|
||||
client: RelayClient | null;
|
||||
status: RelayStatus;
|
||||
label: string;
|
||||
dappName: string | null;
|
||||
dappIcon: string | null;
|
||||
connectedAt: number;
|
||||
dappDiscovered: boolean;
|
||||
/// Prevents duplicate wallet_ready messages within a single connection cycle.
|
||||
/// Reset to false on each new connect/reconnect; set to true after sending.
|
||||
walletReadySentThisCycle: boolean;
|
||||
notificationQueue: ProtocolMessage[];
|
||||
notificationProcessor: ReturnType<typeof setInterval> | null;
|
||||
/// Key exchange data embedded in wallet_ready
|
||||
walletPublicKeyHex: string;
|
||||
keyExchangeSecret: string;
|
||||
}
|
||||
|
||||
export type WalletConnectionManagerEvents = {
|
||||
connectionStatusChanged: [connectionId: string, status: RelayStatus];
|
||||
pendingSignRequest: [request: PendingSignRequest];
|
||||
connectionsChanged: [];
|
||||
remoteDisconnect: [
|
||||
connectionId: string,
|
||||
reason: DisconnectReason,
|
||||
message: string | undefined,
|
||||
];
|
||||
signCancelled: [
|
||||
connectionId: string,
|
||||
sequence: number,
|
||||
reason: string | undefined,
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages multiple simultaneous dapp connections for a wallet.
|
||||
* Handles sign request queuing and reconnection.
|
||||
*/
|
||||
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
|
||||
private connections: Map<string, ActiveConnection> = new Map();
|
||||
private adapter: WalletAdapter;
|
||||
|
||||
constructor(adapter: WalletAdapter) {
|
||||
super();
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a dapp using a wiz:// URI.
|
||||
* Returns the connection ID.
|
||||
*/
|
||||
connect(uri: string): string {
|
||||
// Return existing connection if one for this URI is already active
|
||||
for (const conn of this.connections.values()) {
|
||||
if (conn.uri === uri) {
|
||||
return conn.id;
|
||||
}
|
||||
}
|
||||
|
||||
const id = `rc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const conn: ActiveConnection = {
|
||||
id,
|
||||
uri,
|
||||
cleanup: () => {},
|
||||
client: null,
|
||||
status: RelayStatus.disconnected(),
|
||||
label: "Connecting...",
|
||||
dappName: null,
|
||||
dappIcon: null,
|
||||
connectedAt: Date.now(),
|
||||
dappDiscovered: false,
|
||||
walletReadySentThisCycle: false,
|
||||
notificationQueue: [],
|
||||
notificationProcessor: null,
|
||||
walletPublicKeyHex: "",
|
||||
keyExchangeSecret: "",
|
||||
};
|
||||
|
||||
this.connections.set(id, conn);
|
||||
|
||||
const statusCallback: RelayStatusCallback = (
|
||||
payload: RelayUpdatePayload,
|
||||
) => {
|
||||
// Attach message listener exactly once, the first time we receive the client.
|
||||
if (!conn.client && payload.client) {
|
||||
payload.client.on("message", (message: ProtocolMessage) => {
|
||||
this.handleMessage(conn, message).catch((err) => {
|
||||
console.error(
|
||||
"[wizardconnect/wallet] Error handling message:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
conn.client = payload.client;
|
||||
conn.status = payload.status;
|
||||
|
||||
if (payload.status.status === "connected") {
|
||||
this.onConnected(conn);
|
||||
} else if (
|
||||
payload.status.status === "disconnected" ||
|
||||
payload.status.status === "reconnecting"
|
||||
) {
|
||||
if (conn.notificationProcessor) {
|
||||
clearInterval(conn.notificationProcessor);
|
||||
conn.notificationProcessor = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.emit("connectionStatusChanged", id, payload.status);
|
||||
this.emit("connectionsChanged");
|
||||
};
|
||||
|
||||
const result = initiateWalletRelay(statusCallback, {
|
||||
uri,
|
||||
walletPrivateKey: this.adapter.getRelayPrivateKey(),
|
||||
});
|
||||
|
||||
conn.cleanup = result.cleanup;
|
||||
conn.walletPublicKeyHex = binToHex(result.walletPublicKey);
|
||||
conn.keyExchangeSecret = result.secret;
|
||||
|
||||
this.emit("connectionsChanged");
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a specific dapp connection, sending a courtesy UserDisconnect message.
|
||||
*/
|
||||
disconnect(connectionId: string): void {
|
||||
this.doDisconnect(connectionId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all connections.
|
||||
*/
|
||||
disconnectAll(): void {
|
||||
for (const id of [...this.connections.keys()]) {
|
||||
this.doDisconnect(id, true);
|
||||
}
|
||||
}
|
||||
|
||||
private doDisconnect(connectionId: string, sendMessage: boolean): void {
|
||||
const conn = this.connections.get(connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
if (sendMessage && conn.client) {
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
conn.client.relay(disconnectMsg).catch(() => {});
|
||||
}
|
||||
|
||||
clearInterval(conn.notificationProcessor ?? undefined);
|
||||
conn.cleanup();
|
||||
this.connections.delete(connectionId);
|
||||
this.emit("connectionsChanged");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connection states (for UI/Redux).
|
||||
*/
|
||||
getConnections(): Record<string, RelayConnectionState> {
|
||||
const result: Record<string, RelayConnectionState> = {};
|
||||
for (const [id, conn] of this.connections) {
|
||||
result[id] = {
|
||||
id: conn.id,
|
||||
uri: conn.uri,
|
||||
status: conn.status,
|
||||
label: conn.label,
|
||||
dappName: conn.dappName,
|
||||
dappIcon: conn.dappIcon,
|
||||
connectedAt: conn.connectedAt,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sign transaction response back to the dapp.
|
||||
*/
|
||||
async sendSignResponse(
|
||||
connectionId: string,
|
||||
sequence: number,
|
||||
signedTransactionHex: string,
|
||||
): Promise<void> {
|
||||
const conn = this.connections.get(connectionId);
|
||||
if (!conn?.client) {
|
||||
throw new Error(`Connection ${connectionId} not found or not connected`);
|
||||
}
|
||||
|
||||
const response: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence,
|
||||
signedTransaction: signedTransactionHex,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
await conn.client.relay(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a sign error response back to the dapp.
|
||||
*/
|
||||
async sendSignError(
|
||||
connectionId: string,
|
||||
sequence: number,
|
||||
errorMessage: string,
|
||||
): Promise<void> {
|
||||
const conn = this.connections.get(connectionId);
|
||||
if (!conn?.client) {
|
||||
return; // Already disconnected, nothing to do
|
||||
}
|
||||
|
||||
const response: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence,
|
||||
signedTransaction: "",
|
||||
error: errorMessage,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
await conn.client.relay(response);
|
||||
}
|
||||
|
||||
// --- Private connection lifecycle ---
|
||||
|
||||
/** Immediately attempt to flush the notification queue (fire-and-forget). */
|
||||
private flushNotificationQueue(conn: ActiveConnection): void {
|
||||
this.processNotificationQueue(conn).catch((err) => {
|
||||
console.error("[wizardconnect/wallet] Notification queue error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
private onConnected(conn: ActiveConnection): void {
|
||||
// New connection cycle: reset dedup flag so wallet_ready is sent fresh
|
||||
conn.walletReadySentThisCycle = false;
|
||||
|
||||
// Start notification processor as fallback for retries after send errors
|
||||
if (conn.notificationProcessor) {
|
||||
clearInterval(conn.notificationProcessor);
|
||||
}
|
||||
conn.notificationProcessor = setInterval(() => {
|
||||
this.processNotificationQueue(conn).catch((err) => {
|
||||
console.error("[wizardconnect/wallet] Notification queue error:", err);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Wait for key exchange, then send wallet_ready
|
||||
(async () => {
|
||||
while (conn.client && !conn.client.isKeyExchangeComplete()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (conn.client) {
|
||||
this.pushWalletReady(conn).catch((err) => {
|
||||
console.error(
|
||||
"[wizardconnect/wallet] Failed to send wallet_ready:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async handleMessage(
|
||||
conn: ActiveConnection,
|
||||
message: ProtocolMessage,
|
||||
): Promise<void> {
|
||||
switch (message.action) {
|
||||
case RelayMsgAction.DappReady:
|
||||
await this.handleDappReady(conn, message as DappReadyMessage);
|
||||
break;
|
||||
case RelayMsgAction.SignTransactionRequest:
|
||||
this.handleSignRequest(conn, message as SignTransactionRequest);
|
||||
break;
|
||||
case RelayMsgAction.WalletReady:
|
||||
console.warn(
|
||||
"[wizardconnect/wallet] Got wallet_ready as wallet, ignoring",
|
||||
);
|
||||
break;
|
||||
case RelayMsgAction.Disconnect:
|
||||
this.handleRemoteDisconnect(conn, message as DisconnectMessage);
|
||||
break;
|
||||
case RelayMsgAction.SignCancel:
|
||||
this.handleSignCancel(conn, message as SignCancelMessage);
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
"[wizardconnect/wallet] Unknown message action:",
|
||||
message.action,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteDisconnect(
|
||||
conn: ActiveConnection,
|
||||
msg: DisconnectMessage,
|
||||
): void {
|
||||
this.emit("remoteDisconnect", conn.id, msg.reason, msg.message);
|
||||
this.doDisconnect(conn.id, false);
|
||||
}
|
||||
|
||||
private handleSignCancel(
|
||||
conn: ActiveConnection,
|
||||
msg: SignCancelMessage,
|
||||
): void {
|
||||
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
|
||||
}
|
||||
|
||||
private async handleDappReady(
|
||||
conn: ActiveConnection,
|
||||
msg: DappReadyMessage,
|
||||
): Promise<void> {
|
||||
// Log the dapp's protocol selection for diagnostics
|
||||
if (msg.selected_protocol) {
|
||||
console.debug(
|
||||
"[wizardconnect/wallet] Dapp selected protocol:",
|
||||
msg.selected_protocol,
|
||||
);
|
||||
}
|
||||
|
||||
// Update dapp metadata from the first dapp_ready that carries it
|
||||
if (msg.dapp_name && !conn.dappName) {
|
||||
conn.dappName = msg.dapp_name;
|
||||
conn.dappIcon = msg.dapp_icon ?? null;
|
||||
conn.label = msg.dapp_name;
|
||||
this.emit("connectionStatusChanged", conn.id, conn.status);
|
||||
}
|
||||
|
||||
if (!msg.wallet_discovered) {
|
||||
// Dapp hasn't seen us yet (or has reset, e.g. browser refresh) — force
|
||||
// re-introduction even if we already sent wallet_ready this cycle.
|
||||
conn.walletReadySentThisCycle = false;
|
||||
await this.pushWalletReady(conn);
|
||||
return;
|
||||
}
|
||||
|
||||
conn.dappDiscovered = true;
|
||||
}
|
||||
|
||||
private async pushWalletReady(conn: ActiveConnection): Promise<void> {
|
||||
if (conn.walletReadySentThisCycle) return;
|
||||
conn.walletReadySentThisCycle = true;
|
||||
|
||||
const paths: PathXpub[] = [
|
||||
{ name: "receive", xpub: this.adapter.getXpub(DerivationPath.Receive) },
|
||||
{ name: "change", xpub: this.adapter.getXpub(DerivationPath.Change) },
|
||||
{ name: "defi", xpub: this.adapter.getXpub(DerivationPath.Cauldron) },
|
||||
];
|
||||
|
||||
const hdwv1Session: Hdwalletv1Session = {
|
||||
paths,
|
||||
};
|
||||
|
||||
const msg: WalletReadyMessage = {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_name: this.adapter.walletName,
|
||||
wallet_icon: this.adapter.walletIcon,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
dapp_discovered: conn.dappDiscovered,
|
||||
session: {
|
||||
[PROTOCOL_NAME]: hdwv1Session,
|
||||
},
|
||||
public_key: conn.walletPublicKeyHex,
|
||||
secret: conn.keyExchangeSecret,
|
||||
};
|
||||
|
||||
conn.notificationQueue.push(msg);
|
||||
this.flushNotificationQueue(conn);
|
||||
}
|
||||
|
||||
private handleSignRequest(
|
||||
conn: ActiveConnection,
|
||||
msg: SignTransactionRequest,
|
||||
): void {
|
||||
// Emit to host app for queuing/approval
|
||||
const pendingRequest: PendingSignRequest = {
|
||||
connectionId: conn.id,
|
||||
request: msg,
|
||||
};
|
||||
this.emit("pendingSignRequest", pendingRequest);
|
||||
}
|
||||
|
||||
private async processNotificationQueue(
|
||||
conn: ActiveConnection,
|
||||
): Promise<void> {
|
||||
if (!conn.client || conn.notificationQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = [...conn.notificationQueue];
|
||||
conn.notificationQueue = [];
|
||||
|
||||
for (const notification of notifications) {
|
||||
try {
|
||||
await conn.client.relay(notification);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wizardconnect/wallet] Failed to send notification:",
|
||||
error,
|
||||
);
|
||||
conn.notificationQueue.push(notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
packages/wallet/tsconfig.json
Normal file
15
packages/wallet/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"src/integration/**"
|
||||
]
|
||||
}
|
||||
12
packages/wallet/vitest.config.ts
Normal file
12
packages/wallet/vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Exclude integration tests from the normal fast test run
|
||||
exclude: ["src/integration/**", "**/node_modules/**"],
|
||||
},
|
||||
});
|
||||
18
packages/wallet/vitest.integration.config.ts
Normal file
18
packages/wallet/vitest.integration.config.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/integration/**/*.test.ts"],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
// Run integration tests serially to avoid relay contention
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
forks: { singleFork: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
18
tsconfig.base.json
Normal file
18
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"target": "es2022",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext", "es2020", "esnext.bigint"],
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true
|
||||
}
|
||||
}
|
||||
39
zensical.toml
Normal file
39
zensical.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
[project]
|
||||
site_name = "WizardConnect"
|
||||
site_description = "Relay-based protocol for connecting dapps to HD wallets over encrypted Nostr channel."
|
||||
|
||||
nav = [
|
||||
{ "Home" = "index.md" },
|
||||
{ "Protocol" = "protocol.md" },
|
||||
{ "Connection URI" = "connection-uri.md" },
|
||||
{ "Transport" = "transport.md" },
|
||||
{ "Wallet" = "wallet.md" },
|
||||
{ "Dapp" = "dapp.md" },
|
||||
{ "Pubkey Derivation" = "pubkey-derivation.md" },
|
||||
]
|
||||
|
||||
[project.theme]
|
||||
|
||||
features = [
|
||||
"content.code.copy",
|
||||
"content.code.select",
|
||||
"content.tooltips",
|
||||
"navigation.footer",
|
||||
"navigation.instant",
|
||||
"navigation.instant.prefetch",
|
||||
"navigation.path",
|
||||
"navigation.sections",
|
||||
"navigation.top",
|
||||
"navigation.tracking",
|
||||
"search.highlight",
|
||||
]
|
||||
|
||||
[[project.theme.palette]]
|
||||
scheme = "default"
|
||||
toggle.icon = "lucide/sun"
|
||||
toggle.name = "Switch to dark mode"
|
||||
|
||||
[[project.theme.palette]]
|
||||
scheme = "slate"
|
||||
toggle.icon = "lucide/moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
Loading…
Add table
Reference in a new issue