diff --git a/package.json b/package.json index b7ae56f9..43ded159 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,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": "typedoc", 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. */ diff --git a/src/audit/AuditTrailHasher.ts b/src/audit/AuditTrailHasher.ts index 38ecb966..8a12556e 100644 --- a/src/audit/AuditTrailHasher.ts +++ b/src/audit/AuditTrailHasher.ts @@ -195,18 +195,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 }; } @@ -214,6 +273,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 }; } @@ -223,9 +283,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/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(); + } } 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); + } + } +}