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,
|
toUint8Array,
|
||||||
toBigInt,
|
toBigInt,
|
||||||
} from "./serialize.js";
|
} from "./serialize.js";
|
||||||
|
export { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";
|
||||||
|
|
|
||||||
|
|
@ -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: 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 {
|
export interface ProtocolMessage {
|
||||||
|
|
@ -146,6 +150,25 @@ export interface SignCancelMessage extends ProtocolMessage {
|
||||||
reason?: string;
|
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
|
// Type guard functions
|
||||||
|
|
||||||
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
|
export function isProtocolMessage(payload: any): payload is ProtocolMessage {
|
||||||
|
|
@ -193,3 +216,15 @@ export function isSignCancelMessage(msg: any): msg is SignCancelMessage {
|
||||||
typeof msg.sequence === "number"
|
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 { EventEmitter } from "eventemitter3";
|
||||||
import {
|
import {
|
||||||
isProtocolMessage,
|
isProtocolMessage,
|
||||||
|
isChunkRelayMessage,
|
||||||
|
type ChunkRelayMessage,
|
||||||
ProtocolMessage,
|
ProtocolMessage,
|
||||||
RelayMsgAction,
|
RelayMsgAction,
|
||||||
} from "./protocols/hdwalletv1.js";
|
} from "./protocols/hdwalletv1.js";
|
||||||
import { deriveNostrPublicKey } from "./utilnostr.js";
|
import { deriveNostrPublicKey } from "./utilnostr.js";
|
||||||
import { MessageQueue } from "./message-queue.js";
|
import { MessageQueue } from "./message-queue.js";
|
||||||
|
import { ChunkAssembler } from "./chunk-assembler.js";
|
||||||
import { debug, error as logError, Scope } from "./log.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);
|
useWebSocketImplementation(WebSocket);
|
||||||
|
|
||||||
const KIND_GIFT_WRAP = 1059;
|
const KIND_GIFT_WRAP = 1059;
|
||||||
|
|
@ -45,6 +65,8 @@ export class RelayClient extends EventEmitter {
|
||||||
private lastProcessedTimestamp: number = 0;
|
private lastProcessedTimestamp: number = 0;
|
||||||
private messageQueue: MessageQueue;
|
private messageQueue: MessageQueue;
|
||||||
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
private readyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private peerSupportsChunking = false;
|
||||||
|
private chunkAssembler = new ChunkAssembler();
|
||||||
|
|
||||||
private disconnecting: boolean = false;
|
private disconnecting: boolean = false;
|
||||||
|
|
||||||
|
|
@ -194,6 +216,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.peerSupportsChunking = false;
|
||||||
|
|
||||||
if (this.readyTimeoutId) {
|
if (this.readyTimeoutId) {
|
||||||
clearTimeout(this.readyTimeoutId);
|
clearTimeout(this.readyTimeoutId);
|
||||||
|
|
@ -218,6 +241,16 @@ export class RelayClient extends EventEmitter {
|
||||||
this.lastProcessedTimestamp = timestamp;
|
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> {
|
async relay(message: ProtocolMessage): Promise<void> {
|
||||||
if (!this.config.pairedPublicKey) {
|
if (!this.config.pairedPublicKey) {
|
||||||
throw new Error(
|
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()) {
|
if (!this.messageQueue.getReady()) {
|
||||||
return this.messageQueue.enqueue(message);
|
return this.messageQueue.enqueue(message);
|
||||||
}
|
}
|
||||||
|
|
@ -232,6 +273,36 @@ export class RelayClient extends EventEmitter {
|
||||||
return this.publishMessage(message);
|
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> {
|
private async publishMessage(message: ProtocolMessage): Promise<void> {
|
||||||
this.netlog("send", message.action);
|
this.netlog("send", message.action);
|
||||||
|
|
||||||
|
|
@ -391,6 +462,19 @@ export class RelayClient extends EventEmitter {
|
||||||
isProtocolMessage(message),
|
isProtocolMessage(message),
|
||||||
`Invalid protocol message: ${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);
|
this.emit("message", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ describe("DappConnectionManager", () => {
|
||||||
relayed.push(msg);
|
relayed.push(msg);
|
||||||
}),
|
}),
|
||||||
isKeyExchangeComplete: () => true,
|
isKeyExchangeComplete: () => true,
|
||||||
|
setPeerSupportsChunking: vi.fn(),
|
||||||
nextSequence: (() => {
|
nextSequence: (() => {
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
return () => (seq += 2);
|
return () => (seq += 2);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
childIndexOfPathName,
|
childIndexOfPathName,
|
||||||
isHdwalletv1Session,
|
isHdwalletv1Session,
|
||||||
binToHex,
|
binToHex,
|
||||||
|
EXTENSION_CHUNKED_MESSAGES,
|
||||||
} 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";
|
||||||
|
|
@ -472,6 +473,11 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
|
||||||
return;
|
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
|
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ program
|
||||||
"--sign",
|
"--sign",
|
||||||
"Send a dummy sign request after wallet is ready (tests approval flow)",
|
"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) => {
|
.action(async (options) => {
|
||||||
await runDappMode(options);
|
await runDappMode(options);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
initiateDappRelay,
|
initiateDappRelay,
|
||||||
RelayMsgAction,
|
RelayMsgAction,
|
||||||
PROTOCOL_NAME,
|
PROTOCOL_NAME,
|
||||||
|
EXTENSION_CHUNKED_MESSAGES,
|
||||||
type RelayUpdatePayload,
|
type RelayUpdatePayload,
|
||||||
type DappReadyMessage,
|
type DappReadyMessage,
|
||||||
type WalletReadyMessage,
|
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 ----
|
// ---- Handle incoming messages ----
|
||||||
|
|
||||||
function handleMessage(
|
function handleMessage(
|
||||||
|
|
@ -108,6 +200,7 @@ function handleMessage(
|
||||||
client: RelayClient,
|
client: RelayClient,
|
||||||
state: DappState,
|
state: DappState,
|
||||||
sendSign: boolean,
|
sendSign: boolean,
|
||||||
|
sendLargeSign: boolean,
|
||||||
): void {
|
): void {
|
||||||
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
const now = chalk.dim(new Date().toISOString().slice(11, 23));
|
||||||
|
|
||||||
|
|
@ -123,6 +216,12 @@ function handleMessage(
|
||||||
| Hdwalletv1Session
|
| Hdwalletv1Session
|
||||||
| undefined;
|
| undefined;
|
||||||
const pathsSummary = hdwv1?.paths?.map((p) => p.name).join(",") ?? "none";
|
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(
|
console.log(
|
||||||
`${now} ` +
|
`${now} ` +
|
||||||
|
|
@ -130,7 +229,10 @@ function handleMessage(
|
||||||
` wallet="${chalk.bold(msg.wallet_name)}"` +
|
` wallet="${chalk.bold(msg.wallet_name)}"` +
|
||||||
` dapp_discovered=${msg.dapp_discovered}` +
|
` dapp_discovered=${msg.dapp_discovered}` +
|
||||||
` protocols=[${msg.supported_protocols.join(",")}]` +
|
` protocols=[${msg.supported_protocols.join(",")}]` +
|
||||||
` paths=[${pathsSummary}]`,
|
` paths=[${pathsSummary}]` +
|
||||||
|
(supportsChunking
|
||||||
|
? chalk.cyan(" chunking=✓")
|
||||||
|
: chalk.dim(" chunking=✗")),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!msg.dapp_discovered) {
|
if (!msg.dapp_discovered) {
|
||||||
|
|
@ -147,6 +249,17 @@ function handleMessage(
|
||||||
sendSignRequest(client, state).catch(() => {});
|
sendSignRequest(client, state).catch(() => {});
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sendLargeSign && state.walletReady) {
|
||||||
|
console.log(
|
||||||
|
chalk.dim(
|
||||||
|
" (--large-sign: scheduling large sign request in 500ms...)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
sendLargeSignRequest(client, state).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +296,7 @@ export async function runDappMode(options: {
|
||||||
secret?: string;
|
secret?: string;
|
||||||
walletPublicKey?: string;
|
walletPublicKey?: string;
|
||||||
sign?: boolean;
|
sign?: boolean;
|
||||||
|
largeSign?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
|
|
||||||
|
|
@ -191,7 +305,13 @@ export async function runDappMode(options: {
|
||||||
if (options.sign)
|
if (options.sign)
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
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();
|
console.log();
|
||||||
|
|
@ -273,7 +393,13 @@ export async function runDappMode(options: {
|
||||||
|
|
||||||
// Register message handler
|
// Register message handler
|
||||||
client.on("message", (message: ProtocolMessage) => {
|
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
|
// Send initial dapp_ready
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,15 @@ export async function runWalletMode(options: {
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on("pendingSignRequest", (request) => {
|
manager.on("pendingSignRequest", (request) => {
|
||||||
|
const sourceCount =
|
||||||
|
(request.request.transaction as any)?.sourceOutputs?.length ?? "?";
|
||||||
|
const jsonSize = JSON.stringify(request.request).length;
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow("← sign_request") +
|
chalk.yellow("← sign_request") +
|
||||||
chalk.dim(
|
chalk.dim(
|
||||||
` conn=${request.connectionId} seq=${request.request.sequence}`,
|
` conn=${request.connectionId} seq=${request.request.sequence}`,
|
||||||
),
|
) +
|
||||||
|
chalk.dim(` ${jsonSize} chars sourceOutputs=${sourceCount}`),
|
||||||
);
|
);
|
||||||
console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)"));
|
console.log(chalk.dim(" (auto-rejecting — test wallet does not sign)"));
|
||||||
manager
|
manager
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
Hdwalletv1Session,
|
Hdwalletv1Session,
|
||||||
PROTOCOL_NAME,
|
PROTOCOL_NAME,
|
||||||
binToHex,
|
binToHex,
|
||||||
|
EXTENSION_CHUNKED_MESSAGES,
|
||||||
} 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";
|
||||||
|
|
@ -407,10 +408,12 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
||||||
...(this.adapter.getAdditionalPaths?.() ?? []),
|
...(this.adapter.getAdditionalPaths?.() ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const extensions = this.adapter.getExtensions?.();
|
|
||||||
const hdwv1Session: Hdwalletv1Session = {
|
const hdwv1Session: Hdwalletv1Session = {
|
||||||
paths,
|
paths,
|
||||||
...(extensions ? { extensions } : {}),
|
extensions: {
|
||||||
|
[EXTENSION_CHUNKED_MESSAGES]: {},
|
||||||
|
...this.adapter.getExtensions?.(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const msg: WalletReadyMessage = {
|
const msg: WalletReadyMessage = {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue