Compare commits
4 commits
master
...
chunkingPl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d710dbb5 | ||
|
|
cdd357cb08 | ||
|
|
318d97439f | ||
|
|
538513246b |
11 changed files with 787 additions and 6 deletions
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,3 +44,4 @@ export {
|
|||
toUint8Array,
|
||||
toBigInt,
|
||||
} from "./serialize.js";
|
||||
export { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export enum RelayMsgAction {
|
|||
SignCancel = "sign_cancel",
|
||||
/// Courtesy notification: one side is closing the connection.
|
||||
Disconnect = "disconnect",
|
||||
/// Transport: one fragment of a large message split for NIP-44 size limits.
|
||||
/// Fragments are reassembled by the receiving RelayClient before being
|
||||
/// dispatched to higher layers. See docs/transport.md#chunking.
|
||||
ChunkRelay = "chunk_relay",
|
||||
}
|
||||
|
||||
export interface ProtocolMessage {
|
||||
|
|
@ -146,6 +150,25 @@ export interface SignCancelMessage extends ProtocolMessage {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
/// Extension name advertised by wallets whose RelayClient supports chunk reassembly.
|
||||
/// Presence in Hdwalletv1Session.extensions means the peer will accept chunked messages.
|
||||
export const EXTENSION_CHUNKED_MESSAGES = "chunked_messages" as const;
|
||||
|
||||
/// One fragment of a large message split across multiple relay events.
|
||||
/// All fragments share the same chunk_id. The receiver buffers by chunk_id
|
||||
/// and reassembles when chunk_total fragments have arrived.
|
||||
export interface ChunkRelayMessage extends ProtocolMessage {
|
||||
action: RelayMsgAction.ChunkRelay;
|
||||
/// Identifies which large message these fragments belong to.
|
||||
chunk_id: string;
|
||||
/// Zero-based position of this fragment.
|
||||
chunk_index: number;
|
||||
/// Total number of fragments for this message.
|
||||
chunk_total: number;
|
||||
/// Slice of JSON.stringify(originalMessage) for this fragment.
|
||||
chunk_data: string;
|
||||
}
|
||||
|
||||
// Type guard functions
|
||||
|
||||
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
|
||||
|
|
@ -193,3 +216,15 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
|
|||
typeof msg.sequence === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function isChunkRelayMessage(msg: any): msg is ChunkRelayMessage {
|
||||
return (
|
||||
msg &&
|
||||
typeof msg === "object" &&
|
||||
msg.action === RelayMsgAction.ChunkRelay &&
|
||||
typeof msg.chunk_id === "string" &&
|
||||
typeof msg.chunk_index === "number" &&
|
||||
typeof msg.chunk_total === "number" &&
|
||||
typeof msg.chunk_data === "string"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,33 @@ import { binToHex, hash256, secp256k1 } from "@bitauth/libauth";
|
|||
import { EventEmitter } from "eventemitter3";
|
||||
import {
|
||||
isProtocolMessage,
|
||||
isChunkRelayMessage,
|
||||
type ChunkRelayMessage,
|
||||
ProtocolMessage,
|
||||
RelayMsgAction,
|
||||
} from "./protocols/hdwalletv1.js";
|
||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||
import { MessageQueue } from "./message-queue.js";
|
||||
import { ChunkAssembler } from "./chunk-assembler.js";
|
||||
import { debug, error as logError, Scope } from "./log.js";
|
||||
|
||||
/// Messages larger than this (JSON chars) are split into chunks when the peer
|
||||
/// supports chunking. The NIP-44 limit is 65 535 bytes of plaintext per call.
|
||||
/// createWrap encrypts the seal JSON; that seal JSON contains the base64 of the
|
||||
/// inner NIP-44 ciphertext. NIP-44 pads plaintext to the next power of two, so
|
||||
/// the ciphertext size jumps sharply once the rumor JSON crosses 32 768 bytes:
|
||||
/// rumor JSON ≤ 32 768 B → inner ciphertext ≈ 43 780 B (base64) → seal JSON
|
||||
/// ≈ 44 130 B → safe. Rumor JSON > 32 768 B → inner ciphertext ≈ 87 472 B
|
||||
/// → seal JSON ≈ 87 822 B → exceeds the 65 535-byte limit → NIP-44 throws.
|
||||
/// A 30 000-char message produces a rumor JSON of ≈ 30 300 bytes, leaving a
|
||||
/// comfortable 2 500-byte margin below the 32 768-byte cliff.
|
||||
const MAX_SAFE_MESSAGE_SIZE = 30_000;
|
||||
|
||||
/// Size of each chunk_data slice (chars). A 28 000-char slice produces a chunk
|
||||
/// JSON of ≈ 28 100 bytes, which sits safely below MAX_SAFE_MESSAGE_SIZE so
|
||||
/// chunk metadata overhead cannot push a fragment over the limit.
|
||||
const CHUNK_SIZE = 28_000;
|
||||
|
||||
useWebSocketImplementation(WebSocket);
|
||||
|
||||
const KIND_GIFT_WRAP = 1059;
|
||||
|
|
@ -45,6 +65,8 @@ 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;
|
||||
|
||||
|
|
@ -194,6 +216,7 @@ export class RelayClient extends EventEmitter {
|
|||
async disconnect(): Promise<void> {
|
||||
this.lastProcessedTimestamp = Math.floor(Date.now() / 1000);
|
||||
this.messageQueue.setNotReady();
|
||||
this.peerSupportsChunking = false;
|
||||
|
||||
if (this.readyTimeoutId) {
|
||||
clearTimeout(this.readyTimeoutId);
|
||||
|
|
@ -218,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(
|
||||
|
|
@ -225,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);
|
||||
}
|
||||
|
|
@ -232,6 +273,36 @@ export class RelayClient extends EventEmitter {
|
|||
return this.publishMessage(message);
|
||||
}
|
||||
|
||||
private async relayChunked(
|
||||
originalAction: string,
|
||||
messageJson: string,
|
||||
): Promise<void> {
|
||||
const chunkId = Math.random().toString(36).slice(2, 14);
|
||||
const chunkTotal = Math.ceil(messageJson.length / CHUNK_SIZE);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
debug(
|
||||
Scope.Relay,
|
||||
`Chunking ${originalAction} into ${chunkTotal} fragments (${messageJson.length} chars)`,
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async publishMessage(message: ProtocolMessage): Promise<void> {
|
||||
this.netlog("send", message.action);
|
||||
|
||||
|
|
@ -391,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ describe("DappConnectionManager", () => {
|
|||
relayed.push(msg);
|
||||
}),
|
||||
isKeyExchangeComplete: () => true,
|
||||
setPeerSupportsChunking: vi.fn(),
|
||||
nextSequence: (() => {
|
||||
let seq = 0;
|
||||
return () => (seq += 2);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
childIndexOfPathName,
|
||||
isHdwalletv1Session,
|
||||
binToHex,
|
||||
EXTENSION_CHUNKED_MESSAGES,
|
||||
} from "@wizardconnect/core";
|
||||
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
|
||||
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
|
||||
|
|
@ -472,6 +473,11 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
|||
return;
|
||||
}
|
||||
|
||||
// Enable chunked sends if the wallet advertises the transport extension
|
||||
this.conn?.setPeerSupportsChunking(
|
||||
!!sessionData.extensions?.[EXTENSION_CHUNKED_MESSAGES],
|
||||
);
|
||||
|
||||
// Store raw paths for getSessionPaths() and xpub nodes for derivation
|
||||
this.sessionPaths = [...sessionData.paths];
|
||||
for (const pathInfo of sessionData.paths) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
Hdwalletv1Session,
|
||||
PROTOCOL_NAME,
|
||||
binToHex,
|
||||
EXTENSION_CHUNKED_MESSAGES,
|
||||
} from "@wizardconnect/core";
|
||||
import { WalletAdapter } from "./wallet-adapter.js";
|
||||
import { DerivationPath } from "./derivation-path.js";
|
||||
|
|
@ -407,10 +408,12 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
...(this.adapter.getAdditionalPaths?.() ?? []),
|
||||
];
|
||||
|
||||
const extensions = this.adapter.getExtensions?.();
|
||||
const hdwv1Session: Hdwalletv1Session = {
|
||||
paths,
|
||||
...(extensions ? { extensions } : {}),
|
||||
extensions: {
|
||||
[EXTENSION_CHUNKED_MESSAGES]: {},
|
||||
...this.adapter.getExtensions?.(),
|
||||
},
|
||||
};
|
||||
|
||||
const msg: WalletReadyMessage = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue