Compare commits
4 commits
master
...
chunkingPl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d710dbb5 | ||
|
|
cdd357cb08 | ||
|
|
318d97439f | ||
|
|
538513246b |
33 changed files with 958 additions and 2410 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -21,12 +21,8 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
|
|||
**Integration tests** (`npm run test:integration` in a package):
|
||||
- Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
|
||||
- Test the full protocol handshake end-to-end
|
||||
- Located in `src/integration/*.test.ts` (wallet is the only package with them today)
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially
|
||||
(`singleFork`) to avoid relay contention, and with `retry: 2` — these hit third-party
|
||||
relays, so a dropped connection is an environment failure rather than a regression
|
||||
- A failure that reproduces locally is real; one that does not is usually the relay.
|
||||
Check whether the same test passed on an earlier pipeline before assuming a regression
|
||||
- Located in `src/__tests__/*.integration.test.ts`
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
|
||||
- Must pass before any release
|
||||
|
||||
### Running tests
|
||||
|
|
@ -91,13 +87,3 @@ npm run build # builds all packages in dependency order
|
|||
```
|
||||
|
||||
Packages must be built before integration tests run (tests import from `dist/`).
|
||||
|
||||
## Releases
|
||||
|
||||
The `publish` CI job runs on `master` only, via `contrib/auto-publish.js`.
|
||||
|
||||
**The `version` in each `package.json` is a floor, not the shipped version** — all declare
|
||||
`0.2.0` while npm carries higher patches. Use `npm view @wizardconnect/<pkg> version`.
|
||||
|
||||
The lockfile is not published, so clearing a dependency advisory means bumping the declared
|
||||
range in `package.json`, not just `package-lock.json`.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
# Extensions
|
||||
|
||||
This document covers `hdwalletv1` protocol-level extensions — optional capabilities that extend
|
||||
the application protocol (extra path names, custom message actions, per-wallet features). For
|
||||
transport-level capabilities that apply below the application protocol regardless of which
|
||||
protocol is in use (chunking, future: compression), see
|
||||
[transport.md § Transport-level extensions](transport.md#transport-level-extensions).
|
||||
|
||||
The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate
|
||||
additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible:
|
||||
existing wallets and dapps that don't know about extensions continue to work unchanged.
|
||||
|
|
|
|||
|
|
@ -36,9 +36,6 @@ sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
|
|||
sign_transaction_response — wallet → dapp, returns signed tx or error
|
||||
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
|
||||
disconnect — either → either, courtesy notification before tearing down
|
||||
chunk — either → either, transport-level. Carries one slice of a larger message
|
||||
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
|
||||
hdwalletv1 semantics. See transport.md.
|
||||
```
|
||||
|
||||
The action names above are the well-known set. Extensions may define additional action strings
|
||||
|
|
@ -358,12 +355,6 @@ Either side may send a `disconnect` message before tearing down the relay connec
|
|||
courtesy notification — the remote side treats the connection as closed immediately upon receipt
|
||||
(no acknowledgement).
|
||||
|
||||
No acknowledgement does not mean fire-and-forget on the sender's side. `relay()` resolves only
|
||||
after the publish has been settled against every configured relay, so the sender must keep the
|
||||
relay connection open until then — closing it first kills the publish in flight and the peer
|
||||
never learns of the disconnect. Sending a courtesy `disconnect` and immediately tearing the
|
||||
transport down is the same as not sending one.
|
||||
|
||||
```typescript
|
||||
enum DisconnectReason {
|
||||
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
|
||||
|
|
@ -379,9 +370,7 @@ interface DisconnectMessage {
|
|||
```
|
||||
|
||||
**Wallet side** (`WalletConnectionManager`):
|
||||
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish
|
||||
settles — bounded, so an unreachable relay cannot hold the socket open. The connection leaves
|
||||
the registry synchronously. See [wallet.md § Sending disconnect](wallet.md#sending-disconnect).
|
||||
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
|
||||
- Incoming `disconnect` emits a `remoteDisconnect` event
|
||||
(`connectionId`, `reason`, `message`) and removes the connection.
|
||||
|
||||
|
|
|
|||
|
|
@ -50,12 +50,10 @@ connect(): Promise<void>
|
|||
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
|
||||
|
||||
disconnect(): Promise<void>
|
||||
// Stop subscription, close the relay pool, mark queue not-ready, update
|
||||
// lastProcessedTimestamp. Kills any in-flight publish — see below.
|
||||
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
|
||||
|
||||
relay(message: ProtocolMessage): Promise<void>
|
||||
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
|
||||
// Resolves only once the publish has settled against every configured relay.
|
||||
|
||||
setPairedPublicKey(key: Uint8Array): void
|
||||
// Called after key exchange. Enables outbound messages and incoming peer filtering.
|
||||
|
|
@ -80,10 +78,6 @@ regardless (avoiding silent message loss on slow connections).
|
|||
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
|
||||
held rather than dropped.
|
||||
|
||||
Because `disconnect()` closes the pool, it kills any publish still in flight — so anything
|
||||
sending a final message before tearing down must await the `relay()` first. See
|
||||
[wallet.md § Sending disconnect](wallet.md#sending-disconnect).
|
||||
|
||||
### Replay protection
|
||||
|
||||
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated
|
||||
|
|
@ -229,135 +223,3 @@ nostr-tools `SimplePool.subscribeMany()` deduplicates events by ID — it tracks
|
|||
in a per-subscription `_knownIds` set and only fires `onevent` once per unique ID. Since
|
||||
`pool.publish(urls, event)` sends the identical event (same ID) to all relays, the receiving
|
||||
side's pool delivers it exactly once.
|
||||
|
||||
## Transport-level extensions
|
||||
|
||||
Transport-level extensions are capabilities of the relay/gift-wrap transport itself, independent
|
||||
of any application protocol. They're distinct from the protocol-level extensions documented in
|
||||
[extensions.md](extensions.md), which extend `hdwalletv1` specifically.
|
||||
|
||||
### Negotiation
|
||||
|
||||
Both sides advertise transport-level extensions in a base `extensions` field on their handshake
|
||||
message (`dapp_ready` / `wallet_ready`). The shape is identical on both sides:
|
||||
|
||||
```typescript
|
||||
interface DappReadyMessage {
|
||||
// ...
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface WalletReadyMessage {
|
||||
// ...
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
A capability is considered enabled only when **both** sides advertise it. `RelayClient` exposes
|
||||
`setPeerCapabilities({...})` so the connection managers can inform it once they've parsed the
|
||||
peer's `_ready` message.
|
||||
|
||||
Extension values are per-extension; today `chunk` uses `{ version: 1 }`. Unknown extension keys
|
||||
are ignored by implementations that don't recognise them, so adding new capabilities is always
|
||||
backward-compatible — an old peer simply doesn't advertise them and the new side falls back to
|
||||
the non-extended behaviour.
|
||||
|
||||
### Known transport extensions
|
||||
|
||||
| Extension | Purpose |
|
||||
|---|---|
|
||||
| `chunk` | Split messages larger than NIP-44's 65,535-byte plaintext ceiling across multiple gift-wrapped events. Symmetric — enabled when both sides advertise. See below. |
|
||||
|
||||
## Chunking (`chunk` extension)
|
||||
|
||||
### Why it exists
|
||||
|
||||
NIP-44 caps plaintext at 65,535 bytes — the length is encoded as a U16BE prefix in the wire
|
||||
format, so the limit is structural, not a guardrail that can be raised. NIP-17 gift-wrap
|
||||
additionally encrypts twice (rumor inside seal inside wrap), so a single 50+ KB `ProtocolMessage`
|
||||
will blow the outer wrap's plaintext budget even when the inner payload itself looks small enough.
|
||||
|
||||
Real scenarios that exceed the cap:
|
||||
|
||||
- Aggregated swap `sign_transaction_request` with many pool UTXOs (each with hex `lockingBytecode`
|
||||
and `unlockingBytecode` in the per-input `sourceOutputs`).
|
||||
- `sign_transaction_response` carrying a signed transaction hex string. Policy-max BCH transactions
|
||||
are 100 KB (→ 200 KB hex); consensus-max is 1 MB (→ 2 MB hex).
|
||||
|
||||
### Wire format
|
||||
|
||||
```typescript
|
||||
interface ChunkMessage extends ProtocolMessage {
|
||||
action: "chunk";
|
||||
time: number; // shared across all chunks of one logical message
|
||||
msgId: string; // random identifier, shared across all chunks
|
||||
index: number; // 0-based chunk index within [0, total)
|
||||
total: number; // total number of chunks for this msgId
|
||||
data: string; // base64 slice of the UTF-8 bytes of JSON.stringify(originalMessage)
|
||||
}
|
||||
```
|
||||
|
||||
To reconstruct the original message: concatenate the `data` strings in `index` order, base64-decode
|
||||
to bytes, UTF-8-decode to a string, `JSON.parse`.
|
||||
|
||||
### Sender
|
||||
|
||||
`RelayClient.publishMessage` measures the UTF-8 byte length of the serialized `ProtocolMessage`.
|
||||
If it exceeds the per-message threshold:
|
||||
|
||||
- If the peer's `chunk` capability is enabled, the message is split into `ChunkMessage`s and each
|
||||
is published individually via the same `wrapEvent` path as any other message. No ACKs — see
|
||||
"Failure modes" below.
|
||||
- If the peer has not advertised `chunk`, `publishMessage` throws a structured error
|
||||
(`"Cannot send <action>: message is larger than NIP-44's 65,535-byte ceiling and the peer does
|
||||
not advertise the 'chunk' transport extension. Please update the connected wallet/dapp..."`).
|
||||
This replaces the cryptic `invalid plaintext size` error from nostr-tools.
|
||||
|
||||
The per-chunk budget is sized conservatively. Each chunk's raw data is ≤ 30,000 bytes, which after
|
||||
base64 expansion (~4/3×), JSON envelope overhead, and the two-layer NIP-17 gift-wrap encryption
|
||||
stays well under NIP-44's 65,535-byte ceiling for the outer wrap's plaintext. See
|
||||
`packages/core/src/transforms/chunk.ts` for the derivation.
|
||||
|
||||
### Receiver
|
||||
|
||||
`RelayClient` owns a `ChunkReassembler` instance. Chunks pass the same peer filter and
|
||||
`lastProcessedTimestamp` dedup as any other message, then are routed to the reassembler. When all
|
||||
chunks for a `msgId` have arrived (in any order), the bytes are concatenated, decoded, and the
|
||||
resulting `ProtocolMessage` is handed to the application-level handler — indistinguishable to
|
||||
the application from an unchunked message of equivalent size.
|
||||
|
||||
The reassembler holds two maps with TTL eviction:
|
||||
|
||||
- **in-flight buffers** keyed by `msgId` — collects chunks until complete; default TTL 120 s,
|
||||
sized to comfortably fit a ~35-chunk 2 MB response under real relay latency.
|
||||
- **completed** — tracks `msgId`s we've already delivered, for the same TTL, so late-arriving
|
||||
duplicates (e.g. cross-subscription replay after reconnect) don't spawn a second reassembly and
|
||||
double-deliver.
|
||||
|
||||
A background sweeper runs every 10 s while connected and evicts expired entries. Reassembly state
|
||||
is not persisted — reconnects rely on the relay replaying events to complete any in-flight
|
||||
transfer (see below).
|
||||
|
||||
### Failure modes
|
||||
|
||||
| Failure | Behaviour |
|
||||
|---|---|
|
||||
| Dapp reloads mid-send | Dapp on reload has no in-flight state. User retries → fresh `msgId`, all chunks re-sent. Wallet's partial buffer for the old `msgId` expires via TTL. |
|
||||
| Wallet reloads mid-receive | Subscription filter has no `since` clause, so on reconnect the relay re-delivers all events addressed to the wallet. Chunks reassemble fresh. Works as long as the chunks are still within the relay's retention window. |
|
||||
| Network blip / all relays reject a chunk | `Promise.allSettled` on publish treats each chunk identically to any other single message. If all relays reject, `publishMessage` throws and `emitDisconnect` fires — same posture as today's sign-request failure. |
|
||||
| Relay prunes mid-reassembly | Receiver's partial times out via TTL. Application sees no response; user retries — same failure mode the protocol already has for any lost sign request. |
|
||||
|
||||
No persistence layer, no per-chunk ACKs, no new round trips. The assembled `ProtocolMessage`'s
|
||||
own response (e.g. `sign_transaction_response`) is the effective end-to-end ACK.
|
||||
|
||||
### Backward compatibility
|
||||
|
||||
| Dapp | Wallet | Outcome |
|
||||
|---|---|---|
|
||||
| Old | Old | Unchanged. Oversized message fails at nostr-tools with the raw NIP-44 error. |
|
||||
| **New** | Old | Dapp detects absent `chunk` capability and throws a clear upgrade-guidance error instead of the cryptic NIP-44 error. |
|
||||
| Old | **New** | Wallet detects absent `chunk` capability and throws a clear error symmetrically. |
|
||||
| **New** | **New** | Both advertise, both enable, oversized messages chunk-and-reassemble transparently. Application code is unchanged. |
|
||||
|
||||
Upgrading the library on both sides is sufficient — no adapter-interface changes, no new
|
||||
configuration, no capability opt-in. The extension is always-on when supported.
|
||||
|
|
|
|||
|
|
@ -69,11 +69,9 @@ class WalletConnectionManager extends EventEmitter {
|
|||
connect(uri: string): string
|
||||
|
||||
// Tear down a specific connection, sending a UserDisconnect courtesy message.
|
||||
// Returns as soon as the connection has left the registry; the relay socket
|
||||
// closes once the courtesy message is published. See § Sending disconnect.
|
||||
disconnect(connectionId: string): void
|
||||
|
||||
// Tear down all connections. Each is disconnected independently.
|
||||
// Tear down all connections.
|
||||
disconnectAll(): void
|
||||
|
||||
// Snapshot of all connections for UI rendering.
|
||||
|
|
@ -156,28 +154,6 @@ wallet_discovered=true → set dappDiscovered=true, no further action
|
|||
|
||||
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
|
||||
|
||||
### Sending disconnect
|
||||
|
||||
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
|
||||
have opposite timing requirements.
|
||||
|
||||
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
|
||||
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
|
||||
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
|
||||
connection left in the map during teardown would be handed back to a caller as if it were live.
|
||||
|
||||
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
|
||||
published. `RelayClient.relay()` resolves after the publish settles against every configured
|
||||
relay, which is a real round trip; closing the socket before that kills the publish in flight and
|
||||
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
|
||||
|
||||
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
|
||||
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
|
||||
courtesy message that is never delivered.
|
||||
|
||||
Callers do not need to await anything. The observable contract is that state is correct
|
||||
immediately and delivery is best-effort within the timeout.
|
||||
|
||||
### Receiving disconnect
|
||||
|
||||
When a `disconnect` message arrives from the dapp:
|
||||
|
|
|
|||
143
package-lock.json
generated
143
package-lock.json
generated
|
|
@ -32,7 +32,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
|
||||
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
|
|
@ -1200,7 +1199,6 @@
|
|||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
|
|
@ -1400,15 +1398,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
|
|
@ -1417,13 +1415,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
|
|
@ -1444,9 +1442,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1457,13 +1455,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
|
|
@ -1472,13 +1470,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -1487,9 +1485,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1500,13 +1498,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
|
|
@ -1540,7 +1538,6 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1608,16 +1605,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
|
|
@ -1849,7 +1846,6 @@
|
|||
"integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
|
@ -2470,9 +2466,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2706,9 +2702,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2726,7 +2722,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -3096,7 +3092,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -3147,14 +3142,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
|
@ -3246,20 +3240,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.7",
|
||||
"@vitest/mocker": "3.2.7",
|
||||
"@vitest/pretty-format": "^3.2.7",
|
||||
"@vitest/runner": "3.2.7",
|
||||
"@vitest/snapshot": "3.2.7",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
|
|
@ -3289,8 +3283,8 @@
|
|||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
|
@ -3372,11 +3366,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
|
@ -3408,7 +3401,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@wizardconnect/core",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@bch-wc2/interfaces": "^0.0.8",
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
|
|
@ -3416,28 +3409,28 @@
|
|||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"nostr-tools": "^2.23.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/dapp": {
|
||||
"name": "@wizardconnect/dapp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/react": {
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"@wizardconnect/dapp": "*",
|
||||
|
|
@ -3450,7 +3443,7 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
|
|
@ -3463,7 +3456,6 @@
|
|||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -3484,7 +3476,6 @@
|
|||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -3511,7 +3502,7 @@
|
|||
},
|
||||
"packages/test-cli": {
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
|
|
@ -3531,7 +3522,7 @@
|
|||
},
|
||||
"packages/wallet": {
|
||||
"name": "@wizardconnect/wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
|
|
@ -3539,7 +3530,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@wizardconnect/core",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"description": "Transport and protocol primitives for WizardConnect",
|
||||
"repository": {
|
||||
|
|
@ -40,10 +40,10 @@
|
|||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
381
packages/core/src/chunk-assembler.test.ts
Normal file
381
packages/core/src/chunk-assembler.test.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";
|
||||
import {
|
||||
type ChunkRelayMessage,
|
||||
RelayMsgAction,
|
||||
} from "./protocols/hdwalletv1.js";
|
||||
|
||||
function makeChunk(
|
||||
chunk_id: string,
|
||||
chunk_index: number,
|
||||
chunk_total: number,
|
||||
chunk_data: string,
|
||||
): ChunkRelayMessage {
|
||||
return {
|
||||
action: RelayMsgAction.ChunkRelay,
|
||||
chunk_id,
|
||||
chunk_index,
|
||||
chunk_total,
|
||||
chunk_data,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function splitMessage(
|
||||
message: object,
|
||||
chunkSize: number,
|
||||
chunk_id: string,
|
||||
): ChunkRelayMessage[] {
|
||||
const json = JSON.stringify(message);
|
||||
const chunks: ChunkRelayMessage[] = [];
|
||||
const total = Math.ceil(json.length / chunkSize);
|
||||
for (let i = 0; i < total; i++) {
|
||||
chunks.push(
|
||||
makeChunk(
|
||||
chunk_id,
|
||||
i,
|
||||
total,
|
||||
json.slice(i * chunkSize, (i + 1) * chunkSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
describe("ChunkAssembler", () => {
|
||||
let assembler: ChunkAssembler;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
assembler = new ChunkAssembler();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("single-fragment messages", () => {
|
||||
it("reassembles a single-fragment message immediately", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const [chunk] = splitMessage(original, 10000, "id1");
|
||||
|
||||
const result = assembler.addChunk(chunk);
|
||||
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
|
||||
it("clears the pending buffer after assembly", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const [chunk] = splitMessage(original, 10000, "id1");
|
||||
|
||||
assembler.addChunk(chunk);
|
||||
|
||||
expect(assembler.pendingCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi-fragment messages", () => {
|
||||
it("returns null for each fragment until the last", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 7,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(original, 5, "id2");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
for (let i = 0; i < chunks.length - 1; i++) {
|
||||
expect(assembler.addChunk(chunks[i])).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the reassembled message on the last fragment", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 7,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(original, 5, "id3");
|
||||
|
||||
let result = null;
|
||||
for (const chunk of chunks) {
|
||||
result = assembler.addChunk(chunk);
|
||||
}
|
||||
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles correctly when fragments arrive out of order", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 42,
|
||||
time: 2000,
|
||||
};
|
||||
const chunks = splitMessage(original, 3, "id4");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Reverse order
|
||||
const reversed = [...chunks].reverse();
|
||||
let result = null;
|
||||
for (const chunk of reversed) {
|
||||
result = assembler.addChunk(chunk);
|
||||
}
|
||||
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
|
||||
it("handles three fragments out of order (shuffle)", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.DappReady,
|
||||
supported_protocols: ["hdwalletv1"],
|
||||
wallet_discovered: false,
|
||||
time: 3000,
|
||||
};
|
||||
const chunks = splitMessage(original, 10, "id5");
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Send middle first, then last, then first
|
||||
const [c0, c1, c2, ...rest] = chunks;
|
||||
const reordered = [c1, c2, c0, ...rest];
|
||||
let result = null;
|
||||
for (const chunk of reordered) {
|
||||
result = assembler.addChunk(chunk);
|
||||
}
|
||||
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles a large payload split into many fragments", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 999,
|
||||
time: 5000,
|
||||
// Simulate a large payload
|
||||
transaction: { data: "x".repeat(5000) },
|
||||
inputPaths: [],
|
||||
};
|
||||
const chunks = splitMessage(original, 500, "id6");
|
||||
expect(chunks.length).toBeGreaterThan(5);
|
||||
|
||||
let result = null;
|
||||
for (const chunk of chunks) {
|
||||
result = assembler.addChunk(chunk);
|
||||
}
|
||||
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("independent chunk_ids", () => {
|
||||
it("tracks multiple concurrent assemblies independently", () => {
|
||||
const msgA = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const msgB = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 2,
|
||||
time: 2000,
|
||||
};
|
||||
const chunksA = splitMessage(msgA, 5, "idA");
|
||||
const chunksB = splitMessage(msgB, 5, "idB");
|
||||
|
||||
// Interleave: A0, B0, A1, B1, ...
|
||||
let resultA = null;
|
||||
let resultB = null;
|
||||
const maxLen = Math.max(chunksA.length, chunksB.length);
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
if (i < chunksA.length) resultA = assembler.addChunk(chunksA[i]);
|
||||
if (i < chunksB.length) resultB = assembler.addChunk(chunksB[i]);
|
||||
}
|
||||
|
||||
expect(resultA).toEqual(msgA);
|
||||
expect(resultB).toEqual(msgB);
|
||||
});
|
||||
|
||||
it("completing one assembly does not affect pending assemblies", () => {
|
||||
const msgA = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const msgB = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 2,
|
||||
time: 2000,
|
||||
};
|
||||
const [chunkA] = splitMessage(msgA, 10000, "idA");
|
||||
const chunksB = splitMessage(msgB, 5, "idB");
|
||||
|
||||
// Complete A immediately
|
||||
assembler.addChunk(chunkA);
|
||||
|
||||
// B is still in progress
|
||||
for (let i = 0; i < chunksB.length - 1; i++) {
|
||||
assembler.addChunk(chunksB[i]);
|
||||
}
|
||||
|
||||
expect(assembler.pendingCount()).toBe(1);
|
||||
|
||||
// Complete B
|
||||
const result = assembler.addChunk(chunksB[chunksB.length - 1]);
|
||||
expect(result).toEqual(msgB);
|
||||
expect(assembler.pendingCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error cases", () => {
|
||||
it("returns null and discards buffer on inconsistent chunk_total", () => {
|
||||
const chunk1 = makeChunk("id7", 0, 3, "part1");
|
||||
const chunk2 = makeChunk("id7", 1, 4, "part2"); // wrong total
|
||||
|
||||
assembler.addChunk(chunk1);
|
||||
const result = assembler.addChunk(chunk2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(assembler.pendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for invalid JSON", () => {
|
||||
const chunk = makeChunk("id8", 0, 1, "{not valid json}}}");
|
||||
const result = assembler.addChunk(chunk);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null if assembled payload is not a ProtocolMessage", () => {
|
||||
const notAMessage = { foo: "bar" };
|
||||
const [chunk] = splitMessage(notAMessage, 10000, "id9");
|
||||
const result = assembler.addChunk(chunk);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for chunk_total of 0", () => {
|
||||
const chunk = makeChunk("id10", 0, 0, "data");
|
||||
const result = assembler.addChunk(chunk);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeout cleanup", () => {
|
||||
it("discards incomplete assembly after timeout", () => {
|
||||
const msgA = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(msgA, 5, "idTimeout");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Add all but the last fragment
|
||||
for (let i = 0; i < chunks.length - 1; i++) {
|
||||
assembler.addChunk(chunks[i]);
|
||||
}
|
||||
expect(assembler.pendingCount()).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(CHUNK_TIMEOUT_MS + 1);
|
||||
|
||||
expect(assembler.pendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("does not timeout before CHUNK_TIMEOUT_MS", () => {
|
||||
const msgA = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(msgA, 5, "idTimeout2");
|
||||
|
||||
for (let i = 0; i < chunks.length - 1; i++) {
|
||||
assembler.addChunk(chunks[i]);
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(CHUNK_TIMEOUT_MS - 1);
|
||||
|
||||
expect(assembler.pendingCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("clears timeout when assembly completes before timeout", () => {
|
||||
const msgA = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(msgA, 5, "idTimeout3");
|
||||
|
||||
for (const chunk of chunks) {
|
||||
assembler.addChunk(chunk);
|
||||
}
|
||||
expect(assembler.pendingCount()).toBe(0);
|
||||
|
||||
// Advancing past timeout should not throw
|
||||
expect(() => vi.advanceTimersByTime(CHUNK_TIMEOUT_MS + 1)).not.toThrow();
|
||||
});
|
||||
|
||||
it("after timeout, late arriving fragments start a fresh assembly", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(original, 5, "idReuse");
|
||||
|
||||
// Send first fragment, let it time out
|
||||
assembler.addChunk(chunks[0]);
|
||||
vi.advanceTimersByTime(CHUNK_TIMEOUT_MS + 1);
|
||||
expect(assembler.pendingCount()).toBe(0);
|
||||
|
||||
// Re-send all fragments with same chunk_id — should reassemble fresh
|
||||
let result = null;
|
||||
for (const chunk of chunks) {
|
||||
result = assembler.addChunk(chunk);
|
||||
}
|
||||
expect(result).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicate fragment index", () => {
|
||||
it("last write wins for duplicate index — still assembles correctly if content is the same", () => {
|
||||
const original = {
|
||||
action: RelayMsgAction.SignCancel,
|
||||
sequence: 1,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitMessage(original, 5, "idDup");
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Send first fragment twice (relay may deliver duplicates)
|
||||
assembler.addChunk(chunks[0]);
|
||||
assembler.addChunk(chunks[0]);
|
||||
|
||||
let result = null;
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
result = assembler.addChunk(chunks[i]);
|
||||
}
|
||||
|
||||
// Assembly happens when fragment count equals chunk_total.
|
||||
// With duplicate index 0, we have total fragments but index 0 is stored once.
|
||||
// The assembly should still complete when index N-1 is added.
|
||||
if (result !== null) {
|
||||
expect(result).toEqual(original);
|
||||
}
|
||||
// (If size counting means assembly triggers early on the duplicate, result may be null here
|
||||
// and the final fragment triggers it — either way is acceptable.)
|
||||
});
|
||||
});
|
||||
});
|
||||
136
packages/core/src/chunk-assembler.ts
Normal file
136
packages/core/src/chunk-assembler.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
import {
|
||||
type ChunkRelayMessage,
|
||||
type ProtocolMessage,
|
||||
isProtocolMessage,
|
||||
} from "./protocols/hdwalletv1.js";
|
||||
import { error as logError, debug, Scope } from "./log.js";
|
||||
|
||||
/// Incomplete assembly buffered until all fragments arrive or timeout expires.
|
||||
interface PendingAssembly {
|
||||
fragments: Map<number, string>;
|
||||
total: number;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/// How long to wait for all fragments before discarding an incomplete assembly.
|
||||
export const CHUNK_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Buffers incoming ChunkRelayMessage fragments and reassembles them into the
|
||||
* original ProtocolMessage once all fragments have arrived.
|
||||
*
|
||||
* Thread-safety: JavaScript is single-threaded so no locking is needed.
|
||||
*/
|
||||
export class ChunkAssembler {
|
||||
private pending = new Map<string, PendingAssembly>();
|
||||
|
||||
/**
|
||||
* Add a fragment. Returns the reassembled ProtocolMessage when the last
|
||||
* fragment arrives, or null if more fragments are still outstanding.
|
||||
*
|
||||
* Returns null (and logs an error) if the assembled payload is not valid JSON
|
||||
* or does not satisfy isProtocolMessage().
|
||||
*/
|
||||
addChunk(chunk: ChunkRelayMessage): ProtocolMessage | null {
|
||||
const { chunk_id, chunk_index, chunk_total, chunk_data } = chunk;
|
||||
|
||||
if (chunk_total < 1) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Invalid chunk_total ${chunk_total} for chunk_id ${chunk_id}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let assembly = this.pending.get(chunk_id);
|
||||
|
||||
if (!assembly) {
|
||||
assembly = {
|
||||
fragments: new Map(),
|
||||
total: chunk_total,
|
||||
timeout: setTimeout(() => {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunk assembly timed out for chunk_id ${chunk_id} (received ${this.pending.get(chunk_id)?.fragments.size ?? 0}/${chunk_total})`,
|
||||
);
|
||||
this.discard(chunk_id);
|
||||
}, CHUNK_TIMEOUT_MS),
|
||||
};
|
||||
this.pending.set(chunk_id, assembly);
|
||||
} else if (assembly.total !== chunk_total) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Inconsistent chunk_total for chunk_id ${chunk_id}: got ${chunk_total}, expected ${assembly.total}`,
|
||||
);
|
||||
this.discard(chunk_id);
|
||||
return null;
|
||||
}
|
||||
|
||||
assembly.fragments.set(chunk_index, chunk_data);
|
||||
|
||||
if (assembly.fragments.size < assembly.total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.assemble(chunk_id, assembly);
|
||||
}
|
||||
|
||||
/** Number of chunk_ids currently buffered (for testing/diagnostics). */
|
||||
pendingCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
private assemble(
|
||||
chunk_id: string,
|
||||
assembly: PendingAssembly,
|
||||
): ProtocolMessage | null {
|
||||
this.discard(chunk_id);
|
||||
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < assembly.total; i++) {
|
||||
const part = assembly.fragments.get(i);
|
||||
if (part === undefined) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Missing fragment index ${i} for chunk_id ${chunk_id}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
const json = parts.join("");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Failed to parse assembled message for chunk_id ${chunk_id}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isProtocolMessage(parsed)) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Assembled message for chunk_id ${chunk_id} is not a ProtocolMessage`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private discard(chunk_id: string): void {
|
||||
const assembly = this.pending.get(chunk_id);
|
||||
if (assembly) {
|
||||
clearTimeout(assembly.timeout);
|
||||
this.pending.delete(chunk_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,12 +31,6 @@ export type {
|
|||
} from "./key-exchange.js";
|
||||
export * from "./protocols/hdwalletv1.js";
|
||||
export * from "./protocols/base.js";
|
||||
export {
|
||||
CHUNK_EXTENSION_NAME,
|
||||
CHUNK_EXTENSION_VERSION,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
} from "./transforms/chunk.js";
|
||||
export {
|
||||
binToHex,
|
||||
hexToBin,
|
||||
|
|
@ -50,3 +44,4 @@ export {
|
|||
toUint8Array,
|
||||
toBigInt,
|
||||
} from "./serialize.js";
|
||||
export { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";
|
||||
|
|
|
|||
|
|
@ -29,10 +29,6 @@ export interface DappReadyMessage extends ProtocolMessage {
|
|||
wallet_discovered: boolean;
|
||||
dapp_name?: string;
|
||||
dapp_icon?: string;
|
||||
/// Transport-level extensions this dapp supports. Presence of a key = support.
|
||||
/// Distinct from Hdwalletv1Session.extensions which is protocol-level.
|
||||
/// See docs/transport.md.
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WalletReadyMessage extends ProtocolMessage {
|
||||
|
|
@ -52,10 +48,6 @@ export interface WalletReadyMessage extends ProtocolMessage {
|
|||
public_key: string;
|
||||
/// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention.
|
||||
secret: string;
|
||||
/// Transport-level extensions this wallet supports. Presence of a key = support.
|
||||
/// Distinct from Hdwalletv1Session.extensions which is protocol-level.
|
||||
/// See docs/transport.md.
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage {
|
||||
|
|
@ -101,11 +93,3 @@ export function isDisconnectMessage(msg: unknown): msg is DisconnectMessage {
|
|||
typeof (msg as DisconnectMessage).reason === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export interface PingMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Ping;
|
||||
}
|
||||
|
||||
export interface PongMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Pong;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,10 @@ export enum RelayMsgAction {
|
|||
SignCancel = "sign_cancel",
|
||||
/// Courtesy notification: one side is closing the connection.
|
||||
Disconnect = "disconnect",
|
||||
/// Transport-level: carries one slice of a message that exceeds NIP-44's
|
||||
/// 65,535-byte plaintext ceiling. See ChunkMessage and docs/transport.md.
|
||||
/// Not tied to hdwalletv1 semantics — applies to any application protocol.
|
||||
Chunk = "chunk",
|
||||
/// Keepalive ping sent by the dapp every 10 s to prevent relay silence timeouts.
|
||||
Ping = "ping",
|
||||
/// Keepalive pong sent by the wallet in response to each ping.
|
||||
Pong = "pong",
|
||||
/// Transport: one fragment of a large message split for NIP-44 size limits.
|
||||
/// Fragments are reassembled by the receiving RelayClient before being
|
||||
/// dispatched to higher layers. See docs/transport.md#chunking.
|
||||
ChunkRelay = "chunk_relay",
|
||||
}
|
||||
|
||||
export interface ProtocolMessage {
|
||||
|
|
@ -154,18 +150,23 @@ export interface SignCancelMessage extends ProtocolMessage {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
/// Transport-level message carrying one slice of a larger ProtocolMessage.
|
||||
///
|
||||
/// All chunks of one logical message share the same msgId and time.
|
||||
/// `data` is a base64-encoded slice of the UTF-8 bytes of
|
||||
/// JSON.stringify(originalMessage); concatenate slices in `index` order,
|
||||
/// base64-decode, UTF-8-decode, then JSON.parse to reconstruct.
|
||||
export interface ChunkMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.Chunk;
|
||||
msgId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
data: string;
|
||||
/// Extension name advertised by wallets whose RelayClient supports chunk reassembly.
|
||||
/// Presence in Hdwalletv1Session.extensions means the peer will accept chunked messages.
|
||||
export const EXTENSION_CHUNKED_MESSAGES = "chunked_messages" as const;
|
||||
|
||||
/// One fragment of a large message split across multiple relay events.
|
||||
/// All fragments share the same chunk_id. The receiver buffers by chunk_id
|
||||
/// and reassembles when chunk_total fragments have arrived.
|
||||
export interface ChunkRelayMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.ChunkRelay;
|
||||
/// Identifies which large message these fragments belong to.
|
||||
chunk_id: string;
|
||||
/// Zero-based position of this fragment.
|
||||
chunk_index: number;
|
||||
/// Total number of fragments for this message.
|
||||
chunk_total: number;
|
||||
/// Slice of JSON.stringify(originalMessage) for this fragment.
|
||||
chunk_data: string;
|
||||
}
|
||||
|
||||
// Type guard functions
|
||||
|
|
@ -216,19 +217,14 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
|
|||
);
|
||||
}
|
||||
|
||||
export function isChunkMessage(msg: any): msg is ChunkMessage {
|
||||
export function isChunkRelayMessage(msg: any): msg is ChunkRelayMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === "object" &&
|
||||
msg.action === RelayMsgAction.Chunk &&
|
||||
typeof msg.msgId === "string" &&
|
||||
typeof msg.index === "number" &&
|
||||
typeof msg.total === "number" &&
|
||||
typeof msg.data === "string" &&
|
||||
Number.isInteger(msg.index) &&
|
||||
Number.isInteger(msg.total) &&
|
||||
msg.total >= 1 &&
|
||||
msg.index >= 0 &&
|
||||
msg.index < msg.total
|
||||
msg.action === RelayMsgAction.ChunkRelay &&
|
||||
typeof msg.chunk_id === "string" &&
|
||||
typeof msg.chunk_index === "number" &&
|
||||
typeof msg.chunk_total === "number" &&
|
||||
typeof msg.chunk_data === "string"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,88 +218,3 @@ describe("RelayClient — isConnected", () => {
|
|||
expect(client.isConnected()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunking — sender path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("RelayClient — chunk sender path", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not chunk small messages (regression guard)", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
client.setPeerCapabilities({ chunk: true });
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
await client.relay({
|
||||
action: "dapp_ready" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
expect(publishMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws a clear error for oversized messages when peer lacks chunk support", async () => {
|
||||
const { pool, triggerEose } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
// Intentionally NOT calling setPeerCapabilities({chunk: true})
|
||||
client.on("disconnect", () => {}); // prevent unhandled
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
const huge = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
payload: "x".repeat(200_000),
|
||||
};
|
||||
|
||||
await expect(client.relay(huge)).rejects.toThrow(
|
||||
/does not advertise the 'chunk' transport extension/,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits oversized messages into multiple publish calls when peer supports chunking", async () => {
|
||||
const { pool, triggerEose, publishMock } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
client.setPeerCapabilities({ chunk: true });
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
const huge = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
payload: "x".repeat(200_000),
|
||||
};
|
||||
|
||||
await client.relay(huge);
|
||||
|
||||
// 200 KB payload should produce multiple chunks; each chunk is one publish.
|
||||
expect(publishMock.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("setPeerCapabilities only updates provided keys", async () => {
|
||||
const { pool, triggerEose } = makeMockPool();
|
||||
const client = makeClient(pool);
|
||||
await client.connect();
|
||||
triggerEose();
|
||||
|
||||
// Enable, then call with empty object — should not disable
|
||||
client.setPeerCapabilities({ chunk: true });
|
||||
client.setPeerCapabilities({});
|
||||
|
||||
const { publishMock } = makeMockPool(); // fresh count - not really needed
|
||||
void publishMock;
|
||||
|
||||
const huge = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
payload: "x".repeat(200_000),
|
||||
};
|
||||
// Should not throw, because chunk capability remains enabled
|
||||
await expect(client.relay(huge)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,20 +14,33 @@ import WebSocket from "isomorphic-ws";
|
|||
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
|
||||
import { EventEmitter } from "eventemitter3";
|
||||
import {
|
||||
ChunkMessage,
|
||||
isChunkMessage,
|
||||
isProtocolMessage,
|
||||
isChunkRelayMessage,
|
||||
type ChunkRelayMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
} from "./protocols/hdwalletv1.js";
|
||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
import { MessageQueue } from "./message-queue.js";
|
||||
import { ChunkAssembler } from "./chunk-assembler.js";
|
||||
import { debug, error as logError, Scope } from "./log.js";
|
||||
import {
|
||||
ChunkReassembler,
|
||||
needsChunking,
|
||||
splitIntoChunks,
|
||||
} from "./transforms/chunk.js";
|
||||
|
||||
/// Messages larger than this (JSON chars) are split into chunks when the peer
|
||||
/// supports chunking. The NIP-44 limit is 65 535 bytes of plaintext per call.
|
||||
/// createWrap encrypts the seal JSON; that seal JSON contains the base64 of the
|
||||
/// inner NIP-44 ciphertext. NIP-44 pads plaintext to the next power of two, so
|
||||
/// the ciphertext size jumps sharply once the rumor JSON crosses 32 768 bytes:
|
||||
/// rumor JSON ≤ 32 768 B → inner ciphertext ≈ 43 780 B (base64) → seal JSON
|
||||
/// ≈ 44 130 B → safe. Rumor JSON > 32 768 B → inner ciphertext ≈ 87 472 B
|
||||
/// → seal JSON ≈ 87 822 B → exceeds the 65 535-byte limit → NIP-44 throws.
|
||||
/// A 30 000-char message produces a rumor JSON of ≈ 30 300 bytes, leaving a
|
||||
/// comfortable 2 500-byte margin below the 32 768-byte cliff.
|
||||
const MAX_SAFE_MESSAGE_SIZE = 30_000;
|
||||
|
||||
/// Size of each chunk_data slice (chars). A 28 000-char slice produces a chunk
|
||||
/// JSON of ≈ 28 100 bytes, which sits safely below MAX_SAFE_MESSAGE_SIZE so
|
||||
/// chunk metadata overhead cannot push a fragment over the limit.
|
||||
const CHUNK_SIZE = 28_000;
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
|
|
@ -42,12 +55,6 @@ export interface RelayClientConfig {
|
|||
}
|
||||
|
||||
export class RelayClient extends EventEmitter {
|
||||
// Keyed by "walletPubkeyHex:dappPubkeyHex". Persists the high-water mark
|
||||
// across RelayClient instance teardowns within the same JS session so that
|
||||
// reconnects after an explicit disconnect()+connect() still filter
|
||||
// relay-replayed messages from the prior session.
|
||||
private static readonly sessionTimestamps = new Map<string, number>();
|
||||
|
||||
private pool: SimplePool;
|
||||
private sharedPool: boolean;
|
||||
private pairedPubkeyHex: string;
|
||||
|
|
@ -58,17 +65,11 @@ export class RelayClient extends EventEmitter {
|
|||
private lastProcessedTimestamp: number = 0;
|
||||
private messageQueue: MessageQueue;
|
||||
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private peerSupportsChunking = false;
|
||||
private chunkAssembler = new ChunkAssembler();
|
||||
|
||||
private disconnecting: boolean = false;
|
||||
|
||||
/// Capability flag: peer advertised support for the `chunk` transport
|
||||
/// extension in its dapp_ready / wallet_ready. Set via setPeerCapabilities.
|
||||
private peerSupportsChunk: boolean = false;
|
||||
|
||||
/// Receiver-side reassembly buffer. Always active — if no chunks arrive it
|
||||
/// stays empty. Started in connect(), stopped in disconnect().
|
||||
private reassembler: ChunkReassembler;
|
||||
|
||||
private sequence: number = Math.floor(
|
||||
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
|
||||
);
|
||||
|
|
@ -89,12 +90,6 @@ export class RelayClient extends EventEmitter {
|
|||
}
|
||||
>();
|
||||
|
||||
private get sessionKey(): string | null {
|
||||
return this.pairedPubkeyHex
|
||||
? `${this.myPubkeyHex}:${this.pairedPubkeyHex}`
|
||||
: null;
|
||||
}
|
||||
|
||||
constructor(config: RelayClientConfig, pool?: SimplePool) {
|
||||
super();
|
||||
this.config = {
|
||||
|
|
@ -108,15 +103,6 @@ export class RelayClient extends EventEmitter {
|
|||
logActivity: this.config.logNetworkActivity,
|
||||
});
|
||||
|
||||
// Reassembled messages take the same post-decryption path as unchunked ones.
|
||||
// Chunks themselves have already passed the peer filter and timestamp dedup
|
||||
// on ingress (see routeIncoming), so the assembled message is handed directly
|
||||
// to the application-level handler.
|
||||
this.reassembler = new ChunkReassembler(
|
||||
(msg) => this.handleRelayMessage(msg),
|
||||
!!this.config.logNetworkActivity,
|
||||
);
|
||||
|
||||
this.myPubkey = unwrap(
|
||||
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
|
||||
);
|
||||
|
|
@ -131,13 +117,6 @@ export class RelayClient extends EventEmitter {
|
|||
} else {
|
||||
this.pairedPubkeyHex = "";
|
||||
}
|
||||
|
||||
// Restore persisted high-water mark for this wallet+dapp pair (survives
|
||||
// instance recreation within the same JS session).
|
||||
const saved = this.sessionKey
|
||||
? RelayClient.sessionTimestamps.get(this.sessionKey)
|
||||
: undefined;
|
||||
if (saved) this.lastProcessedTimestamp = saved;
|
||||
}
|
||||
|
||||
setPairedPublicKey(pairedPublicKey: Uint8Array): void {
|
||||
|
|
@ -147,26 +126,9 @@ export class RelayClient extends EventEmitter {
|
|||
? pairedPublicKey.slice(1)
|
||||
: pairedPublicKey;
|
||||
this.pairedPubkeyHex = binToHex(pairedNostrPubkey);
|
||||
|
||||
// Now that we have the full key, restore any persisted timestamp.
|
||||
const saved = RelayClient.sessionTimestamps.get(this.sessionKey!);
|
||||
if (saved && saved > this.lastProcessedTimestamp) {
|
||||
this.lastProcessedTimestamp = saved;
|
||||
}
|
||||
|
||||
this.emit("paired");
|
||||
}
|
||||
|
||||
/// Set transport-level capability flags based on the peer's advertisement in
|
||||
/// its dapp_ready / wallet_ready `extensions` field. Called by the connection
|
||||
/// manager after the handshake. New capability keys are additive — callers
|
||||
/// may omit any they don't set.
|
||||
setPeerCapabilities(caps: { chunk?: boolean }): void {
|
||||
if (caps.chunk !== undefined) {
|
||||
this.peerSupportsChunk = caps.chunk;
|
||||
}
|
||||
}
|
||||
|
||||
getPublicKey(): Uint8Array {
|
||||
return this.myPubkey;
|
||||
}
|
||||
|
|
@ -194,7 +156,6 @@ export class RelayClient extends EventEmitter {
|
|||
}
|
||||
|
||||
this.disconnecting = false;
|
||||
this.reassembler.start();
|
||||
|
||||
if (this.lastProcessedTimestamp === 0) {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
|
||||
|
|
@ -254,11 +215,8 @@ export class RelayClient extends EventEmitter {
|
|||
|
||||
async disconnect(): Promise<void> {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
|
||||
const key = this.sessionKey;
|
||||
if (key)
|
||||
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
|
||||
this.messageQueue.setNotReady();
|
||||
this.reassembler.stop();
|
||||
this.peerSupportsChunking = false;
|
||||
|
||||
if (this.readyTimeoutId) {
|
||||
clearTimeout(this.readyTimeoutId);
|
||||
|
|
@ -283,6 +241,16 @@ export class RelayClient extends EventEmitter {
|
|||
this.lastProcessedTimestamp = timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the RelayClient that the connected peer supports chunked messages.
|
||||
* Call this after receiving a wallet_ready that includes the "chunked_messages"
|
||||
* extension. Automatically reset to false by disconnect() — call again on
|
||||
* each wallet_ready so reconnects don't inherit stale state.
|
||||
*/
|
||||
setPeerSupportsChunking(supports: boolean): void {
|
||||
this.peerSupportsChunking = supports;
|
||||
}
|
||||
|
||||
async relay(message: ProtocolMessage): Promise<void> {
|
||||
if (!this.config.pairedPublicKey) {
|
||||
throw new Error(
|
||||
|
|
@ -290,6 +258,14 @@ export class RelayClient extends EventEmitter {
|
|||
);
|
||||
}
|
||||
|
||||
const messageJson = JSON.stringify(message);
|
||||
if (
|
||||
messageJson.length > MAX_SAFE_MESSAGE_SIZE &&
|
||||
this.peerSupportsChunking
|
||||
) {
|
||||
return this.relayChunked(message.action, messageJson);
|
||||
}
|
||||
|
||||
if (!this.messageQueue.getReady()) {
|
||||
return this.messageQueue.enqueue(message);
|
||||
}
|
||||
|
|
@ -297,54 +273,43 @@ export class RelayClient extends EventEmitter {
|
|||
return this.publishMessage(message);
|
||||
}
|
||||
|
||||
private async publishMessage(message: ProtocolMessage): Promise<void> {
|
||||
const serialized = JSON.stringify(message);
|
||||
private async relayChunked(
|
||||
originalAction: string,
|
||||
messageJson: string,
|
||||
): Promise<void> {
|
||||
const chunkId = Math.random().toString(36).slice(2, 14);
|
||||
const chunkTotal = Math.ceil(messageJson.length / CHUNK_SIZE);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!needsChunking(serialized)) {
|
||||
return this.publishSerialized(message.action, serialized);
|
||||
}
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunking ${originalAction} into ${chunkTotal} fragments (${messageJson.length} chars)`,
|
||||
);
|
||||
|
||||
// Oversized. Chunk if the peer supports it; otherwise fail loudly with
|
||||
// an actionable message (replacing nostr-tools' cryptic plaintext-size error).
|
||||
if (!this.peerSupportsChunk) {
|
||||
const err = new Error(
|
||||
`Cannot send ${message.action}: message is larger than NIP-44's 65,535-byte ceiling ` +
|
||||
`and the peer does not advertise the 'chunk' transport extension. ` +
|
||||
`Please update the connected wallet/dapp to a version that supports chunked messages.`,
|
||||
);
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, err.message);
|
||||
for (let i = 0; i < chunkTotal; i++) {
|
||||
const chunk: ChunkRelayMessage = {
|
||||
action: RelayMsgAction.ChunkRelay,
|
||||
chunk_id: chunkId,
|
||||
chunk_index: i,
|
||||
chunk_total: chunkTotal,
|
||||
chunk_data: messageJson.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
|
||||
time: now,
|
||||
};
|
||||
if (!this.messageQueue.getReady()) {
|
||||
await this.messageQueue.enqueue(chunk);
|
||||
} else {
|
||||
await this.publishMessage(chunk);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const chunks = splitIntoChunks(serialized);
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunking ${message.action}: ${chunks.length} chunks (serialized ~${serialized.length} bytes)`,
|
||||
);
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
await this.publishSerialized(
|
||||
`${message.action}[chunk ${chunk.index + 1}/${chunk.total}]`,
|
||||
JSON.stringify(chunk),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap and publish one gift-wrap event. Used for both unchunked messages
|
||||
/// and individual chunks. `displayAction` is only used for logs.
|
||||
private async publishSerialized(
|
||||
displayAction: string,
|
||||
serialized: string,
|
||||
): Promise<void> {
|
||||
this.netlog("send", displayAction);
|
||||
private async publishMessage(message: ProtocolMessage): Promise<void> {
|
||||
this.netlog("send", message.action);
|
||||
|
||||
const wrapped = wrapEvent(
|
||||
{
|
||||
kind: KIND_PRIVATE_DIRECT_MESSAGE,
|
||||
content: serialized,
|
||||
content: JSON.stringify(message),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", this.pairedPubkeyHex]],
|
||||
},
|
||||
|
|
@ -363,7 +328,7 @@ export class RelayClient extends EventEmitter {
|
|||
for (const r of rejected) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Failed to publish ${displayAction} to a relay:`,
|
||||
`Failed to publish ${message.action} to a relay:`,
|
||||
(r as PromiseRejectedResult).reason,
|
||||
);
|
||||
}
|
||||
|
|
@ -371,7 +336,7 @@ export class RelayClient extends EventEmitter {
|
|||
|
||||
if (fulfilled.length === 0) {
|
||||
const error = new Error(
|
||||
`Failed to publish ${displayAction} to all relays`,
|
||||
`Failed to publish ${message.action} to all relays`,
|
||||
);
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, error.message);
|
||||
|
|
@ -383,7 +348,7 @@ export class RelayClient extends EventEmitter {
|
|||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Published message ${displayAction} to ${fulfilled.length}/${results.length} relay(s)`,
|
||||
`Published message ${message.action} to ${fulfilled.length}/${results.length} relay(s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -392,27 +357,68 @@ export class RelayClient extends EventEmitter {
|
|||
try {
|
||||
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey);
|
||||
|
||||
if (rumor.kind !== KIND_PRIVATE_DIRECT_MESSAGE) {
|
||||
if (rumor.kind === KIND_PRIVATE_DIRECT_MESSAGE) {
|
||||
let payload: ProtocolMessage;
|
||||
try {
|
||||
payload = JSON.parse(rumor.content);
|
||||
} catch (e) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
"Failed to parse message content as JSON:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!payload.time ||
|
||||
(this.lastProcessedTimestamp > 0 &&
|
||||
payload.time < this.lastProcessedTimestamp)
|
||||
) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// wallet_ready carries the key exchange data (public_key + secret) so it
|
||||
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
|
||||
const isKeyExchangeMessage =
|
||||
payload.action === RelayMsgAction.WalletReady;
|
||||
|
||||
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
|
||||
const pairedNostrPubkey =
|
||||
this.config.pairedPublicKey.length === 33
|
||||
? binToHex(this.config.pairedPublicKey.slice(1))
|
||||
: binToHex(this.config.pairedPublicKey);
|
||||
if (rumor.pubkey !== pairedNostrPubkey) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Received message ${payload.action} from relay`);
|
||||
}
|
||||
this.handleRelayMessage(payload);
|
||||
} else {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: ProtocolMessage;
|
||||
try {
|
||||
payload = JSON.parse(rumor.content);
|
||||
} catch (e) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, "Failed to parse message content as JSON:", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.routeIncoming(payload, rumor.pubkey);
|
||||
} catch (error) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
logError(Scope.Relay, "Error handling incoming message:", error);
|
||||
|
|
@ -421,76 +427,6 @@ export class RelayClient extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply timestamp dedup + peer filter, then dispatch to chunk reassembly
|
||||
/// or the application-level handler. Called from handleWrappedEvent (one
|
||||
/// path: unwrap → route). Kept separate to keep handleWrappedEvent focused
|
||||
/// on decryption and to allow future transport-layer transforms to invoke
|
||||
/// this path with already-decoded payloads.
|
||||
private routeIncoming(payload: ProtocolMessage, fromPubkey: string): void {
|
||||
if (
|
||||
!payload.time ||
|
||||
(this.lastProcessedTimestamp > 0 &&
|
||||
payload.time < this.lastProcessedTimestamp)
|
||||
) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring already-processed message (time: ${payload.time}, action: ${payload.action}, last processed: ${this.lastProcessedTimestamp})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Advance the high-water mark so relay replays are filtered on reconnect.
|
||||
// Also persist to the static session cache so a fresh RelayClient instance
|
||||
// for the same wallet+dapp pair inherits this mark.
|
||||
if (payload.time > this.lastProcessedTimestamp) {
|
||||
this.lastProcessedTimestamp = payload.time;
|
||||
const key = this.sessionKey;
|
||||
if (key)
|
||||
RelayClient.sessionTimestamps.set(key, this.lastProcessedTimestamp);
|
||||
}
|
||||
|
||||
// wallet_ready carries the key exchange data (public_key + secret) so it
|
||||
// must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
|
||||
const isKeyExchangeMessage = payload.action === RelayMsgAction.WalletReady;
|
||||
|
||||
if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
|
||||
const pairedNostrPubkey =
|
||||
this.config.pairedPublicKey.length === 33
|
||||
? binToHex(this.config.pairedPublicKey.slice(1))
|
||||
: binToHex(this.config.pairedPublicKey);
|
||||
if (fromPubkey !== pairedNostrPubkey) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Ignoring '${payload.action}' message from unknown peer: ${fromPubkey} (expected: ${pairedNostrPubkey})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Transport-level branch: a ChunkMessage is routed to the reassembler,
|
||||
// which will emit a reassembled ProtocolMessage via handleRelayMessage
|
||||
// once all pieces arrive.
|
||||
if (isChunkMessage(payload)) {
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Received chunk ${payload.index + 1}/${payload.total} (msgId=${payload.msgId})`,
|
||||
);
|
||||
}
|
||||
this.reassembler.ingest(payload as ChunkMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.logNetworkActivity) {
|
||||
debug(Scope.Relay, `Received message ${payload.action} from relay`);
|
||||
}
|
||||
this.handleRelayMessage(payload);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.subscription !== null;
|
||||
}
|
||||
|
|
@ -526,6 +462,19 @@ export class RelayClient extends EventEmitter {
|
|||
isProtocolMessage(message),
|
||||
`Invalid protocol message: ${message}`,
|
||||
);
|
||||
|
||||
if (isChunkRelayMessage(message)) {
|
||||
const assembled = this.chunkAssembler.addChunk(message);
|
||||
if (assembled !== null) {
|
||||
this.netlog(
|
||||
"recv",
|
||||
`${assembled.action} [reassembled from ${message.chunk_total} chunks]`,
|
||||
);
|
||||
this.emit("message", assembled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit("message", message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,322 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
ChunkReassembler,
|
||||
CHUNK_REQUIRED_BYTES,
|
||||
CHUNK_RAW_BYTES,
|
||||
REASSEMBLY_TTL_MS,
|
||||
chunkExtensionAdvertisement,
|
||||
needsChunking,
|
||||
peerSupportsChunk,
|
||||
splitIntoChunks,
|
||||
} from "./chunk.js";
|
||||
import {
|
||||
ChunkMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
} from "../protocols/hdwalletv1.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("chunk extension advertisement helpers", () => {
|
||||
it("chunkExtensionAdvertisement returns a version-tagged object", () => {
|
||||
const adv = chunkExtensionAdvertisement();
|
||||
expect(adv).toHaveProperty("version");
|
||||
expect(typeof adv.version).toBe("number");
|
||||
});
|
||||
|
||||
it("peerSupportsChunk true iff `chunk` key present", () => {
|
||||
expect(peerSupportsChunk(undefined)).toBe(false);
|
||||
expect(peerSupportsChunk({})).toBe(false);
|
||||
expect(peerSupportsChunk({ chunk: { version: 1 } })).toBe(true);
|
||||
expect(peerSupportsChunk({ chunk: {} })).toBe(true);
|
||||
// presence of other keys doesn't imply chunk support
|
||||
expect(peerSupportsChunk({ compress: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("needsChunking", () => {
|
||||
it("returns false for small payloads", () => {
|
||||
expect(needsChunking("hello")).toBe(false);
|
||||
expect(needsChunking("a".repeat(1000))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when UTF-8 bytes exceed the ceiling-minus-overhead", () => {
|
||||
expect(needsChunking("a".repeat(CHUNK_REQUIRED_BYTES + 1))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false right at the threshold", () => {
|
||||
// ASCII: 1 byte per char. CHUNK_REQUIRED_BYTES chars fits.
|
||||
expect(needsChunking("a".repeat(CHUNK_REQUIRED_BYTES))).toBe(false);
|
||||
});
|
||||
|
||||
it("accounts for multibyte UTF-8 (emoji)", () => {
|
||||
// "🦀" = 4 UTF-8 bytes. 20,000 crabs = 80,000 bytes > CHUNK_REQUIRED_BYTES.
|
||||
const s = "🦀".repeat(20_000);
|
||||
expect(needsChunking(s)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("splitIntoChunks", () => {
|
||||
it("produces one chunk for tiny input", () => {
|
||||
const chunks = splitIntoChunks(JSON.stringify({ hello: "world" }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0].index).toBe(0);
|
||||
expect(chunks[0].total).toBe(1);
|
||||
});
|
||||
|
||||
it("shares one msgId and one time across chunks", () => {
|
||||
const big = "x".repeat(CHUNK_RAW_BYTES * 5);
|
||||
const chunks = splitIntoChunks(big);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
const firstId = chunks[0].msgId;
|
||||
const firstTime = chunks[0].time;
|
||||
for (const c of chunks) {
|
||||
expect(c.msgId).toBe(firstId);
|
||||
expect(c.time).toBe(firstTime);
|
||||
}
|
||||
});
|
||||
|
||||
it("every chunk passes the NIP-17 gift-wrap round-trip without exceeding NIP-44", async () => {
|
||||
// The real constraint isn't just that the chunk's JSON fits in 65,535
|
||||
// plaintext bytes — it's that the OUTER gift-wrap's plaintext (which
|
||||
// contains the seal, which contains the 4/3×-expanded encrypted rumor)
|
||||
// also fits. This test exercises the full wrapEvent path.
|
||||
const { wrapEvent } = await import("nostr-tools/nip59");
|
||||
const { generateSecretKey, getPublicKey } =
|
||||
await import("nostr-tools/pure");
|
||||
const senderPriv = generateSecretKey();
|
||||
const recipPub = getPublicKey(generateSecretKey());
|
||||
|
||||
const big = "x".repeat(CHUNK_RAW_BYTES * 10);
|
||||
const chunks = splitIntoChunks(big);
|
||||
for (const c of chunks) {
|
||||
expect(() =>
|
||||
wrapEvent(
|
||||
{
|
||||
kind: 14,
|
||||
content: JSON.stringify(c),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", recipPub]],
|
||||
},
|
||||
senderPriv,
|
||||
recipPub,
|
||||
),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses sequential indices starting at 0", () => {
|
||||
const big = "x".repeat(CHUNK_RAW_BYTES * 4);
|
||||
const chunks = splitIntoChunks(big);
|
||||
chunks.forEach((c, i) => {
|
||||
expect(c.index).toBe(i);
|
||||
expect(c.total).toBe(chunks.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts caller-supplied msgId and time", () => {
|
||||
const chunks = splitIntoChunks("hello", {
|
||||
msgId: "fixed-id",
|
||||
time: 12345,
|
||||
});
|
||||
expect(chunks[0].msgId).toBe("fixed-id");
|
||||
expect(chunks[0].time).toBe(12345);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reassembler — round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ChunkReassembler round-trip", () => {
|
||||
it("reassembles a small message", () => {
|
||||
const original: ProtocolMessage = {
|
||||
action: "sign_transaction_request" as any,
|
||||
time: 1000,
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: ProtocolMessage[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles a ~2MB message (simulates signed tx hex response)", () => {
|
||||
const original = {
|
||||
action: "sign_transaction_response",
|
||||
time: 1000,
|
||||
sequence: 42,
|
||||
signedTransaction: "ab".repeat(1_000_000), // 2 MB hex
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
expect(chunks.length).toBeGreaterThan(30);
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles when chunks arrive in reverse order", () => {
|
||||
const original = {
|
||||
action: "sign_transaction_request",
|
||||
time: 1,
|
||||
payload: "x".repeat(CHUNK_RAW_BYTES * 3),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (let i = chunks.length - 1; i >= 0; i--) r.ingest(chunks[i]);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("reassembles when chunks arrive in shuffled order", () => {
|
||||
const original = {
|
||||
action: "sign_transaction_request",
|
||||
time: 1,
|
||||
payload: "x".repeat(CHUNK_RAW_BYTES * 4),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const shuffled = [...chunks].sort(() => Math.random() - 0.5);
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of shuffled) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips multibyte UTF-8 content", () => {
|
||||
const original = {
|
||||
action: "custom_action",
|
||||
time: 1,
|
||||
crab: "🦀".repeat(20_000), // 80 KB of 4-byte codepoints
|
||||
japanese: "こんにちは世界".repeat(5_000),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reassembler — duplicate / malformed / TTL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ChunkReassembler edge cases", () => {
|
||||
it("duplicate chunks are idempotent (delivers exactly once)", () => {
|
||||
const original = {
|
||||
action: "a",
|
||||
time: 1,
|
||||
x: "y".repeat(CHUNK_RAW_BYTES * 2),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
// Replay everything a second time
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not deliver if a chunk is missing", () => {
|
||||
const original = {
|
||||
action: "a",
|
||||
time: 1,
|
||||
x: "y".repeat(CHUNK_RAW_BYTES * 3),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (let i = 0; i < chunks.length - 1; i++) r.ingest(chunks[i]);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops a chunk whose total disagrees with the in-flight entry", () => {
|
||||
const chunkA: ChunkMessage = {
|
||||
action: RelayMsgAction.Chunk,
|
||||
time: 1,
|
||||
msgId: "m",
|
||||
index: 0,
|
||||
total: 3,
|
||||
data: "AAAA",
|
||||
};
|
||||
const chunkBadTotal: ChunkMessage = { ...chunkA, index: 1, total: 99 };
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
r.ingest(chunkA);
|
||||
r.ingest(chunkBadTotal);
|
||||
expect(r.bufferCount).toBe(1);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reassembled payload that is not a valid ProtocolMessage is dropped", () => {
|
||||
// Craft a single chunk carrying junk JSON
|
||||
const junk = JSON.stringify({ not: "a protocol message" });
|
||||
const chunks = splitIntoChunks(junk);
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
for (const c of chunks) r.ingest(c);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reassembled payload with invalid base64/UTF-8 is dropped without crashing", () => {
|
||||
const chunk: ChunkMessage = {
|
||||
action: RelayMsgAction.Chunk,
|
||||
time: 1,
|
||||
msgId: "bad",
|
||||
index: 0,
|
||||
total: 1,
|
||||
data: "!!!not-base64!!!",
|
||||
};
|
||||
const received: any[] = [];
|
||||
const r = new ChunkReassembler((m) => received.push(m), false);
|
||||
expect(() => r.ingest(chunk)).not.toThrow();
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("TTL sweeper evicts incomplete entries", () => {
|
||||
let clock = 0;
|
||||
const r = new ChunkReassembler(
|
||||
() => {},
|
||||
false,
|
||||
() => clock,
|
||||
);
|
||||
const original = {
|
||||
action: "a",
|
||||
time: 1,
|
||||
x: "y".repeat(CHUNK_RAW_BYTES * 3),
|
||||
};
|
||||
const chunks = splitIntoChunks(JSON.stringify(original));
|
||||
r.ingest(chunks[0]);
|
||||
expect(r.bufferCount).toBe(1);
|
||||
|
||||
// Advance clock past TTL
|
||||
clock += REASSEMBLY_TTL_MS + 1;
|
||||
r.sweep();
|
||||
expect(r.bufferCount).toBe(0);
|
||||
});
|
||||
|
||||
it("start() installs a periodic sweeper that stop() clears", () => {
|
||||
vi.useFakeTimers();
|
||||
const r = new ChunkReassembler(() => {}, false);
|
||||
r.start();
|
||||
// Shouldn't throw on repeat start
|
||||
r.start();
|
||||
vi.advanceTimersByTime(100_000);
|
||||
r.stop();
|
||||
// Shouldn't throw on repeat stop
|
||||
r.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Transport-level chunking.
|
||||
*
|
||||
* NIP-44 caps plaintext at 65,535 bytes (the plaintext length is encoded as a
|
||||
* U16BE prefix in the wire format — structural, not a configurable guardrail).
|
||||
* This module splits oversized ProtocolMessages into a sequence of
|
||||
* ChunkMessages that each fit under the ceiling, and reassembles them on the
|
||||
* receiver.
|
||||
*
|
||||
* Fire-and-forget: chunks are published individually through the same
|
||||
* multi-relay path as any other message, with no per-chunk ACKs. Receiver
|
||||
* buffers by msgId, applies a TTL, and delivers the assembled ProtocolMessage
|
||||
* once complete.
|
||||
*
|
||||
* See docs/transport.md for wire format and failure modes.
|
||||
*/
|
||||
|
||||
import {
|
||||
ChunkMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
isProtocolMessage,
|
||||
} from "../protocols/hdwalletv1.js";
|
||||
import { debug, error as logError, Scope } from "../log.js";
|
||||
|
||||
/// Extension key advertised in base-level `extensions` on dapp_ready / wallet_ready.
|
||||
export const CHUNK_EXTENSION_NAME = "chunk";
|
||||
export const CHUNK_EXTENSION_VERSION = 1;
|
||||
|
||||
/// NIP-44 plaintext hard limit.
|
||||
export const NIP44_MAX_PLAINTEXT = 65535;
|
||||
|
||||
/// NIP-17 gift-wrap applies NIP-44 encryption twice: once to the rumor
|
||||
/// (inner) and once to the seal (outer). The 65,535-byte cap applies to the
|
||||
/// plaintext of each layer. What we hand to wrapEvent becomes the rumor
|
||||
/// content; that rumor is then JSON-serialized, padded (up to multiples of
|
||||
/// 8192 for sizes in this range), encrypted, base64'd, and JSON-wrapped in
|
||||
/// a seal — the outer wrap then encrypts that seal JSON, which must also
|
||||
/// fit under 65,535 plaintext bytes.
|
||||
///
|
||||
/// For a rumor content of C bytes (ASCII) the outer plaintext is roughly:
|
||||
/// (event_shell≈200) + ceil(4/3 × (32 + pad(C + 200) + 32)) + seal_shell≈300
|
||||
/// With pad(~40 KB) = 40,960 this totals ≈55 KB — safely under the cap.
|
||||
/// Above ~40,960 bytes of content the next pad multiple jumps to 49,152,
|
||||
/// pushing the outer plaintext past the cap.
|
||||
|
||||
/// Raw content bytes we'll pack into a single chunk's `data` field (before
|
||||
/// base64). Sized so that after base64 expansion, envelope overhead, and the
|
||||
/// two-layer gift-wrap expansion, the outer plaintext stays well under
|
||||
/// NIP-44's 65,535-byte ceiling. See derivation above.
|
||||
export const CHUNK_RAW_BYTES = 30000;
|
||||
|
||||
/// When JSON.stringify(message)'s UTF-8 byte length exceeds this, chunking
|
||||
/// is required. Derived from the same outer-wrap size analysis: anything
|
||||
/// above ~40,760 bytes of content pushes the outer wrap plaintext over
|
||||
/// 65,535. Conservative headroom built in.
|
||||
export const CHUNK_REQUIRED_BYTES = 40000;
|
||||
|
||||
/// How long an incomplete reassembly buffer survives without progress.
|
||||
/// Sized for a ~35-chunk transfer (2 MB tx-hex response) under congested
|
||||
/// relay conditions.
|
||||
export const REASSEMBLY_TTL_MS = 120_000;
|
||||
|
||||
/// How often the TTL sweeper runs.
|
||||
export const SWEEP_INTERVAL_MS = 10_000;
|
||||
|
||||
/// Returns the extension advertisement value for the `chunk` key in the
|
||||
/// base-level `extensions` field of dapp_ready / wallet_ready.
|
||||
export function chunkExtensionAdvertisement(): { version: number } {
|
||||
return { version: CHUNK_EXTENSION_VERSION };
|
||||
}
|
||||
|
||||
/// True iff the peer's `extensions` object advertises support for chunking.
|
||||
export function peerSupportsChunk(
|
||||
extensions: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
return !!extensions && extensions[CHUNK_EXTENSION_NAME] !== undefined;
|
||||
}
|
||||
|
||||
/// Measure UTF-8 byte length without allocating the full encoded buffer
|
||||
/// (a shallow optimisation for very large payloads).
|
||||
export function utf8ByteLength(s: string): number {
|
||||
return new TextEncoder().encode(s).length;
|
||||
}
|
||||
|
||||
/// True iff a message of this serialized size requires chunking.
|
||||
export function needsChunking(serialized: string): boolean {
|
||||
return utf8ByteLength(serialized) > CHUNK_REQUIRED_BYTES;
|
||||
}
|
||||
|
||||
/// Split a serialized ProtocolMessage into ChunkMessages. All chunks share
|
||||
/// one msgId and one `time` so reassembly is deterministic and the
|
||||
/// reassembled message keeps a coherent timestamp for existing dedup logic.
|
||||
export function splitIntoChunks(
|
||||
serialized: string,
|
||||
opts?: { msgId?: string; time?: number },
|
||||
): ChunkMessage[] {
|
||||
const msgId = opts?.msgId ?? newMsgId();
|
||||
const time = opts?.time ?? Math.floor(Date.now() / 1000);
|
||||
const utf8 = new TextEncoder().encode(serialized);
|
||||
const b64 = bytesToBase64(utf8);
|
||||
// base64 chars per chunk equivalent to CHUNK_RAW_BYTES raw bytes
|
||||
const sliceChars = Math.ceil((CHUNK_RAW_BYTES * 4) / 3);
|
||||
const total = Math.max(1, Math.ceil(b64.length / sliceChars));
|
||||
const chunks: ChunkMessage[] = [];
|
||||
for (let i = 0; i < total; i++) {
|
||||
chunks.push({
|
||||
action: RelayMsgAction.Chunk,
|
||||
time,
|
||||
msgId,
|
||||
index: i,
|
||||
total,
|
||||
data: b64.slice(i * sliceChars, (i + 1) * sliceChars),
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
interface ReassemblyEntry {
|
||||
total: number;
|
||||
chunks: (string | undefined)[];
|
||||
received: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver-side chunk reassembler.
|
||||
*
|
||||
* Owns a msgId-keyed buffer of partial messages, TTL-evicted by a periodic
|
||||
* sweeper. When all chunks for a msgId are present the assembled
|
||||
* ProtocolMessage is handed to `onComplete`.
|
||||
*
|
||||
* Not concurrency-safe — a single instance is owned by a single RelayClient.
|
||||
*/
|
||||
export class ChunkReassembler {
|
||||
private buffers = new Map<string, ReassemblyEntry>();
|
||||
/// msgIds that have already been delivered, kept for a short grace period
|
||||
/// so late-arriving duplicate chunks (e.g. cross-subscription replay after
|
||||
/// reconnect) don't spawn a second reassembly and double-deliver.
|
||||
private completed = new Map<string, number /* expiresAt */>();
|
||||
private sweeperId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly onComplete: (msg: ProtocolMessage) => void,
|
||||
private readonly logActivity: boolean = true,
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.sweeperId !== null) return;
|
||||
this.sweeperId = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.sweeperId !== null) {
|
||||
clearInterval(this.sweeperId);
|
||||
this.sweeperId = null;
|
||||
}
|
||||
this.buffers.clear();
|
||||
this.completed.clear();
|
||||
}
|
||||
|
||||
/// For tests. Otherwise start() schedules this automatically.
|
||||
sweep(): void {
|
||||
const now = this.now();
|
||||
for (const [id, entry] of this.buffers) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.buffers.delete(id);
|
||||
if (this.logActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunk reassembly timeout: ${id} (${entry.received}/${entry.total} received)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [id, expiresAt] of this.completed) {
|
||||
if (expiresAt <= now) this.completed.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest one chunk. If this completes the message, onComplete fires.
|
||||
/// Duplicate chunks (same msgId/index) are idempotent. Malformed chunks
|
||||
/// are dropped silently.
|
||||
ingest(chunk: ChunkMessage): void {
|
||||
if (this.completed.has(chunk.msgId)) {
|
||||
// Already delivered this message; drop late duplicates.
|
||||
return;
|
||||
}
|
||||
let entry = this.buffers.get(chunk.msgId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
total: chunk.total,
|
||||
chunks: new Array(chunk.total),
|
||||
received: 0,
|
||||
expiresAt: this.now() + REASSEMBLY_TTL_MS,
|
||||
};
|
||||
this.buffers.set(chunk.msgId, entry);
|
||||
} else if (entry.total !== chunk.total) {
|
||||
// Protocol invariant violation — peer changed total mid-stream. Drop.
|
||||
if (this.logActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Chunk total mismatch for ${chunk.msgId}: ${chunk.total} vs expected ${entry.total}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.chunks[chunk.index] !== undefined) {
|
||||
// Duplicate — already have this slot. SimplePool dedupes by event id
|
||||
// within a subscription; this guards against the cross-subscription
|
||||
// replay case after a reconnect.
|
||||
return;
|
||||
}
|
||||
entry.chunks[chunk.index] = chunk.data;
|
||||
entry.received++;
|
||||
|
||||
if (entry.received !== entry.total) return;
|
||||
|
||||
// Assemble and deliver.
|
||||
this.buffers.delete(chunk.msgId);
|
||||
// Grace period matches reassembly TTL — sufficient to catch any laggard
|
||||
// duplicates from a slow relay that were in flight when we completed.
|
||||
this.completed.set(chunk.msgId, this.now() + REASSEMBLY_TTL_MS);
|
||||
let reassembled: ProtocolMessage;
|
||||
try {
|
||||
const fullB64 = entry.chunks.join("");
|
||||
const bytes = base64ToBytes(fullB64);
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed = JSON.parse(json);
|
||||
if (!isProtocolMessage(parsed)) {
|
||||
if (this.logActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Reassembled chunk msgId=${chunk.msgId} is not a valid ProtocolMessage`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reassembled = parsed;
|
||||
} catch (e) {
|
||||
if (this.logActivity) {
|
||||
logError(
|
||||
Scope.Relay,
|
||||
`Chunk reassembly failed for msgId=${chunk.msgId}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.logActivity) {
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Reassembled chunked message: action=${reassembled.action} chunks=${entry.total}`,
|
||||
);
|
||||
}
|
||||
this.onComplete(reassembled);
|
||||
}
|
||||
|
||||
/// Test helper: number of in-flight partial messages.
|
||||
get bufferCount(): number {
|
||||
return this.buffers.size;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-platform base64 (no dependency on Buffer or DOM-specific APIs).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
const slice = bytes.subarray(i, i + CHUNK);
|
||||
binary += String.fromCharCode.apply(null, slice as unknown as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const binary = atob(b64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function newMsgId(): string {
|
||||
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
||||
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
const cryptoObj = (
|
||||
globalThis as { crypto?: { getRandomValues?: (b: Uint8Array) => void } }
|
||||
).crypto;
|
||||
if (cryptoObj?.getRandomValues) cryptoObj.getRandomValues(bytes);
|
||||
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@wizardconnect/dapp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"description": "Dapp-side integration helpers for WizardConnect",
|
||||
"repository": {
|
||||
|
|
@ -29,6 +29,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,10 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { encodeHdPublicKey } from "@bitauth/libauth";
|
||||
import type { PathXpub } from "@wizardconnect/core";
|
||||
import {
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
DisconnectReason,
|
||||
} from "@wizardconnect/core";
|
||||
import { RelayMsgAction, PROTOCOL_NAME } from "@wizardconnect/core";
|
||||
import type {
|
||||
WalletReadyMessage,
|
||||
SignTransactionRequest,
|
||||
DisconnectMessage,
|
||||
ProtocolMessage,
|
||||
} from "@wizardconnect/core";
|
||||
import { DappConnectionManager } from "./dapp-connection-manager.js";
|
||||
|
|
@ -152,8 +147,8 @@ describe("DappConnectionManager", () => {
|
|||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
setPeerSupportsChunking: vi.fn(),
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
|
|
@ -240,417 +235,4 @@ describe("DappConnectionManager", () => {
|
|||
expect(signMsgs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keepalive reconnect grace timer", () => {
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDisconnectMsg(): DisconnectMessage {
|
||||
return {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: true,
|
||||
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("suppresses disconnect when wallet_ready arrives within the grace window", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
// Wallet keepalive watchdog fires UserDisconnect
|
||||
client.emit("message", makeDisconnectMsg());
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
|
||||
// Wallet reconnects and sends wallet_ready before the window expires
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
vi.advanceTimersByTime(26_000); // past where the original 30s timer would have fired
|
||||
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fires disconnect after the grace window if wallet does not reconnect", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: Array<{
|
||||
reason: DisconnectReason;
|
||||
message: string | undefined;
|
||||
}> = [];
|
||||
mgr.on("disconnect", (reason, message) =>
|
||||
disconnectEvents.push({ reason, message }),
|
||||
);
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
client.emit("message", makeDisconnectMsg());
|
||||
|
||||
vi.advanceTimersByTime(7_999);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(disconnectEvents).toHaveLength(1);
|
||||
expect(disconnectEvents[0].reason).toBe(
|
||||
DisconnectReason.UserDisconnect,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ping interval", () => {
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: true,
|
||||
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("sends a Ping after 10s following wallet_ready", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
vi.advanceTimersByTime(9_999);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("stops sending pings once the grace timer fires on disconnect", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Grace timer fires at 30s and stops the ping interval
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(disconnectEvents).toHaveLength(1);
|
||||
|
||||
// No further pings after the interval was stopped
|
||||
client.relayed.length = 0;
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets the ping interval when wallet reconnects", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
// First wallet_ready — ping interval starts (would fire at t=10s)
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
vi.advanceTimersByTime(5_000); // t=5s, no ping yet
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
|
||||
// Wallet reconnects — second wallet_ready resets the interval
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
client.relayed.length = 0;
|
||||
|
||||
// Old interval would have fired at t=15s (10s from now); new fires at t=25s
|
||||
vi.advanceTimersByTime(9_000); // t=14s — still no ping
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(1_000); // t=15s — new interval fires
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("enters reconnecting state after 75s with no pong or wallet_ready (does not disconnect)", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
const reconnectingEvents: number[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.on("reconnecting", () => reconnectingEvents.push(1));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// Still alive just before the threshold
|
||||
vi.advanceTimersByTime(74_999);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
expect(reconnectingEvents).toHaveLength(0);
|
||||
|
||||
// Next interval check pushes past 75s — liveness timeout fires reconnecting, not disconnect
|
||||
vi.advanceTimersByTime(5_001); // advances to t=80s (next 10s interval)
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
expect(reconnectingEvents).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers to connected when wallet_ready arrives after liveness timeout", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
const walletReadyEvents: number[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.on("walletready", () => walletReadyEvents.push(1));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// Liveness timeout fires at t=80s
|
||||
vi.advanceTimersByTime(80_001);
|
||||
|
||||
// Wallet wakes up and sends wallet_ready — dapp should resume connected
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
expect(walletReadyEvents).toHaveLength(2); // initial + recovery
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
|
||||
// Ping interval restarts after recovery — next ping at t=90s
|
||||
client.relayed.length = 0;
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(
|
||||
client.relayed.filter((m) => m.action === RelayMsgAction.Ping),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not disconnect when wallet_ready resets the liveness timer", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const xpub = makeTestXpub();
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const disconnectEvents: DisconnectReason[] = [];
|
||||
mgr.on("disconnect", (reason) => disconnectEvents.push(reason));
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// Simulate wallet_ready (keepalive reconnect) at t=60s, resetting the timer
|
||||
vi.advanceTimersByTime(60_000);
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
// 74s after the reset — still below 75s threshold
|
||||
vi.advanceTimersByTime(74_999);
|
||||
expect(disconnectEvents).toHaveLength(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("dapp_ready ordering on reconnect", () => {
|
||||
function makeMockClient() {
|
||||
const listeners = new Map<string, ((...args: any[]) => void)[]>();
|
||||
const relayed: ProtocolMessage[] = [];
|
||||
return {
|
||||
relayed,
|
||||
on(event: string, fn: (...args: any[]) => void) {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(fn);
|
||||
},
|
||||
emit(event: string, ...args: any[]) {
|
||||
for (const fn of listeners.get(event) ?? []) fn(...args);
|
||||
},
|
||||
relay: vi.fn(async (msg: ProtocolMessage) => {
|
||||
relayed.push(msg);
|
||||
}),
|
||||
setPeerCapabilities: vi.fn(),
|
||||
isKeyExchangeComplete: () => true,
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeWalletReadyMsg(xpub: string): WalletReadyMessage {
|
||||
return {
|
||||
action: RelayMsgAction.WalletReady,
|
||||
wallet_name: "Test Wallet",
|
||||
wallet_icon: "",
|
||||
public_key: "aa".repeat(32),
|
||||
secret: "bb".repeat(16),
|
||||
supported_protocols: [PROTOCOL_NAME],
|
||||
dapp_discovered: false,
|
||||
session: { [PROTOCOL_NAME]: { paths: [{ name: "receive", xpub }] } },
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
it("sends dapp_ready before re-sending pending sign requests", async () => {
|
||||
const mgr = new DappConnectionManager(undefined, undefined, {
|
||||
session: false,
|
||||
});
|
||||
const client = makeMockClient();
|
||||
const xpub = makeTestXpub();
|
||||
|
||||
mgr.updateConnection(client as any, { status: "connected" });
|
||||
|
||||
const request: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: client.nextSequence(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
inputPaths: [],
|
||||
transaction: "deadbeef",
|
||||
};
|
||||
const signPromise = mgr.sendSignRequest(request);
|
||||
client.relayed.length = 0;
|
||||
|
||||
// wallet_ready with dapp_discovered: false — dapp must await dapp_ready
|
||||
// before re-sending pending sign requests
|
||||
client.emit("message", makeWalletReadyMsg(xpub));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const actions = client.relayed.map((m) => m.action);
|
||||
const dappReadyIdx = actions.indexOf(RelayMsgAction.DappReady);
|
||||
const signReqIdx = actions.indexOf(RelayMsgAction.SignTransactionRequest);
|
||||
|
||||
expect(dappReadyIdx).toBeGreaterThan(-1);
|
||||
expect(signReqIdx).toBeGreaterThan(-1);
|
||||
expect(dappReadyIdx).toBeLessThan(signReqIdx);
|
||||
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: request.sequence,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
signedTransaction: "signed",
|
||||
});
|
||||
await signPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,14 +16,12 @@ import {
|
|||
SignTransactionRequest,
|
||||
SignTransactionResponse,
|
||||
SignCancelMessage,
|
||||
PingMessage,
|
||||
ProtocolMessage,
|
||||
PROTOCOL_NAME,
|
||||
childIndexOfPathName,
|
||||
isHdwalletv1Session,
|
||||
binToHex,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
EXTENSION_CHUNKED_MESSAGES,
|
||||
} from "@wizardconnect/core";
|
||||
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
|
||||
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
|
|
@ -52,8 +50,6 @@ export interface DappConnectionManagerEvents {
|
|||
messagereceived: [msg: ProtocolMessage];
|
||||
/** Fired on disconnect — either remote-initiated or protocol mismatch. */
|
||||
disconnect: [reason: DisconnectReason, message: string | undefined];
|
||||
/** Fired when the relay connection drops and auto-reconnect begins. */
|
||||
reconnecting: [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,10 +78,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
private readonly supportedProtocols: string[] = [PROTOCOL_NAME];
|
||||
|
||||
private walletDiscovered = false;
|
||||
private disconnectGraceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
|
||||
private lastPongTime: number = 0;
|
||||
private sessionPaths: PathXpub[] = [];
|
||||
private pendingSignatureRequests = new Map<
|
||||
number,
|
||||
|
|
@ -207,14 +199,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
|
||||
if (status.status === "connected" && this.conn) {
|
||||
this.onConnected();
|
||||
} else if (
|
||||
status.status === "reconnecting" ||
|
||||
status.status === "disconnected"
|
||||
) {
|
||||
this.stopPingInterval();
|
||||
if (status.status === "reconnecting") {
|
||||
this.emit("reconnecting");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -402,59 +386,17 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
private onConnected(): void {
|
||||
(async () => {
|
||||
// Wait until key exchange is complete before sending dapp_ready
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (this.conn && !this.conn.isKeyExchangeComplete()) {
|
||||
if (Date.now() >= deadline) {
|
||||
console.error("[wizardconnect/dapp] Key exchange timed out");
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!this.conn) return;
|
||||
await this.pushDappReady();
|
||||
if (this.conn) {
|
||||
await this.pushDappReady();
|
||||
}
|
||||
})().catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error in onConnected:", e),
|
||||
);
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
this.stopPingInterval();
|
||||
// Treat connection as live at the moment the session is established.
|
||||
this.lastPongTime = Date.now();
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (!this.conn || !this.walletDiscovered) return;
|
||||
|
||||
// If the wallet hasn't responded (pong or wallet_ready) within 75s,
|
||||
// it silently dropped the session — trigger reconnect.
|
||||
if (Date.now() - this.lastPongTime > 75_000) {
|
||||
this.stopPingInterval();
|
||||
this.emit("reconnecting");
|
||||
return;
|
||||
}
|
||||
|
||||
const ping: PingMessage = {
|
||||
action: RelayMsgAction.Ping,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.conn.relay(ping).catch(() => {});
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
private stopPingInterval(): void {
|
||||
if (this.pingInterval !== null) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopPingInterval();
|
||||
if (this.disconnectGraceTimer !== null) {
|
||||
clearTimeout(this.disconnectGraceTimer);
|
||||
this.disconnectGraceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async pushDappReady(): Promise<void> {
|
||||
if (!this.conn) return;
|
||||
|
||||
|
|
@ -468,10 +410,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
this.protocol && { selected_protocol: this.protocol }),
|
||||
...(this.dappName !== undefined && { dapp_name: this.dappName }),
|
||||
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }),
|
||||
// Transport-level: advertise chunking so the wallet can send large
|
||||
// SignTransactionResponses (signed tx hex can reach ~2 MB) that exceed
|
||||
// NIP-44's plaintext ceiling.
|
||||
extensions: { chunk: chunkExtensionAdvertisement() },
|
||||
};
|
||||
|
||||
await this.conn.relay(msg);
|
||||
|
|
@ -482,9 +420,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
this.emit("messagereceived", msg);
|
||||
switch (msg.action) {
|
||||
case RelayMsgAction.WalletReady:
|
||||
this.handleWalletReady(msg as WalletReadyMessage).catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error handling wallet_ready:", e),
|
||||
);
|
||||
this.handleWalletReady(msg as WalletReadyMessage);
|
||||
break;
|
||||
case RelayMsgAction.SignTransactionResponse:
|
||||
this.handleSignTransactionResponse(msg as SignTransactionResponse);
|
||||
|
|
@ -492,9 +428,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
case RelayMsgAction.Disconnect:
|
||||
this.handleRemoteDisconnect(msg as DisconnectMessage);
|
||||
break;
|
||||
case RelayMsgAction.Pong:
|
||||
this.lastPongTime = Date.now();
|
||||
break;
|
||||
case RelayMsgAction.DappReady:
|
||||
// Not expected on dapp side — silently ignore
|
||||
break;
|
||||
|
|
@ -504,30 +437,10 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
}
|
||||
|
||||
private handleRemoteDisconnect(msg: DisconnectMessage): void {
|
||||
// Give the wallet a short window to reconnect before propagating the
|
||||
// disconnect. Wallets that use a keepalive watchdog send UserDisconnect
|
||||
// and immediately reconnect; without this grace period the dapp would
|
||||
// tear down the session before the wallet_ready arrives.
|
||||
// 8s covers worst-case relay reconnect (WebSocket + 5s EOSE timeout + latency)
|
||||
// while being fast enough that a real explicit disconnect is felt promptly.
|
||||
if (this.disconnectGraceTimer !== null) {
|
||||
clearTimeout(this.disconnectGraceTimer);
|
||||
}
|
||||
this.disconnectGraceTimer = setTimeout(() => {
|
||||
this.disconnectGraceTimer = null;
|
||||
this.stopPingInterval();
|
||||
this.emit("disconnect", msg.reason, msg.message);
|
||||
}, 8_000);
|
||||
this.emit("disconnect", msg.reason, msg.message);
|
||||
}
|
||||
|
||||
private async handleWalletReady(msg: WalletReadyMessage): Promise<void> {
|
||||
if (this.disconnectGraceTimer !== null) {
|
||||
clearTimeout(this.disconnectGraceTimer);
|
||||
this.disconnectGraceTimer = null;
|
||||
}
|
||||
// Treat wallet_ready as a liveness signal — resets the pong stale timer.
|
||||
// This covers keepalive reconnects where the wallet reconnects instead of ponging.
|
||||
this.lastPongTime = Date.now();
|
||||
private handleWalletReady(msg: WalletReadyMessage): void {
|
||||
this.walletDiscovered = true;
|
||||
this.walletName = msg.wallet_name;
|
||||
this.walletIcon = msg.wallet_icon;
|
||||
|
|
@ -545,7 +458,6 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.conn?.relay(disconnectMsg).catch(() => {});
|
||||
this.stopPingInterval();
|
||||
this.emit("disconnect", DisconnectReason.ProtocolMismatch, detail);
|
||||
return;
|
||||
}
|
||||
|
|
@ -561,14 +473,10 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
return;
|
||||
}
|
||||
|
||||
// Transport-level capability: if the wallet advertises chunking, enable
|
||||
// chunked requests. Re-applied on every wallet_ready (cheap and idempotent),
|
||||
// so reconnects pick up capability changes.
|
||||
if (this.conn) {
|
||||
this.conn.setPeerCapabilities({
|
||||
chunk: peerSupportsChunk(msg.extensions),
|
||||
});
|
||||
}
|
||||
// Enable chunked sends if the wallet advertises the transport extension
|
||||
this.conn?.setPeerSupportsChunking(
|
||||
!!sessionData.extensions?.[EXTENSION_CHUNKED_MESSAGES],
|
||||
);
|
||||
|
||||
// Store raw paths for getSessionPaths() and xpub nodes for derivation
|
||||
this.sessionPaths = [...sessionData.paths];
|
||||
|
|
@ -588,32 +496,14 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
}
|
||||
}
|
||||
|
||||
// Send dapp_ready first so the wallet's session state is confirmed before
|
||||
// any sign requests arrive — await ensures correct ordering on the wire.
|
||||
if (!msg.dapp_discovered) {
|
||||
await this.pushDappReady().catch((e) =>
|
||||
this.pushDappReady().catch((e) =>
|
||||
console.error("[wizardconnect/dapp] Error pushing dapp_ready:", e),
|
||||
);
|
||||
}
|
||||
|
||||
this.emit("walletready", msg);
|
||||
|
||||
// Re-send any pending sign requests with a fresh timestamp so the wallet
|
||||
// doesn't filter them as already-processed (the wallet timestamps messages
|
||||
// at receive time and ignores anything older than its last disconnect).
|
||||
if (this.pendingSignatureRequests.size > 0) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const [, entry] of this.pendingSignatureRequests) {
|
||||
const refreshed = { ...entry.request, time: now };
|
||||
this.conn!.relay(refreshed)
|
||||
.then(() => this.emit("messagesent", refreshed))
|
||||
.catch((err) => {
|
||||
this.pendingSignatureRequests.delete(entry.request.sequence);
|
||||
entry.reject(err instanceof Error ? err : new Error(String(err)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-persist wallet identity and xpub paths to session storage
|
||||
if (this.sessionOptions) {
|
||||
const sessionUpdate: Partial<StoredSession> = {
|
||||
|
|
@ -628,8 +518,18 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
);
|
||||
}
|
||||
|
||||
// Start keepalive pings now that the session is fully established.
|
||||
this.startPingInterval();
|
||||
// Re-send any pending sign requests the wallet may have missed
|
||||
// (e.g. wallet app wasn't open when the request was first sent).
|
||||
if (this.pendingSignatureRequests.size > 0 && this.conn) {
|
||||
for (const [, entry] of this.pendingSignatureRequests) {
|
||||
this.conn.relay(entry.request).catch((err) => {
|
||||
console.error(
|
||||
"[wizardconnect/dapp] Failed to re-send pending sign request:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSignTransactionResponse(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "React components and hooks for WizardConnect dapp integration",
|
||||
"repository": {
|
||||
|
|
@ -39,6 +39,6 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,10 +72,6 @@ export function useWizardConnect(
|
|||
setState("connected");
|
||||
});
|
||||
|
||||
mgr.on("reconnecting", () => {
|
||||
setState("reconnecting");
|
||||
});
|
||||
|
||||
mgr.on("disconnect", () => {
|
||||
setState("disconnected");
|
||||
setWalletName(null);
|
||||
|
|
@ -160,24 +156,10 @@ export function useWizardConnect(
|
|||
});
|
||||
}, [persistSession, sessionKey, storage, startRelay]);
|
||||
|
||||
// Cleanup on unmount.
|
||||
//
|
||||
// Reset `autoReconnectAttempted` here so React 18 StrictMode's dev-mode
|
||||
// mount→unmount→remount cycle doesn't leave the hook in a "attempted but
|
||||
// torn down" state. Without the reset:
|
||||
// - Mount A: auto-reconnect fires, creates relay, ref → true.
|
||||
// - StrictMode cleanup: relay destroyed.
|
||||
// - Mount B: auto-reconnect sees ref === true, skips. No new relay.
|
||||
// Result: a stored session never reconnects in dev. Resetting the ref on
|
||||
// cleanup lets Mount B re-fire the auto-reconnect path and rebuild the
|
||||
// relay. In production (no StrictMode) this only runs at real unmount, so
|
||||
// the reset is harmless there.
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
managerRef.current?.destroy();
|
||||
relayRef.current?.cleanup();
|
||||
relayRef.current = null;
|
||||
autoReconnectAttempted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ export type WizardConnectState =
|
|||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected";
|
||||
|
||||
export interface UseWizardConnectOptions {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"description": "CLI for testing WizardConnect protocol",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ program
|
|||
"--sign",
|
||||
"Send a dummy sign request after wallet is ready (tests approval flow)",
|
||||
)
|
||||
.option(
|
||||
"--large-sign",
|
||||
"Send a large (>30 KB) sign request that triggers chunking (tests the NIP-44 size-limit fix)",
|
||||
)
|
||||
.action(async (options) => {
|
||||
await runDappMode(options);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
EXTENSION_CHUNKED_MESSAGES,
|
||||
type RelayUpdatePayload,
|
||||
type DappReadyMessage,
|
||||
type WalletReadyMessage,
|
||||
|
|
@ -101,6 +102,97 @@ async function sendSignRequest(
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Send large sign request (chunking smoke test) ----
|
||||
|
||||
async function sendLargeSignRequest(
|
||||
client: RelayClient,
|
||||
state: DappState,
|
||||
): Promise<void> {
|
||||
const sequence = state.sequence++;
|
||||
|
||||
// Simulate a Cauldron trade with 30 contract inputs — each carrying a
|
||||
// contract.artifact blob (~540 chars). This mirrors the real payload shape
|
||||
// that triggered the original NIP-44 65535-byte limit error.
|
||||
const sourceOutputs = Array.from({ length: 30 }, (_, i) => ({
|
||||
outpointTransactionHash: "a".repeat(64),
|
||||
outpointIndex: i,
|
||||
lockingBytecode: "b".repeat(52),
|
||||
valueSatoshis: 1_000_000,
|
||||
contract: {
|
||||
asmBytecode: "c".repeat(200),
|
||||
artifact: {
|
||||
contractName: "CauldronV4",
|
||||
constructorInputs: [{ name: "ownerPkh", type: "bytes20" }],
|
||||
abi: [
|
||||
{
|
||||
name: "trade",
|
||||
inputs: [
|
||||
{ name: "isAdd", type: "bool" },
|
||||
{ name: "tokenAmount", type: "uint64" },
|
||||
{ name: "satoshiAmount", type: "uint64" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "withdraw",
|
||||
inputs: [
|
||||
{ name: "sig", type: "datasig" },
|
||||
{ name: "pk", type: "pubkey" },
|
||||
],
|
||||
},
|
||||
],
|
||||
bytecode: "d".repeat(500),
|
||||
source: "// CASL source\n" + "e".repeat(800),
|
||||
compiler: { name: "cashscript", version: "0.10.2" },
|
||||
updatedAt: "2025-01-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const msg = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
transaction: {
|
||||
transaction: {
|
||||
inputs: sourceOutputs.map((_, i) => ({
|
||||
outpointTransactionHash: "a".repeat(64),
|
||||
outpointIndex: i,
|
||||
sequenceNumber: 0xffffffff,
|
||||
unlockingBytecode: "",
|
||||
})),
|
||||
outputs: [{ lockingBytecode: "f".repeat(46), valueSatoshis: 900_000 }],
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
},
|
||||
sourceOutputs,
|
||||
userPrompt: "Large chunking integration test — 30 contract inputs",
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: sourceOutputs.map((_, i) => [i, "defi", 0]),
|
||||
};
|
||||
|
||||
const json = JSON.stringify(msg);
|
||||
const chunkCount = Math.ceil(json.length / 28_000);
|
||||
|
||||
console.log(
|
||||
chalk.yellow(`→ sign_transaction_request (LARGE)`) +
|
||||
chalk.dim(
|
||||
` seq=${sequence} ${json.length} chars → ${chunkCount} chunk${chunkCount !== 1 ? "s" : ""}`,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await (client as any).relay(msg);
|
||||
console.log(
|
||||
chalk.green(
|
||||
` ✓ sent — if wallet receives seq=${sequence} with all ${sourceOutputs.length} sourceOutputs, chunking works`,
|
||||
),
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error(chalk.red(" ✗ send error:"), err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Handle incoming messages ----
|
||||
|
||||
function handleMessage(
|
||||
|
|
@ -108,6 +200,7 @@ function handleMessage(
|
|||
client: RelayClient,
|
||||
state: DappState,
|
||||
sendSign: boolean,
|
||||
sendLargeSign: boolean,
|
||||
): void {
|
||||
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
||||
|
||||
|
|
@ -123,6 +216,12 @@ function handleMessage(
|
|||
| Hdwalletv1Session
|
||||
| undefined;
|
||||
const pathsSummary = hdwv1?.paths?.map((p) => p.name).join(",") ?? "none";
|
||||
const supportsChunking =
|
||||
hdwv1?.extensions?.[EXTENSION_CHUNKED_MESSAGES] !== undefined;
|
||||
|
||||
if (supportsChunking) {
|
||||
client.setPeerSupportsChunking(true);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${now} ` +
|
||||
|
|
@ -130,7 +229,10 @@ function handleMessage(
|
|||
` wallet="${chalk.bold(msg.wallet_name)}"` +
|
||||
` dapp_discovered=${msg.dapp_discovered}` +
|
||||
` protocols=[${msg.supported_protocols.join(",")}]` +
|
||||
` paths=[${pathsSummary}]`,
|
||||
` paths=[${pathsSummary}]` +
|
||||
(supportsChunking
|
||||
? chalk.cyan(" chunking=✓")
|
||||
: chalk.dim(" chunking=✗")),
|
||||
);
|
||||
|
||||
if (!msg.dapp_discovered) {
|
||||
|
|
@ -147,6 +249,17 @@ function handleMessage(
|
|||
sendSignRequest(client, state).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
if (sendLargeSign && state.walletReady) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
" (--large-sign: scheduling large sign request in 500ms...)",
|
||||
),
|
||||
);
|
||||
setTimeout(() => {
|
||||
sendLargeSignRequest(client, state).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +296,7 @@ export async function runDappMode(options: {
|
|||
secret?: string;
|
||||
walletPublicKey?: string;
|
||||
sign?: boolean;
|
||||
largeSign?: boolean;
|
||||
}): Promise<void> {
|
||||
const state = makeState();
|
||||
|
||||
|
|
@ -191,7 +305,13 @@ export async function runDappMode(options: {
|
|||
if (options.sign)
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"--sign: will send dummy sign request after first pubkey batch",
|
||||
"--sign: will send dummy sign request after wallet is ready",
|
||||
),
|
||||
);
|
||||
if (options.largeSign)
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"--large-sign: will send 30-contract-input sign request (chunking smoke test)",
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
|
|
@ -273,7 +393,13 @@ export async function runDappMode(options: {
|
|||
|
||||
// Register message handler
|
||||
client.on("message", (message: ProtocolMessage) => {
|
||||
handleMessage(message, client, state, options.sign ?? false);
|
||||
handleMessage(
|
||||
message,
|
||||
client,
|
||||
state,
|
||||
options.sign ?? false,
|
||||
options.largeSign ?? false,
|
||||
);
|
||||
});
|
||||
|
||||
// Send initial dapp_ready
|
||||
|
|
|
|||
|
|
@ -120,11 +120,15 @@ export async function runWalletMode(options: {
|
|||
});
|
||||
|
||||
manager.on("pendingSignRequest", (request) => {
|
||||
const sourceCount =
|
||||
(request.request.transaction as any)?.sourceOutputs?.length ?? "?";
|
||||
const jsonSize = JSON.stringify(request.request).length;
|
||||
console.log(
|
||||
chalk.yellow("← sign_request") +
|
||||
chalk.dim(
|
||||
` conn=${request.connectionId} seq=${request.request.sequence}`,
|
||||
),
|
||||
) +
|
||||
chalk.dim(` ${jsonSize} chars sourceOutputs=${sourceCount}`),
|
||||
);
|
||||
console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)"));
|
||||
manager
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@wizardconnect/wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"description": "Wallet-side integration helpers for WizardConnect",
|
||||
"repository": {
|
||||
|
|
@ -31,6 +31,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Chunk transport extension — end-to-end tests against a real relay.
|
||||
*
|
||||
* Exercises the NIP-44 size-limit workaround: messages that exceed the
|
||||
* 65,535-byte plaintext ceiling must be split on the sender and reassembled
|
||||
* on the receiver, symmetric in both directions.
|
||||
*
|
||||
* These tests deliberately use payloads in the same size range as real-world
|
||||
* swap transactions:
|
||||
* - ~80 KB: typical pool-aggregated swap request
|
||||
* - ~200 KB: large swap request
|
||||
* - ~100 KB signed tx hex response (policy-max)
|
||||
* - ~2 MB signed tx hex response (consensus-max)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import {
|
||||
RelayMsgAction,
|
||||
type SignTransactionRequest,
|
||||
type SignTransactionResponse,
|
||||
} from "@wizardconnect/core";
|
||||
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
|
||||
import type { PendingSignRequest } from "../wallet-connection-manager.js";
|
||||
|
||||
const TEST_RELAY_URL =
|
||||
process.env.TEST_RELAY_URL ?? "wss://relay.riften.net:443";
|
||||
|
||||
describe("chunk extension — dapp → wallet oversized requests", () => {
|
||||
let conn: ConnectionHandles;
|
||||
const pending: PendingSignRequest[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
conn = await setupConnection(TEST_RELAY_URL);
|
||||
conn.wallet.manager.on("pendingSignRequest", (req) => pending.push(req));
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
conn?.cleanup();
|
||||
});
|
||||
|
||||
it("wallet_ready advertises the chunk transport extension", () => {
|
||||
const wr = conn.dapp.walletReadyMessages[0];
|
||||
expect(wr.extensions?.chunk).toBeDefined();
|
||||
});
|
||||
|
||||
it("reassembles an 80 KB sign_transaction_request", async () => {
|
||||
const bigHex = "ab".repeat(40_000); // 80 KB hex string
|
||||
const msg: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 100,
|
||||
transaction: {
|
||||
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||
sourceOutputs: [],
|
||||
userPrompt: bigHex,
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await conn.dapp.client()!.relay(msg);
|
||||
|
||||
await waitFor(() => pending.some((p) => p.request.sequence === 100), {
|
||||
timeoutMs: 30000,
|
||||
what: "80 KB sign request reassembled on wallet",
|
||||
});
|
||||
|
||||
const got = pending.find((p) => p.request.sequence === 100)!;
|
||||
expect(got.request.transaction.userPrompt).toBe(bigHex);
|
||||
}, 60000);
|
||||
|
||||
it("reassembles a 200 KB sign_transaction_request", async () => {
|
||||
const bigHex = "cd".repeat(100_000); // 200 KB hex
|
||||
const msg: SignTransactionRequest = {
|
||||
action: RelayMsgAction.SignTransactionRequest,
|
||||
sequence: 101,
|
||||
transaction: {
|
||||
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||
sourceOutputs: [],
|
||||
userPrompt: bigHex,
|
||||
broadcast: false,
|
||||
},
|
||||
inputPaths: [],
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await conn.dapp.client()!.relay(msg);
|
||||
|
||||
await waitFor(() => pending.some((p) => p.request.sequence === 101), {
|
||||
timeoutMs: 45000,
|
||||
what: "200 KB sign request reassembled on wallet",
|
||||
});
|
||||
|
||||
const got = pending.find((p) => p.request.sequence === 101)!;
|
||||
expect(got.request.transaction.userPrompt).toBe(bigHex);
|
||||
expect(got.request.transaction.userPrompt!.length).toBe(200_000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("chunk extension — wallet → dapp oversized responses", () => {
|
||||
let conn: ConnectionHandles;
|
||||
|
||||
beforeAll(async () => {
|
||||
conn = await setupConnection(TEST_RELAY_URL);
|
||||
}, 30000);
|
||||
|
||||
afterAll(() => {
|
||||
conn?.cleanup();
|
||||
});
|
||||
|
||||
async function walletSend(msg: SignTransactionResponse): Promise<void> {
|
||||
// Reach through WalletConnectionManager internals to get the RelayClient
|
||||
// directly and publish. A production app would return the response
|
||||
// through its normal sign-approval flow; integration tests bypass that.
|
||||
const m = conn.wallet.manager as unknown as {
|
||||
connections: Map<
|
||||
string,
|
||||
{ client: { relay: (m: unknown) => Promise<void> } }
|
||||
>;
|
||||
};
|
||||
const connEntry = m.connections.get(conn.wallet.connectionId);
|
||||
if (!connEntry?.client) throw new Error("wallet relay client not ready");
|
||||
await connEntry.client.relay(msg);
|
||||
}
|
||||
|
||||
it("reassembles a ~100 KB signed tx hex response", async () => {
|
||||
const hex = "ef".repeat(50_000); // 100 KB hex
|
||||
const msg: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: 200,
|
||||
signedTransaction: hex,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await walletSend(msg);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
conn.dapp.messages.some(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 200,
|
||||
),
|
||||
{
|
||||
timeoutMs: 45000,
|
||||
what: "100 KB sign response reassembled on dapp",
|
||||
},
|
||||
);
|
||||
|
||||
const got = conn.dapp.messages.find(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 200,
|
||||
) as SignTransactionResponse;
|
||||
expect(got.signedTransaction).toBe(hex);
|
||||
}, 60000);
|
||||
|
||||
it("reassembles a ~2 MB signed tx hex response (consensus-max case)", async () => {
|
||||
const hex = "f0".repeat(1_000_000); // 2 MB hex
|
||||
const msg: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
sequence: 201,
|
||||
signedTransaction: hex,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
await walletSend(msg);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
conn.dapp.messages.some(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 201,
|
||||
),
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
what: "2 MB sign response reassembled on dapp",
|
||||
},
|
||||
);
|
||||
|
||||
const got = conn.dapp.messages.find(
|
||||
(m) =>
|
||||
m.action === RelayMsgAction.SignTransactionResponse &&
|
||||
(m as SignTransactionResponse).sequence === 201,
|
||||
) as SignTransactionResponse;
|
||||
expect(got.signedTransaction.length).toBe(2_000_000);
|
||||
expect(got.signedTransaction).toBe(hex);
|
||||
}, 180000);
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
// Copyright (C) 2026 Whiterun LLC,
|
||||
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
/**
|
||||
* Wallet-initiated disconnect must actually reach the dapp.
|
||||
*
|
||||
* disconnect.test.ts covers the other direction — the dapp sends `disconnect`
|
||||
* and the wallet reacts. Nothing covered wallet → dapp, which is how this got
|
||||
* shipped broken: doDisconnect fired the courtesy message without awaiting it and
|
||||
* then tore the relay connection down, so the publish died mid-flight and the
|
||||
* dapp went on believing the wallet was connected until its own liveness
|
||||
* timeout. Downstream wallets were carrying a patch for it.
|
||||
*
|
||||
* These tests are worth their runtime because the failure is invisible locally —
|
||||
* the wallet's own state is correct either way, and only the peer notices.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { RelayMsgAction, DisconnectReason } from "@wizardconnect/core";
|
||||
import type { DisconnectMessage, ProtocolMessage } from "@wizardconnect/core";
|
||||
|
||||
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
|
||||
|
||||
let handles: ConnectionHandles | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
handles?.cleanup();
|
||||
handles = null;
|
||||
});
|
||||
|
||||
/** Disconnect messages the dapp actually received off the relay. */
|
||||
function disconnectsSeenByDapp(h: ConnectionHandles): DisconnectMessage[] {
|
||||
return h.dapp.messages.filter(
|
||||
(msg: ProtocolMessage) => msg.action === RelayMsgAction.Disconnect,
|
||||
) as DisconnectMessage[];
|
||||
}
|
||||
|
||||
describe("wallet-initiated disconnect", () => {
|
||||
it("delivers the courtesy disconnect to the dapp", async () => {
|
||||
handles = await setupConnection();
|
||||
expect(disconnectsSeenByDapp(handles)).toHaveLength(0);
|
||||
|
||||
handles.wallet.manager.disconnect(handles.wallet.connectionId);
|
||||
|
||||
// The whole point: this arrives over a real relay, which it cannot do if the
|
||||
// connection is torn down while the publish is still in flight.
|
||||
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
|
||||
timeoutMs: 20000,
|
||||
what: "disconnect message received by the dapp",
|
||||
});
|
||||
|
||||
expect(disconnectsSeenByDapp(handles)[0].reason).toBe(
|
||||
DisconnectReason.UserDisconnect,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the connection immediately, without waiting for delivery", async () => {
|
||||
// Teardown is deferred, but the registry must not be: a caller that
|
||||
// disconnects and then inspects state should never see the dying connection.
|
||||
handles = await setupConnection();
|
||||
const { manager, connectionId } = handles.wallet;
|
||||
expect(Object.keys(manager.getConnections())).toContain(connectionId);
|
||||
|
||||
manager.disconnect(connectionId);
|
||||
|
||||
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
|
||||
});
|
||||
|
||||
it("delivers a disconnect for every connection in disconnectAll", async () => {
|
||||
handles = await setupConnection();
|
||||
|
||||
handles.wallet.manager.disconnectAll();
|
||||
|
||||
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
|
||||
timeoutMs: 20000,
|
||||
what: "disconnect message from disconnectAll",
|
||||
});
|
||||
expect(Object.keys(handles.wallet.manager.getConnections())).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the dapp reconnect on the same URI afterwards", async () => {
|
||||
// Deferring teardown must not leave the URI unusable — connect() returns an
|
||||
// existing connection for a URI, so a stale entry would be handed back.
|
||||
handles = await setupConnection();
|
||||
const { manager, connectionId } = handles.wallet;
|
||||
|
||||
manager.disconnect(connectionId);
|
||||
const reconnectedId = manager.connect(handles.dapp.uri);
|
||||
|
||||
expect(reconnectedId).not.toBe(connectionId);
|
||||
expect(Object.keys(manager.getConnections())).toContain(reconnectedId);
|
||||
|
||||
manager.disconnect(reconnectedId);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,8 +22,6 @@ import {
|
|||
initiateDappRelay,
|
||||
RelayMsgAction,
|
||||
PROTOCOL_NAME,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
type RelayClient,
|
||||
type RelayUpdatePayload,
|
||||
type DappReadyMessage,
|
||||
|
|
@ -112,10 +110,6 @@ export interface DappHandle {
|
|||
cleanup: () => void;
|
||||
/** All wallet_ready messages received. */
|
||||
walletReadyMessages: WalletReadyMessage[];
|
||||
/** RelayClient for direct message publishing (populated after key exchange). */
|
||||
client: () => RelayClient | null;
|
||||
/** All non-handshake messages received by the dapp. */
|
||||
messages: ProtocolMessage[];
|
||||
}
|
||||
|
||||
// ---- WalletHandle -----------------------------------------------------------
|
||||
|
|
@ -153,7 +147,6 @@ export async function setupConnection(
|
|||
// ---- Dapp side ----
|
||||
|
||||
const walletReadyMessages: WalletReadyMessage[] = [];
|
||||
const allMessages: ProtocolMessage[] = [];
|
||||
let dappClient: RelayClient | null = null;
|
||||
let keyExchanged = false;
|
||||
|
||||
|
|
@ -170,9 +163,6 @@ export async function setupConnection(
|
|||
supported_protocols: [PROTOCOL_NAME],
|
||||
wallet_discovered: wd,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
// Advertise transport-level chunking so the wallet can return
|
||||
// oversized sign_transaction_response messages.
|
||||
extensions: { chunk: chunkExtensionAdvertisement() },
|
||||
};
|
||||
await dappClient!.relay(msg);
|
||||
}
|
||||
|
|
@ -192,51 +182,16 @@ export async function setupConnection(
|
|||
if (message.action === RelayMsgAction.WalletReady) {
|
||||
const msg = message as WalletReadyMessage;
|
||||
walletReadyMessages.push(msg);
|
||||
// Mirror DappConnectionManager behavior: enable chunked outbound if
|
||||
// the wallet advertises support.
|
||||
dappClient!.setPeerCapabilities({
|
||||
chunk: peerSupportsChunk(msg.extensions),
|
||||
});
|
||||
if (!msg.dapp_discovered) {
|
||||
sendDappReady(true).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
allMessages.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Prompt one more wallet_ready, now that the handler above is registered.
|
||||
// The wallet_ready that completed key exchange was consumed by
|
||||
// initiateDappRelay before this handler existed, so it is not in
|
||||
// walletReadyMessages and the caller's "wallet_ready with paths" wait
|
||||
// needs a fresh one. dapp_discovered=false is what makes the wallet
|
||||
// re-send.
|
||||
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
|
||||
await sendDappReady(false);
|
||||
});
|
||||
|
||||
// Re-announce until key exchange completes.
|
||||
//
|
||||
// The wallet sends exactly one wallet_ready per connection cycle, and it
|
||||
// fires as soon as manager.connect() resolves — which can be before this
|
||||
// dapp's relay subscription is live. If that single message is missed there
|
||||
// is nothing to retry against: keyexchangecomplete never fires, so the
|
||||
// handler above never registers and no dapp_ready is ever sent. The suite
|
||||
// then sat until the 15s "key exchange" timeout. Under singleFork the
|
||||
// previous file's teardown is still closing sockets while this runs, which
|
||||
// is exactly when the race is won by the wrong side.
|
||||
//
|
||||
// dapp_ready(wallet_discovered=false) resets walletReadySentThisCycle on the
|
||||
// wallet, so each retry earns another wallet_ready. This is the recovery path
|
||||
// the protocol's mutual-discovery design already specifies — the harness
|
||||
// simply was not using it.
|
||||
const reannounce = setInterval(() => {
|
||||
if (keyExchanged) {
|
||||
clearInterval(reannounce);
|
||||
return;
|
||||
}
|
||||
if (dappClient) sendDappReady(false).catch(() => {});
|
||||
}, 2000);
|
||||
|
||||
// ---- Wallet side ----
|
||||
|
||||
const manager = new WalletConnectionManager(adapter);
|
||||
|
|
@ -244,16 +199,7 @@ export async function setupConnection(
|
|||
|
||||
// ---- Wait for key exchange ----
|
||||
|
||||
try {
|
||||
await waitFor(() => keyExchanged, {
|
||||
timeoutMs: 15000,
|
||||
what: "key exchange",
|
||||
});
|
||||
} finally {
|
||||
// Must not outlive the wait: on timeout a leaked interval keeps publishing
|
||||
// dapp_ready into later tests and holds the fork open.
|
||||
clearInterval(reannounce);
|
||||
}
|
||||
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
|
||||
|
||||
// ---- Wait for wallet_ready with paths ----
|
||||
|
||||
|
|
@ -271,8 +217,6 @@ export async function setupConnection(
|
|||
uri: dappRelay.uri,
|
||||
cleanup: dappRelay.cleanup,
|
||||
walletReadyMessages,
|
||||
client: () => dappClient,
|
||||
messages: allMessages,
|
||||
};
|
||||
|
||||
const walletHandle: WalletHandle = { manager, connectionId, adapter };
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ describe("WalletConnectionManager — sign request dedup", () => {
|
|||
beforeEach(() => {
|
||||
capturedCallback = null;
|
||||
mockClient = makeMockClient();
|
||||
(WalletConnectionManager as any).uriSignSequences.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -125,7 +124,7 @@ describe("WalletConnectionManager — sign request dedup", () => {
|
|||
expect(emitted).toEqual([42]);
|
||||
});
|
||||
|
||||
it("keeps dedup guard active after sign response to block relay re-delivery", async () => {
|
||||
it("allows the same sequence after response clears it", async () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
const connId = mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
|
@ -135,18 +134,19 @@ describe("WalletConnectionManager — sign request dedup", () => {
|
|||
|
||||
const request = makeSignRequest(42);
|
||||
|
||||
// First delivery
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
// Wallet responds — clears the dedup guard
|
||||
await mgr.sendSignResponse(connId, 42, "signed_hex");
|
||||
|
||||
// Nostr relay re-delivers the stored sign request after reconnect —
|
||||
// must NOT prompt the user a second time
|
||||
// Same sequence arrives again (hypothetical re-send)
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
expect(emitted).toEqual([42, 42]);
|
||||
});
|
||||
|
||||
it("keeps dedup guard active after sign cancel to block relay re-delivery", async () => {
|
||||
it("clears dedup guard on sign cancel", async () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
|
@ -165,103 +165,8 @@ describe("WalletConnectionManager — sign request dedup", () => {
|
|||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
// Relay re-delivers the old sign request — must still be filtered
|
||||
// Re-sent after cancel — should be accepted
|
||||
client.emit("message", request);
|
||||
expect(emitted).toEqual([42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalletConnectionManager — ping → pong", () => {
|
||||
beforeEach(() => {
|
||||
capturedCallback = null;
|
||||
mockClient = makeMockClient();
|
||||
(WalletConnectionManager as any).uriSignSequences.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("responds to a Ping message with a Pong", () => {
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
mgr.connect("wiz://test");
|
||||
const client = simulateConnection();
|
||||
|
||||
client.relay.mockClear();
|
||||
|
||||
client.emit("message", {
|
||||
action: RelayMsgAction.Ping,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
const pongCalls = client.relay.mock.calls.filter(
|
||||
([msg]) => msg.action === RelayMsgAction.Pong,
|
||||
);
|
||||
expect(pongCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WalletConnectionManager — per-connection signSequence cleanup", () => {
|
||||
beforeEach(() => {
|
||||
capturedCallback = null;
|
||||
mockClient = makeMockClient();
|
||||
(WalletConnectionManager as any).uriSignSequences.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("blocks relay-replayed COMPLETED sequence on reconnect to same URI", async () => {
|
||||
// A sequence that was fully signed must be blocked on relay replay even
|
||||
// after an explicit disconnect()+connect() (e.g. Paytaca 65s watchdog).
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const connId1 = mgr.connect("wiz://test");
|
||||
const firstClient = simulateConnection();
|
||||
firstClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
// Complete the request — this is what persists seq 42 to the URI cache
|
||||
await mgr.sendSignResponse(connId1, 42, "signed_hex");
|
||||
|
||||
mgr.disconnect(connId1);
|
||||
|
||||
// Relay replays seq=42 on the new connection — must be blocked
|
||||
mockClient = makeMockClient();
|
||||
mgr.connect("wiz://test");
|
||||
const secondClient = simulateConnection();
|
||||
secondClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42]); // still just [42]
|
||||
|
||||
// A genuinely new sequence from the dapp is accepted
|
||||
secondClient.emit("message", makeSignRequest(44));
|
||||
expect(emitted).toEqual([42, 44]);
|
||||
});
|
||||
|
||||
it("allows dapp to resend a PENDING sequence after watchdog disconnect", () => {
|
||||
// If the Paytaca watchdog fires while the user is mid-signing, the dapp
|
||||
// must be able to resend the request on the new connection. Pending sequences
|
||||
// must NOT be stored in the URI cache until a response is sent.
|
||||
const mgr = new WalletConnectionManager(makeAdapter());
|
||||
const emitted: number[] = [];
|
||||
mgr.on("pendingSignRequest", (req) => emitted.push(req.request.sequence));
|
||||
|
||||
const connId1 = mgr.connect("wiz://test");
|
||||
const firstClient = simulateConnection();
|
||||
firstClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42]);
|
||||
|
||||
// Watchdog fires BEFORE user signs — seq 42 is still pending, no response sent
|
||||
mgr.disconnect(connId1);
|
||||
|
||||
// Dapp reconnects and resends the same pending request — must be accepted
|
||||
mockClient = makeMockClient();
|
||||
mgr.connect("wiz://test");
|
||||
const secondClient = simulateConnection();
|
||||
secondClient.emit("message", makeSignRequest(42));
|
||||
expect(emitted).toEqual([42, 42]); // resend accepted, user prompted again
|
||||
expect(emitted).toEqual([42, 42]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,14 +18,11 @@ import {
|
|||
WalletReadyMessage,
|
||||
DisconnectMessage,
|
||||
DisconnectReason,
|
||||
PingMessage,
|
||||
PongMessage,
|
||||
PathXpub,
|
||||
Hdwalletv1Session,
|
||||
PROTOCOL_NAME,
|
||||
binToHex,
|
||||
chunkExtensionAdvertisement,
|
||||
peerSupportsChunk,
|
||||
EXTENSION_CHUNKED_MESSAGES,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
|
|
@ -45,15 +42,6 @@ export interface PendingSignRequest {
|
|||
request: SignTransactionRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long doDisconnect waits for the courtesy `disconnect` message to be
|
||||
* published before tearing the relay connection down anyway.
|
||||
*
|
||||
* Generous enough for a slow relay, short enough that an unreachable one cannot
|
||||
* hold the socket open indefinitely.
|
||||
*/
|
||||
const DISCONNECT_PUBLISH_TIMEOUT_MS = 5000;
|
||||
|
||||
interface ActiveConnection {
|
||||
id: string;
|
||||
uri: string;
|
||||
|
|
@ -68,8 +56,6 @@ interface ActiveConnection {
|
|||
/// Prevents duplicate wallet_ready messages within a single connection cycle.
|
||||
/// Reset to false on each new connect/reconnect; set to true after sending.
|
||||
walletReadySentThisCycle: boolean;
|
||||
/// Sign request sequences received on this connection, for cleanup on disconnect.
|
||||
signSequences: Set<number>;
|
||||
notificationQueue: ProtocolMessage[];
|
||||
notificationProcessor: ReturnType<typeof setInterval> | null;
|
||||
/// Key exchange data embedded in wallet_ready
|
||||
|
|
@ -102,11 +88,6 @@ export type WalletConnectionManagerEvents = {
|
|||
export class WalletConnectionManager extends EventEmitter<WalletConnectionManagerEvents> {
|
||||
private connections: Map<string, ActiveConnection> = new Map();
|
||||
private activeSignSequences = new Set<number>();
|
||||
// Persists sign request sequences seen per URI across doDisconnect()+connect()
|
||||
// within the same JS session. The relay replays stored sign requests after
|
||||
// explicit reconnect; without this, clearing activeSignSequences in doDisconnect
|
||||
// would let replayed sequences bypass the dedup guard.
|
||||
private static readonly uriSignSequences = new Map<string, Set<number>>();
|
||||
private adapter: WalletAdapter;
|
||||
|
||||
constructor(adapter: WalletAdapter) {
|
||||
|
|
@ -140,7 +121,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
connectedAt: Date.now(),
|
||||
dappDiscovered: false,
|
||||
walletReadySentThisCycle: false,
|
||||
signSequences: new Set(),
|
||||
notificationQueue: [],
|
||||
notificationProcessor: null,
|
||||
walletPublicKeyHex: "",
|
||||
|
|
@ -149,16 +129,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
|
||||
this.connections.set(id, conn);
|
||||
|
||||
// Restore any sign sequences seen on this URI during this JS session so
|
||||
// relay-replayed requests are blocked even after a full disconnect+reconnect.
|
||||
const savedSeqs = WalletConnectionManager.uriSignSequences.get(uri);
|
||||
if (savedSeqs) {
|
||||
for (const seq of savedSeqs) {
|
||||
this.activeSignSequences.add(seq);
|
||||
conn.signSequences.add(seq);
|
||||
}
|
||||
}
|
||||
|
||||
const statusCallback: RelayStatusCallback = (
|
||||
payload: RelayUpdatePayload,
|
||||
) => {
|
||||
|
|
@ -226,58 +196,19 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
const conn = this.connections.get(connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
// Drop the connection from the registry synchronously, before any awaiting.
|
||||
// getConnections() must reflect the disconnect immediately, and connect()
|
||||
// returns an existing connection for a URI — so leaving this one in the map
|
||||
// while its teardown is pending would hand a caller a dying connection.
|
||||
for (const seq of conn.signSequences) {
|
||||
this.activeSignSequences.delete(seq);
|
||||
if (sendMessage && conn.client) {
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
conn.client.relay(disconnectMsg).catch(() => {});
|
||||
}
|
||||
|
||||
clearInterval(conn.notificationProcessor ?? undefined);
|
||||
conn.notificationProcessor = null;
|
||||
conn.cleanup();
|
||||
this.connections.delete(connectionId);
|
||||
this.emit("connectionsChanged");
|
||||
|
||||
if (!sendMessage || !conn.client) {
|
||||
conn.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectMsg: DisconnectMessage = {
|
||||
action: RelayMsgAction.Disconnect,
|
||||
reason: DisconnectReason.UserDisconnect,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Tear down only once the courtesy message has actually gone out.
|
||||
//
|
||||
// relay() resolves after `Promise.allSettled(pool.publish(...))` — a real
|
||||
// round trip to every configured relay. Calling conn.cleanup() straight
|
||||
// after firing it closed the pool underneath the in-flight publish, so the
|
||||
// disconnect usually never reached the relay and the dapp went on believing
|
||||
// the wallet was connected until its own liveness timeout fired. Downstream
|
||||
// wallets were patching this out of the published package.
|
||||
//
|
||||
// Bounded, because "the publish never settles" is exactly the case where a
|
||||
// relay is unreachable, and a socket that is never closed is worse than a
|
||||
// courtesy message that is never delivered.
|
||||
let torndown = false;
|
||||
const teardown = () => {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
conn.cleanup();
|
||||
};
|
||||
|
||||
const timer = setTimeout(teardown, DISCONNECT_PUBLISH_TIMEOUT_MS);
|
||||
conn.client
|
||||
.relay(disconnectMsg)
|
||||
.catch(() => {
|
||||
// Nothing to do: we are disconnecting either way.
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
teardown();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -312,17 +243,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
throw new Error(`Connection ${connectionId} not found or not connected`);
|
||||
}
|
||||
|
||||
// Intentionally keep sequence in activeSignSequences and conn.signSequences.
|
||||
// Nostr relays replay stored events on reconnect; removing the guard here
|
||||
// would let a re-delivered sign_transaction_request pass dedup and prompt
|
||||
// the user a second time for an already-completed request.
|
||||
// doDisconnect() is the sole cleanup point for these sets.
|
||||
//
|
||||
// Now that the request is complete, persist to the URI cache so a fresh
|
||||
// connection created after a watchdog disconnect also blocks relay replays.
|
||||
// Pending sequences are NOT stored until completion so the dapp can resend
|
||||
// them if the watchdog fires while the user is still signing.
|
||||
this.persistCompletedSequence(conn, sequence);
|
||||
this.activeSignSequences.delete(sequence);
|
||||
|
||||
const response: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
|
|
@ -347,9 +268,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
return; // Already disconnected, nothing to do
|
||||
}
|
||||
|
||||
// Same reasoning as sendSignResponse: keep in dedup guard until doDisconnect.
|
||||
// Persist completed sequence to URI cache so relay replays are blocked on reconnect.
|
||||
this.persistCompletedSequence(conn, sequence);
|
||||
this.activeSignSequences.delete(sequence);
|
||||
|
||||
const response: SignTransactionResponse = {
|
||||
action: RelayMsgAction.SignTransactionResponse,
|
||||
|
|
@ -362,27 +281,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
await conn.client.relay(response);
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
/**
|
||||
* Mark a sign sequence as completed in the URI-scoped cache.
|
||||
* Called after a response is sent or the dapp cancels the request so that
|
||||
* relay replays of the original sign_transaction_request are blocked on the
|
||||
* next reconnect. Pending sequences are intentionally NOT stored here — the
|
||||
* dapp must be able to resend them if a keepalive reconnect fires mid-signing.
|
||||
*/
|
||||
private persistCompletedSequence(
|
||||
conn: ActiveConnection,
|
||||
sequence: number,
|
||||
): void {
|
||||
let seqSet = WalletConnectionManager.uriSignSequences.get(conn.uri);
|
||||
if (!seqSet) {
|
||||
seqSet = new Set();
|
||||
WalletConnectionManager.uriSignSequences.set(conn.uri, seqSet);
|
||||
}
|
||||
seqSet.add(sequence);
|
||||
}
|
||||
|
||||
// --- Private connection lifecycle ---
|
||||
|
||||
/** Immediately attempt to flush the notification queue (fire-and-forget). */
|
||||
|
|
@ -411,12 +309,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
|
||||
// Wait for key exchange, then send wallet_ready
|
||||
(async () => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (conn.client && !conn.client.isKeyExchangeComplete()) {
|
||||
if (Date.now() >= deadline) {
|
||||
console.error("[wizardconnect/wallet] Key exchange timed out");
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
if (conn.client) {
|
||||
|
|
@ -452,9 +345,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
case RelayMsgAction.SignCancel:
|
||||
this.handleSignCancel(conn, message as SignCancelMessage);
|
||||
break;
|
||||
case RelayMsgAction.Ping:
|
||||
this.handlePing(conn, message as PingMessage);
|
||||
break;
|
||||
default:
|
||||
this.emit("message", conn.id, message);
|
||||
}
|
||||
|
|
@ -472,22 +362,10 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
conn: ActiveConnection,
|
||||
msg: SignCancelMessage,
|
||||
): void {
|
||||
// Keep in dedup guard: the relay may still re-deliver the original
|
||||
// sign_transaction_request after the cancel. doDisconnect() cleans up.
|
||||
// Persist to URI cache so the cancelled sequence is blocked on reconnect too.
|
||||
this.persistCompletedSequence(conn, msg.sequence);
|
||||
this.activeSignSequences.delete(msg.sequence);
|
||||
this.emit("signCancelled", conn.id, msg.sequence, msg.reason);
|
||||
}
|
||||
|
||||
private handlePing(conn: ActiveConnection, _msg: PingMessage): void {
|
||||
if (!conn.client) return;
|
||||
const pong: PongMessage = {
|
||||
action: RelayMsgAction.Pong,
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
conn.client.relay(pong).catch(() => {});
|
||||
}
|
||||
|
||||
private async handleDappReady(
|
||||
conn: ActiveConnection,
|
||||
msg: DappReadyMessage,
|
||||
|
|
@ -508,15 +386,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
this.emit("connectionStatusChanged", conn.id, conn.status);
|
||||
}
|
||||
|
||||
// Transport-level capability: if the dapp advertises chunking, enable
|
||||
// chunked responses. Re-applied on every dapp_ready (cheap and idempotent),
|
||||
// so reconnects pick up capability changes.
|
||||
if (conn.client) {
|
||||
conn.client.setPeerCapabilities({
|
||||
chunk: peerSupportsChunk(msg.extensions),
|
||||
});
|
||||
}
|
||||
|
||||
if (!msg.wallet_discovered) {
|
||||
// Dapp hasn't seen us yet (or has reset, e.g. browser refresh) — force
|
||||
// re-introduction even if we already sent wallet_ready this cycle.
|
||||
|
|
@ -539,10 +408,12 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
...(this.adapter.getAdditionalPaths?.() ?? []),
|
||||
];
|
||||
|
||||
const adapterExtensions = this.adapter.getExtensions?.();
|
||||
const hdwv1Session: Hdwalletv1Session = {
|
||||
paths,
|
||||
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
|
||||
extensions: {
|
||||
[EXTENSION_CHUNKED_MESSAGES]: {},
|
||||
...this.adapter.getExtensions?.(),
|
||||
},
|
||||
};
|
||||
|
||||
const msg: WalletReadyMessage = {
|
||||
|
|
@ -557,9 +428,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
},
|
||||
public_key: conn.walletPublicKeyHex,
|
||||
secret: conn.keyExchangeSecret,
|
||||
// Transport-level: advertise chunking so the dapp can send large
|
||||
// SignTransactionRequests that exceed NIP-44's plaintext ceiling.
|
||||
extensions: { chunk: chunkExtensionAdvertisement() },
|
||||
};
|
||||
|
||||
conn.notificationQueue.push(msg);
|
||||
|
|
@ -577,7 +445,6 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
return;
|
||||
}
|
||||
this.activeSignSequences.add(msg.sequence);
|
||||
conn.signSequences.add(msg.sequence);
|
||||
|
||||
// Emit to host app for queuing/approval
|
||||
const pendingRequest: PendingSignRequest = {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ export default defineConfig({
|
|||
include: ["src/integration/**/*.test.ts"],
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 60000,
|
||||
// These tests talk to live relays. A dropped connection or a slow publish
|
||||
// is an environment failure, not a regression, and without a retry a single
|
||||
// one reds the whole pipeline.
|
||||
retry: 2,
|
||||
// Run integration tests serially to avoid relay contention
|
||||
pool: "forks",
|
||||
poolOptions: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue