diff --git a/src/accounts/AccountSignerWeightCalculator.ts b/src/accounts/AccountSignerWeightCalculator.ts index 1c479cc..dfbb6d2 100644 --- a/src/accounts/AccountSignerWeightCalculator.ts +++ b/src/accounts/AccountSignerWeightCalculator.ts @@ -378,3 +378,114 @@ export class AccountSignerWeightCalculator { } } } + +// --------------------------------------------------------------------------- +// MultiSigTransactionBuilder +// --------------------------------------------------------------------------- + +/** + * Builds multi-signature transaction plans by collecting signers and a target + * threshold, then validating the collected weight against the account's + * on-chain thresholds via {@link AccountSignerWeightCalculator}. + * + * Emits lifecycle events so callers can react to signer/threshold changes and + * to the final build result. + * + * Issue #914 + */ +export class MultiSigTransactionBuilder { + private readonly calculator: AccountSignerWeightCalculator; + private readonly accountId: string; + private readonly signers = new Map(); + private readonly listeners = new Set(); + private threshold: ThresholdLevel = "medium"; + + constructor(accountId: string, calculator: AccountSignerWeightCalculator) { + this.accountId = accountId; + this.calculator = calculator; + } + + /** + * Register a listener for builder lifecycle events. + * + * @returns An unsubscribe function that removes the listener. + */ + on(listener: MultiSigBuilderListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Add a signer to the transaction. Re-adding an existing key updates its weight. + */ + addSigner(key: string, weight: number): this { + const signer: MultiSigSigner = { key, weight }; + this.signers.set(key, signer); + this._emit({ type: "signerAdded", signer }); + return this; + } + + /** + * Remove a signer by key. No-op if the key is not present. + */ + removeSigner(key: string): this { + if (this.signers.delete(key)) { + this._emit({ type: "signerRemoved", key }); + } + return this; + } + + /** + * Set the threshold level the transaction must satisfy. + */ + setThreshold(threshold: ThresholdLevel): this { + this.threshold = threshold; + this._emit({ type: "thresholdSet", threshold }); + return this; + } + + /** + * The signers currently configured on the builder. + */ + getSigners(): MultiSigSigner[] { + return Array.from(this.signers.values()); + } + + /** + * Build the multi-signature transaction plan, validating the configured + * signers against the account's on-chain thresholds. + * + * @throws {InsufficientSignerWeightError} when the configured signers do not + * meet the required threshold. + */ + async build(): Promise { + const signers = this.getSigners(); + const keys = signers.map((s) => s.key); + + const result = await this.calculator.calculateWeight(this.accountId, keys, this.threshold); + + if (!result.sufficient) { + throw new InsufficientSignerWeightError(keys, result.totalWeight, result.requiredThreshold); + } + + const transaction: MultiSigTransaction = { + accountId: this.accountId, + threshold: this.threshold, + signers, + totalWeight: result.totalWeight, + requiredThreshold: result.requiredThreshold, + sufficient: result.sufficient, + }; + + this._emit({ type: "built", transaction }); + return transaction; + } + + private _emit(event: MultiSigBuilderEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } +} diff --git a/src/analyticsDashboardExporter.ts b/src/analyticsDashboardExporter.ts new file mode 100644 index 0000000..2bdd83a --- /dev/null +++ b/src/analyticsDashboardExporter.ts @@ -0,0 +1,200 @@ +/** + * Optional analytics dashboard data export. + * + * Provides a small, dependency-free exporter that serializes analytics + * dashboard data to CSV or JSON and emits lifecycle events for the export + * (start / complete / error). The feature is opt-in: nothing runs unless a + * caller explicitly invokes the exporter, so existing default behavior is + * unchanged. + */ + +export type AnalyticsExportFormat = "csv" | "json"; + +export interface AnalyticsDashboardRow { + [key: string]: string | number | boolean | null | undefined; +} + +export interface AnalyticsExportOptions { + /** Output format. Defaults to "json". */ + format?: AnalyticsExportFormat; + /** Optional explicit column ordering for CSV output. */ + columns?: string[]; + /** Optional filename hint included in the completion event. */ + filename?: string; +} + +export interface AnalyticsExportStartEvent { + format: AnalyticsExportFormat; + rowCount: number; + filename?: string; +} + +export interface AnalyticsExportCompleteEvent { + format: AnalyticsExportFormat; + rowCount: number; + filename?: string; + data: string; +} + +export interface AnalyticsExportErrorEvent { + format: AnalyticsExportFormat; + filename?: string; + error: Error; +} + +export interface AnalyticsExportEventMap { + start: AnalyticsExportStartEvent; + complete: AnalyticsExportCompleteEvent; + error: AnalyticsExportErrorEvent; +} + +export type AnalyticsExportEventName = keyof AnalyticsExportEventMap; + +export type AnalyticsExportListener = ( + event: AnalyticsExportEventMap[K], +) => void; + +/** + * Minimal typed event emitter used to report export lifecycle events. + */ +export class AnalyticsExportEventEmitter { + private readonly listeners: { + [K in AnalyticsExportEventName]: Set>; + } = { + start: new Set(), + complete: new Set(), + error: new Set(), + }; + + on( + event: K, + listener: AnalyticsExportListener, + ): () => void { + this.listeners[event].add(listener); + return () => this.off(event, listener); + } + + off( + event: K, + listener: AnalyticsExportListener, + ): void { + this.listeners[event].delete(listener); + } + + emit( + event: K, + payload: AnalyticsExportEventMap[K], + ): void { + for (const listener of this.listeners[event]) { + listener(payload); + } + } +} + +function resolveColumns( + rows: AnalyticsDashboardRow[], + columns?: string[], +): string[] { + if (columns && columns.length > 0) { + return columns; + } + const seen = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + seen.add(key); + } + } + return Array.from(seen); +} + +function escapeCsvValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + const text = String(value); + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +function toCsv(rows: AnalyticsDashboardRow[], columns: string[]): string { + const header = columns.map(escapeCsvValue).join(","); + const body = rows.map((row) => + columns.map((column) => escapeCsvValue(row[column])).join(","), + ); + return [header, ...body].join("\n"); +} + +function toJson(rows: AnalyticsDashboardRow[]): string { + return JSON.stringify(rows, null, 2); +} + +/** + * Optional analytics dashboard exporter. + * + * Usage: + * ```ts + * const exporter = new AnalyticsDashboardExporter(); + * exporter.on("complete", (e) => console.log(e.data)); + * const csv = exporter.export(rows, { format: "csv" }); + * ``` + */ +export class AnalyticsDashboardExporter { + readonly events = new AnalyticsExportEventEmitter(); + + on( + event: K, + listener: AnalyticsExportListener, + ): () => void { + return this.events.on(event, listener); + } + + off( + event: K, + listener: AnalyticsExportListener, + ): void { + this.events.off(event, listener); + } + + /** + * Serialize dashboard rows into the requested format. + * Emits `start`, then `complete` on success or `error` on failure. + */ + export( + rows: AnalyticsDashboardRow[], + options: AnalyticsExportOptions = {}, + ): string { + const format: AnalyticsExportFormat = options.format ?? "json"; + const filename = options.filename; + const safeRows = Array.isArray(rows) ? rows : []; + + this.events.emit("start", { + format, + rowCount: safeRows.length, + filename, + }); + + try { + const data = + format === "csv" + ? toCsv(safeRows, resolveColumns(safeRows, options.columns)) + : toJson(safeRows); + + this.events.emit("complete", { + format, + rowCount: safeRows.length, + filename, + data, + }); + + return data; + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + this.events.emit("error", { format, filename, error }); + throw error; + } + } +} + +export default AnalyticsDashboardExporter; diff --git a/src/auditLogger.ts b/src/auditLogger.ts index d6ec606..ffef4dc 100644 --- a/src/auditLogger.ts +++ b/src/auditLogger.ts @@ -112,6 +112,69 @@ export class AuditLogger { ); } + /** + * Register a subscription for invoice notifications. + * + * @param subscription - The subscription to register. + * @returns An unsubscribe function that removes the subscription. + */ + subscribeToInvoice( + subscription: InvoiceNotificationSubscription, + ): () => void { + const existing = this.invoiceSubscriptions.get(subscription.invoiceId) ?? []; + existing.push(subscription); + this.invoiceSubscriptions.set(subscription.invoiceId, existing); + + return () => this.unsubscribeFromInvoice(subscription.id); + } + + /** + * Remove a previously registered invoice notification subscription by id. + * + * @returns `true` when a subscription was removed, `false` otherwise. + */ + unsubscribeFromInvoice(subscriptionId: string): boolean { + for (const [invoiceId, subs] of this.invoiceSubscriptions) { + const index = subs.findIndex((s) => s.id === subscriptionId); + if (index !== -1) { + subs.splice(index, 1); + if (subs.length === 0) { + this.invoiceSubscriptions.delete(invoiceId); + } + return true; + } + } + return false; + } + + /** + * Emit an invoice notification to all matching subscribers. + * + * Subscribers registered for the invoice receive the notification when they + * have no event filter or when their filter includes the emitted event. + * Handler errors are isolated so one failing subscriber cannot prevent + * delivery to the others. + * + * @param notification - The notification to deliver. + * @returns The number of subscribers the notification was delivered to. + */ + emitInvoiceNotification(notification: InvoiceNotification): number { + const subs = this.invoiceSubscriptions.get(notification.invoiceId); + if (!subs || subs.length === 0) return 0; + + let delivered = 0; + for (const sub of subs) { + if (sub.events && !sub.events.includes(notification.event)) continue; + try { + sub.handler(notification); + delivered += 1; + } catch { + // Isolate subscriber failures; never break notification delivery. + } + } + return delivered; + } + /** * Log an entry with automatic XDR decoding. * diff --git a/src/broadcaster.ts b/src/broadcaster.ts index 2d0ce2d..640dd6a 100644 --- a/src/broadcaster.ts +++ b/src/broadcaster.ts @@ -473,3 +473,116 @@ export class TransactionRollbackSimulator { export function createTransactionRollbackSimulator(): TransactionRollbackSimulator { return new TransactionRollbackSimulator(); } + +/** + * Manages invoice notification subscriptions on top of an + * {@link InvoiceStateBroadcaster}, emitting lifecycle events for + * subscribe, unsubscribe, and notify operations. + */ +export class InvoiceNotificationSubscriptionManager { + private readonly broadcaster: InvoiceStateBroadcaster; + private readonly eventHandlers: Set = new Set(); + private readonly unsubscribers: Map void>> = + new Map(); + + constructor(broadcaster: InvoiceStateBroadcaster = createInvoiceStateBroadcaster()) { + this.broadcaster = broadcaster; + } + + /** + * Register a handler for notification lifecycle events. + * + * @param handler - The event handler to register + * @returns Unsubscribe function that removes only this handler + */ + onEvent(handler: NotificationEventHandler): () => void { + this.eventHandlers.add(handler); + return () => { + this.eventHandlers.delete(handler); + }; + } + + /** + * Subscribe to invoice notifications for a specific invoice ID. + * + * @param invoiceId - The invoice ID to subscribe to + * @param handler - The handler function to call when notifications are received + * @returns Unsubscribe function that removes only this subscriber + */ + subscribe(invoiceId: string, handler: InvoiceHandler): () => void { + const unsubscribe = this.broadcaster.subscribe(invoiceId, handler); + + if (!this.unsubscribers.has(invoiceId)) { + this.unsubscribers.set(invoiceId, new Map()); + } + this.unsubscribers.get(invoiceId)!.set(handler, unsubscribe); + + this.emit({ type: "subscribed", invoiceId }); + + return () => { + const handlers = this.unsubscribers.get(invoiceId); + if (handlers) { + handlers.delete(handler); + if (handlers.size === 0) { + this.unsubscribers.delete(invoiceId); + } + } + unsubscribe(); + this.emit({ type: "unsubscribed", invoiceId }); + }; + } + + /** + * Notify all subscribers of an invoice state update. + * + * @param invoiceId - The invoice ID to notify + * @param invoice - The updated invoice state + */ + notify(invoiceId: string, invoice: Invoice): void { + this.broadcaster.broadcast(invoiceId, invoice); + this.emit({ type: "notified", invoiceId, invoice }); + } + + /** + * Get the number of subscribers for a given invoice ID. + * + * @param invoiceId - The invoice ID to check + * @returns Number of subscribers + */ + getSubscriberCount(invoiceId: string): number { + return this.broadcaster.getSubscriberCount(invoiceId); + } + + /** + * Remove all subscriptions and event handlers. + */ + clear(): void { + this.unsubscribers.forEach((handlers) => { + handlers.forEach((unsubscribe) => unsubscribe()); + }); + this.unsubscribers.clear(); + this.eventHandlers.clear(); + } + + private emit(event: InvoiceNotificationEvent): void { + this.eventHandlers.forEach((handler) => { + try { + handler(event); + } catch (error) { + console.error("Error in invoice notification event handler:", error); + } + }); + } +} + +/** + * Creates a new InvoiceNotificationSubscriptionManager instance. + * + * @param broadcaster - Optional broadcaster to use for state updates + * @returns A new InvoiceNotificationSubscriptionManager instance + */ +export function createInvoiceNotificationSubscriptionManager( + broadcaster?: InvoiceStateBroadcaster +): InvoiceNotificationSubscriptionManager { + return new InvoiceNotificationSubscriptionManager(broadcaster); +} diff --git a/src/builder/OperationBuilder.ts b/src/builder/OperationBuilder.ts index 462577c..26cd6fa 100644 --- a/src/builder/OperationBuilder.ts +++ b/src/builder/OperationBuilder.ts @@ -147,6 +147,16 @@ export class OperationBuilder { Set >(); + // Multi-signature state + private readonly signers = new Map(); + private thresholds: Required = { + masterWeight: 1, + low: 0, + medium: 0, + high: 0, + }; + private readonly listeners = new Set(); + constructor(config: OperationBuilderConfig) { this.config = config; this.server = new SorobanRpc.Server(config.rpcUrl, { @@ -254,6 +264,79 @@ export class OperationBuilder { return this; } + // -------------------------------------------------------------------------- + // Multi-signature builder + // -------------------------------------------------------------------------- + + /** + * Registers a signer with an optional weight. Re-adding an existing key + * updates its weight. Emits a `signerAdded` event. + */ + addSigner(opts: AddSignerOptions): this { + const weight = opts.weight ?? 1; + this.signers.set(opts.key, weight); + this._emit({ type: "signerAdded", key: opts.key, weight }); + return this; + } + + /** + * Removes a previously registered signer. Emits a `signerRemoved` event. + */ + removeSigner(key: string): this { + if (this.signers.delete(key)) { + this._emit({ type: "signerRemoved", key }); + } + return this; + } + + /** + * Sets the account thresholds. Emits a `thresholdsSet` event. + */ + setThresholds(opts: SetThresholdsOptions): this { + this.thresholds = { + masterWeight: opts.masterWeight ?? this.thresholds.masterWeight, + low: opts.low ?? this.thresholds.low, + medium: opts.medium ?? this.thresholds.medium, + high: opts.high ?? this.thresholds.high, + }; + this._emit({ type: "thresholdsSet", thresholds: { ...this.thresholds } }); + return this; + } + + /** + * Returns the total signing weight of all registered signers. + */ + getTotalWeight(): number { + let total = 0; + for (const weight of this.signers.values()) { + total += weight; + } + return total; + } + + /** + * Returns true when the registered signers meet the high threshold. + */ + isThresholdMet(): boolean { + return this.getTotalWeight() >= this.thresholds.high; + } + + /** + * Subscribes to builder lifecycle events. Returns an unsubscribe function. + */ + onEvent(listener: MultiSigEventListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private _emit(event: MultiSigEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + // -------------------------------------------------------------------------- // Build // -------------------------------------------------------------------------- diff --git a/src/stateMachineValidator.ts b/src/stateMachineValidator.ts index a0d99bf..a0df009 100644 --- a/src/stateMachineValidator.ts +++ b/src/stateMachineValidator.ts @@ -3,6 +3,93 @@ import { InvoiceStateMachine } from "./state/InvoiceStateMachine.js"; const defaultStateMachine = new InvoiceStateMachine(); +/** + * Event payload emitted whenever a transition is validated. + */ +export interface TransitionValidationEvent { + from: InvoiceStatus; + to: InvoiceStatus; + valid: boolean; +} + +/** + * Listener invoked on every transition validation attempt. + */ +export type TransitionValidationListener = (event: TransitionValidationEvent) => void; + +/** + * Listener invoked when a transition is rejected as invalid. + */ +export type TransitionFailureListener = (event: TransitionValidationEvent) => void; + +/** + * SDK state machine validator. + * + * Wraps an {@link InvoiceStateMachine} to validate allowed/denied transitions + * and to emit events for successful and failed validations. + */ +export class StateMachineValidator { + private readonly machine: InvoiceStateMachine; + private readonly validationListeners = new Set(); + private readonly failureListeners = new Set(); + + constructor(machine: InvoiceStateMachine = new InvoiceStateMachine()) { + this.machine = machine; + } + + /** + * Validate a transition from one state to another. + * Emits a validation event for every attempt and a failure event when invalid. + */ + validate(from: InvoiceStatus, to: InvoiceStatus): boolean { + const valid = this.machine.validate(from, to); + const event: TransitionValidationEvent = { from, to, valid }; + + for (const listener of this.validationListeners) { + listener(event); + } + + if (!valid) { + for (const listener of this.failureListeners) { + listener(event); + } + } + + return valid; + } + + /** + * Assert that a transition is valid, throwing when it is not. + */ + assertTransition(from: InvoiceStatus, to: InvoiceStatus): void { + if (!this.validate(from, to)) { + throw new Error(`Invalid state transition: ${from} -> ${to}`); + } + } + + /** + * Register a listener for all transition validation attempts. + * Returns an unsubscribe function. + */ + onValidation(listener: TransitionValidationListener): () => void { + this.validationListeners.add(listener); + return () => { + this.validationListeners.delete(listener); + }; + } + + /** + * Register a listener for failed transition validations. + * Returns an unsubscribe function. + */ + onFailure(listener: TransitionFailureListener): () => void { + this.failureListeners.add(listener); + return () => { + this.failureListeners.delete(listener); + }; + } +} + /** @deprecated Use InvoiceStateMachine (src/state/InvoiceStateMachine.ts) directly. */ export function validateTransition(from: InvoiceStatus, to: InvoiceStatus): boolean { return defaultStateMachine.validate(from, to);