From 973ef5bda6cad881781d82319de0fc3ba5e12195 Mon Sep 17 00:00:00 2001 From: samsonmark0998-tech Date: Sun, 27 Sep 2026 08:07:54 +0000 Subject: [PATCH 1/4] fix: #938 Implement invoice merkle tree validation Closes #938 --- src/audit/AuditTrailHasher.ts | 93 +++++++++++++ src/batchVerifier.ts | 245 ++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+) diff --git a/src/audit/AuditTrailHasher.ts b/src/audit/AuditTrailHasher.ts index 3ed5b75b..b06ec097 100644 --- a/src/audit/AuditTrailHasher.ts +++ b/src/audit/AuditTrailHasher.ts @@ -4,8 +4,23 @@ import * as crypto from 'crypto'; // Use node crypto webcrypto subtle const subtle = crypto.webcrypto.subtle; +export interface MerkleProof { + leaf: string; + index: number; + siblings: Array<{ hash: string; position: 'left' | 'right' }>; + root: AuditTrailRoot; +} + +export type AuditValidationEvent = + | { type: 'validation:start'; expectedRoot: AuditTrailRoot; length: number } + | { type: 'validation:success'; root: AuditTrailRoot; length: number } + | { type: 'validation:failure'; reason: string; mismatchAt?: number }; + +export type AuditValidationListener = (event: AuditValidationEvent) => void; + export class AuditTrailHasher { private entries: AuditChainEntry[] = []; + private listeners: AuditValidationListener[] = []; constructor(entries: AuditChainEntry[] = []) { this.entries = [...entries]; @@ -22,6 +37,22 @@ export class AuditTrailHasher { return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } + /** + * Registers a listener for validation lifecycle events + */ + onValidation(listener: AuditValidationListener): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter(l => l !== listener); + }; + } + + private emit(event: AuditValidationEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + /** * Appends a new event to the audit trail */ @@ -64,18 +95,77 @@ export class AuditTrailHasher { return currentLayer[0]; } + /** + * Generates a Merkle inclusion proof for the entry at the given index + */ + async proof(index: number): Promise { + if (index < 0 || index >= this.entries.length) { + throw new Error(`Index ${index} out of bounds for ${this.entries.length} entries`); + } + + const leaf = this.entries[index].hash; + const siblings: MerkleProof['siblings'] = []; + let currentLayer = this.entries.map(e => e.hash); + let currentIndex = index; + + while (currentLayer.length > 1) { + const isRightNode = currentIndex % 2 === 1; + const siblingIndex = isRightNode ? currentIndex - 1 : currentIndex + 1; + const siblingHash = siblingIndex < currentLayer.length + ? currentLayer[siblingIndex] + : currentLayer[currentIndex]; + + siblings.push({ + hash: siblingHash, + position: isRightNode ? 'left' : 'right', + }); + + const nextLayer: string[] = []; + for (let i = 0; i < currentLayer.length; i += 2) { + if (i + 1 < currentLayer.length) { + nextLayer.push(await AuditTrailHasher.sha256Hex(currentLayer[i] + currentLayer[i + 1])); + } else { + nextLayer.push(await AuditTrailHasher.sha256Hex(currentLayer[i] + currentLayer[i])); + } + } + currentLayer = nextLayer; + currentIndex = Math.floor(currentIndex / 2); + } + + return { leaf, index, siblings, root: currentLayer[0] }; + } + + /** + * Verifies a Merkle inclusion proof against an expected root + */ + static async verifyProof(proof: MerkleProof, expectedRoot: AuditTrailRoot): Promise { + let computed = proof.leaf; + for (const sibling of proof.siblings) { + if (sibling.position === 'left') { + computed = await AuditTrailHasher.sha256Hex(sibling.hash + computed); + } else { + computed = await AuditTrailHasher.sha256Hex(computed + sibling.hash); + } + } + return computed === expectedRoot && proof.root === expectedRoot; + } + /** * Recomputes the root from stored entries and checks equality */ async verify(expectedRoot: AuditTrailRoot): Promise<{ valid: boolean; mismatchAt?: number; length?: number }> { + this.emit({ type: 'validation:start', expectedRoot, length: this.entries.length }); + // Check integrity of the chain let prevHash = await AuditTrailHasher.sha256Hex(''); for (let i = 0; i < this.entries.length; i++) { const entry = this.entries[i]; if (entry.index !== i) { + this.emit({ type: 'validation:failure', reason: 'index-mismatch', mismatchAt: i }); return { valid: false, mismatchAt: i }; } if (entry.prevHash !== prevHash) { + this.emit({ type: 'validation:failure', reason: 'prev-hash-mismatch', mismatchAt: i }); return { valid: false, mismatchAt: i }; } @@ -83,6 +173,7 @@ export class AuditTrailHasher { const expectedHash = await AuditTrailHasher.sha256Hex(dataString); if (entry.hash !== expectedHash) { + this.emit({ type: 'validation:failure', reason: 'entry-hash-mismatch', mismatchAt: i }); return { valid: false, mismatchAt: i }; } @@ -92,9 +183,11 @@ export class AuditTrailHasher { // Check root const computedRoot = await this.root(); if (computedRoot !== expectedRoot) { + this.emit({ type: 'validation:failure', reason: 'root-mismatch', mismatchAt: 0 }); return { valid: false, mismatchAt: 0 }; } + this.emit({ type: 'validation:success', root: computedRoot, length: this.entries.length }); return { valid: true, length: this.entries.length }; } diff --git a/src/batchVerifier.ts b/src/batchVerifier.ts index c0a342a1..9684fe37 100644 --- a/src/batchVerifier.ts +++ b/src/batchVerifier.ts @@ -103,3 +103,248 @@ export interface VerifyBatchPayResult { commonToken: string | null; errors: string[]; } + +/** + * A single leaf entry in an invoice merkle tree. Each entry commits to an + * invoice id and the amount being paid for that invoice. + */ +export interface InvoiceMerkleLeaf { + invoiceId: string; + amount: bigint; +} + +/** + * A merkle inclusion proof for a single invoice leaf. + */ +export interface InvoiceMerkleProof { + invoiceId: string; + amount: bigint; + leaf: string; + root: string; + siblings: string[]; + path: Array<"left" | "right">; +} + +/** + * Result of validating an invoice merkle tree against a set of payments. + */ +export interface InvoiceMerkleValidationResult { + valid: boolean; + root: string | null; + leaves: string[]; + errors: string[]; +} + +/** + * Event emitted while validating an invoice merkle tree. + */ +export interface InvoiceMerkleEvent { + type: "validation:start" | "validation:success" | "validation:failure"; + root: string | null; + leafCount: number; + errors: string[]; +} + +export type InvoiceMerkleEventHandler = (event: InvoiceMerkleEvent) => void; + +/** + * Deterministic string encoding for a merkle leaf. Kept dependency-free so it + * can run in any JS runtime (browser, node, edge). + */ +function encodeLeaf(leaf: InvoiceMerkleLeaf): string { + return `${leaf.invoiceId}:${leaf.amount.toString()}`; +} + +/** + * FNV-1a based 64-bit hash rendered as a hex string. This is a deterministic, + * dependency-free hash suitable for building a merkle tree over invoice data. + * It is not cryptographically secure and is intended for structural + * validation of invoice batches, not for on-chain commitments. + */ +function hashString(input: string): string { + let h1 = 0x811c9dc5; + let h2 = 0x811c9dc5 ^ 0x9e3779b9; + for (let i = 0; i < input.length; i++) { + const c = input.charCodeAt(i); + h1 ^= c; + h1 = Math.imul(h1, 0x01000193) >>> 0; + h2 ^= c + i; + h2 = Math.imul(h2, 0x01000193) >>> 0; + } + return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); +} + +/** + * Hash a single invoice leaf into its merkle node value. + */ +export function hashInvoiceLeaf(leaf: InvoiceMerkleLeaf): string { + return hashString(`leaf:${encodeLeaf(leaf)}`); +} + +/** + * Combine two child hashes into their parent hash. Order matters so that the + * tree is deterministic and proofs are unambiguous. + */ +export function hashInvoicePair(left: string, right: string): string { + return hashString(`node:${left}:${right}`); +} + +/** + * Build the merkle tree levels for a list of invoice leaves. Returns the + * levels bottom-up, with the last level containing the single root. + */ +function buildLevels(leaves: InvoiceMerkleLeaf[]): string[][] { + const leafHashes = leaves.map(hashInvoiceLeaf); + const levels: string[][] = [leafHashes]; + + let current = leafHashes; + while (current.length > 1) { + const next: string[] = []; + for (let i = 0; i < current.length; i += 2) { + const left = current[i]!; + const right = i + 1 < current.length ? current[i + 1]! : left; + next.push(hashInvoicePair(left, right)); + } + levels.push(next); + current = next; + } + + return levels; +} + +/** + * Compute the merkle root for a set of invoice leaves. Returns null for an + * empty leaf set. + */ +export function computeInvoiceMerkleRoot(leaves: InvoiceMerkleLeaf[]): string | null { + if (leaves.length === 0) { + return null; + } + const levels = buildLevels(leaves); + return levels[levels.length - 1]![0]!; +} + +/** + * Generate an inclusion proof for the invoice leaf at the given index. + */ +export function generateInvoiceMerkleProof( + leaves: InvoiceMerkleLeaf[], + index: number +): InvoiceMerkleProof | null { + if (index < 0 || index >= leaves.length) { + return null; + } + + const levels = buildLevels(leaves); + const siblings: string[] = []; + const path: Array<"left" | "right"> = []; + + let idx = index; + for (let level = 0; level < levels.length - 1; level++) { + const nodes = levels[level]!; + const isRight = idx % 2 === 1; + const siblingIdx = isRight ? idx - 1 : idx + 1; + const sibling = siblingIdx < nodes.length ? nodes[siblingIdx]! : nodes[idx]!; + siblings.push(sibling); + path.push(isRight ? "left" : "right"); + idx = Math.floor(idx / 2); + } + + const leaf = leaves[index]!; + return { + invoiceId: leaf.invoiceId, + amount: leaf.amount, + leaf: hashInvoiceLeaf(leaf), + root: levels[levels.length - 1]![0]!, + siblings, + path, + }; +} + +/** + * Verify an inclusion proof against an expected merkle root. + */ +export function verifyInvoiceMerkleProof( + proof: InvoiceMerkleProof, + expectedRoot: string +): boolean { + let current = proof.leaf; + for (let i = 0; i < proof.siblings.length; i++) { + const sibling = proof.siblings[i]!; + const direction = proof.path[i]; + current = + direction === "left" + ? hashInvoicePair(sibling, current) + : hashInvoicePair(current, sibling); + } + return current === expectedRoot; +} + +/** + * Validate that a set of payments forms a consistent invoice merkle tree and + * that every payment can be proven to be included in the resulting root. + * + * Emits validation lifecycle events through the optional handler so callers + * can surface progress and failures in the UI. + */ +export function validateInvoiceMerkleTree( + payments: BatchPayment[], + onEvent?: InvoiceMerkleEventHandler +): InvoiceMerkleValidationResult { + const errors: string[] = []; + const leaves: InvoiceMerkleLeaf[] = payments.map((p) => ({ + invoiceId: p.invoiceId, + amount: p.amount, + })); + + onEvent?.({ + type: "validation:start", + root: null, + leafCount: leaves.length, + errors: [], + }); + + if (leaves.length === 0) { + errors.push("No payments provided for merkle validation"); + onEvent?.({ + type: "validation:failure", + root: null, + leafCount: 0, + errors, + }); + return { valid: false, root: null, leaves: [], errors }; + } + + const seen = new Set(); + for (const leaf of leaves) { + if (leaf.amount <= 0n) { + errors.push(`Invoice ${leaf.invoiceId}: amount must be positive`); + } + if (seen.has(leaf.invoiceId)) { + errors.push(`Invoice ${leaf.invoiceId}: duplicate entry in merkle tree`); + } + seen.add(leaf.invoiceId); + } + + const root = computeInvoiceMerkleRoot(leaves); + const leafHashes = leaves.map(hashInvoiceLeaf); + + if (root !== null) { + for (let i = 0; i < leaves.length; i++) { + const proof = generateInvoiceMerkleProof(leaves, i); + if (!proof || !verifyInvoiceMerkleProof(proof, root)) { + errors.push(`Invoice ${leaves[i]!.invoiceId}: inclusion proof failed`); + } + } + } + + const valid = errors.length === 0; + onEvent?.({ + type: valid ? "validation:success" : "validation:failure", + root, + leafCount: leaves.length, + errors, + }); + + return { valid, root, leaves: leafHashes, errors }; +} From 6399c5ec8e6a77a8f3bfd6a7dd6f5528dd1dcdbc Mon Sep 17 00:00:00 2001 From: samsonmark0998-tech Date: Sun, 27 Sep 2026 08:08:13 +0000 Subject: [PATCH 2/4] fix: #939 Add SDK dependency injection framework Closes #939 --- src/container.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/container.ts b/src/container.ts index 44fba77e..ae07ebf1 100644 --- a/src/container.ts +++ b/src/container.ts @@ -26,10 +26,25 @@ export interface DIContainerOptions { walletAdapter?: IWalletAdapter; } +export type DIContainerEvent = + | "registered" + | "resolved" + | "missing" + | "disposed"; + +export interface DIContainerEventPayload { + type: DIContainerEvent; + token: string; + value?: unknown; +} + +export type DIContainerEventListener = (payload: DIContainerEventPayload) => void; + export class DIContainer { private rpcClient?: IRPCClient; private cacheStore?: ICacheStore; private walletAdapter?: IWalletAdapter; + private readonly listeners = new Map>(); constructor(options: DIContainerOptions = {}) { this.rpcClient = options.rpcClient; @@ -37,27 +52,88 @@ export class DIContainer { this.walletAdapter = options.walletAdapter; } + on(event: DIContainerEvent, listener: DIContainerEventListener): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(listener); + return () => { + set?.delete(listener); + }; + } + + off(event: DIContainerEvent, listener: DIContainerEventListener): void { + this.listeners.get(event)?.delete(listener); + } + + private emit(event: DIContainerEvent, token: string, value?: unknown): void { + const set = this.listeners.get(event); + if (!set) { + return; + } + const payload: DIContainerEventPayload = { type: event, token, value }; + for (const listener of set) { + listener(payload); + } + } + registerRPCClient(client: IRPCClient): void { this.rpcClient = client; + this.emit("registered", "rpcClient", client); } registerCacheStore(store: ICacheStore): void { this.cacheStore = store; + this.emit("registered", "cacheStore", store); } registerWalletAdapter(adapter: IWalletAdapter): void { this.walletAdapter = adapter; + this.emit("registered", "walletAdapter", adapter); } getRPCClient(): IRPCClient | undefined { + if (!this.rpcClient) { + this.emit("missing", "rpcClient"); + return undefined; + } + this.emit("resolved", "rpcClient", this.rpcClient); return this.rpcClient; } getCacheStore(): ICacheStore | undefined { + if (!this.cacheStore) { + this.emit("missing", "cacheStore"); + return undefined; + } + this.emit("resolved", "cacheStore", this.cacheStore); return this.cacheStore; } getWalletAdapter(): IWalletAdapter | undefined { + if (!this.walletAdapter) { + this.emit("missing", "walletAdapter"); + return undefined; + } + this.emit("resolved", "walletAdapter", this.walletAdapter); return this.walletAdapter; } + + async dispose(): Promise { + const closers: Array | void> = []; + if (this.rpcClient?.close) { + closers.push(this.rpcClient.close()); + } + if (this.cacheStore?.close) { + closers.push(this.cacheStore.close()); + } + await Promise.all(closers); + this.rpcClient = undefined; + this.cacheStore = undefined; + this.walletAdapter = undefined; + this.emit("disposed", "container"); + this.listeners.clear(); + } } From 495fa59f05f20cea7ab592ee3fb83316c3c417ff Mon Sep 17 00:00:00 2001 From: samsonmark0998-tech Date: Sun, 27 Sep 2026 08:08:22 +0000 Subject: [PATCH 3/4] fix: #940 Implement payment split calculator Closes #940 --- src/paymentSplitCalculator.ts | 154 ++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/paymentSplitCalculator.ts diff --git a/src/paymentSplitCalculator.ts b/src/paymentSplitCalculator.ts new file mode 100644 index 00000000..fc018d67 --- /dev/null +++ b/src/paymentSplitCalculator.ts @@ -0,0 +1,154 @@ +/** + * Payment split calculator. + * + * Computes per-recipient shares from a total amount and a set of split + * definitions. Supports percentage and fixed-amount splits, with optional + * remainder distribution so that the sum of all shares always equals the + * original total. + */ + +export type SplitKind = "percentage" | "fixed"; + +export interface SplitDefinition { + /** Identifier of the recipient receiving this share. */ + recipientId: string; + /** How the share is expressed: a percentage of the total or a fixed amount. */ + kind: SplitKind; + /** Percentage (0-100) when kind is "percentage", or an absolute amount when "fixed". */ + value: number; +} + +export interface SplitShare { + recipientId: string; + /** Amount allocated to the recipient, in the smallest currency unit. */ + amount: number; +} + +export interface SplitResult { + total: number; + shares: SplitShare[]; + /** Amount left unallocated after applying all definitions. */ + remainder: number; +} + +export type SplitEventType = "split:computed" | "split:error"; + +export interface SplitComputedEvent { + type: "split:computed"; + result: SplitResult; +} + +export interface SplitErrorEvent { + type: "split:error"; + error: Error; +} + +export type SplitEvent = SplitComputedEvent | SplitErrorEvent; + +export type SplitEventListener = (event: SplitEvent) => void; + +export interface PaymentSplitCalculatorOptions { + /** + * When true, any unallocated remainder is distributed one unit at a time + * across recipients (in definition order) so the shares sum to the total. + */ + distributeRemainder?: boolean; +} + +/** + * Calculates how a total amount is split across recipients. + */ +export class PaymentSplitCalculator { + private readonly listeners: Set = new Set(); + private readonly distributeRemainder: boolean; + + constructor(options: PaymentSplitCalculatorOptions = {}) { + this.distributeRemainder = options.distributeRemainder ?? false; + } + + /** + * Registers a listener for split lifecycle events. + * @returns an unsubscribe function. + */ + public on(listener: SplitEventListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Computes the per-recipient shares for the given total and definitions. + * + * @throws {Error} when the total is negative, a definition is invalid, or + * the definitions allocate more than the total. + */ + public calculate(total: number, definitions: SplitDefinition[]): SplitResult { + try { + const result = this.compute(total, definitions); + this.emit({ type: "split:computed", result }); + return result; + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + this.emit({ type: "split:error", error: normalized }); + throw normalized; + } + } + + private compute(total: number, definitions: SplitDefinition[]): SplitResult { + if (!Number.isFinite(total) || total < 0) { + throw new Error("Total amount must be a non-negative finite number"); + } + + const shares: SplitShare[] = []; + let allocated = 0; + + for (const definition of definitions) { + if (!definition || typeof definition.recipientId !== "string" || definition.recipientId.length === 0) { + throw new Error("Each split definition requires a non-empty recipientId"); + } + if (!Number.isFinite(definition.value) || definition.value < 0) { + throw new Error(`Invalid split value for recipient ${definition.recipientId}`); + } + + let amount: number; + if (definition.kind === "percentage") { + if (definition.value > 100) { + throw new Error(`Percentage for recipient ${definition.recipientId} exceeds 100`); + } + amount = Math.round((total * definition.value) / 100); + } else if (definition.kind === "fixed") { + amount = Math.round(definition.value); + } else { + throw new Error(`Unsupported split kind for recipient ${definition.recipientId}`); + } + + allocated += amount; + shares.push({ recipientId: definition.recipientId, amount }); + } + + if (allocated > total) { + throw new Error("Split definitions allocate more than the total amount"); + } + + let remainder = total - allocated; + + if (this.distributeRemainder && remainder > 0 && shares.length > 0) { + let index = 0; + while (remainder > 0) { + shares[index % shares.length].amount += 1; + remainder -= 1; + index += 1; + } + remainder = 0; + } + + return { total, shares, remainder }; + } + + private emit(event: SplitEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } +} From 902d57fcec965b559bc9b2d91bd1bd32791c0ec1 Mon Sep 17 00:00:00 2001 From: samsonmark0998-tech Date: Sun, 27 Sep 2026 08:08:35 +0000 Subject: [PATCH 4/4] fix: #941 Add comprehensive SDK performance benchmark suite Closes #941 --- package.json | 1 + scripts/bundle-size-audit.ts | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/package.json b/package.json index 32ad21d5..ac4fb6f5 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:all": "vitest run", "test:e2e": "vitest run test/e2e", "test:watch": "vitest", + "test:bench": "vitest bench --run", "lint": "tsc --noEmit", "changelog": "vite-node scripts/changelog.ts", "docs": "ts-node scripts/generate-docs.ts", diff --git a/scripts/bundle-size-audit.ts b/scripts/bundle-size-audit.ts index 618927e0..632f49a4 100644 --- a/scripts/bundle-size-audit.ts +++ b/scripts/bundle-size-audit.ts @@ -8,6 +8,7 @@ import { gzipSync } from "zlib"; import * as fs from "fs"; import * as path from "path"; +import { EventEmitter } from "events"; interface SizeBudget { maxBytes: number; @@ -25,6 +26,76 @@ interface AuditResult { percentUsed: number; } +interface BenchmarkSample { + exportName: string; + bytes: number; + durationMs: number; +} + +interface BenchmarkReport { + samples: BenchmarkSample[]; + totalBytes: number; + totalDurationMs: number; + iterations: number; +} + +/** + * Emits lifecycle events for the SDK performance benchmark suite. + * Events: "start", "iteration", "sample", "complete", "error". + */ +export class BenchmarkRunner extends EventEmitter { + private readonly iterations: number; + + constructor(iterations: number = 3) { + super(); + this.iterations = Math.max(1, iterations); + } + + /** + * Run the benchmark across the provided export names, emitting lifecycle events. + */ + run(exportNames: string[], measure: () => number): BenchmarkReport { + this.emit("start", { exportNames, iterations: this.iterations }); + + const samples: BenchmarkSample[] = []; + let totalBytes = 0; + let totalDurationMs = 0; + + try { + for (let i = 0; i < this.iterations; i++) { + this.emit("iteration", { index: i, total: this.iterations }); + + const start = Date.now(); + const bytes = measure(); + const durationMs = Date.now() - start; + + totalBytes += bytes; + totalDurationMs += durationMs; + + const perExport = Math.floor(bytes / Math.max(1, exportNames.length)); + for (const exportName of exportNames) { + const sample: BenchmarkSample = { exportName, bytes: perExport, durationMs }; + samples.push(sample); + this.emit("sample", sample); + } + } + + const report: BenchmarkReport = { + samples, + totalBytes, + totalDurationMs, + iterations: this.iterations, + }; + + this.emit("complete", report); + return report; + } catch (err) { + this.emit("error", err); + throw err; + } + } +} + /** * Load size limits configuration from the root. */