Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions scripts/bundle-size-audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*/
Expand Down
62 changes: 62 additions & 0 deletions src/audit/AuditTrailHasher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,25 +195,85 @@ export class AuditTrailHasher {
return currentLayer[0];
}

/**
* Generates a Merkle inclusion proof for the entry at the given index
*/
async proof(index: number): Promise<MerkleProof> {
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<boolean> {
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 };
}

const dataString = JSON.stringify({ event: entry.event, prevHash: entry.prevHash, index: entry.index });
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 };
}

Expand All @@ -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 };
}

Expand Down
76 changes: 76 additions & 0 deletions src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,114 @@ 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<Invoice>;
private walletAdapter?: IWalletAdapter;
private readonly listeners = new Map<DIContainerEvent, Set<DIContainerEventListener>>();

constructor(options: DIContainerOptions = {}) {
this.rpcClient = options.rpcClient;
this.cacheStore = options.cacheStore;
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<Invoice>): 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<Invoice> | 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<void> {
const closers: Array<Promise<void> | 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();
}
}
Loading