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
111 changes: 111 additions & 0 deletions src/accounts/AccountSignerWeightCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, MultiSigSigner>();
private readonly listeners = new Set<MultiSigBuilderListener>();
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<MultiSigTransaction> {
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);
}
}
}
200 changes: 200 additions & 0 deletions src/analyticsDashboardExporter.ts
Original file line number Diff line number Diff line change
@@ -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<K extends AnalyticsExportEventName> = (
event: AnalyticsExportEventMap[K],
) => void;

/**
* Minimal typed event emitter used to report export lifecycle events.
*/
export class AnalyticsExportEventEmitter {
private readonly listeners: {
[K in AnalyticsExportEventName]: Set<AnalyticsExportListener<K>>;
} = {
start: new Set(),
complete: new Set(),
error: new Set(),
};

on<K extends AnalyticsExportEventName>(
event: K,
listener: AnalyticsExportListener<K>,
): () => void {
this.listeners[event].add(listener);
return () => this.off(event, listener);
}

off<K extends AnalyticsExportEventName>(
event: K,
listener: AnalyticsExportListener<K>,
): void {
this.listeners[event].delete(listener);
}

emit<K extends AnalyticsExportEventName>(
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<string>();
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<K extends AnalyticsExportEventName>(
event: K,
listener: AnalyticsExportListener<K>,
): () => void {
return this.events.on(event, listener);
}

off<K extends AnalyticsExportEventName>(
event: K,
listener: AnalyticsExportListener<K>,
): 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;
63 changes: 63 additions & 0 deletions src/auditLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading