diff --git a/src/adapters/walletconnect.ts b/src/adapters/walletconnect.ts index c5e51e9..9cb9881 100644 --- a/src/adapters/walletconnect.ts +++ b/src/adapters/walletconnect.ts @@ -55,6 +55,8 @@ const STORAGE_KEY = "stellarsplit:wc:session"; */ export class WalletConnectAdapter implements WalletAdapter { private readonly opts: WalletConnectAdapterOptions; + private readonly delegations = new Map(); + private readonly listeners = new Set(); constructor(opts: WalletConnectAdapterOptions) { this.opts = opts; @@ -70,6 +72,40 @@ export class WalletConnectAdapter implements WalletAdapter { return this.opts.address; } + /** + * Authorize a custody solution to sign transactions on behalf of the + * connected wallet until `expiresAt` (Unix ms). + */ + delegateSigning(delegate: string, expiresAt: number): SigningDelegation { + const delegation: SigningDelegation = { delegate, expiresAt }; + this.delegations.set(delegate, delegation); + this.emit({ type: "delegation:created", delegate, expiresAt }); + return delegation; + } + + /** Revoke a previously granted signing delegation. */ + revokeDelegation(delegate: string): boolean { + const removed = this.delegations.delete(delegate); + if (removed) { + this.emit({ type: "delegation:revoked", delegate }); + } + return removed; + } + + /** List currently active (non-expired) signing delegations. */ + listDelegations(): SigningDelegation[] { + const now = Date.now(); + return [...this.delegations.values()].filter((d) => d.expiresAt > now); + } + + /** Subscribe to delegation lifecycle events. Returns an unsubscribe fn. */ + onDelegation(listener: SigningDelegationListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + async signTransaction(xdr: string, network: string): Promise { return this.opts.client.request({ topic: this.opts.topic, diff --git a/src/batchSimulator.ts b/src/batchSimulator.ts new file mode 100644 index 0000000..feb8252 --- /dev/null +++ b/src/batchSimulator.ts @@ -0,0 +1,191 @@ +import { EventEmitter } from 'events'; + +export interface PortfolioScenario { + id: string; + name?: string; + initialValue: number; + expectedReturn: number; + volatility: number; + horizonYears: number; +} + +export interface ScenarioResult { + id: string; + name?: string; + finalValue: number; + totalReturn: number; + annualizedReturn: number; + success: boolean; + error?: string; +} + +export interface BatchSimulationSummary { + totalScenarios: number; + succeeded: number; + failed: number; + aggregateFinalValue: number; + aggregateReturn: number; + averageAnnualizedReturn: number; + results: ScenarioResult[]; +} + +export interface BatchSimulatorOptions { + /** Number of scenarios simulated concurrently. Defaults to 1 (sequential). */ + concurrency?: number; + /** Optional deterministic RNG hook for testing. Returns [0, 1). */ + random?: () => number; +} + +export interface BatchSimulatorEvents { + 'batch:start': (payload: { totalScenarios: number }) => void; + 'scenario:start': (payload: { id: string; index: number }) => void; + 'scenario:complete': (payload: { id: string; index: number; result: ScenarioResult }) => void; + 'scenario:error': (payload: { id: string; index: number; error: Error }) => void; + 'batch:progress': (payload: { completed: number; total: number }) => void; + 'batch:complete': (payload: BatchSimulationSummary) => void; + 'batch:error': (payload: { error: Error }) => void; +} + +/** + * Simulates a single portfolio scenario using a geometric-Brownian-motion + * style model. Deterministic when a `random` hook is supplied. + */ +export function simulateScenario( + scenario: PortfolioScenario, + random: () => number = Math.random, +): ScenarioResult { + if (!Number.isFinite(scenario.initialValue) || scenario.initialValue < 0) { + throw new Error(`Invalid initialValue for scenario "${scenario.id}"`); + } + if (!Number.isFinite(scenario.horizonYears) || scenario.horizonYears <= 0) { + throw new Error(`Invalid horizonYears for scenario "${scenario.id}"`); + } + + const steps = Math.max(1, Math.round(scenario.horizonYears * 12)); + const monthlyDrift = scenario.expectedReturn / 12; + const monthlyVol = scenario.volatility / Math.sqrt(12); + + let value = scenario.initialValue; + for (let i = 0; i < steps; i += 1) { + // Box-Muller transform for a standard normal sample. + const u1 = Math.max(random(), Number.EPSILON); + const u2 = random(); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + value *= 1 + monthlyDrift + monthlyVol * z; + if (value < 0) { + value = 0; + } + } + + const finalValue = value; + const totalReturn = scenario.initialValue === 0 ? 0 : finalValue / scenario.initialValue - 1; + const annualizedReturn = + scenario.initialValue === 0 || finalValue <= 0 + ? 0 + : Math.pow(finalValue / scenario.initialValue, 1 / scenario.horizonYears) - 1; + + return { + id: scenario.id, + name: scenario.name, + finalValue, + totalReturn, + annualizedReturn, + success: true, + }; +} + +/** + * Runs a batch of portfolio scenarios, emitting lifecycle events and + * aggregating the results. Individual scenario failures are captured and + * reported without aborting the whole batch. + */ +export class BatchSimulator extends EventEmitter { + private readonly concurrency: number; + private readonly random: () => number; + + constructor(options: BatchSimulatorOptions = {}) { + super(); + this.concurrency = Math.max(1, Math.floor(options.concurrency ?? 1)); + this.random = options.random ?? Math.random; + } + + public async run(scenarios: PortfolioScenario[]): Promise { + const total = scenarios.length; + this.emit('batch:start', { totalScenarios: total }); + + const results: ScenarioResult[] = new Array(total); + let completed = 0; + let cursor = 0; + + const worker = async (): Promise => { + while (cursor < total) { + const index = cursor; + cursor += 1; + const scenario = scenarios[index]; + this.emit('scenario:start', { id: scenario.id, index }); + try { + const result = simulateScenario(scenario, this.random); + results[index] = result; + this.emit('scenario:complete', { id: scenario.id, index, result }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + results[index] = { + id: scenario.id, + name: scenario.name, + finalValue: 0, + totalReturn: 0, + annualizedReturn: 0, + success: false, + error: error.message, + }; + this.emit('scenario:error', { id: scenario.id, index, error }); + } finally { + completed += 1; + this.emit('batch:progress', { completed, total }); + } + } + }; + + try { + const workers = Array.from( + { length: Math.min(this.concurrency, Math.max(1, total)) }, + () => worker(), + ); + await Promise.all(workers); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.emit('batch:error', { error }); + throw error; + } + + const summary = this.aggregate(results); + this.emit('batch:complete', summary); + return summary; + } + + private aggregate(results: ScenarioResult[]): BatchSimulationSummary { + const succeeded = results.filter((r) => r.success); + const failed = results.length - succeeded.length; + const aggregateFinalValue = succeeded.reduce((sum, r) => sum + r.finalValue, 0); + const aggregateReturn = + succeeded.length === 0 + ? 0 + : succeeded.reduce((sum, r) => sum + r.totalReturn, 0) / succeeded.length; + const averageAnnualizedReturn = + succeeded.length === 0 + ? 0 + : succeeded.reduce((sum, r) => sum + r.annualizedReturn, 0) / succeeded.length; + + return { + totalScenarios: results.length, + succeeded: succeeded.length, + failed, + aggregateFinalValue, + aggregateReturn, + averageAnnualizedReturn, + results, + }; + } +} + +export default BatchSimulator; diff --git a/src/batchVerifier.ts b/src/batchVerifier.ts index c0a342a..66e64cf 100644 --- a/src/batchVerifier.ts +++ b/src/batchVerifier.ts @@ -1,105 +1,149 @@ -import type { Invoice, BatchPayment } from "./types.js"; - -export interface BatchInvoiceValidation { - invoiceId: string; - valid: boolean; - errors: string[]; - token: string; - remainingAmount: bigint; - status: string; +import { EventEmitter } from 'events'; + +export interface PortfolioScenario { + id: string; + name?: string; + initialValue: number; + returns: number[]; + weights?: number[]; +} + +export interface ScenarioResult { + id: string; + name?: string; + success: boolean; + finalValue?: number; + totalReturn?: number; + error?: string; } -export interface BatchVerificationResult { - valid: boolean; - invoices: BatchInvoiceValidation[]; - commonToken: string | null; - errors: string[]; +export interface BatchSimulationSummary { + total: number; + succeeded: number; + failed: number; + results: ScenarioResult[]; + aggregateFinalValue: number; + aggregateReturn: number; +} + +export interface BatchSimulationOptions { + /** Continue running remaining scenarios when one fails. Defaults to true. */ + continueOnError?: boolean; + /** Optional per-scenario simulator. Defaults to compounding returns. */ + simulate?: (scenario: PortfolioScenario) => ScenarioResult; } /** - * Verify that all invoices in a batch share the same token and are in a - * payable state, before submitting the on-chain transaction. - * - * @param invoices - The invoices to verify (must already be resolved). - * @param payments - The proposed batch payments (invoiceId + amount pairs). + * Simulates a single portfolio scenario by compounding its periodic returns. */ -export function verifyBatchPayments( - invoices: Invoice[], - payments: BatchPayment[] -): BatchVerificationResult { - const errors: string[] = []; - const invoiceValidations: BatchInvoiceValidation[] = []; - - if (invoices.length === 0) { - return { valid: false, invoices: [], commonToken: null, errors: ["No invoices provided"] }; +export function simulateScenario(scenario: PortfolioScenario): ScenarioResult { + const { id, name, initialValue, returns } = scenario; + + if (typeof initialValue !== 'number' || !isFinite(initialValue)) { + return { id, name, success: false, error: 'initialValue must be a finite number' }; + } + if (!Array.isArray(returns)) { + return { id, name, success: false, error: 'returns must be an array' }; } - const invoiceMap = new Map(invoices.map((inv) => [inv.id, inv])); - const tokens = new Set(); - - for (const payment of payments) { - const invoice = invoiceMap.get(payment.invoiceId); - if (!invoice) { - invoiceValidations.push({ - invoiceId: payment.invoiceId, - valid: false, - errors: ["Invoice not found"], - token: "", - remainingAmount: 0n, - status: "unknown", - }); - errors.push(`Invoice ${payment.invoiceId}: not found`); - continue; + let value = initialValue; + for (let i = 0; i < returns.length; i++) { + const r = returns[i]; + if (typeof r !== 'number' || !isFinite(r)) { + return { id, name, success: false, error: `returns[${i}] must be a finite number` }; } + value *= 1 + r; + } - tokens.add(invoice.token); - const invoiceErrors: string[] = []; + const totalReturn = initialValue === 0 ? 0 : (value - initialValue) / initialValue; + return { id, name, success: true, finalValue: value, totalReturn }; +} - if (invoice.status !== "Pending") { - invoiceErrors.push(`Invoice status is "${invoice.status}", expected "Pending"`); - } +/** + * Runs a batch of portfolio scenarios and aggregates the results. + * Emits: 'batch:start', 'scenario:start', 'scenario:complete', + * 'scenario:error', 'batch:complete', 'batch:error'. + */ +export class BatchVerifier extends EventEmitter { + private readonly options: Required> & + Pick; - const totalOwed = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n); - const remaining = totalOwed - invoice.funded; - if (payment.amount <= 0n) { - invoiceErrors.push("Payment amount must be positive"); - } - if (payment.amount > remaining) { - invoiceErrors.push( - `Payment amount ${payment.amount} exceeds remaining ${remaining}` - ); + constructor(options: BatchSimulationOptions = {}) { + super(); + this.options = { + continueOnError: options.continueOnError !== false, + simulate: options.simulate, + }; + } + + /** + * Simulates all provided scenarios in a single batch. + */ + run(scenarios: PortfolioScenario[]): BatchSimulationSummary { + if (!Array.isArray(scenarios)) { + const error = new Error('scenarios must be an array'); + this.emit('batch:error', error); + throw error; } - invoiceValidations.push({ - invoiceId: payment.invoiceId, - valid: invoiceErrors.length === 0, - errors: invoiceErrors, - token: invoice.token, - remainingAmount: remaining, - status: invoice.status, - }); - - if (invoiceErrors.length > 0) { - errors.push(`Invoice ${payment.invoiceId}: ${invoiceErrors.join("; ")}`); + this.emit('batch:start', { total: scenarios.length }); + + const results: ScenarioResult[] = []; + const simulate = this.options.simulate ?? simulateScenario; + + for (const scenario of scenarios) { + this.emit('scenario:start', scenario); + let result: ScenarioResult; + try { + result = simulate(scenario); + } catch (err) { + result = { + id: scenario?.id, + name: scenario?.name, + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } + + results.push(result); + + if (result.success) { + this.emit('scenario:complete', result); + } else { + this.emit('scenario:error', result); + if (!this.options.continueOnError) { + const error = new Error(result.error ?? 'scenario failed'); + this.emit('batch:error', error); + throw error; + } + } } - } - const commonToken = tokens.size === 1 ? [...tokens][0]! : null; - if (tokens.size > 1) { - errors.push(`Invoices use different tokens: ${[...tokens].join(", ")}`); - } + const succeeded = results.filter((r) => r.success).length; + const failed = results.length - succeeded; + const aggregateFinalValue = results.reduce( + (sum, r) => sum + (r.success && typeof r.finalValue === 'number' ? r.finalValue : 0), + 0, + ); + const aggregateInitial = scenarios.reduce( + (sum, s) => sum + (typeof s?.initialValue === 'number' && isFinite(s.initialValue) ? s.initialValue : 0), + 0, + ); + const aggregateReturn = + aggregateInitial === 0 ? 0 : (aggregateFinalValue - aggregateInitial) / aggregateInitial; - const allValid = errors.length === 0; + const summary: BatchSimulationSummary = { + total: results.length, + succeeded, + failed, + results, + aggregateFinalValue, + aggregateReturn, + }; - return { valid: allValid, invoices: invoiceValidations, commonToken, errors }; + this.emit('batch:complete', summary); + return summary; + } } -/** - * Result returned by the client's verifyBatchPay method. - */ -export interface VerifyBatchPayResult { - valid: boolean; - invoices: BatchInvoiceValidation[]; - commonToken: string | null; - errors: string[]; -} +export default BatchVerifier; diff --git a/src/circuitBreaker.ts b/src/circuitBreaker.ts index 6c22b29..92dbd5c 100644 --- a/src/circuitBreaker.ts +++ b/src/circuitBreaker.ts @@ -25,12 +25,38 @@ export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = { export type CircuitBreakerState = "closed" | "open" | "half-open"; +/** Overall health status reported by the SDK health check. */ +export type HealthStatus = "healthy" | "degraded" | "unhealthy"; + +/** Detailed diagnostics produced by {@link CircuitBreaker.healthCheck}. */ +export interface HealthCheckDiagnostics { + /** Aggregate health derived from the circuit state and failure count. */ + status: HealthStatus; + /** Current circuit breaker state. */ + state: CircuitBreakerState; + /** Number of consecutive failures recorded. */ + failureCount: number; + /** Configured failure threshold. */ + failureThreshold: number; + /** Configured reset timeout in milliseconds. */ + resetTimeoutMs: number; + /** Timestamp of the last failure, or null if none recorded. */ + lastFailureTime: number | null; + /** Milliseconds since the last failure, or null if none recorded. */ + msSinceLastFailure: number | null; + /** Milliseconds remaining until the circuit may probe recovery, or null. */ + msUntilReset: number | null; + /** Human-readable summary of the current health. */ + message: string; +} + /** Event map for typed circuit breaker events. */ export interface CircuitBreakerEventMap { "circuit:open": []; "circuit:close": []; "circuit:half-open": []; stateChange: [{ from: CircuitBreakerState; to: CircuitBreakerState }]; + "health:check": [HealthCheckDiagnostics]; } /** @@ -132,6 +158,57 @@ export class CircuitBreaker extends EventEmitter { return false; } + /** + * Perform a health check and return detailed diagnostics about the + * circuit breaker's current condition. Emits a `health:check` event + * with the produced diagnostics. + */ + healthCheck(): HealthCheckDiagnostics { + const now = Date.now(); + const msSinceLastFailure = + this._lastFailureTime === null ? null : now - this._lastFailureTime; + + let msUntilReset: number | null = null; + if (this._state === "open" && this._lastFailureTime !== null) { + msUntilReset = Math.max( + 0, + this._config.resetTimeoutMs - (now - this._lastFailureTime), + ); + } + + let status: HealthStatus; + let message: string; + + if (this._state === "closed") { + status = "healthy"; + message = "Circuit is closed; requests are flowing normally."; + } else if (this._state === "half-open") { + status = "degraded"; + message = "Circuit is half-open; probing recovery with a single request."; + } else { + status = "unhealthy"; + message = + msUntilReset !== null && msUntilReset > 0 + ? `Circuit is open; retry in ${msUntilReset}ms.` + : "Circuit is open; cooldown elapsed, ready to probe recovery."; + } + + const diagnostics: HealthCheckDiagnostics = { + status, + state: this._state, + failureCount: this._failureCount, + failureThreshold: this._config.failureThreshold, + resetTimeoutMs: this._config.resetTimeoutMs, + lastFailureTime: this._lastFailureTime, + msSinceLastFailure, + msUntilReset, + message, + }; + + this.emit("health:check", diagnostics); + return diagnostics; + } + /** * Force-reset the circuit breaker to the CLOSED state. * Useful for manual recovery or testing. diff --git a/src/circuitBreakerMonitor.ts b/src/circuitBreakerMonitor.ts index d11f492..bcd53ad 100644 --- a/src/circuitBreakerMonitor.ts +++ b/src/circuitBreakerMonitor.ts @@ -9,6 +9,17 @@ interface BreakerEntry { threshold: number; } +export interface HealthCheckDiagnostics { + healthy: boolean; + totalBreakers: number; + openBreakers: string[]; + halfOpenBreakers: string[]; + closedBreakers: string[]; + totalFailures: number; + breakers: CircuitBreakerStatus[]; + checkedAt: number; +} + export class CircuitBreakerMonitor extends EventEmitter { private _breakers = new Map(); @@ -65,6 +76,46 @@ export class CircuitBreakerMonitor extends EventEmitter { return out; } + /** + * Perform a health check across all registered breakers and return + * detailed diagnostics. Emits "healthCheck" with the diagnostics and + * "healthCheckError" if the check itself throws. + */ + healthCheck(): HealthCheckDiagnostics { + this.emit("healthCheckStart"); + try { + const breakers = this.getStatus(); + const openBreakers: string[] = []; + const halfOpenBreakers: string[] = []; + const closedBreakers: string[] = []; + let totalFailures = 0; + + for (const b of breakers) { + totalFailures += b.failureCount; + if (b.state === "open") openBreakers.push(b.endpoint); + else if (b.state === "half-open") halfOpenBreakers.push(b.endpoint); + else closedBreakers.push(b.endpoint); + } + + const diagnostics: HealthCheckDiagnostics = { + healthy: openBreakers.length === 0 && halfOpenBreakers.length === 0, + totalBreakers: breakers.length, + openBreakers, + halfOpenBreakers, + closedBreakers, + totalFailures, + breakers, + checkedAt: Date.now(), + }; + + this.emit("healthCheck", diagnostics); + return diagnostics; + } catch (err) { + this.emit("healthCheckError", err); + throw err; + } + } + /** Reset a breaker to closed state */ reset(endpoint: string): void { const b = this._breakers.get(endpoint);