Compare commits

..

4 commits

Author SHA1 Message Date
Jakob Notland
52d710dbb5 Lint - also gonna leave this branch and probably drop it. Dagur has a design plan. 2026-04-21 14:19:42 +02:00
Jakob Notland
cdd357cb08 Accept change 2026-04-21 14:10:33 +02:00
Jakob Notland
318d97439f Integration test 2026-04-21 13:27:35 +02:00
Jakob Notland
538513246b Enable backwards compatible chunking 2026-04-21 12:49:17 +02:00
33 changed files with 958 additions and 2410 deletions

View file

@ -21,12 +21,8 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
**Integration tests** (`npm run test:integration` in a package):
- Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
- Test the full protocol handshake end-to-end
- Located in `src/integration/*.test.ts` (wallet is the only package with them today)
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially
(`singleFork`) to avoid relay contention, and with `retry: 2` — these hit third-party
relays, so a dropped connection is an environment failure rather than a regression
- A failure that reproduces locally is real; one that does not is usually the relay.
Check whether the same test passed on an earlier pipeline before assuming a regression
- Located in `src/__tests__/*.integration.test.ts`
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
- Must pass before any release
### Running tests
@ -91,13 +87,3 @@ npm run build # builds all packages in dependency order
```
Packages must be built before integration tests run (tests import from `dist/`).
## Releases
The `publish` CI job runs on `master` only, via `contrib/auto-publish.js`.
**The `version` in each `package.json` is a floor, not the shipped version** — all declare
`0.2.0` while npm carries higher patches. Use `npm view @wizardconnect/<pkg> version`.
The lockfile is not published, so clearing a dependency advisory means bumping the declared
range in `package.json`, not just `package-lock.json`.

View file

@ -1,11 +1,5 @@
# Extensions
This document covers `hdwalletv1` protocol-level extensions — optional capabilities that extend
the application protocol (extra path names, custom message actions, per-wallet features). For
transport-level capabilities that apply below the application protocol regardless of which
protocol is in use (chunking, future: compression), see
[transport.md § Transport-level extensions](transport.md#transport-level-extensions).
The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate
additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible:
existing wallets and dapps that don't know about extensions continue to work unchanged.

View file

@ -36,9 +36,6 @@ sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
sign_transaction_response — wallet → dapp, returns signed tx or error
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
disconnect — either → either, courtesy notification before tearing down
chunk — either → either, transport-level. Carries one slice of a larger message
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
hdwalletv1 semantics. See transport.md.
```
The action names above are the well-known set. Extensions may define additional action strings
@ -358,12 +355,6 @@ Either side may send a `disconnect` message before tearing down the relay connec
courtesy notification — the remote side treats the connection as closed immediately upon receipt
(no acknowledgement).
No acknowledgement does not mean fire-and-forget on the sender's side. `relay()` resolves only
after the publish has been settled against every configured relay, so the sender must keep the
relay connection open until then — closing it first kills the publish in flight and the peer
never learns of the disconnect. Sending a courtesy `disconnect` and immediately tearing the
transport down is the same as not sending one.
```typescript
enum DisconnectReason {
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
@ -379,9 +370,7 @@ interface DisconnectMessage {
```
**Wallet side** (`WalletConnectionManager`):
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish
settles — bounded, so an unreachable relay cannot hold the socket open. The connection leaves
the registry synchronously. See [wallet.md § Sending disconnect](wallet.md#sending-disconnect).
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
- Incoming `disconnect` emits a `remoteDisconnect` event
(`connectionId`, `reason`, `message`) and removes the connection.

View file

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

View file

@ -69,11 +69,9 @@ class WalletConnectionManager extends EventEmitter {
connect(uri: string): string
// Tear down a specific connection, sending a UserDisconnect courtesy message.
// Returns as soon as the connection has left the registry; the relay socket
// closes once the courtesy message is published. See § Sending disconnect.
disconnect(connectionId: string): void
// Tear down all connections. Each is disconnected independently.
// Tear down all connections.
disconnectAll(): void
// Snapshot of all connections for UI rendering.
@ -156,28 +154,6 @@ wallet_discovered=true → set dappDiscovered=true, no further action
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
### Sending disconnect
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
have opposite timing requirements.
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
connection left in the map during teardown would be handed back to a caller as if it were live.
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
published. `RelayClient.relay()` resolves after the publish settles against every configured
relay, which is a real round trip; closing the socket before that kills the publish in flight and
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
courtesy message that is never delivered.
Callers do not need to await anything. The observable contract is that state is correct
immediately and delivery is best-effort within the timeout.
### Receiving disconnect
When a `disconnect` message arrives from the dapp:

143
package-lock.json generated
View file

@ -32,7 +32,6 @@
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
"license": "MIT",
"peer": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
@ -1200,7 +1199,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@ -1400,15 +1398,15 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@ -1417,13 +1415,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.7",
"@vitest/spy": "3.2.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@ -1444,9 +1442,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1457,13 +1455,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.7",
"@vitest/utils": "3.2.4",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
@ -1472,13 +1470,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"@vitest/pretty-format": "3.2.4",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@ -1487,9 +1485,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1500,13 +1498,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.7",
"@vitest/pretty-format": "3.2.4",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
@ -1540,7 +1538,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1608,16 +1605,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/cac": {
@ -1849,7 +1846,6 @@
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@ -2470,9 +2466,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
@ -2706,9 +2702,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@ -2726,7 +2722,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -3096,7 +3092,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3147,14 +3142,13 @@
}
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0 || ^0.28.0",
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@ -3246,20 +3240,20 @@
}
},
"node_modules/vitest": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
@ -3289,8 +3283,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"happy-dom": "*",
"jsdom": "*"
},
@ -3372,11 +3366,10 @@
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -3408,7 +3401,7 @@
},
"packages/core": {
"name": "@wizardconnect/core",
"version": "0.2.0",
"version": "0.1.2",
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"@bitauth/libauth": "^3.1.0-next.2",
@ -3416,28 +3409,28 @@
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"nostr-tools": "^2.23.0",
"ws": "^8.21.3"
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
},
"packages/dapp": {
"name": "@wizardconnect/dapp",
"version": "0.2.0",
"version": "0.1.2",
"dependencies": {
"@wizardconnect/core": "*",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
},
"packages/react": {
"name": "@wizardconnect/react",
"version": "0.2.0",
"version": "0.1.0",
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
@ -3450,7 +3443,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
},
"peerDependencies": {
"react": ">=18.0.0",
@ -3463,7 +3456,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3484,7 +3476,6 @@
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -3511,7 +3502,7 @@
},
"packages/test-cli": {
"name": "@wizardconnect/test-cli",
"version": "0.2.0",
"version": "0.1.0",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3531,7 +3522,7 @@
},
"packages/wallet": {
"name": "@wizardconnect/wallet",
"version": "0.2.0",
"version": "0.1.2",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3539,7 +3530,7 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/core",
"version": "0.2.0",
"version": "0.1.2",
"type": "module",
"description": "Transport and protocol primitives for WizardConnect",
"repository": {
@ -40,10 +40,10 @@
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"ws": "^8.21.3"
"ws": "^8.18.0"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

@ -0,0 +1,381 @@
// 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, afterEach, vi } from "vitest";
import { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";
import {
type ChunkRelayMessage,
RelayMsgAction,
} from "./protocols/hdwalletv1.js";
function makeChunk(
chunk_id: string,
chunk_index: number,
chunk_total: number,
chunk_data: string,
): ChunkRelayMessage {
return {
action: RelayMsgAction.ChunkRelay,
chunk_id,
chunk_index,
chunk_total,
chunk_data,
time: Math.floor(Date.now() / 1000),
};
}
function splitMessage(
message: object,
chunkSize: number,
chunk_id: string,
): ChunkRelayMessage[] {
const json = JSON.stringify(message);
const chunks: ChunkRelayMessage[] = [];
const total = Math.ceil(json.length / chunkSize);
for (let i = 0; i < total; i++) {
chunks.push(
makeChunk(
chunk_id,
i,
total,
json.slice(i * chunkSize, (i + 1) * chunkSize),
),
);
}
return chunks;
}
describe("ChunkAssembler", () => {
let assembler: ChunkAssembler;
beforeEach(() => {
vi.useFakeTimers();
assembler = new ChunkAssembler();
});
afterEach(() => {
vi.useRealTimers();
});
describe("single-fragment messages", () => {
it("reassembles a single-fragment message immediately", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const [chunk] = splitMessage(original, 10000, "id1");
const result = assembler.addChunk(chunk);
expect(result).toEqual(original);
});
it("clears the pending buffer after assembly", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const [chunk] = splitMessage(original, 10000, "id1");
assembler.addChunk(chunk);
expect(assembler.pendingCount()).toBe(0);
});
});
describe("multi-fragment messages", () => {
it("returns null for each fragment until the last", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 7,
time: 1000,
};
const chunks = splitMessage(original, 5, "id2");
expect(chunks.length).toBeGreaterThan(1);
for (let i = 0; i < chunks.length - 1; i++) {
expect(assembler.addChunk(chunks[i])).toBeNull();
}
});
it("returns the reassembled message on the last fragment", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 7,
time: 1000,
};
const chunks = splitMessage(original, 5, "id3");
let result = null;
for (const chunk of chunks) {
result = assembler.addChunk(chunk);
}
expect(result).toEqual(original);
});
it("reassembles correctly when fragments arrive out of order", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 42,
time: 2000,
};
const chunks = splitMessage(original, 3, "id4");
expect(chunks.length).toBeGreaterThan(1);
// Reverse order
const reversed = [...chunks].reverse();
let result = null;
for (const chunk of reversed) {
result = assembler.addChunk(chunk);
}
expect(result).toEqual(original);
});
it("handles three fragments out of order (shuffle)", () => {
const original = {
action: RelayMsgAction.DappReady,
supported_protocols: ["hdwalletv1"],
wallet_discovered: false,
time: 3000,
};
const chunks = splitMessage(original, 10, "id5");
expect(chunks.length).toBeGreaterThanOrEqual(3);
// Send middle first, then last, then first
const [c0, c1, c2, ...rest] = chunks;
const reordered = [c1, c2, c0, ...rest];
let result = null;
for (const chunk of reordered) {
result = assembler.addChunk(chunk);
}
expect(result).toEqual(original);
});
it("reassembles a large payload split into many fragments", () => {
const original = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 999,
time: 5000,
// Simulate a large payload
transaction: { data: "x".repeat(5000) },
inputPaths: [],
};
const chunks = splitMessage(original, 500, "id6");
expect(chunks.length).toBeGreaterThan(5);
let result = null;
for (const chunk of chunks) {
result = assembler.addChunk(chunk);
}
expect(result).toEqual(original);
});
});
describe("independent chunk_ids", () => {
it("tracks multiple concurrent assemblies independently", () => {
const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const msgB = {
action: RelayMsgAction.SignCancel,
sequence: 2,
time: 2000,
};
const chunksA = splitMessage(msgA, 5, "idA");
const chunksB = splitMessage(msgB, 5, "idB");
// Interleave: A0, B0, A1, B1, ...
let resultA = null;
let resultB = null;
const maxLen = Math.max(chunksA.length, chunksB.length);
for (let i = 0; i < maxLen; i++) {
if (i < chunksA.length) resultA = assembler.addChunk(chunksA[i]);
if (i < chunksB.length) resultB = assembler.addChunk(chunksB[i]);
}
expect(resultA).toEqual(msgA);
expect(resultB).toEqual(msgB);
});
it("completing one assembly does not affect pending assemblies", () => {
const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const msgB = {
action: RelayMsgAction.SignCancel,
sequence: 2,
time: 2000,
};
const [chunkA] = splitMessage(msgA, 10000, "idA");
const chunksB = splitMessage(msgB, 5, "idB");
// Complete A immediately
assembler.addChunk(chunkA);
// B is still in progress
for (let i = 0; i < chunksB.length - 1; i++) {
assembler.addChunk(chunksB[i]);
}
expect(assembler.pendingCount()).toBe(1);
// Complete B
const result = assembler.addChunk(chunksB[chunksB.length - 1]);
expect(result).toEqual(msgB);
expect(assembler.pendingCount()).toBe(0);
});
});
describe("error cases", () => {
it("returns null and discards buffer on inconsistent chunk_total", () => {
const chunk1 = makeChunk("id7", 0, 3, "part1");
const chunk2 = makeChunk("id7", 1, 4, "part2"); // wrong total
assembler.addChunk(chunk1);
const result = assembler.addChunk(chunk2);
expect(result).toBeNull();
expect(assembler.pendingCount()).toBe(0);
});
it("returns null for invalid JSON", () => {
const chunk = makeChunk("id8", 0, 1, "{not valid json}}}");
const result = assembler.addChunk(chunk);
expect(result).toBeNull();
});
it("returns null if assembled payload is not a ProtocolMessage", () => {
const notAMessage = { foo: "bar" };
const [chunk] = splitMessage(notAMessage, 10000, "id9");
const result = assembler.addChunk(chunk);
expect(result).toBeNull();
});
it("returns null for chunk_total of 0", () => {
const chunk = makeChunk("id10", 0, 0, "data");
const result = assembler.addChunk(chunk);
expect(result).toBeNull();
});
});
describe("timeout cleanup", () => {
it("discards incomplete assembly after timeout", () => {
const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(msgA, 5, "idTimeout");
expect(chunks.length).toBeGreaterThan(1);
// Add all but the last fragment
for (let i = 0; i < chunks.length - 1; i++) {
assembler.addChunk(chunks[i]);
}
expect(assembler.pendingCount()).toBe(1);
vi.advanceTimersByTime(CHUNK_TIMEOUT_MS + 1);
expect(assembler.pendingCount()).toBe(0);
});
it("does not timeout before CHUNK_TIMEOUT_MS", () => {
const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(msgA, 5, "idTimeout2");
for (let i = 0; i < chunks.length - 1; i++) {
assembler.addChunk(chunks[i]);
}
vi.advanceTimersByTime(CHUNK_TIMEOUT_MS - 1);
expect(assembler.pendingCount()).toBe(1);
});
it("clears timeout when assembly completes before timeout", () => {
const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(msgA, 5, "idTimeout3");
for (const chunk of chunks) {
assembler.addChunk(chunk);
}
expect(assembler.pendingCount()).toBe(0);
// Advancing past timeout should not throw
expect(() => vi.advanceTimersByTime(CHUNK_TIMEOUT_MS + 1)).not.toThrow();
});
it("after timeout, late arriving fragments start a fresh assembly", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(original, 5, "idReuse");
// Send first fragment, let it time out
assembler.addChunk(chunks[0]);
vi.advanceTimersByTime(CHUNK_TIMEOUT_MS + 1);
expect(assembler.pendingCount()).toBe(0);
// Re-send all fragments with same chunk_id — should reassemble fresh
let result = null;
for (const chunk of chunks) {
result = assembler.addChunk(chunk);
}
expect(result).toEqual(original);
});
});
describe("duplicate fragment index", () => {
it("last write wins for duplicate index — still assembles correctly if content is the same", () => {
const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(original, 5, "idDup");
expect(chunks.length).toBeGreaterThan(1);
// Send first fragment twice (relay may deliver duplicates)
assembler.addChunk(chunks[0]);
assembler.addChunk(chunks[0]);
let result = null;
for (let i = 1; i < chunks.length; i++) {
result = assembler.addChunk(chunks[i]);
}
// Assembly happens when fragment count equals chunk_total.
// With duplicate index 0, we have total fragments but index 0 is stored once.
// The assembly should still complete when index N-1 is added.
if (result !== null) {
expect(result).toEqual(original);
}
// (If size counting means assembly triggers early on the duplicate, result may be null here
// and the final fragment triggers it — either way is acceptable.)
});
});
});

View file

@ -0,0 +1,136 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import {
type ChunkRelayMessage,
type ProtocolMessage,
isProtocolMessage,
} from "./protocols/hdwalletv1.js";
import { error as logError, debug, Scope } from "./log.js";
/// Incomplete assembly buffered until all fragments arrive or timeout expires.
interface PendingAssembly {
fragments: Map<number, string>;
total: number;
timeout: ReturnType<typeof setTimeout>;
}
/// How long to wait for all fragments before discarding an incomplete assembly.
export const CHUNK_TIMEOUT_MS = 60_000;
/**
* Buffers incoming ChunkRelayMessage fragments and reassembles them into the
* original ProtocolMessage once all fragments have arrived.
*
* Thread-safety: JavaScript is single-threaded so no locking is needed.
*/
export class ChunkAssembler {
private pending = new Map<string, PendingAssembly>();
/**
* Add a fragment. Returns the reassembled ProtocolMessage when the last
* fragment arrives, or null if more fragments are still outstanding.
*
* Returns null (and logs an error) if the assembled payload is not valid JSON
* or does not satisfy isProtocolMessage().
*/
addChunk(chunk: ChunkRelayMessage): ProtocolMessage | null {
const { chunk_id, chunk_index, chunk_total, chunk_data } = chunk;
if (chunk_total < 1) {
logError(
Scope.Relay,
`Invalid chunk_total ${chunk_total} for chunk_id ${chunk_id}`,
);
return null;
}
let assembly = this.pending.get(chunk_id);
if (!assembly) {
assembly = {
fragments: new Map(),
total: chunk_total,
timeout: setTimeout(() => {
debug(
Scope.Relay,
`Chunk assembly timed out for chunk_id ${chunk_id} (received ${this.pending.get(chunk_id)?.fragments.size ?? 0}/${chunk_total})`,
);
this.discard(chunk_id);
}, CHUNK_TIMEOUT_MS),
};
this.pending.set(chunk_id, assembly);
} else if (assembly.total !== chunk_total) {
logError(
Scope.Relay,
`Inconsistent chunk_total for chunk_id ${chunk_id}: got ${chunk_total}, expected ${assembly.total}`,
);
this.discard(chunk_id);
return null;
}
assembly.fragments.set(chunk_index, chunk_data);
if (assembly.fragments.size < assembly.total) {
return null;
}
return this.assemble(chunk_id, assembly);
}
/** Number of chunk_ids currently buffered (for testing/diagnostics). */
pendingCount(): number {
return this.pending.size;
}
private assemble(
chunk_id: string,
assembly: PendingAssembly,
): ProtocolMessage | null {
this.discard(chunk_id);
const parts: string[] = [];
for (let i = 0; i < assembly.total; i++) {
const part = assembly.fragments.get(i);
if (part === undefined) {
logError(
Scope.Relay,
`Missing fragment index ${i} for chunk_id ${chunk_id}`,
);
return null;
}
parts.push(part);
}
const json = parts.join("");
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
logError(
Scope.Relay,
`Failed to parse assembled message for chunk_id ${chunk_id}`,
);
return null;
}
if (!isProtocolMessage(parsed)) {
logError(
Scope.Relay,
`Assembled message for chunk_id ${chunk_id} is not a ProtocolMessage`,
);
return null;
}
return parsed;
}
private discard(chunk_id: string): void {
const assembly = this.pending.get(chunk_id);
if (assembly) {
clearTimeout(assembly.timeout);
this.pending.delete(chunk_id);
}
}
}

View file

@ -31,12 +31,6 @@ export type {
} from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js";
export * from "./protocols/base.js";
export {
CHUNK_EXTENSION_NAME,
CHUNK_EXTENSION_VERSION,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "./transforms/chunk.js";
export {
binToHex,
hexToBin,
@ -50,3 +44,4 @@ export {
toUint8Array,
toBigInt,
} from "./serialize.js";
export { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";

View file

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

View file

@ -25,14 +25,10 @@ export enum RelayMsgAction {
SignCancel = "sign_cancel",
/// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect",
/// Transport-level: carries one slice of a message that exceeds NIP-44's
/// 65,535-byte plaintext ceiling. See ChunkMessage and docs/transport.md.
/// Not tied to hdwalletv1 semantics — applies to any application protocol.
Chunk = "chunk",
/// Keepalive ping sent by the dapp every 10 s to prevent relay silence timeouts.
Ping = "ping",
/// Keepalive pong sent by the wallet in response to each ping.
Pong = "pong",
/// Transport: one fragment of a large message split for NIP-44 size limits.
/// Fragments are reassembled by the receiving RelayClient before being
/// dispatched to higher layers. See docs/transport.md#chunking.
ChunkRelay = "chunk_relay",
}
export interface ProtocolMessage {
@ -154,18 +150,23 @@ export interface SignCancelMessage extends ProtocolMessage {
reason?: string;
}
/// Transport-level message carrying one slice of a larger ProtocolMessage.
///
/// All chunks of one logical message share the same msgId and time.
/// `data` is a base64-encoded slice of the UTF-8 bytes of
/// JSON.stringify(originalMessage); concatenate slices in `index` order,
/// base64-decode, UTF-8-decode, then JSON.parse to reconstruct.
export interface ChunkMessage extends ProtocolMessage {
action: RelayMsgAction.Chunk;
msgId: string;
index: number;
total: number;
data: string;
/// Extension name advertised by wallets whose RelayClient supports chunk reassembly.
/// Presence in Hdwalletv1Session.extensions means the peer will accept chunked messages.
export const EXTENSION_CHUNKED_MESSAGES = "chunked_messages" as const;
/// One fragment of a large message split across multiple relay events.
/// All fragments share the same chunk_id. The receiver buffers by chunk_id
/// and reassembles when chunk_total fragments have arrived.
export interface ChunkRelayMessage extends ProtocolMessage {
action: RelayMsgAction.ChunkRelay;
/// Identifies which large message these fragments belong to.
chunk_id: string;
/// Zero-based position of this fragment.
chunk_index: number;
/// Total number of fragments for this message.
chunk_total: number;
/// Slice of JSON.stringify(originalMessage) for this fragment.
chunk_data: string;
}
// Type guard functions
@ -216,19 +217,14 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
);
}
export function isChunkMessage(msg: any): msg is ChunkMessage {
export function isChunkRelayMessage(msg: any): msg is ChunkRelayMessage {
return (
msg &&
typeof msg === "object" &&
msg.action === RelayMsgAction.Chunk &&
typeof msg.msgId === "string" &&
typeof msg.index === "number" &&
typeof msg.total === "number" &&
typeof msg.data === "string" &&
Number.isInteger(msg.index) &&
Number.isInteger(msg.total) &&
msg.total >= 1 &&
msg.index >= 0 &&
msg.index < msg.total
msg.action === RelayMsgAction.ChunkRelay &&
typeof msg.chunk_id === "string" &&
typeof msg.chunk_index === "number" &&
typeof msg.chunk_total === "number" &&
typeof msg.chunk_data === "string"
);
}

View file

@ -218,88 +218,3 @@ describe("RelayClient — isConnected", () => {
expect(client.isConnected()).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Chunking — sender path
// ---------------------------------------------------------------------------
describe("RelayClient — chunk sender path", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not chunk small messages (regression guard)", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
client.setPeerCapabilities({ chunk: true });
await client.connect();
triggerEose();
await client.relay({
action: "dapp_ready" as any,
time: Math.floor(Date.now() / 1000),
});
expect(publishMock).toHaveBeenCalledTimes(1);
});
it("throws a clear error for oversized messages when peer lacks chunk support", async () => {
const { pool, triggerEose } = makeMockPool();
const client = makeClient(pool);
// Intentionally NOT calling setPeerCapabilities({chunk: true})
client.on("disconnect", () => {}); // prevent unhandled
await client.connect();
triggerEose();
const huge = {
action: "sign_transaction_request" as any,
time: Math.floor(Date.now() / 1000),
payload: "x".repeat(200_000),
};
await expect(client.relay(huge)).rejects.toThrow(
/does not advertise the 'chunk' transport extension/,
);
});
it("splits oversized messages into multiple publish calls when peer supports chunking", async () => {
const { pool, triggerEose, publishMock } = makeMockPool();
const client = makeClient(pool);
client.setPeerCapabilities({ chunk: true });
await client.connect();
triggerEose();
const huge = {
action: "sign_transaction_request" as any,
time: Math.floor(Date.now() / 1000),
payload: "x".repeat(200_000),
};
await client.relay(huge);
// 200 KB payload should produce multiple chunks; each chunk is one publish.
expect(publishMock.mock.calls.length).toBeGreaterThan(1);
});
it("setPeerCapabilities only updates provided keys", async () => {
const { pool, triggerEose } = makeMockPool();
const client = makeClient(pool);
await client.connect();
triggerEose();
// Enable, then call with empty object — should not disable
client.setPeerCapabilities({ chunk: true });
client.setPeerCapabilities({});
const { publishMock } = makeMockPool(); // fresh count - not really needed
void publishMock;
const huge = {
action: "sign_transaction_request" as any,
time: Math.floor(Date.now() / 1000),
payload: "x".repeat(200_000),
};
// Should not throw, because chunk capability remains enabled
await expect(client.relay(huge)).resolves.toBeUndefined();
});
});

View file

@ -14,20 +14,33 @@ import WebSocket from "isomorphic-ws";
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3";
import {
ChunkMessage,
isChunkMessage,
isProtocolMessage,
isChunkRelayMessage,
type ChunkRelayMessage,
ProtocolMessage,
RelayMsgAction,
} from "./protocols/hdwalletv1.js";
import { deriveNostrPublicKey } from "./utilnostr.js";
import { MessageQueue } from "./message-queue.js";
import { ChunkAssembler } from "./chunk-assembler.js";
import { debug, error as logError, Scope } from "./log.js";
import {
ChunkReassembler,
needsChunking,
splitIntoChunks,
} from "./transforms/chunk.js";
/// Messages larger than this (JSON chars) are split into chunks when the peer
/// supports chunking. The NIP-44 limit is 65 535 bytes of plaintext per call.
/// createWrap encrypts the seal JSON; that seal JSON contains the base64 of the
/// inner NIP-44 ciphertext. NIP-44 pads plaintext to the next power of two, so
/// the ciphertext size jumps sharply once the rumor JSON crosses 32 768 bytes:
/// rumor JSON ≤ 32 768 B → inner ciphertext ≈ 43 780 B (base64) → seal JSON
/// ≈ 44 130 B → safe. Rumor JSON > 32 768 B → inner ciphertext ≈ 87 472 B
/// → seal JSON ≈ 87 822 B → exceeds the 65 535-byte limit → NIP-44 throws.
/// A 30 000-char message produces a rumor JSON of ≈ 30 300 bytes, leaving a
/// comfortable 2 500-byte margin below the 32 768-byte cliff.
const MAX_SAFE_MESSAGE_SIZE = 30_000;
/// Size of each chunk_data slice (chars). A 28 000-char slice produces a chunk
/// JSON of ≈ 28 100 bytes, which sits safely below MAX_SAFE_MESSAGE_SIZE so
/// chunk metadata overhead cannot push a fragment over the limit.
const CHUNK_SIZE = 28_000;
useWebSocketImplementation(WebSocket);
@ -42,12 +55,6 @@ export interface RelayClientConfig {
}
export class RelayClient extends EventEmitter {
// Keyed by "walletPubkeyHex:dappPubkeyHex". Persists the high-water mark
// across RelayClient instance teardowns within the same JS session so that
// reconnects after an explicit disconnect()+connect() still filter
// relay-replayed messages from the prior session.
private static readonly sessionTimestamps = new Map<string, number>();
private pool: SimplePool;
private sharedPool: boolean;
private pairedPubkeyHex: string;
@ -58,17 +65,11 @@ export class RelayClient extends EventEmitter {
private lastProcessedTimestamp: number = 0;
private messageQueue: MessageQueue;
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
private peerSupportsChunking = false;
private chunkAssembler = new ChunkAssembler();
private disconnecting: boolean = false;
/// Capability flag: peer advertised support for the `chunk` transport
/// extension in its dapp_ready / wallet_ready. Set via setPeerCapabilities.
private peerSupportsChunk: boolean = false;
/// Receiver-side reassembly buffer. Always active — if no chunks arrive it
/// stays empty. Started in connect(), stopped in disconnect().
private reassembler: ChunkReassembler;
private sequence: number = Math.floor(
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
);
@ -89,12 +90,6 @@ export class RelayClient extends EventEmitter {
}
>();
private get sessionKey(): string | null {
return this.pairedPubkeyHex
? `${this.myPubkeyHex}:${this.pairedPubkeyHex}`
: null;
}
constructor(config: RelayClientConfig, pool?: SimplePool) {
super();
this.config = {
@ -108,15 +103,6 @@ export class RelayClient extends EventEmitter {
logActivity: this.config.logNetworkActivity,
});
// Reassembled messages take the same post-decryption path as unchunked ones.
// Chunks themselves have already passed the peer filter and timestamp dedup
// on ingress (see routeIncoming), so the assembled message is handed directly
// to the application-level handler.
this.reassembler = new ChunkReassembler(
(msg) => this.handleRelayMessage(msg),
!!this.config.logNetworkActivity,
);
this.myPubkey = unwrap(
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
);
@ -131,13 +117,6 @@ export class RelayClient extends EventEmitter {
} else {
this.pairedPubkeyHex = "";
}
// Restore persisted high-water mark for this wallet+dapp pair (survives
// instance recreation within the same JS session).
const saved = this.sessionKey
? RelayClient.sessionTimestamps.get(this.sessionKey)
: undefined;
if (saved) this.lastProcessedTimestamp = saved;
}
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
@ -147,26 +126,9 @@ export class RelayClient extends EventEmitter {
? pairedPublicKey.slice(1)
: pairedPublicKey;
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
// Now that we have the full key, restore any persisted timestamp.
const saved = RelayClient.sessionTimestamps.get(this.sessionKey!);
if (saved && saved > this.lastProcessedTimestamp) {
this.lastProcessedTimestamp = saved;
}
this.emit("paired");
}
/// Set transport-level capability flags based on the peer's advertisement in
/// its dapp_ready / wallet_ready `extensions` field. Called by the connection
/// manager after the handshake. New capability keys are additive — callers
/// may omit any they don't set.
setPeerCapabilities(caps: { chunk?: boolean }): void {
if (caps.chunk !== undefined) {
this.peerSupportsChunk = caps.chunk;
}
}
getPublicKey(): Uint8Array {
return this.myPubkey;
}
@ -194,7 +156,6 @@ export class RelayClient extends EventEmitter {
}
this.disconnecting = false;
this.reassembler.start();
if (this.lastProcessedTimestamp === 0) {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
@ -254,11 +215,8 @@ export class RelayClient extends EventEmitter {
async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
this.messageQueue.setNotReady();
this.reassembler.stop();
this.peerSupportsChunking = false;
if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId);
@ -283,6 +241,16 @@ export class RelayClient extends EventEmitter {
this.lastProcessedTimestamp = timestamp;
}
/**
* Notify the RelayClient that the connected peer supports chunked messages.
* Call this after receiving a wallet_ready that includes the "chunked_messages"
* extension. Automatically reset to false by disconnect() call again on
* each wallet_ready so reconnects don't inherit stale state.
*/
setPeerSupportsChunking(supports: boolean): void {
this.peerSupportsChunking = supports;
}
async relay(message: ProtocolMessage): Promise<void> {
if (!this.config.pairedPublicKey) {
throw new Error(
@ -290,6 +258,14 @@ export class RelayClient extends EventEmitter {
);
}
const messageJson = JSON.stringify(message);
if (
messageJson.length > MAX_SAFE_MESSAGE_SIZE &&
this.peerSupportsChunking
) {
return this.relayChunked(message.action, messageJson);
}
if (!this.messageQueue.getReady()) {
return this.messageQueue.enqueue(message);
}
@ -297,54 +273,43 @@ export class RelayClient extends EventEmitter {
return this.publishMessage(message);
}
private async publishMessage(message: ProtocolMessage): Promise<void> {
const serialized = JSON.stringify(message);
private async relayChunked(
originalAction: string,
messageJson: string,
): Promise<void> {
const chunkId = Math.random().toString(36).slice(2, 14);
const chunkTotal = Math.ceil(messageJson.length / CHUNK_SIZE);
const now = Math.floor(Date.now() / 1000);
if (!needsChunking(serialized)) {
return this.publishSerialized(message.action, serialized);
}
debug(
Scope.Relay,
`Chunking ${originalAction} into ${chunkTotal} fragments (${messageJson.length} chars)`,
);
// Oversized. Chunk if the peer supports it; otherwise fail loudly with
// an actionable message (replacing nostr-tools' cryptic plaintext-size error).
if (!this.peerSupportsChunk) {
const err = new Error(
`Cannot send ${message.action}: message is larger than NIP-44's 65,535-byte ceiling ` +
`and the peer does not advertise the 'chunk' transport extension. ` +
`Please update the connected wallet/dapp to a version that supports chunked messages.`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, err.message);
for (let i = 0; i < chunkTotal; i++) {
const chunk: ChunkRelayMessage = {
action: RelayMsgAction.ChunkRelay,
chunk_id: chunkId,
chunk_index: i,
chunk_total: chunkTotal,
chunk_data: messageJson.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
time: now,
};
if (!this.messageQueue.getReady()) {
await this.messageQueue.enqueue(chunk);
} else {
await this.publishMessage(chunk);
}
throw err;
}
const chunks = splitIntoChunks(serialized);
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Chunking ${message.action}: ${chunks.length} chunks (serialized ~${serialized.length} bytes)`,
);
}
for (const chunk of chunks) {
await this.publishSerialized(
`${message.action}[chunk ${chunk.index + 1}/${chunk.total}]`,
JSON.stringify(chunk),
);
}
}
/// Wrap and publish one gift-wrap event. Used for both unchunked messages
/// and individual chunks. `displayAction` is only used for logs.
private async publishSerialized(
displayAction: string,
serialized: string,
): Promise<void> {
this.netlog("send", displayAction);
private async publishMessage(message: ProtocolMessage): Promise<void> {
this.netlog("send", message.action);
const wrapped = wrapEvent(
{
kind: KIND_PRIVATE_DIRECT_MESSAGE,
content: serialized,
content: JSON.stringify(message),
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.pairedPubkeyHex]],
},
@ -363,7 +328,7 @@ export class RelayClient extends EventEmitter {
for (const r of rejected) {
logError(
Scope.Relay,
`Failed to publish ${displayAction} to a relay:`,
`Failed to publish ${message.action} to a relay:`,
(r as PromiseRejectedResult).reason,
);
}
@ -371,7 +336,7 @@ export class RelayClient extends EventEmitter {
if (fulfilled.length === 0) {
const error = new Error(
`Failed to publish ${displayAction} to all relays`,
`Failed to publish ${message.action} to all relays`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, error.message);
@ -383,7 +348,7 @@ export class RelayClient extends EventEmitter {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Published message ${displayAction} to ${fulfilled.length}/${results.length} relay(s)`,
`Published message ${message.action} to ${fulfilled.length}/${results.length} relay(s)`,
);
}
}
@ -392,27 +357,68 @@ export class RelayClient extends EventEmitter {
try {
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
if (rumor.kind !== KIND_PRIVATE_DIRECT_MESSAGE) {
if (rumor.kind === KIND_PRIVATE_DIRECT_MESSAGE) {
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}`,
);
}
return;
}
let payload: ProtocolMessage;
try {
payload = JSON.parse(rumor.content);
} catch (e) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Failed to parse message content as JSON:", e);
}
return;
}
this.routeIncoming(payload, rumor.pubkey);
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Error handling incoming message:", error);
@ -421,76 +427,6 @@ export class RelayClient extends EventEmitter {
}
}
/// Apply timestamp dedup + peer filter, then dispatch to chunk reassembly
/// or the application-level handler. Called from handleWrappedEvent (one
/// path: unwrap → route). Kept separate to keep handleWrappedEvent focused
/// on decryption and to allow future transport-layer transforms to invoke
/// this path with already-decoded payloads.
private routeIncoming(payload: ProtocolMessage, fromPubkey: string): void {
if (
!payload.time ||
(this.lastProcessedTimestamp > 0 &&
payload.time < this.lastProcessedTimestamp)
) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
);
}
return;
}
// Advance the high-water mark so relay replays are filtered on reconnect.
// Also persist to the static session cache so a fresh RelayClient instance
// for the same wallet+dapp pair inherits this mark.
if (payload.time > this.lastProcessedTimestamp) {
this.lastProcessedTimestamp = payload.time;
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
}
// wallet_ready carries the key exchange data (public_key + secret) so it
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
const isKeyExchangeMessage = payload.action === RelayMsgAction.WalletReady;
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
const pairedNostrPubkey =
this.config.pairedPublicKey.length === 33
? binToHex(this.config.pairedPublicKey.slice(1))
: binToHex(this.config.pairedPublicKey);
if (fromPubkey !== pairedNostrPubkey) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring '${payload.action}' message from unknown peer: ${fromPubkey} (expected: ${pairedNostrPubkey})`,
);
}
return;
}
}
// Transport-level branch: a ChunkMessage is routed to the reassembler,
// which will emit a reassembled ProtocolMessage via handleRelayMessage
// once all pieces arrive.
if (isChunkMessage(payload)) {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Received chunk ${payload.index + 1}/${payload.total} (msgId=${payload.msgId})`,
);
}
this.reassembler.ingest(payload as ChunkMessage);
return;
}
if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Received message ${payload.action} from relay`);
}
this.handleRelayMessage(payload);
}
isConnected(): boolean {
return this.subscription !== null;
}
@ -526,6 +462,19 @@ export class RelayClient extends EventEmitter {
isProtocolMessage(message),
`Invalid protocol message: ${message}`,
);
if (isChunkRelayMessage(message)) {
const assembled = this.chunkAssembler.addChunk(message);
if (assembled !== null) {
this.netlog(
"recv",
`${assembled.action} [reassembled from ${message.chunk_total} chunks]`,
);
this.emit("message", assembled);
}
return;
}
this.emit("message", message);
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/dapp",
"version": "0.2.0",
"version": "0.1.2",
"type": "module",
"description": "Dapp-side integration helpers for WizardConnect",
"repository": {
@ -29,6 +29,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

@ -5,15 +5,10 @@
import { describe, it, expect, vi } from "vitest";
import { encodeHdPublicKey } from "@bitauth/libauth";
import type { PathXpub } from "@wizardconnect/core";
import {
RelayMsgAction,
PROTOCOL_NAME,
DisconnectReason,
} from "@wizardconnect/core";
import { RelayMsgAction, PROTOCOL_NAME } from "@wizardconnect/core";
import type {
WalletReadyMessage,
SignTransactionRequest,
DisconnectMessage,
ProtocolMessage,
} from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
@ -152,8 +147,8 @@ describe("DappConnectionManager", () => {
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
setPeerSupportsChunking: vi.fn(),
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
@ -240,417 +235,4 @@ describe("DappConnectionManager", () => {
expect(signMsgs).toHaveLength(0);
});
});
describe("keepalive reconnect grace timer", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeDisconnectMsg(): DisconnectMessage {
return {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("suppresses disconnect when wallet_ready arrives within the grace window", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
// Wallet keepalive watchdog fires UserDisconnect
client.emit("message", makeDisconnectMsg());
vi.advanceTimersByTime(5_000);
expect(disconnectEvents).toHaveLength(0);
// Wallet reconnects and sends wallet_ready before the window expires
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(26_000); // past where the original 30s timer would have fired
expect(disconnectEvents).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it("fires disconnect after the grace window if wallet does not reconnect", () => {
vi.useFakeTimers();
try {
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: Array<{
reason: DisconnectReason;
message: string | undefined;
}> = [];
mgr.on("disconnect", (reason, message) =>
disconnectEvents.push({ reason, message }),
);
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeDisconnectMsg());
vi.advanceTimersByTime(7_999);
expect(disconnectEvents).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(disconnectEvents).toHaveLength(1);
expect(disconnectEvents[0].reason).toBe(
DisconnectReason.UserDisconnect,
);
} finally {
vi.useRealTimers();
}
});
});
describe("ping interval", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("sends a Ping after 10s following wallet_ready", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(9_999);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
vi.advanceTimersByTime(1);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("stops sending pings once the grace timer fires on disconnect", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
client.emit("message", {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
});
// Grace timer fires at 30s and stops the ping interval
vi.advanceTimersByTime(30_000);
expect(disconnectEvents).toHaveLength(1);
// No further pings after the interval was stopped
client.relayed.length = 0;
vi.advanceTimersByTime(30_000);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it("resets the ping interval when wallet reconnects", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
mgr.updateConnection(client as any, { status: "connected" });
// First wallet_ready — ping interval starts (would fire at t=10s)
client.emit("message", makeWalletReadyMsg(xpub));
vi.advanceTimersByTime(5_000); // t=5s, no ping yet
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
// Wallet reconnects — second wallet_ready resets the interval
client.emit("message", makeWalletReadyMsg(xpub));
client.relayed.length = 0;
// Old interval would have fired at t=15s (10s from now); new fires at t=25s
vi.advanceTimersByTime(9_000); // t=14s — still no ping
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(0);
vi.advanceTimersByTime(1_000); // t=15s — new interval fires
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("enters reconnecting state after 75s with no pong or wallet_ready (does not disconnect)", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
const reconnectingEvents: number[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.on("reconnecting", () => reconnectingEvents.push(1));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Still alive just before the threshold
vi.advanceTimersByTime(74_999);
expect(disconnectEvents).toHaveLength(0);
expect(reconnectingEvents).toHaveLength(0);
// Next interval check pushes past 75s — liveness timeout fires reconnecting, not disconnect
vi.advanceTimersByTime(5_001); // advances to t=80s (next 10s interval)
expect(disconnectEvents).toHaveLength(0);
expect(reconnectingEvents).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("recovers to connected when wallet_ready arrives after liveness timeout", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
const walletReadyEvents: number[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.on("walletready", () => walletReadyEvents.push(1));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Liveness timeout fires at t=80s
vi.advanceTimersByTime(80_001);
// Wallet wakes up and sends wallet_ready — dapp should resume connected
client.emit("message", makeWalletReadyMsg(xpub));
expect(walletReadyEvents).toHaveLength(2); // initial + recovery
expect(disconnectEvents).toHaveLength(0);
// Ping interval restarts after recovery — next ping at t=90s
client.relayed.length = 0;
vi.advanceTimersByTime(10_000);
expect(
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("does not disconnect when wallet_ready resets the liveness timer", () => {
vi.useFakeTimers();
try {
const xpub = makeTestXpub();
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const disconnectEvents: DisconnectReason[] = [];
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
mgr.updateConnection(client as any, { status: "connected" });
client.emit("message", makeWalletReadyMsg(xpub));
// Simulate wallet_ready (keepalive reconnect) at t=60s, resetting the timer
vi.advanceTimersByTime(60_000);
client.emit("message", makeWalletReadyMsg(xpub));
// 74s after the reset — still below 75s threshold
vi.advanceTimersByTime(74_999);
expect(disconnectEvents).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
});
describe("dapp_ready ordering on reconnect", () => {
function makeMockClient() {
const listeners = new Map<string, ((...args: any[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: any[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: any[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: false,
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
time: Math.floor(Date.now() / 1000),
};
}
it("sends dapp_ready before re-sending pending sign requests", async () => {
const mgr = new DappConnectionManager(undefined, undefined, {
session: false,
});
const client = makeMockClient();
const xpub = makeTestXpub();
mgr.updateConnection(client as any, { status: "connected" });
const request: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: client.nextSequence(),
time: Math.floor(Date.now() / 1000),
inputPaths: [],
transaction: "deadbeef",
};
const signPromise = mgr.sendSignRequest(request);
client.relayed.length = 0;
// wallet_ready with dapp_discovered: false — dapp must await dapp_ready
// before re-sending pending sign requests
client.emit("message", makeWalletReadyMsg(xpub));
await new Promise((r) => setTimeout(r, 50));
const actions = client.relayed.map((m) => m.action);
const dappReadyIdx = actions.indexOf(RelayMsgAction.DappReady);
const signReqIdx = actions.indexOf(RelayMsgAction.SignTransactionRequest);
expect(dappReadyIdx).toBeGreaterThan(-1);
expect(signReqIdx).toBeGreaterThan(-1);
expect(dappReadyIdx).toBeLessThan(signReqIdx);
client.emit("message", {
action: RelayMsgAction.SignTransactionResponse,
sequence: request.sequence,
time: Math.floor(Date.now() / 1000),
signedTransaction: "signed",
});
await signPromise;
});
});
});

View file

@ -16,14 +16,12 @@ import {
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
PingMessage,
ProtocolMessage,
PROTOCOL_NAME,
childIndexOfPathName,
isHdwalletv1Session,
binToHex,
chunkExtensionAdvertisement,
peerSupportsChunk,
EXTENSION_CHUNKED_MESSAGES,
} from "@wizardconnect/core";
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
@ -52,8 +50,6 @@ export interface DappConnectionManagerEvents {
messagereceived: [msg: ProtocolMessage];
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
disconnect: [reason: DisconnectReason, message: string | undefined];
/** Fired when the relay connection drops and auto-reconnect begins. */
reconnecting: [];
}
/**
@ -82,10 +78,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
private walletDiscovered = false;
private disconnectGraceTimer: ReturnType<typeof setTimeout> | null = null;
private pingInterval: ReturnType<typeof setInterval> | null = null;
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
private lastPongTime: number = 0;
private sessionPaths: PathXpub[] = [];
private pendingSignatureRequests = new Map<
number,
@ -207,14 +199,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
if (status.status === "connected" && this.conn) {
this.onConnected();
} else if (
status.status === "reconnecting" ||
status.status === "disconnected"
) {
this.stopPingInterval();
if (status.status === "reconnecting") {
this.emit("reconnecting");
}
}
}
@ -402,59 +386,17 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
private onConnected(): void {
(async () => {
// Wait until key exchange is complete before sending dapp_ready
const deadline = Date.now() + 30_000;
while (this.conn && !this.conn.isKeyExchangeComplete()) {
if (Date.now() >= deadline) {
console.error("[wizardconnect/dapp] Key exchange timed out");
return;
}
await new Promise((r) => setTimeout(r, 100));
}
if (!this.conn) return;
await this.pushDappReady();
if (this.conn) {
await this.pushDappReady();
}
})().catch((e) =>
console.error("[wizardconnect/dapp] Error in onConnected:", e),
);
}
private startPingInterval(): void {
this.stopPingInterval();
// Treat connection as live at the moment the session is established.
this.lastPongTime = Date.now();
this.pingInterval = setInterval(() => {
if (!this.conn || !this.walletDiscovered) return;
// If the wallet hasn't responded (pong or wallet_ready) within 75s,
// it silently dropped the session — trigger reconnect.
if (Date.now() - this.lastPongTime > 75_000) {
this.stopPingInterval();
this.emit("reconnecting");
return;
}
const ping: PingMessage = {
action: RelayMsgAction.Ping,
time: Math.floor(Date.now() / 1000),
};
this.conn.relay(ping).catch(() => {});
}, 10_000);
}
private stopPingInterval(): void {
if (this.pingInterval !== null) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
destroy(): void {
this.stopPingInterval();
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
this.disconnectGraceTimer = null;
}
}
private async pushDappReady(): Promise<void> {
if (!this.conn) return;
@ -468,10 +410,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.protocol && { selected_protocol: this.protocol }),
...(this.dappName !== undefined && { dapp_name: this.dappName }),
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
// Transport-level: advertise chunking so the wallet can send large
// SignTransactionResponses (signed tx hex can reach ~2 MB) that exceed
// NIP-44's plaintext ceiling.
extensions: { chunk: chunkExtensionAdvertisement() },
};
await this.conn.relay(msg);
@ -482,9 +420,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagereceived", msg);
switch (msg.action) {
case RelayMsgAction.WalletReady:
this.handleWalletReady(msg as WalletReadyMessage).catch((e) =>
console.error("[wizardconnect/dapp] Error handling wallet_ready:", e),
);
this.handleWalletReady(msg as WalletReadyMessage);
break;
case RelayMsgAction.SignTransactionResponse:
this.handleSignTransactionResponse(msg as SignTransactionResponse);
@ -492,9 +428,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
case RelayMsgAction.Disconnect:
this.handleRemoteDisconnect(msg as DisconnectMessage);
break;
case RelayMsgAction.Pong:
this.lastPongTime = Date.now();
break;
case RelayMsgAction.DappReady:
// Not expected on dapp side — silently ignore
break;
@ -504,30 +437,10 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
private handleRemoteDisconnect(msg: DisconnectMessage): void {
// Give the wallet a short window to reconnect before propagating the
// disconnect. Wallets that use a keepalive watchdog send UserDisconnect
// and immediately reconnect; without this grace period the dapp would
// tear down the session before the wallet_ready arrives.
// 8s covers worst-case relay reconnect (WebSocket + 5s EOSE timeout + latency)
// while being fast enough that a real explicit disconnect is felt promptly.
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
}
this.disconnectGraceTimer = setTimeout(() => {
this.disconnectGraceTimer = null;
this.stopPingInterval();
this.emit("disconnect", msg.reason, msg.message);
}, 8_000);
this.emit("disconnect", msg.reason, msg.message);
}
private async handleWalletReady(msg: WalletReadyMessage): Promise<void> {
if (this.disconnectGraceTimer !== null) {
clearTimeout(this.disconnectGraceTimer);
this.disconnectGraceTimer = null;
}
// Treat wallet_ready as a liveness signal — resets the pong stale timer.
// This covers keepalive reconnects where the wallet reconnects instead of ponging.
this.lastPongTime = Date.now();
private handleWalletReady(msg: WalletReadyMessage): void {
this.walletDiscovered = true;
this.walletName = msg.wallet_name;
this.walletIcon = msg.wallet_icon;
@ -545,7 +458,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
time: Math.floor(Date.now() / 1000),
};
this.conn?.relay(disconnectMsg).catch(() => {});
this.stopPingInterval();
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
return;
}
@ -561,14 +473,10 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return;
}
// Transport-level capability: if the wallet advertises chunking, enable
// chunked requests. Re-applied on every wallet_ready (cheap and idempotent),
// so reconnects pick up capability changes.
if (this.conn) {
this.conn.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
}
// Enable chunked sends if the wallet advertises the transport extension
this.conn?.setPeerSupportsChunking(
!!sessionData.extensions?.[EXTENSION_CHUNKED_MESSAGES],
);
// Store raw paths for getSessionPaths() and xpub nodes for derivation
this.sessionPaths = [...sessionData.paths];
@ -588,32 +496,14 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
}
// Send dapp_ready first so the wallet's session state is confirmed before
// any sign requests arrive — await ensures correct ordering on the wire.
if (!msg.dapp_discovered) {
await this.pushDappReady().catch((e) =>
this.pushDappReady().catch((e) =>
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
);
}
this.emit("walletready", msg);
// Re-send any pending sign requests with a fresh timestamp so the wallet
// doesn't filter them as already-processed (the wallet timestamps messages
// at receive time and ignores anything older than its last disconnect).
if (this.pendingSignatureRequests.size > 0) {
const now = Math.floor(Date.now() / 1000);
for (const [, entry] of this.pendingSignatureRequests) {
const refreshed = { ...entry.request, time: now };
this.conn!.relay(refreshed)
.then(() => this.emit("messagesent", refreshed))
.catch((err) => {
this.pendingSignatureRequests.delete(entry.request.sequence);
entry.reject(err instanceof Error ? err : new Error(String(err)));
});
}
}
// Auto-persist wallet identity and xpub paths to session storage
if (this.sessionOptions) {
const sessionUpdate: Partial<StoredSession> = {
@ -628,8 +518,18 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
);
}
// Start keepalive pings now that the session is fully established.
this.startPingInterval();
// Re-send any pending sign requests the wallet may have missed
// (e.g. wallet app wasn't open when the request was first sent).
if (this.pendingSignatureRequests.size > 0 && this.conn) {
for (const [, entry] of this.pendingSignatureRequests) {
this.conn.relay(entry.request).catch((err) => {
console.error(
"[wizardconnect/dapp] Failed to re-send pending sign request:",
err,
);
});
}
}
}
private handleSignTransactionResponse(

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/react",
"version": "0.2.0",
"version": "0.1.0",
"type": "module",
"description": "React components and hooks for WizardConnect dapp integration",
"repository": {
@ -39,6 +39,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

@ -72,10 +72,6 @@ export function useWizardConnect(
setState("connected");
});
mgr.on("reconnecting", () => {
setState("reconnecting");
});
mgr.on("disconnect", () => {
setState("disconnected");
setWalletName(null);
@ -160,24 +156,10 @@ export function useWizardConnect(
});
}, [persistSession, sessionKey, storage, startRelay]);
// Cleanup on unmount.
//
// Reset `autoReconnectAttempted` here so React 18 StrictMode's dev-mode
// mount→unmount→remount cycle doesn't leave the hook in a "attempted but
// torn down" state. Without the reset:
// - Mount A: auto-reconnect fires, creates relay, ref → true.
// - StrictMode cleanup: relay destroyed.
// - Mount B: auto-reconnect sees ref === true, skips. No new relay.
// Result: a stored session never reconnects in dev. Resetting the ref on
// cleanup lets Mount B re-fire the auto-reconnect path and rebuild the
// relay. In production (no StrictMode) this only runs at real unmount, so
// the reset is harmless there.
// Cleanup on unmount
useEffect(() => {
return () => {
managerRef.current?.destroy();
relayRef.current?.cleanup();
relayRef.current = null;
autoReconnectAttempted.current = false;
};
}, []);

View file

@ -81,7 +81,6 @@ export type WizardConnectState =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "disconnected";
export interface UseWizardConnectOptions {

View file

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

View file

@ -37,6 +37,10 @@ program
"--sign",
"Send a dummy sign request after wallet is ready (tests approval flow)",
)
.option(
"--large-sign",
"Send a large (>30 KB) sign request that triggers chunking (tests the NIP-44 size-limit fix)",
)
.action(async (options) => {
await runDappMode(options);
});

View file

@ -8,6 +8,7 @@ import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
EXTENSION_CHUNKED_MESSAGES,
type RelayUpdatePayload,
type DappReadyMessage,
type WalletReadyMessage,
@ -101,6 +102,97 @@ async function sendSignRequest(
}
}
// ---- Send large sign request (chunking smoke test) ----
async function sendLargeSignRequest(
client: RelayClient,
state: DappState,
): Promise<void> {
const sequence = state.sequence++;
// Simulate a Cauldron trade with 30 contract inputs — each carrying a
// contract.artifact blob (~540 chars). This mirrors the real payload shape
// that triggered the original NIP-44 65535-byte limit error.
const sourceOutputs = Array.from({ length: 30 }, (_, i) => ({
outpointTransactionHash: "a".repeat(64),
outpointIndex: i,
lockingBytecode: "b".repeat(52),
valueSatoshis: 1_000_000,
contract: {
asmBytecode: "c".repeat(200),
artifact: {
contractName: "CauldronV4",
constructorInputs: [{ name: "ownerPkh", type: "bytes20" }],
abi: [
{
name: "trade",
inputs: [
{ name: "isAdd", type: "bool" },
{ name: "tokenAmount", type: "uint64" },
{ name: "satoshiAmount", type: "uint64" },
],
},
{
name: "withdraw",
inputs: [
{ name: "sig", type: "datasig" },
{ name: "pk", type: "pubkey" },
],
},
],
bytecode: "d".repeat(500),
source: "// CASL source\n" + "e".repeat(800),
compiler: { name: "cashscript", version: "0.10.2" },
updatedAt: "2025-01-01T00:00:00.000Z",
},
},
}));
const msg = {
action: RelayMsgAction.SignTransactionRequest,
sequence,
time: Math.floor(Date.now() / 1000),
transaction: {
transaction: {
inputs: sourceOutputs.map((_, i) => ({
outpointTransactionHash: "a".repeat(64),
outpointIndex: i,
sequenceNumber: 0xffffffff,
unlockingBytecode: "",
})),
outputs: [{ lockingBytecode: "f".repeat(46), valueSatoshis: 900_000 }],
version: 2,
locktime: 0,
},
sourceOutputs,
userPrompt: "Large chunking integration test — 30 contract inputs",
broadcast: false,
},
inputPaths: sourceOutputs.map((_, i) => [i, "defi", 0]),
};
const json = JSON.stringify(msg);
const chunkCount = Math.ceil(json.length / 28_000);
console.log(
chalk.yellow(`→ sign_transaction_request (LARGE)`) +
chalk.dim(
` seq=${sequence} ${json.length} chars → ${chunkCount} chunk${chunkCount !== 1 ? "s" : ""}`,
),
);
try {
await (client as any).relay(msg);
console.log(
chalk.green(
` ✓ sent — if wallet receives seq=${sequence} with all ${sourceOutputs.length} sourceOutputs, chunking works`,
),
);
} catch (err: any) {
console.error(chalk.red(" ✗ send error:"), err.message);
}
}
// ---- Handle incoming messages ----
function handleMessage(
@ -108,6 +200,7 @@ function handleMessage(
client: RelayClient,
state: DappState,
sendSign: boolean,
sendLargeSign: boolean,
): void {
const now = chalk.dim(new Date().toISOString().slice(11, 23));
@ -123,6 +216,12 @@ function handleMessage(
| Hdwalletv1Session
| undefined;
const pathsSummary = hdwv1?.paths?.map((p) => p.name).join(",") ?? "none";
const supportsChunking =
hdwv1?.extensions?.[EXTENSION_CHUNKED_MESSAGES] !== undefined;
if (supportsChunking) {
client.setPeerSupportsChunking(true);
}
console.log(
`${now} ` +
@ -130,7 +229,10 @@ function handleMessage(
` wallet="${chalk.bold(msg.wallet_name)}"` +
` dapp_discovered=${msg.dapp_discovered}` +
` protocols=[${msg.supported_protocols.join(",")}]` +
` paths=[${pathsSummary}]`,
` paths=[${pathsSummary}]` +
(supportsChunking
? chalk.cyan(" chunking=✓")
: chalk.dim(" chunking=✗")),
);
if (!msg.dapp_discovered) {
@ -147,6 +249,17 @@ function handleMessage(
sendSignRequest(client, state).catch(() => {});
}, 500);
}
if (sendLargeSign && state.walletReady) {
console.log(
chalk.dim(
" (--large-sign: scheduling large sign request in 500ms...)",
),
);
setTimeout(() => {
sendLargeSignRequest(client, state).catch(() => {});
}, 500);
}
break;
}
@ -183,6 +296,7 @@ export async function runDappMode(options: {
secret?: string;
walletPublicKey?: string;
sign?: boolean;
largeSign?: boolean;
}): Promise<void> {
const state = makeState();
@ -191,7 +305,13 @@ export async function runDappMode(options: {
if (options.sign)
console.log(
chalk.yellow(
"--sign: will send dummy sign request after first pubkey batch",
"--sign: will send dummy sign request after wallet is ready",
),
);
if (options.largeSign)
console.log(
chalk.yellow(
"--large-sign: will send 30-contract-input sign request (chunking smoke test)",
),
);
console.log();
@ -273,7 +393,13 @@ export async function runDappMode(options: {
// Register message handler
client.on("message", (message: ProtocolMessage) => {
handleMessage(message, client, state, options.sign ?? false);
handleMessage(
message,
client,
state,
options.sign ?? false,
options.largeSign ?? false,
);
});
// Send initial dapp_ready

View file

@ -120,11 +120,15 @@ export async function runWalletMode(options: {
});
manager.on("pendingSignRequest", (request) => {
const sourceCount =
(request.request.transaction as any)?.sourceOutputs?.length ?? "?";
const jsonSize = JSON.stringify(request.request).length;
console.log(
chalk.yellow("← sign_request") +
chalk.dim(
` conn=${request.connectionId} seq=${request.request.sequence}`,
),
) +
chalk.dim(` ${jsonSize} chars sourceOutputs=${sourceCount}`),
);
console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)"));
manager

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/wallet",
"version": "0.2.0",
"version": "0.1.2",
"type": "module",
"description": "Wallet-side integration helpers for WizardConnect",
"repository": {
@ -31,6 +31,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.7"
"vitest": "^3.2.3"
}
}

View file

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

View file

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

View file

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

View file

@ -99,7 +99,6 @@ describe("WalletConnectionManager — sign request dedup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
@ -125,7 +124,7 @@ describe("WalletConnectionManager — sign request dedup", () => {
expect(emitted).toEqual([42]);
});
it("keeps dedup guard active after sign response to block relay re-delivery", async () => {
it("allows the same sequence after response clears it", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
@ -135,18 +134,19 @@ describe("WalletConnectionManager — sign request dedup", () => {
const request = makeSignRequest(42);
// First delivery
client.emit("message", request);
expect(emitted).toEqual([42]);
// Wallet responds — clears the dedup guard
await mgr.sendSignResponse(connId, 42, "signed_hex");
// Nostr relay re-delivers the stored sign request after reconnect —
// must NOT prompt the user a second time
// Same sequence arrives again (hypothetical re-send)
client.emit("message", request);
expect(emitted).toEqual([42]);
expect(emitted).toEqual([42, 42]);
});
it("keeps dedup guard active after sign cancel to block relay re-delivery", async () => {
it("clears dedup guard on sign cancel", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
@ -165,103 +165,8 @@ describe("WalletConnectionManager — sign request dedup", () => {
time: Math.floor(Date.now() / 1000),
});
// Relay re-delivers the old sign request — must still be filtered
// Re-sent after cancel — should be accepted
client.emit("message", request);
expect(emitted).toEqual([42]);
});
});
describe("WalletConnectionManager — ping → pong", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("responds to a Ping message with a Pong", () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
client.relay.mockClear();
client.emit("message", {
action: RelayMsgAction.Ping,
time: Math.floor(Date.now() / 1000),
});
const pongCalls = client.relay.mock.calls.filter(
([msg]) => msg.action === RelayMsgAction.Pong,
);
expect(pongCalls).toHaveLength(1);
});
});
describe("WalletConnectionManager — per-connection signSequence cleanup", () => {
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(WalletConnectionManager as any).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("blocks relay-replayed COMPLETED sequence on reconnect to same URI", async () => {
// A sequence that was fully signed must be blocked on relay replay even
// after an explicit disconnect()+connect() (e.g. Paytaca 65s watchdog).
const mgr = new WalletConnectionManager(makeAdapter());
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const connId1 = mgr.connect("wiz://test");
const firstClient = simulateConnection();
firstClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]);
// Complete the request — this is what persists seq 42 to the URI cache
await mgr.sendSignResponse(connId1, 42, "signed_hex");
mgr.disconnect(connId1);
// Relay replays seq=42 on the new connection — must be blocked
mockClient = makeMockClient();
mgr.connect("wiz://test");
const secondClient = simulateConnection();
secondClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]); // still just [42]
// A genuinely new sequence from the dapp is accepted
secondClient.emit("message", makeSignRequest(44));
expect(emitted).toEqual([42, 44]);
});
it("allows dapp to resend a PENDING sequence after watchdog disconnect", () => {
// If the Paytaca watchdog fires while the user is mid-signing, the dapp
// must be able to resend the request on the new connection. Pending sequences
// must NOT be stored in the URI cache until a response is sent.
const mgr = new WalletConnectionManager(makeAdapter());
const emitted: number[] = [];
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
const connId1 = mgr.connect("wiz://test");
const firstClient = simulateConnection();
firstClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42]);
// Watchdog fires BEFORE user signs — seq 42 is still pending, no response sent
mgr.disconnect(connId1);
// Dapp reconnects and resends the same pending request — must be accepted
mockClient = makeMockClient();
mgr.connect("wiz://test");
const secondClient = simulateConnection();
secondClient.emit("message", makeSignRequest(42));
expect(emitted).toEqual([42, 42]); // resend accepted, user prompted again
expect(emitted).toEqual([42, 42]);
});
});

View file

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

View file

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