Compare commits

...
Sign in to create a new pull request.

4 commits

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

View file

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

View file

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

View file

@ -44,3 +44,4 @@ export {
toUint8Array,
toBigInt,
} from "./serialize.js";
export { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";

View file

@ -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"
);
}

View file

@ -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);
}

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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 = {