Merge branch 'chunk' into 'master'

Add chunk transport extension for oversized messages

See merge request riftenlabs/lib/wizardconnect!26
This commit is contained in:
jakobsn 2026-04-27 13:08:16 +00:00
commit 7d188c290b
22 changed files with 1309 additions and 71 deletions

View file

@ -60,7 +60,8 @@ Protocol and architecture documentation lives in `docs/`. Keep it up to date whe
| File | Update when… | | File | Update when… |
|------|-------------| |------|-------------|
| `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change | | `docs/protocol.md` | Protocol messages, handshake logic, or `PathName`/`PathXpub`/`NextIndex` types change |
| `docs/extensions.md` | Extension system, `WalletAdapter` extension hooks, known extensions, or custom message conventions change | | `docs/extensions.md` | hdwalletv1 protocol-level extensions, `WalletAdapter` extension hooks, known protocol extensions, or custom message conventions change |
| `docs/transport.md` (transport extensions section) | Transport-level extensions (`chunk`, future: compression), base-level `extensions` field on `dapp_ready`/`wallet_ready`, reassembly semantics |
| `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes | | `docs/connection-uri.md` | URI format, key exchange flow, or credential structure changes |
| `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes | | `docs/transport.md` | `RelayClient`, `initiateRelay`, reconnect logic, or encryption scheme changes |
| `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes | | `docs/wallet.md` | `WalletAdapter`, `WalletConnectionManager`, or connection lifecycle changes |

View file

@ -1,5 +1,11 @@
# Extensions # 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 The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate
additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible: 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. existing wallets and dapps that don't know about extensions continue to work unchanged.

View file

@ -36,6 +36,9 @@ sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
sign_transaction_response — wallet → dapp, returns signed tx or error sign_transaction_response — wallet → dapp, returns signed tx or error
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
disconnect — either → either, courtesy notification before tearing down 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 The action names above are the well-known set. Extensions may define additional action strings

View file

@ -223,3 +223,135 @@ nostr-tools `SimplePool.subscribeMany()` deduplicates events by ID — it tracks
in a per-subscription `_knownIds` set and only fires `onevent` once per unique ID. Since 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 `pool.publish(urls, event)` sends the identical event (same ID) to all relays, the receiving
side's pool delivers it exactly once. 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.

10
package-lock.json generated
View file

@ -3401,7 +3401,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@wizardconnect/core", "name": "@wizardconnect/core",
"version": "0.1.2", "version": "0.2.0",
"dependencies": { "dependencies": {
"@bch-wc2/interfaces": "^0.0.8", "@bch-wc2/interfaces": "^0.0.8",
"@bitauth/libauth": "^3.1.0-next.2", "@bitauth/libauth": "^3.1.0-next.2",
@ -3418,7 +3418,7 @@
}, },
"packages/dapp": { "packages/dapp": {
"name": "@wizardconnect/dapp", "name": "@wizardconnect/dapp",
"version": "0.1.2", "version": "0.2.0",
"dependencies": { "dependencies": {
"@wizardconnect/core": "*", "@wizardconnect/core": "*",
"eventemitter3": "^5.0.1" "eventemitter3": "^5.0.1"
@ -3430,7 +3430,7 @@
}, },
"packages/react": { "packages/react": {
"name": "@wizardconnect/react", "name": "@wizardconnect/react",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@wizardconnect/core": "*", "@wizardconnect/core": "*",
"@wizardconnect/dapp": "*", "@wizardconnect/dapp": "*",
@ -3502,7 +3502,7 @@
}, },
"packages/test-cli": { "packages/test-cli": {
"name": "@wizardconnect/test-cli", "name": "@wizardconnect/test-cli",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.1.0-next.2", "@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*", "@wizardconnect/core": "*",
@ -3522,7 +3522,7 @@
}, },
"packages/wallet": { "packages/wallet": {
"name": "@wizardconnect/wallet", "name": "@wizardconnect/wallet",
"version": "0.1.2", "version": "0.2.0",
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.1.0-next.2", "@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*", "@wizardconnect/core": "*",

View file

@ -1,6 +1,6 @@
{ {
"name": "@wizardconnect/core", "name": "@wizardconnect/core",
"version": "0.1.2", "version": "0.2.0",
"type": "module", "type": "module",
"description": "Transport and protocol primitives for WizardConnect", "description": "Transport and protocol primitives for WizardConnect",
"repository": { "repository": {

View file

@ -31,6 +31,12 @@ export type {
} from "./key-exchange.js"; } from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js"; export * from "./protocols/hdwalletv1.js";
export * from "./protocols/base.js"; export * from "./protocols/base.js";
export {
CHUNK_EXTENSION_NAME,
CHUNK_EXTENSION_VERSION,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "./transforms/chunk.js";
export { export {
binToHex, binToHex,
hexToBin, hexToBin,

View file

@ -29,6 +29,10 @@ export interface DappReadyMessage extends ProtocolMessage {
wallet_discovered: boolean; wallet_discovered: boolean;
dapp_name?: string; dapp_name?: string;
dapp_icon?: 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 { export interface WalletReadyMessage extends ProtocolMessage {
@ -48,6 +52,10 @@ export interface WalletReadyMessage extends ProtocolMessage {
public_key: string; public_key: string;
/// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention. /// Echo of the shared secret from the connection URI (hex, 8 bytes). MITM prevention.
secret: string; 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 { export function isDappReadyMessage(msg: unknown): msg is DappReadyMessage {

View file

@ -25,6 +25,10 @@ export enum RelayMsgAction {
SignCancel = "sign_cancel", SignCancel = "sign_cancel",
/// Courtesy notification: one side is closing the connection. /// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect", 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",
} }
export interface ProtocolMessage { export interface ProtocolMessage {
@ -146,6 +150,20 @@ export interface SignCancelMessage extends ProtocolMessage {
reason?: string; reason?: string;
} }
/// Transport-level message carrying one slice of a larger ProtocolMessage.
///
/// All chunks of one logical message share the same msgId and time.
/// `data` is a base64-encoded slice of the UTF-8 bytes of
/// JSON.stringify(originalMessage); concatenate slices in `index` order,
/// base64-decode, UTF-8-decode, then JSON.parse to reconstruct.
export interface ChunkMessage extends ProtocolMessage {
action: RelayMsgAction.Chunk;
msgId: string;
index: number;
total: number;
data: string;
}
// Type guard functions // Type guard functions
export function isProtocolMessage(payload: any): payload is ProtocolMessage { export function isProtocolMessage(payload: any): payload is ProtocolMessage {
@ -193,3 +211,20 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
typeof msg.sequence === "number" typeof msg.sequence === "number"
); );
} }
export function isChunkMessage(msg: any): msg is ChunkMessage {
return (
msg &&
typeof msg === "object" &&
msg.action === RelayMsgAction.Chunk &&
typeof msg.msgId === "string" &&
typeof msg.index === "number" &&
typeof msg.total === "number" &&
typeof msg.data === "string" &&
Number.isInteger(msg.index) &&
Number.isInteger(msg.total) &&
msg.total >= 1 &&
msg.index >= 0 &&
msg.index < msg.total
);
}

View file

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

View file

@ -14,6 +14,8 @@ import WebSocket from "isomorphic-ws";
import { binToHex, hash256, secp256k1 } from "@bitauth/libauth"; import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
import { EventEmitter } from "eventemitter3"; import { EventEmitter } from "eventemitter3";
import { import {
ChunkMessage,
isChunkMessage,
isProtocolMessage, isProtocolMessage,
ProtocolMessage, ProtocolMessage,
RelayMsgAction, RelayMsgAction,
@ -21,6 +23,11 @@ import {
import { deriveNostrPublicKey } from "./utilnostr.js"; import { deriveNostrPublicKey } from "./utilnostr.js";
import { MessageQueue } from "./message-queue.js"; import { MessageQueue } from "./message-queue.js";
import { debug, error as logError, Scope } from "./log.js"; import { debug, error as logError, Scope } from "./log.js";
import {
ChunkReassembler,
needsChunking,
splitIntoChunks,
} from "./transforms/chunk.js";
useWebSocketImplementation(WebSocket); useWebSocketImplementation(WebSocket);
@ -48,6 +55,14 @@ export class RelayClient extends EventEmitter {
private disconnecting: boolean = false; 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( private sequence: number = Math.floor(
Math.random() * (Number.MAX_SAFE_INTEGER - 500_000), Math.random() * (Number.MAX_SAFE_INTEGER - 500_000),
); );
@ -81,6 +96,15 @@ export class RelayClient extends EventEmitter {
logActivity: this.config.logNetworkActivity, 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( this.myPubkey = unwrap(
secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey), secp256k1.derivePublicKeyCompressed(this.config.signerPrivateKey),
); );
@ -107,6 +131,16 @@ export class RelayClient extends EventEmitter {
this.emit("paired"); 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 { getPublicKey(): Uint8Array {
return this.myPubkey; return this.myPubkey;
} }
@ -134,6 +168,7 @@ export class RelayClient extends EventEmitter {
} }
this.disconnecting = false; this.disconnecting = false;
this.reassembler.start();
if (this.lastProcessedTimestamp === 0) { if (this.lastProcessedTimestamp === 0) {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2; this.lastProcessedTimestamp = Math.floor(Date.now() / 1000) - 2;
@ -194,6 +229,7 @@ export class RelayClient extends EventEmitter {
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000); this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
this.messageQueue.setNotReady(); this.messageQueue.setNotReady();
this.reassembler.stop();
if (this.readyTimeoutId) { if (this.readyTimeoutId) {
clearTimeout(this.readyTimeoutId); clearTimeout(this.readyTimeoutId);
@ -233,12 +269,53 @@ export class RelayClient extends EventEmitter {
} }
private async publishMessage(message: ProtocolMessage): Promise<void> { private async publishMessage(message: ProtocolMessage): Promise<void> {
this.netlog("send", message.action); const serialized = JSON.stringify(message);
if (!needsChunking(serialized)) {
return this.publishSerialized(message.action, serialized);
}
// Oversized. Chunk if the peer supports it; otherwise fail loudly with
// an actionable message (replacing nostr-tools' cryptic plaintext-size error).
if (!this.peerSupportsChunk) {
const err = new Error(
`Cannot send ${message.action}: message is larger than NIP-44's 65,535-byte ceiling ` +
`and the peer does not advertise the 'chunk' transport extension. ` +
`Please update the connected wallet/dapp to a version that supports chunked messages.`,
);
if (this.config.logNetworkActivity) {
logError(Scope.Relay, err.message);
}
throw err;
}
const chunks = splitIntoChunks(serialized);
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Chunking ${message.action}: ${chunks.length} chunks (serialized ~${serialized.length} bytes)`,
);
}
for (const chunk of chunks) {
await this.publishSerialized(
`${message.action}[chunk ${chunk.index + 1}/${chunk.total}]`,
JSON.stringify(chunk),
);
}
}
/// Wrap and publish one gift-wrap event. Used for both unchunked messages
/// and individual chunks. `displayAction` is only used for logs.
private async publishSerialized(
displayAction: string,
serialized: string,
): Promise<void> {
this.netlog("send", displayAction);
const wrapped = wrapEvent( const wrapped = wrapEvent(
{ {
kind: KIND_PRIVATE_DIRECT_MESSAGE, kind: KIND_PRIVATE_DIRECT_MESSAGE,
content: JSON.stringify(message), content: serialized,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags: [["p", this.pairedPubkeyHex]], tags: [["p", this.pairedPubkeyHex]],
}, },
@ -257,7 +334,7 @@ export class RelayClient extends EventEmitter {
for (const r of rejected) { for (const r of rejected) {
logError( logError(
Scope.Relay, Scope.Relay,
`Failed to publish ${message.action} to a relay:`, `Failed to publish ${displayAction} to a relay:`,
(r as PromiseRejectedResult).reason, (r as PromiseRejectedResult).reason,
); );
} }
@ -265,7 +342,7 @@ export class RelayClient extends EventEmitter {
if (fulfilled.length === 0) { if (fulfilled.length === 0) {
const error = new Error( const error = new Error(
`Failed to publish ${message.action} to all relays`, `Failed to publish ${displayAction} to all relays`,
); );
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
logError(Scope.Relay, error.message); logError(Scope.Relay, error.message);
@ -277,7 +354,7 @@ export class RelayClient extends EventEmitter {
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
debug( debug(
Scope.Relay, Scope.Relay,
`Published message ${message.action} to ${fulfilled.length}/${results.length} relay(s)`, `Published message ${displayAction} to ${fulfilled.length}/${results.length} relay(s)`,
); );
} }
} }
@ -286,21 +363,41 @@ export class RelayClient extends EventEmitter {
try { try {
const rumor = unwrapEvent(wrappedEvent, this.config.signerPrivateKey); 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) { if (this.config.logNetworkActivity) {
logError( debug(
Scope.Relay, Scope.Relay,
"Failed to parse message content as JSON:", `Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
e,
); );
} }
return; 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);
}
this.emitError(error as Error);
}
}
/// Apply timestamp dedup + peer filter, then dispatch to chunk reassembly
/// or the application-level handler. Called from handleWrappedEvent (one
/// path: unwrap → route). Kept separate to keep handleWrappedEvent focused
/// on decryption and to allow future transport-layer transforms to invoke
/// this path with already-decoded payloads.
private routeIncoming(payload: ProtocolMessage, fromPubkey: string): void {
if ( if (
!payload.time || !payload.time ||
(this.lastProcessedTimestamp > 0 && (this.lastProcessedTimestamp > 0 &&
@ -317,43 +414,42 @@ export class RelayClient extends EventEmitter {
// wallet_ready carries the key exchange data (public_key + secret) so it // 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. // must bypass the peer filter — the dapp doesn't know the wallet's pubkey yet.
const isKeyExchangeMessage = const isKeyExchangeMessage = payload.action === RelayMsgAction.WalletReady;
payload.action === RelayMsgAction.WalletReady;
if (!isKeyExchangeMessage && this.config.pairedPublicKey) { if (!isKeyExchangeMessage && this.config.pairedPublicKey) {
const pairedNostrPubkey = const pairedNostrPubkey =
this.config.pairedPublicKey.length === 33 this.config.pairedPublicKey.length === 33
? binToHex(this.config.pairedPublicKey.slice(1)) ? binToHex(this.config.pairedPublicKey.slice(1))
: binToHex(this.config.pairedPublicKey); : binToHex(this.config.pairedPublicKey);
if (rumor.pubkey !== pairedNostrPubkey) { if (fromPubkey !== pairedNostrPubkey) {
if (this.config.logNetworkActivity) { if (this.config.logNetworkActivity) {
debug( debug(
Scope.Relay, Scope.Relay,
`Ignoring '${payload.action}' message from unknown peer: ${rumor.pubkey} (expected: ${pairedNostrPubkey})`, `Ignoring '${payload.action}' message from unknown peer: ${fromPubkey} (expected: ${pairedNostrPubkey})`,
); );
} }
return; 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) { if (this.config.logNetworkActivity) {
debug(Scope.Relay, `Received message ${payload.action} from relay`); debug(Scope.Relay, `Received message ${payload.action} from relay`);
} }
this.handleRelayMessage(payload); this.handleRelayMessage(payload);
} else {
if (this.config.logNetworkActivity) {
debug(
Scope.Relay,
`Ignoring non-PrivateDirectMessage, kind: ${rumor.kind}`,
);
}
}
} catch (error) {
if (this.config.logNetworkActivity) {
logError(Scope.Relay, "Error handling incoming message:", error);
}
this.emitError(error as Error);
}
} }
isConnected(): boolean { isConnected(): boolean {

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@wizardconnect/dapp", "name": "@wizardconnect/dapp",
"version": "0.1.2", "version": "0.2.0",
"type": "module", "type": "module",
"description": "Dapp-side integration helpers for WizardConnect", "description": "Dapp-side integration helpers for WizardConnect",
"repository": { "repository": {

View file

@ -148,6 +148,7 @@ describe("DappConnectionManager", () => {
relayed.push(msg); relayed.push(msg);
}), }),
isKeyExchangeComplete: () => true, isKeyExchangeComplete: () => true,
setPeerCapabilities: vi.fn(),
nextSequence: (() => { nextSequence: (() => {
let seq = 0; let seq = 0;
return () => (seq += 2); return () => (seq += 2);

View file

@ -21,6 +21,8 @@ import {
childIndexOfPathName, childIndexOfPathName,
isHdwalletv1Session, isHdwalletv1Session,
binToHex, binToHex,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core"; } from "@wizardconnect/core";
import type { PathXpub, DappRelayResult } from "@wizardconnect/core"; import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import { DappPubkeyStateManager } from "./pubkey-state-manager.js"; import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
@ -409,6 +411,10 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.protocol && { selected_protocol: this.protocol }), this.protocol && { selected_protocol: this.protocol }),
...(this.dappName !== undefined && { dapp_name: this.dappName }), ...(this.dappName !== undefined && { dapp_name: this.dappName }),
...(this.dappIcon !== undefined && { dapp_icon: this.dappIcon }), ...(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); await this.conn.relay(msg);
@ -472,6 +478,15 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
return; return;
} }
// Transport-level capability: if the wallet advertises chunking, enable
// chunked requests. Re-applied on every wallet_ready (cheap and idempotent),
// so reconnects pick up capability changes.
if (this.conn) {
this.conn.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
}
// Store raw paths for getSessionPaths() and xpub nodes for derivation // Store raw paths for getSessionPaths() and xpub nodes for derivation
this.sessionPaths = [...sessionData.paths]; this.sessionPaths = [...sessionData.paths];
for (const pathInfo of sessionData.paths) { for (const pathInfo of sessionData.paths) {

View file

@ -1,6 +1,6 @@
{ {
"name": "@wizardconnect/react", "name": "@wizardconnect/react",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"description": "React components and hooks for WizardConnect dapp integration", "description": "React components and hooks for WizardConnect dapp integration",
"repository": { "repository": {

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@wizardconnect/wallet", "name": "@wizardconnect/wallet",
"version": "0.1.2", "version": "0.2.0",
"type": "module", "type": "module",
"description": "Wallet-side integration helpers for WizardConnect", "description": "Wallet-side integration helpers for WizardConnect",
"repository": { "repository": {

View file

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

View file

@ -22,6 +22,8 @@ import {
initiateDappRelay, initiateDappRelay,
RelayMsgAction, RelayMsgAction,
PROTOCOL_NAME, PROTOCOL_NAME,
chunkExtensionAdvertisement,
peerSupportsChunk,
type RelayClient, type RelayClient,
type RelayUpdatePayload, type RelayUpdatePayload,
type DappReadyMessage, type DappReadyMessage,
@ -110,6 +112,10 @@ export interface DappHandle {
cleanup: () => void; cleanup: () => void;
/** All wallet_ready messages received. */ /** All wallet_ready messages received. */
walletReadyMessages: WalletReadyMessage[]; 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 ----------------------------------------------------------- // ---- WalletHandle -----------------------------------------------------------
@ -147,6 +153,7 @@ export async function setupConnection(
// ---- Dapp side ---- // ---- Dapp side ----
const walletReadyMessages: WalletReadyMessage[] = []; const walletReadyMessages: WalletReadyMessage[] = [];
const allMessages: ProtocolMessage[] = [];
let dappClient: RelayClient | null = null; let dappClient: RelayClient | null = null;
let keyExchanged = false; let keyExchanged = false;
@ -163,6 +170,9 @@ export async function setupConnection(
supported_protocols: [PROTOCOL_NAME], supported_protocols: [PROTOCOL_NAME],
wallet_discovered: wd, wallet_discovered: wd,
time: Math.floor(Date.now() / 1000), 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); await dappClient!.relay(msg);
} }
@ -182,9 +192,16 @@ export async function setupConnection(
if (message.action === RelayMsgAction.WalletReady) { if (message.action === RelayMsgAction.WalletReady) {
const msg = message as WalletReadyMessage; const msg = message as WalletReadyMessage;
walletReadyMessages.push(msg); walletReadyMessages.push(msg);
// Mirror DappConnectionManager behavior: enable chunked outbound if
// the wallet advertises support.
dappClient!.setPeerCapabilities({
chunk: peerSupportsChunk(msg.extensions),
});
if (!msg.dapp_discovered) { if (!msg.dapp_discovered) {
sendDappReady(true).catch(() => {}); sendDappReady(true).catch(() => {});
} }
} else {
allMessages.push(message);
} }
}); });
@ -217,6 +234,8 @@ export async function setupConnection(
uri: dappRelay.uri, uri: dappRelay.uri,
cleanup: dappRelay.cleanup, cleanup: dappRelay.cleanup,
walletReadyMessages, walletReadyMessages,
client: () => dappClient,
messages: allMessages,
}; };
const walletHandle: WalletHandle = { manager, connectionId, adapter }; const walletHandle: WalletHandle = { manager, connectionId, adapter };

View file

@ -22,6 +22,8 @@ import {
Hdwalletv1Session, Hdwalletv1Session,
PROTOCOL_NAME, PROTOCOL_NAME,
binToHex, binToHex,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core"; } from "@wizardconnect/core";
import { WalletAdapter } from "./wallet-adapter.js"; import { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js"; import { DerivationPath } from "./derivation-path.js";
@ -385,6 +387,15 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
this.emit("connectionStatusChanged", conn.id, conn.status); 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) { if (!msg.wallet_discovered) {
// Dapp hasn't seen us yet (or has reset, e.g. browser refresh) — force // 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. // re-introduction even if we already sent wallet_ready this cycle.
@ -425,6 +436,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
}, },
public_key: conn.walletPublicKeyHex, public_key: conn.walletPublicKeyHex,
secret: conn.keyExchangeSecret, 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); conn.notificationQueue.push(msg);