Lint - also gonna leave this branch and probably drop it. Dagur has a design plan.

This commit is contained in:
Jakob Notland 2026-04-21 14:19:42 +02:00
parent cdd357cb08
commit 52d710dbb5
6 changed files with 179 additions and 39 deletions

View file

@ -4,7 +4,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js"; import { ChunkAssembler, CHUNK_TIMEOUT_MS } from "./chunk-assembler.js";
import { type ChunkRelayMessage, RelayMsgAction } from "./protocols/hdwalletv1.js"; import {
type ChunkRelayMessage,
RelayMsgAction,
} from "./protocols/hdwalletv1.js";
function makeChunk( function makeChunk(
chunk_id: string, chunk_id: string,
@ -22,12 +25,23 @@ function makeChunk(
}; };
} }
function splitMessage(message: object, chunkSize: number, chunk_id: string): ChunkRelayMessage[] { function splitMessage(
message: object,
chunkSize: number,
chunk_id: string,
): ChunkRelayMessage[] {
const json = JSON.stringify(message); const json = JSON.stringify(message);
const chunks: ChunkRelayMessage[] = []; const chunks: ChunkRelayMessage[] = [];
const total = Math.ceil(json.length / chunkSize); const total = Math.ceil(json.length / chunkSize);
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
chunks.push(makeChunk(chunk_id, i, total, json.slice(i * chunkSize, (i + 1) * chunkSize))); chunks.push(
makeChunk(
chunk_id,
i,
total,
json.slice(i * chunkSize, (i + 1) * chunkSize),
),
);
} }
return chunks; return chunks;
} }
@ -46,7 +60,11 @@ describe("ChunkAssembler", () => {
describe("single-fragment messages", () => { describe("single-fragment messages", () => {
it("reassembles a single-fragment message immediately", () => { it("reassembles a single-fragment message immediately", () => {
const original = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const [chunk] = splitMessage(original, 10000, "id1"); const [chunk] = splitMessage(original, 10000, "id1");
const result = assembler.addChunk(chunk); const result = assembler.addChunk(chunk);
@ -55,7 +73,11 @@ describe("ChunkAssembler", () => {
}); });
it("clears the pending buffer after assembly", () => { it("clears the pending buffer after assembly", () => {
const original = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const [chunk] = splitMessage(original, 10000, "id1"); const [chunk] = splitMessage(original, 10000, "id1");
assembler.addChunk(chunk); assembler.addChunk(chunk);
@ -66,7 +88,11 @@ describe("ChunkAssembler", () => {
describe("multi-fragment messages", () => { describe("multi-fragment messages", () => {
it("returns null for each fragment until the last", () => { it("returns null for each fragment until the last", () => {
const original = { action: RelayMsgAction.SignCancel, sequence: 7, time: 1000 }; const original = {
action: RelayMsgAction.SignCancel,
sequence: 7,
time: 1000,
};
const chunks = splitMessage(original, 5, "id2"); const chunks = splitMessage(original, 5, "id2");
expect(chunks.length).toBeGreaterThan(1); expect(chunks.length).toBeGreaterThan(1);
@ -76,7 +102,11 @@ describe("ChunkAssembler", () => {
}); });
it("returns the reassembled message on the last fragment", () => { it("returns the reassembled message on the last fragment", () => {
const original = { action: RelayMsgAction.SignCancel, sequence: 7, time: 1000 }; const original = {
action: RelayMsgAction.SignCancel,
sequence: 7,
time: 1000,
};
const chunks = splitMessage(original, 5, "id3"); const chunks = splitMessage(original, 5, "id3");
let result = null; let result = null;
@ -88,7 +118,11 @@ describe("ChunkAssembler", () => {
}); });
it("reassembles correctly when fragments arrive out of order", () => { it("reassembles correctly when fragments arrive out of order", () => {
const original = { action: RelayMsgAction.SignCancel, sequence: 42, time: 2000 }; const original = {
action: RelayMsgAction.SignCancel,
sequence: 42,
time: 2000,
};
const chunks = splitMessage(original, 3, "id4"); const chunks = splitMessage(original, 3, "id4");
expect(chunks.length).toBeGreaterThan(1); expect(chunks.length).toBeGreaterThan(1);
@ -146,8 +180,16 @@ describe("ChunkAssembler", () => {
describe("independent chunk_ids", () => { describe("independent chunk_ids", () => {
it("tracks multiple concurrent assemblies independently", () => { it("tracks multiple concurrent assemblies independently", () => {
const msgA = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const msgA = {
const msgB = { action: RelayMsgAction.SignCancel, sequence: 2, time: 2000 }; action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const msgB = {
action: RelayMsgAction.SignCancel,
sequence: 2,
time: 2000,
};
const chunksA = splitMessage(msgA, 5, "idA"); const chunksA = splitMessage(msgA, 5, "idA");
const chunksB = splitMessage(msgB, 5, "idB"); const chunksB = splitMessage(msgB, 5, "idB");
@ -165,8 +207,16 @@ describe("ChunkAssembler", () => {
}); });
it("completing one assembly does not affect pending assemblies", () => { it("completing one assembly does not affect pending assemblies", () => {
const msgA = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const msgA = {
const msgB = { action: RelayMsgAction.SignCancel, sequence: 2, time: 2000 }; action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const msgB = {
action: RelayMsgAction.SignCancel,
sequence: 2,
time: 2000,
};
const [chunkA] = splitMessage(msgA, 10000, "idA"); const [chunkA] = splitMessage(msgA, 10000, "idA");
const chunksB = splitMessage(msgB, 5, "idB"); const chunksB = splitMessage(msgB, 5, "idB");
@ -224,7 +274,11 @@ describe("ChunkAssembler", () => {
describe("timeout cleanup", () => { describe("timeout cleanup", () => {
it("discards incomplete assembly after timeout", () => { it("discards incomplete assembly after timeout", () => {
const msgA = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(msgA, 5, "idTimeout"); const chunks = splitMessage(msgA, 5, "idTimeout");
expect(chunks.length).toBeGreaterThan(1); expect(chunks.length).toBeGreaterThan(1);
@ -240,7 +294,11 @@ describe("ChunkAssembler", () => {
}); });
it("does not timeout before CHUNK_TIMEOUT_MS", () => { it("does not timeout before CHUNK_TIMEOUT_MS", () => {
const msgA = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(msgA, 5, "idTimeout2"); const chunks = splitMessage(msgA, 5, "idTimeout2");
for (let i = 0; i < chunks.length - 1; i++) { for (let i = 0; i < chunks.length - 1; i++) {
@ -253,7 +311,11 @@ describe("ChunkAssembler", () => {
}); });
it("clears timeout when assembly completes before timeout", () => { it("clears timeout when assembly completes before timeout", () => {
const msgA = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const msgA = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(msgA, 5, "idTimeout3"); const chunks = splitMessage(msgA, 5, "idTimeout3");
for (const chunk of chunks) { for (const chunk of chunks) {
@ -266,7 +328,11 @@ describe("ChunkAssembler", () => {
}); });
it("after timeout, late arriving fragments start a fresh assembly", () => { it("after timeout, late arriving fragments start a fresh assembly", () => {
const original = { action: RelayMsgAction.SignCancel, sequence: 1, time: 1000 }; const original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(original, 5, "idReuse"); const chunks = splitMessage(original, 5, "idReuse");
// Send first fragment, let it time out // Send first fragment, let it time out
@ -285,7 +351,11 @@ describe("ChunkAssembler", () => {
describe("duplicate fragment index", () => { describe("duplicate fragment index", () => {
it("last write wins for duplicate index — still assembles correctly if content is the same", () => { 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 original = {
action: RelayMsgAction.SignCancel,
sequence: 1,
time: 1000,
};
const chunks = splitMessage(original, 5, "idDup"); const chunks = splitMessage(original, 5, "idDup");
expect(chunks.length).toBeGreaterThan(1); expect(chunks.length).toBeGreaterThan(1);

View file

@ -39,7 +39,10 @@ export class ChunkAssembler {
const { chunk_id, chunk_index, chunk_total, chunk_data } = chunk; const { chunk_id, chunk_index, chunk_total, chunk_data } = chunk;
if (chunk_total < 1) { if (chunk_total < 1) {
logError(Scope.Relay, `Invalid chunk_total ${chunk_total} for chunk_id ${chunk_id}`); logError(
Scope.Relay,
`Invalid chunk_total ${chunk_total} for chunk_id ${chunk_id}`,
);
return null; return null;
} }
@ -81,14 +84,20 @@ export class ChunkAssembler {
return this.pending.size; return this.pending.size;
} }
private assemble(chunk_id: string, assembly: PendingAssembly): ProtocolMessage | null { private assemble(
chunk_id: string,
assembly: PendingAssembly,
): ProtocolMessage | null {
this.discard(chunk_id); this.discard(chunk_id);
const parts: string[] = []; const parts: string[] = [];
for (let i = 0; i < assembly.total; i++) { for (let i = 0; i < assembly.total; i++) {
const part = assembly.fragments.get(i); const part = assembly.fragments.get(i);
if (part === undefined) { if (part === undefined) {
logError(Scope.Relay, `Missing fragment index ${i} for chunk_id ${chunk_id}`); logError(
Scope.Relay,
`Missing fragment index ${i} for chunk_id ${chunk_id}`,
);
return null; return null;
} }
parts.push(part); parts.push(part);
@ -99,12 +108,18 @@ export class ChunkAssembler {
try { try {
parsed = JSON.parse(json); parsed = JSON.parse(json);
} catch { } catch {
logError(Scope.Relay, `Failed to parse assembled message for chunk_id ${chunk_id}`); logError(
Scope.Relay,
`Failed to parse assembled message for chunk_id ${chunk_id}`,
);
return null; return null;
} }
if (!isProtocolMessage(parsed)) { if (!isProtocolMessage(parsed)) {
logError(Scope.Relay, `Assembled message for chunk_id ${chunk_id} is not a ProtocolMessage`); logError(
Scope.Relay,
`Assembled message for chunk_id ${chunk_id} is not a ProtocolMessage`,
);
return null; return null;
} }

View file

@ -259,7 +259,10 @@ export class RelayClient extends EventEmitter {
} }
const messageJson = JSON.stringify(message); const messageJson = JSON.stringify(message);
if (messageJson.length > MAX_SAFE_MESSAGE_SIZE && this.peerSupportsChunking) { if (
messageJson.length > MAX_SAFE_MESSAGE_SIZE &&
this.peerSupportsChunking
) {
return this.relayChunked(message.action, messageJson); return this.relayChunked(message.action, messageJson);
} }
@ -270,12 +273,18 @@ export class RelayClient extends EventEmitter {
return this.publishMessage(message); return this.publishMessage(message);
} }
private async relayChunked(originalAction: string, messageJson: string): Promise<void> { private async relayChunked(
originalAction: string,
messageJson: string,
): Promise<void> {
const chunkId = Math.random().toString(36).slice(2, 14); const chunkId = Math.random().toString(36).slice(2, 14);
const chunkTotal = Math.ceil(messageJson.length / CHUNK_SIZE); const chunkTotal = Math.ceil(messageJson.length / CHUNK_SIZE);
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
debug(Scope.Relay, `Chunking ${originalAction} into ${chunkTotal} fragments (${messageJson.length} chars)`); debug(
Scope.Relay,
`Chunking ${originalAction} into ${chunkTotal} fragments (${messageJson.length} chars)`,
);
for (let i = 0; i < chunkTotal; i++) { for (let i = 0; i < chunkTotal; i++) {
const chunk: ChunkRelayMessage = { const chunk: ChunkRelayMessage = {
@ -457,7 +466,10 @@ export class RelayClient extends EventEmitter {
if (isChunkRelayMessage(message)) { if (isChunkRelayMessage(message)) {
const assembled = this.chunkAssembler.addChunk(message); const assembled = this.chunkAssembler.addChunk(message);
if (assembled !== null) { if (assembled !== null) {
this.netlog("recv", `${assembled.action} [reassembled from ${message.chunk_total} chunks]`); this.netlog(
"recv",
`${assembled.action} [reassembled from ${message.chunk_total} chunks]`,
);
this.emit("message", assembled); this.emit("message", assembled);
} }
return; return;

View file

@ -474,7 +474,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
} }
// Enable chunked sends if the wallet advertises the transport extension // Enable chunked sends if the wallet advertises the transport extension
this.conn?.setPeerSupportsChunking(!!sessionData.extensions?.[EXTENSION_CHUNKED_MESSAGES]); 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];

View file

@ -124,8 +124,21 @@ async function sendLargeSignRequest(
contractName: "CauldronV4", contractName: "CauldronV4",
constructorInputs: [{ name: "ownerPkh", type: "bytes20" }], constructorInputs: [{ name: "ownerPkh", type: "bytes20" }],
abi: [ 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" }] }, 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), bytecode: "d".repeat(500),
source: "// CASL source\n" + "e".repeat(800), source: "// CASL source\n" + "e".repeat(800),
@ -163,12 +176,18 @@ async function sendLargeSignRequest(
console.log( console.log(
chalk.yellow(`→ sign_transaction_request (LARGE)`) + chalk.yellow(`→ sign_transaction_request (LARGE)`) +
chalk.dim(` seq=${sequence} ${json.length} chars → ${chunkCount} chunk${chunkCount !== 1 ? "s" : ""}`), chalk.dim(
` seq=${sequence} ${json.length} chars → ${chunkCount} chunk${chunkCount !== 1 ? "s" : ""}`,
),
); );
try { try {
await (client as any).relay(msg); await (client as any).relay(msg);
console.log(chalk.green(` ✓ sent — if wallet receives seq=${sequence} with all ${sourceOutputs.length} sourceOutputs, chunking works`)); console.log(
chalk.green(
` ✓ sent — if wallet receives seq=${sequence} with all ${sourceOutputs.length} sourceOutputs, chunking works`,
),
);
} catch (err: any) { } catch (err: any) {
console.error(chalk.red(" ✗ send error:"), err.message); console.error(chalk.red(" ✗ send error:"), err.message);
} }
@ -197,7 +216,8 @@ 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; const supportsChunking =
hdwv1?.extensions?.[EXTENSION_CHUNKED_MESSAGES] !== undefined;
if (supportsChunking) { if (supportsChunking) {
client.setPeerSupportsChunking(true); client.setPeerSupportsChunking(true);
@ -210,7 +230,9 @@ function handleMessage(
` 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=✗")), (supportsChunking
? chalk.cyan(" chunking=✓")
: chalk.dim(" chunking=✗")),
); );
if (!msg.dapp_discovered) { if (!msg.dapp_discovered) {
@ -230,7 +252,9 @@ function handleMessage(
if (sendLargeSign && state.walletReady) { if (sendLargeSign && state.walletReady) {
console.log( console.log(
chalk.dim(" (--large-sign: scheduling large sign request in 500ms...)"), chalk.dim(
" (--large-sign: scheduling large sign request in 500ms...)",
),
); );
setTimeout(() => { setTimeout(() => {
sendLargeSignRequest(client, state).catch(() => {}); sendLargeSignRequest(client, state).catch(() => {});
@ -279,9 +303,17 @@ export async function runDappMode(options: {
console.log(chalk.bold("\nwiz-test dapp mode")); console.log(chalk.bold("\nwiz-test dapp mode"));
console.log(chalk.dim(`relay: ${options.relay}`)); console.log(chalk.dim(`relay: ${options.relay}`));
if (options.sign) if (options.sign)
console.log(chalk.yellow("--sign: will send dummy sign request after wallet is ready")); console.log(
chalk.yellow(
"--sign: will send dummy sign request after wallet is ready",
),
);
if (options.largeSign) if (options.largeSign)
console.log(chalk.yellow("--large-sign: will send 30-contract-input sign request (chunking smoke test)")); console.log(
chalk.yellow(
"--large-sign: will send 30-contract-input sign request (chunking smoke test)",
),
);
console.log(); console.log();
let connected = false; let connected = false;
@ -361,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, options.largeSign ?? false); handleMessage(
message,
client,
state,
options.sign ?? false,
options.largeSign ?? false,
);
}); });
// Send initial dapp_ready // Send initial dapp_ready

View file

@ -120,11 +120,14 @@ export async function runWalletMode(options: {
}); });
manager.on("pendingSignRequest", (request) => { manager.on("pendingSignRequest", (request) => {
const sourceCount = (request.request.transaction as any)?.sourceOutputs?.length ?? "?"; const sourceCount =
(request.request.transaction as any)?.sourceOutputs?.length ?? "?";
const jsonSize = JSON.stringify(request.request).length; const jsonSize = JSON.stringify(request.request).length;
console.log( console.log(
chalk.yellow("← sign_request") + chalk.yellow("← sign_request") +
chalk.dim(` conn=${request.connectionId} seq=${request.request.sequence}`) + chalk.dim(
` conn=${request.connectionId} seq=${request.request.sequence}`,
) +
chalk.dim(` ${jsonSize} chars sourceOutputs=${sourceCount}`), 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)"));