Compare commits

..

16 commits

Author SHA1 Message Date
Håvard Kittelsen
dbb2616689 Merge branch 'docs/claude-md-accuracy' into 'master'
docs: correct the integration test path, note two things easy to get wrong

See merge request riftenlabs/lib/wizardconnect!34
2026-08-19 08:00:20 +00:00
Håvard Kittelsen
34f5bcb6b7 docs: correct the integration test path, note two things easy to get wrong 2026-08-19 08:00:20 +00:00
Håvard Kittelsen
70971c1b2a Merge branch 'fix/audit-advisories' into 'master'
chore(deps): resolve npm audit advisories

See merge request riftenlabs/lib/wizardconnect!33
2026-08-19 07:07:09 +00:00
Håvard Kittelsen
c2b60b5b3a chore(deps): resolve npm audit advisories
Declared ranges — the lockfile is not published, so these are what consumers
resolve against:

  core                       ws     ^8.18.0 -> ^8.21.3  (prod, vuln 8.0.0-8.20.1)
  core, dapp, wallet, react  vitest ^3.2.3  -> ^3.2.7   (dev,  vuln <3.2.6)

Transitive, lockfile only: vite 7.3.2 -> 7.3.6, postcss 8.5.12 -> 8.5.26,
nanoid 3.3.11 -> 3.3.18, brace-expansion 5.0.5 -> 5.0.9.

npm audit --audit-level=moderate now exits 0. esbuild's low-severity Windows
dev-server advisory is left: needs a major bump behind a peer range.
2026-08-19 09:01:29 +02:00
Håvard Kittelsen
2b004a3f77 Merge branch 'fix/disconnect-race' into 'master'
Deliver the courtesy disconnect before tearing the relay down

See merge request riftenlabs/lib/wizardconnect!32
2026-08-19 06:31:09 +00:00
Håvard Kittelsen
dcda4fce6a fix(wallet): deliver the courtesy disconnect before tearing the relay down
doDisconnect fired the courtesy `disconnect` message without awaiting it, then
tore the relay connection down on the next line:

    conn.client.relay(disconnectMsg).catch(() => {});   // fire and forget
    ...
    conn.cleanup();                                      // closes the pool underneath it

relay() resolves only after `Promise.allSettled(pool.publish(...))` — a real
round trip to every configured relay. cleanup() closed the pool while that
publish was still in flight, so the message usually never left and the dapp went
on believing the wallet was connected until its own liveness timeout fired.
Downstream wallets were patching this out of the published package.

Teardown now splits into two halves with opposite timing requirements.

Registry removal stays synchronous. getConnections() is what a UI renders, and
connect() returns an existing connection for a URI, so leaving this one in the
map while its teardown is pending would hand a caller a dying connection. This
is the one place this differs from !30 and from the downstream patches, which
defer the registry removal along with the teardown.

Relay teardown is deferred until the publish settles, bounded by
DISCONNECT_PUBLISH_TIMEOUT_MS (5s). The bound matters: "the publish never
settles" is exactly the case where a relay is unreachable, and a socket that is
never closed is worse than a courtesy message that is never delivered.

disconnect() keeps its synchronous void signature — not a breaking change.

Why it shipped broken: disconnect.test.ts only covered dapp → wallet. Nothing
exercised wallet → dapp, and the failure is invisible from the wallet's side —
its own state is correct either way, and only the peer notices.
disconnect-delivery.test.ts covers that direction over a live relay, including
the two invariants the deferral must not break (registry cleared immediately,
URI reusable afterwards).

Also fixes a latent hang in the integration harness that the new file exposed.
setupConnection gated both the dapp_ready send and the message handler inside
the keyexchangecomplete callback, so the handshake hung on receiving the wallet's
single wallet_ready for the cycle. Miss it — the dapp's subscription can come up
after the wallet has already published — and key exchange never resolves, the
handler never registers, no dapp_ready is ever sent, and the wallet, guarded by
walletReadySentThisCycle, has nothing prompting it to retry. It now re-announces
dapp_ready(wallet_discovered=false) every 2s until key exchange completes, which
resets that guard and earns another wallet_ready: the recovery path mutual
discovery already specifies, which the harness was not using. Plus retry: 2 on
the integration config, since these tests talk to live relays and a dropped
connection is an environment failure rather than a regression.

docs/wallet.md gains a "Sending disconnect" section for the synchronous/deferred
split and what a caller may rely on. docs/protocol.md gains the sender-side half
of the courtesy-disconnect semantics, which previously read as though "no
acknowledgement" licensed fire-and-forget. That reading is what produced the bug.

The race was diagnosed and first fixed by hantyrram (Ronaldo Ramano) in !30,
which this supersedes — the deferral is their fix; this changes only how it is
scoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:29:34 +02:00
jakobsn
167ec21474 Merge branch 'revert-e71f76c1' into 'master'
Revert "Merge branch 'randomTradeSummary' into 'master'"

See merge request riftenlabs/lib/wizardconnect!29
2026-05-11 08:48:04 +00:00
jakobsn
d580bb8927 Revert "Merge branch 'randomTradeSummary' into 'master'"
This reverts merge request !27
2026-05-11 08:46:49 +00:00
Dagur Valberg Johannsson
dc01931ad6 Merge branch 'react-dev' into 'master'
Fix issue with restoring session in React dev mode

See merge request riftenlabs/lib/wizardconnect!28
2026-05-09 18:34:46 +00:00
Dagur Valberg Johannsson
c2b13102b9
Fix issue with restoring session in React dev mode 2026-05-09 20:25:45 +02:00
jakobsn
e71f76c1d0 Merge branch 'randomTradeSummary' into 'master'
TxSummary

See merge request riftenlabs/lib/wizardconnect!27
2026-04-29 11:23:23 +00:00
jakobsn
6b6f0ec1d0 TxSummary 2026-04-29 11:23:23 +00:00
jakobsn
201e234bef Merge branch 'cauldron-191-cauldron-and-wizard-connect-thinks-i-am-connected-but-im-not' into 'master'
Active reconnect.

See merge request riftenlabs/lib/wizardconnect!25
2026-04-29 09:06:54 +00:00
jakobsn
443acccb80 Active reconnect. 2026-04-29 09:06:53 +00:00
jakobsn
896871c0d8 Merge branch 'chunk' into 'master'
Add chunk transport extension for oversized messages

See merge request riftenlabs/lib/wizardconnect!24
2026-04-27 13:30:40 +00:00
Dagur Valberg Johannsson
b96cb8e151
Add chunk transport extension for oversized messages
Splits ProtocolMessages exceeding NIP-44's 65,535-byte plaintext ceiling
across multiple gift-wrapped events. Symmetric (both directions),
fire-and-forget, backward-compatible via a new transport-level
\`extensions\` field on \`dapp_ready\` and \`wallet_ready\`. Resolves the
\"Failed to swap: invalid plaintext size\" error on aggregated swap
requests and enables signed-tx responses up to the 1 MB BCH consensus
limit (~2 MB hex).
2026-04-21 14:58:12 +02:00
33 changed files with 2410 additions and 958 deletions

View file

@ -21,8 +21,12 @@ 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/__tests__/*.integration.test.ts`
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
- 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
- Must pass before any release
### Running tests
@ -87,3 +91,13 @@ 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,5 +1,11 @@
# 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,6 +36,9 @@ 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
@ -355,6 +358,12 @@ 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
@ -370,7 +379,9 @@ interface DisconnectMessage {
```
**Wallet side** (`WalletConnectionManager`):
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
- `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).
- Incoming `disconnect` emits a `remoteDisconnect` event
(`connectionId`, `reason`, `message`) and removes the connection.

View file

@ -50,10 +50,12 @@ connect(): Promise<void>
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
disconnect(): Promise<void>
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
// Stop subscription, close the relay pool, mark queue not-ready, update
// lastProcessedTimestamp. Kills any in-flight publish — see below.
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.
@ -78,6 +80,10 @@ 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
@ -223,3 +229,135 @@ 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,9 +69,11 @@ 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.
// Tear down all connections. Each is disconnected independently.
disconnectAll(): void
// Snapshot of all connections for UI rendering.
@ -154,6 +156,28 @@ 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,6 +32,7 @@
"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"
}
@ -1199,6 +1200,7 @@
"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",
@ -1398,15 +1400,15 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@ -1415,13 +1417,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@ -1442,9 +1444,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1455,13 +1457,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
@ -1470,13 +1472,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@ -1485,9 +1487,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1498,13 +1500,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
@ -1538,6 +1540,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -1605,16 +1608,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/cac": {
@ -1846,6 +1849,7 @@
"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",
@ -2466,9 +2470,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"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==",
"dev": true,
"funding": [
{
@ -2702,9 +2706,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@ -2722,7 +2726,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -3092,6 +3096,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -3142,13 +3147,14 @@
}
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@ -3240,20 +3246,20 @@
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@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",
"@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",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
@ -3283,8 +3289,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
@ -3366,10 +3372,11 @@
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@ -3401,7 +3408,7 @@
},
"packages/core": {
"name": "@wizardconnect/core",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"@bitauth/libauth": "^3.1.0-next.2",
@ -3409,28 +3416,28 @@
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"nostr-tools": "^2.23.0",
"ws": "^8.18.0"
"ws": "^8.21.3"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
},
"packages/dapp": {
"name": "@wizardconnect/dapp",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@wizardconnect/core": "*",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
},
"packages/react": {
"name": "@wizardconnect/react",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
@ -3443,7 +3450,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
},
"peerDependencies": {
"react": ">=18.0.0",
@ -3456,6 +3463,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3476,6 +3484,7 @@
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -3502,7 +3511,7 @@
},
"packages/test-cli": {
"name": "@wizardconnect/test-cli",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3522,7 +3531,7 @@
},
"packages/wallet": {
"name": "@wizardconnect/wallet",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3530,7 +3539,7 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/core",
"version": "0.1.2",
"version": "0.2.0",
"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.18.0"
"ws": "^8.21.3"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -1,381 +0,0 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect, beforeEach, 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

@ -1,136 +0,0 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import {
type 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,6 +31,12 @@ 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,
@ -44,4 +50,3 @@ export {
toUint8Array,
toBigInt,
} from "./serialize.js";
export { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";

View file

@ -29,6 +29,10 @@ 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 {
@ -48,6 +52,10 @@ 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 {
@ -93,3 +101,11 @@ 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,10 +25,14 @@ export enum RelayMsgAction {
SignCancel = "sign_cancel",
/// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect",
/// 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",
/// Transport-level: carries one slice of a message that exceeds NIP-44's
/// 65,535-byte plaintext ceiling. See ChunkMessage and docs/transport.md.
/// Not tied to hdwalletv1 semantics — applies to any application protocol.
Chunk = "chunk",
/// Keepalive ping sent by the dapp every 10 s to prevent relay silence timeouts.
Ping = "ping",
/// Keepalive pong sent by the wallet in response to each ping.
Pong = "pong",
}
export interface ProtocolMessage {
@ -150,23 +154,18 @@ export interface SignCancelMessage extends ProtocolMessage {
reason?: 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;
/// Transport-level message carrying one slice of a larger ProtocolMessage.
///
/// All chunks of one logical message share the same msgId and time.
/// `data` is a base64-encoded slice of the UTF-8 bytes of
/// JSON.stringify(originalMessage); concatenate slices in `index` order,
/// base64-decode, UTF-8-decode, then JSON.parse to reconstruct.
export interface ChunkMessage extends ProtocolMessage {
action: RelayMsgAction.Chunk;
msgId: string;
index: number;
total: number;
data: string;
}
// Type guard functions
@ -217,14 +216,19 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
);
}
export function isChunkRelayMessage(msg: any): msg is ChunkRelayMessage {
export function isChunkMessage(msg: any): msg is ChunkMessage {
return (
msg &&
typeof msg === "object" &&
msg.action === RelayMsgAction.ChunkRelay &&
typeof msg.chunk_id === "string" &&
typeof msg.chunk_index === "number" &&
typeof msg.chunk_total === "number" &&
typeof msg.chunk_data === "string"
msg.action === RelayMsgAction.Chunk &&
typeof msg.msgId === "string" &&
typeof msg.index === "number" &&
typeof msg.total === "number" &&
typeof msg.data === "string" &&
Number.isInteger(msg.index) &&
Number.isInteger(msg.total) &&
msg.total >= 1 &&
msg.index >= 0 &&
msg.index < msg.total
);
}

View file

@ -218,3 +218,88 @@ 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,33 +14,20 @@ 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";
/// 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;
import {
ChunkReassembler,
needsChunking,
splitIntoChunks,
} from "./transforms/chunk.js";
useWebSocketImplementation(WebSocket);
@ -55,6 +42,12 @@ 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;
@ -65,11 +58,17 @@ 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),
);
@ -90,6 +89,12 @@ 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 = {
@ -103,6 +108,15 @@ 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),
);
@ -117,6 +131,13 @@ 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 {
@ -126,9 +147,26 @@ 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;
}
@ -156,6 +194,7 @@ export class RelayClient extends EventEmitter {
}
this.disconnecting = false;
this.reassembler.start();
if (this.lastProcessedTimestamp === 0) {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
@ -215,8 +254,11 @@ export class RelayClient extends EventEmitter {
async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
const key = this.sessionKey;
if (key)
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
this.messageQueue.setNotReady();
this.peerSupportsChunking = false;
this.reassembler.stop();
if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId);
@ -241,16 +283,6 @@ 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(
@ -258,14 +290,6 @@ 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);
}
@ -273,43 +297,54 @@ export class RelayClient extends EventEmitter {
return this.publishMessage(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);
private async publishMessage(message: ProtocolMessage): Promise<void> {
const serialized = JSON.stringify(message);
debug(
Scope.Relay,
`Chunking ${originalAction} into ${chunkTotal} fragments (${messageJson.length} chars)`,
);
if (!needsChunking(serialized)) {
return this.publishSerialized(message.action, serialized);
}
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);
// Oversized. Chunk if the peer supports it; otherwise fail loudly with
// an actionable message (replacing nostr-tools' cryptic plaintext-size error).
if (!this.peerSupportsChunk) {
const err = new Error(
`Cannot send ${message.action}: message is larger than NIP-44's 65,535-byte ceiling ` +
`and the peer does not advertise the 'chunk' transport extension. ` +
`Please update the connected wallet/dapp to a version that supports chunked messages.`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, err.message);
}
throw err;
}
const 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),
);
}
}
private async publishMessage(message: ProtocolMessage): Promise<void> {
this.netlog("send", message.action);
/// Wrap and publish one gift-wrap event. Used for both unchunked messages
/// and individual chunks. `displayAction` is only used for logs.
private async publishSerialized(
displayAction: string,
serialized: string,
): Promise<void> {
this.netlog("send", displayAction);
const wrapped = wrapEvent(
{
kind: KIND_PRIVATE_DIRECT_MESSAGE,
content: JSON.stringify(message),
content: serialized,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.pairedPubkeyHex]],
},
@ -328,7 +363,7 @@ export class RelayClient extends EventEmitter {
for (const r of rejected) {
logError(
Scope.Relay,
`Failed to publish ${message.action} to a relay:`,
`Failed to publish ${displayAction} to a relay:`,
(r as PromiseRejectedResult).reason,
);
}
@ -336,7 +371,7 @@ export class RelayClient extends EventEmitter {
if (fulfilled.length === 0) {
const error = new Error(
`Failed to publish ${message.action} to all relays`,
`Failed to publish ${displayAction} to all relays`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, error.message);
@ -348,7 +383,7 @@ export class RelayClient extends EventEmitter {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Published message ${message.action} to ${fulfilled.length}/${results.length} relay(s)`,
`Published message ${displayAction} to ${fulfilled.length}/${results.length} relay(s)`,
);
}
}
@ -357,68 +392,27 @@ export class RelayClient extends EventEmitter {
try {
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
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 (rumor.kind !== KIND_PRIVATE_DIRECT_MESSAGE) {
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);
@ -427,6 +421,76 @@ 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;
}
@ -462,19 +526,6 @@ 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

@ -0,0 +1,322 @@
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

@ -0,0 +1,305 @@
// 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.1.2",
"version": "0.2.0",
"type": "module",
"description": "Dapp-side integration helpers for WizardConnect",
"repository": {
@ -29,6 +29,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -5,10 +5,15 @@
import { describe, it, expect, vi } from "vitest";
import { encodeHdPublicKey } from "@bitauth/libauth";
import type { PathXpub } from "@wizardconnect/core";
import { RelayMsgAction, PROTOCOL_NAME } from "@wizardconnect/core";
import {
RelayMsgAction,
PROTOCOL_NAME,
DisconnectReason,
} from "@wizardconnect/core";
import type {
WalletReadyMessage,
SignTransactionRequest,
DisconnectMessage,
ProtocolMessage,
} from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
@ -147,8 +152,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);
@ -235,4 +240,417 @@ 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,12 +16,14 @@ import {
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
PingMessage,
ProtocolMessage,
PROTOCOL_NAME,
childIndexOfPathName,
isHdwalletv1Session,
binToHex,
EXTENSION_CHUNKED_MESSAGES,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core";
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
@ -50,6 +52,8 @@ 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: [];
}
/**
@ -78,6 +82,10 @@ 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,
@ -199,6 +207,14 @@ 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");
}
}
}
@ -386,17 +402,59 @@ 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) {
await this.pushDappReady();
}
if (!this.conn) return;
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;
@ -410,6 +468,10 @@ 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);
@ -420,7 +482,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagereceived", msg);
switch (msg.action) {
case RelayMsgAction.WalletReady:
this.handleWalletReady(msg as WalletReadyMessage);
this.handleWalletReady(msg as WalletReadyMessage).catch((e) =>
console.error("[wizardconnect/dapp] Error handling wallet_ready:", e),
);
break;
case RelayMsgAction.SignTransactionResponse:
this.handleSignTransactionResponse(msg as SignTransactionResponse);
@ -428,6 +492,9 @@ 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;
@ -437,10 +504,30 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
private handleRemoteDisconnect(msg: DisconnectMessage): void {
this.emit("disconnect", msg.reason, msg.message);
// 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);
}
private handleWalletReady(msg: WalletReadyMessage): void {
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();
this.walletDiscovered = true;
this.walletName = msg.wallet_name;
this.walletIcon = msg.wallet_icon;
@ -458,6 +545,7 @@ 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;
}
@ -473,10 +561,14 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return;
}
// Enable chunked sends if the wallet advertises the transport extension
this.conn?.setPeerSupportsChunking(
!!sessionData.extensions?.[EXTENSION_CHUNKED_MESSAGES],
);
// Transport-level capability: if the wallet advertises chunking, enable
// chunked requests. Re-applied on every wallet_ready (cheap and idempotent),
// so reconnects pick up capability changes.
if (this.conn) {
this.conn.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
}
// Store raw paths for getSessionPaths() and xpub nodes for derivation
this.sessionPaths = [...sessionData.paths];
@ -496,14 +588,32 @@ 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) {
this.pushDappReady().catch((e) =>
await 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> = {
@ -518,18 +628,8 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
);
}
// 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,
);
});
}
}
// Start keepalive pings now that the session is fully established.
this.startPingInterval();
}
private handleSignTransactionResponse(

View file

@ -1,6 +1,6 @@
{
"name": "@wizardconnect/react",
"version": "0.1.0",
"version": "0.2.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.3"
"vitest": "^3.2.7"
}
}

View file

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

View file

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

View file

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

View file

@ -37,10 +37,6 @@ 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,7 +8,6 @@ import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
EXTENSION_CHUNKED_MESSAGES,
type RelayUpdatePayload,
type DappReadyMessage,
type WalletReadyMessage,
@ -102,97 +101,6 @@ 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(
@ -200,7 +108,6 @@ function handleMessage(
client: RelayClient,
state: DappState,
sendSign: boolean,
sendLargeSign: boolean,
): void {
const now = chalk.dim(new Date().toISOString().slice(11, 23));
@ -216,12 +123,6 @@ 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} ` +
@ -229,10 +130,7 @@ function handleMessage(
` wallet="${chalk.bold(msg.wallet_name)}"` +
` dapp_discovered=${msg.dapp_discovered}` +
` protocols=[${msg.supported_protocols.join(",")}]` +
` paths=[${pathsSummary}]` +
(supportsChunking
? chalk.cyan(" chunking=✓")
: chalk.dim(" chunking=✗")),
` paths=[${pathsSummary}]`,
);
if (!msg.dapp_discovered) {
@ -249,17 +147,6 @@ 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;
}
@ -296,7 +183,6 @@ export async function runDappMode(options: {
secret?: string;
walletPublicKey?: string;
sign?: boolean;
largeSign?: boolean;
}): Promise<void> {
const state = makeState();
@ -305,13 +191,7 @@ export async function runDappMode(options: {
if (options.sign)
console.log(
chalk.yellow(
"--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)",
"--sign: will send dummy sign request after first pubkey batch",
),
);
console.log();
@ -393,13 +273,7 @@ export async function runDappMode(options: {
// Register message handler
client.on("message", (message: ProtocolMessage) => {
handleMessage(
message,
client,
state,
options.sign ?? false,
options.largeSign ?? false,
);
handleMessage(message, client, state, options.sign ?? false);
});
// Send initial dapp_ready

View file

@ -120,15 +120,11 @@ 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.1.2",
"version": "0.2.0",
"type": "module",
"description": "Wallet-side integration helpers for WizardConnect",
"repository": {
@ -31,6 +31,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -0,0 +1,190 @@
// 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

@ -0,0 +1,98 @@
// 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,6 +22,8 @@ import {
initiateDappRelay,
RelayMsgAction,
PROTOCOL_NAME,
chunkExtensionAdvertisement,
peerSupportsChunk,
type RelayClient,
type RelayUpdatePayload,
type DappReadyMessage,
@ -110,6 +112,10 @@ 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 -----------------------------------------------------------
@ -147,6 +153,7 @@ export async function setupConnection(
// ---- Dapp side ----
const walletReadyMessages: WalletReadyMessage[] = [];
const allMessages: ProtocolMessage[] = [];
let dappClient: RelayClient | null = null;
let keyExchanged = false;
@ -163,6 +170,9 @@ 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);
}
@ -182,16 +192,51 @@ 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);
}
});
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
// 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.
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);
@ -199,7 +244,16 @@ export async function setupConnection(
// ---- Wait for key exchange ----
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "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);
}
// ---- Wait for wallet_ready with paths ----
@ -217,6 +271,8 @@ export async function setupConnection(
uri: dappRelay.uri,
cleanup: dappRelay.cleanup,
walletReadyMessages,
client: () => dappClient,
messages: allMessages,
};
const walletHandle: WalletHandle = { manager, connectionId, adapter };

View file

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

View file

@ -18,11 +18,14 @@ import {
WalletReadyMessage,
DisconnectMessage,
DisconnectReason,
PingMessage,
PongMessage,
PathXpub,
Hdwalletv1Session,
PROTOCOL_NAME,
binToHex,
EXTENSION_CHUNKED_MESSAGES,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core";
import { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
@ -42,6 +45,15 @@ 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;
@ -56,6 +68,8 @@ 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
@ -88,6 +102,11 @@ 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) {
@ -121,6 +140,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
connectedAt: Date.now(),
dappDiscovered: false,
walletReadySentThisCycle: false,
signSequences: new Set(),
notificationQueue: [],
notificationProcessor: null,
walletPublicKeyHex: "",
@ -129,6 +149,16 @@ 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,
) => {
@ -196,19 +226,58 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
const conn = this.connections.get(connectionId);
if (!conn) return;
if (sendMessage && conn.client) {
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(disconnectMsg).catch(() => {});
// 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);
}
clearInterval(conn.notificationProcessor ?? undefined);
conn.cleanup();
conn.notificationProcessor = null;
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();
});
}
/**
@ -243,7 +312,17 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
throw new Error(`Connection ${connectionId} not found or not connected`);
}
this.activeSignSequences.delete(sequence);
// Intentionally keep sequence in activeSignSequences and conn.signSequences.
// Nostr relays replay stored events on reconnect; removing the guard here
// would let a re-delivered sign_transaction_request pass dedup and prompt
// the user a second time for an already-completed request.
// doDisconnect() is the sole cleanup point for these sets.
//
// Now that the request is complete, persist to the URI cache so a fresh
// connection created after a watchdog disconnect also blocks relay replays.
// Pending sequences are NOT stored until completion so the dapp can resend
// them if the watchdog fires while the user is still signing.
this.persistCompletedSequence(conn, sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
@ -268,7 +347,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
return; // Already disconnected, nothing to do
}
this.activeSignSequences.delete(sequence);
// Same reasoning as sendSignResponse: keep in dedup guard until doDisconnect.
// Persist completed sequence to URI cache so relay replays are blocked on reconnect.
this.persistCompletedSequence(conn, sequence);
const response: SignTransactionResponse = {
action: RelayMsgAction.SignTransactionResponse,
@ -281,6 +362,27 @@ 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). */
@ -309,7 +411,12 @@ 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) {
@ -345,6 +452,9 @@ 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);
}
@ -362,10 +472,22 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
conn: ActiveConnection,
msg: SignCancelMessage,
): void {
this.activeSignSequences.delete(msg.sequence);
// Keep in dedup guard: the relay may still re-deliver the original
// sign_transaction_request after the cancel. doDisconnect() cleans up.
// Persist to URI cache so the cancelled sequence is blocked on reconnect too.
this.persistCompletedSequence(conn, msg.sequence);
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
}
private handlePing(conn: ActiveConnection, _msg: PingMessage): void {
if (!conn.client) return;
const pong: PongMessage = {
action: RelayMsgAction.Pong,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(pong).catch(() => {});
}
private async handleDappReady(
conn: ActiveConnection,
msg: DappReadyMessage,
@ -386,6 +508,15 @@ 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.
@ -408,12 +539,10 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
...(this.adapter.getAdditionalPaths?.() ?? []),
];
const adapterExtensions = this.adapter.getExtensions?.();
const hdwv1Session: Hdwalletv1Session = {
paths,
extensions: {
[EXTENSION_CHUNKED_MESSAGES]: {},
...this.adapter.getExtensions?.(),
},
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
};
const msg: WalletReadyMessage = {
@ -428,6 +557,9 @@ 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);
@ -445,6 +577,7 @@ 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,6 +9,10 @@ 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: {