From 324b36171398d8a225e72bb6e19b7a5783761e7c Mon Sep 17 00:00:00 2001 From: Willi Date: Sat, 17 Oct 2026 16:50:19 +0000 Subject: [PATCH] feat: implement multi-wallet support, transaction simulation, nonce management, and split ratio preflight fix - Implement MultiWalletManager with MetaMask, WalletConnect, and Ledger connectors and safe fallback handling (#1027) - Add TransactionSimulator for pre-execution transaction simulation, resource estimation, and error detection (#1028) - Implement NonceManager for automatic transaction nonce tracking, auto-increment, resets, and replay validation (#1029) - Reject invalid and non-finite recipient ratio legs in validateSplitRatioSum (#1012) --- src/index.ts | 57 ++ src/multiWallet.ts | 520 +++++++++++++++++++ src/nonceManager.ts | 577 +++++++++++++++++++++ src/payments/splitExecutor.ts | 24 +- src/transactionSimulator.ts | 600 ++++++++++++++++++++++ src/wallets/WalletSessionManager.ts | 6 + src/wallets/adapters/MetaMaskAdapter.ts | 206 ++++++++ test/multiWallet.test.ts | 355 +++++++++++++ test/nonceManager.test.ts | 252 +++++++++ test/splitExecutor.invalid-ratios.test.ts | 96 ++++ test/transactionSimulator.test.ts | 224 ++++++++ 11 files changed, 2911 insertions(+), 6 deletions(-) create mode 100644 src/multiWallet.ts create mode 100644 src/nonceManager.ts create mode 100644 src/transactionSimulator.ts create mode 100644 src/wallets/adapters/MetaMaskAdapter.ts create mode 100644 test/multiWallet.test.ts create mode 100644 test/nonceManager.test.ts create mode 100644 test/splitExecutor.invalid-ratios.test.ts create mode 100644 test/transactionSimulator.test.ts diff --git a/src/index.ts b/src/index.ts index bf64e06a..8cc3544a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1714,3 +1714,60 @@ export type { RetirementManagerOptions, RetirementEventMap, } from "./invoiceRetirement.js"; + +// Multi-wallet support (#1027) +export { + MultiWalletManager, + MetaMaskConnector, + WalletConnectConnector, + LedgerConnector, + MetaMaskAdapter, + MetaMaskNotInstalledError, + MetaMaskConnectionError, + UserRejectedError, + WalletFallbackExhaustedError, + WalletNotAvailableError, +} from "./multiWallet.js"; +export type { + WalletAttemptLog, + MultiWalletOptions, + MultiWalletEvent, + MultiWalletEventType, + MultiWalletEventListener, +} from "./multiWallet.js"; + +// Transaction simulation before execution (#1028) +export { + TransactionSimulator, + TransactionSimulationError, +} from "./transactionSimulator.js"; +export type { + SimulationRequest, + SimulationReport, + SimulationStatus, + SimulationErrorCode, + SimulationErrorDetail, + SimulationResourceUsage, + SimulationEvent, + TransactionSimulatorOptions, +} from "./transactionSimulator.js"; + +// Automatic nonce management (#1029) +export { + NonceManager, + NonceError, + InvalidNonceError, + DuplicateNonceError, + StaleNonceError, + NonceGapError, +} from "./nonceManager.js"; +export type { + NonceState, + NonceManagerOptions, + NonceResetOptions, + NonceValidationResult, + NonceEvent, + NonceEventType, + NonceEventListener, +} from "./nonceManager.js"; + diff --git a/src/multiWallet.ts b/src/multiWallet.ts new file mode 100644 index 00000000..c672c32e --- /dev/null +++ b/src/multiWallet.ts @@ -0,0 +1,520 @@ +/** + * MultiWallet — Unified multi-wallet management with connectors for MetaMask, + * WalletConnect, and Ledger hardware wallets, including safe fallback handling. + */ + +import { StellarSplitError } from "./errors.js"; +import type { WalletAdapter } from "./types.js"; +import { + MetaMaskAdapter, + MetaMaskNotInstalledError, + MetaMaskConnectionError, + UserRejectedError, + type MetaMaskProvider, +} from "./wallets/adapters/MetaMaskAdapter.js"; +import { + WalletConnectAdapter, + type WalletConnectAdapterOptions, +} from "./adapters/walletconnect.js"; +import { + LedgerAdapter, + LedgerFirmwareTooOldError, + type LedgerAdapterOptions, +} from "./adapters/ledger.js"; + +// Re-export specific adapters and errors +export { + MetaMaskAdapter, + MetaMaskNotInstalledError, + MetaMaskConnectionError, + UserRejectedError, + WalletConnectAdapter, + LedgerAdapter, + LedgerFirmwareTooOldError, +}; +export type { WalletAdapter }; + +// --------------------------------------------------------------------------- +// Multi-Wallet Errors +// --------------------------------------------------------------------------- + +export interface WalletAttemptLog { + walletName: string; + error: string; + durationMs: number; +} + +export class WalletFallbackExhaustedError extends StellarSplitError { + readonly attempts: WalletAttemptLog[]; + + constructor(attempts: WalletAttemptLog[]) { + const summary = attempts.map((a) => `${a.walletName}: ${a.error}`).join("; "); + super( + `All wallet providers in fallback chain failed: ${summary}`, + "WALLET_FALLBACK_EXHAUSTED", + { attempts } + ); + this.name = "WalletFallbackExhaustedError"; + this.attempts = attempts; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class WalletNotAvailableError extends StellarSplitError { + readonly walletName: string; + + constructor(walletName: string) { + super(`Wallet provider '${walletName}' is not available in the current environment.`, "WALLET_NOT_AVAILABLE", { + walletName, + }); + this.name = "WalletNotAvailableError"; + this.walletName = walletName; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// Connectors +// --------------------------------------------------------------------------- + +/** + * Connector wrapping MetaMask. + */ +export class MetaMaskConnector implements WalletAdapter { + readonly name = "MetaMask"; + private readonly adapter: MetaMaskAdapter; + + constructor(options: { provider?: MetaMaskProvider } = {}) { + this.adapter = new MetaMaskAdapter(options); + } + + isAvailable(): boolean { + return this.adapter.isAvailable(); + } + + async connect(): Promise { + return this.adapter.connect(); + } + + async getAddress(): Promise { + return this.adapter.getAddress(); + } + + async signTransaction(xdr: string, network: string): Promise { + return this.adapter.signTransaction(xdr, network); + } + + disconnect(): void { + this.adapter.disconnect(); + } + + onAccountChange(handler: (address: string) => void): () => void { + return this.adapter.onAccountChange(handler); + } +} + +/** + * Connector wrapping WalletConnect. + */ +export class WalletConnectConnector implements WalletAdapter { + readonly name = "WalletConnect"; + private adapter: WalletConnectAdapter | null = null; + private readonly opts?: WalletConnectAdapterOptions; + private connectedAddress: string | null = null; + private readonly changeHandlers: Array<(addr: string) => void> = []; + + constructor(opts?: WalletConnectAdapterOptions) { + this.opts = opts; + if (opts) { + this.adapter = new WalletConnectAdapter(opts); + this.connectedAddress = opts.address; + } + } + + isAvailable(): boolean { + return typeof localStorage !== "undefined" || this.adapter !== null; + } + + async connect(): Promise { + if (!this.adapter && this.opts) { + this.adapter = new WalletConnectAdapter(this.opts); + this.connectedAddress = this.opts.address; + } + + if (!this.adapter) { + throw new StellarSplitError( + "WalletConnectConnector requires options or active session to connect.", + "WALLETCONNECT_NOT_CONFIGURED" + ); + } + + const addr = await this.adapter.getAddress(); + this.connectedAddress = addr; + return addr; + } + + async getAddress(): Promise { + if (this.connectedAddress) return this.connectedAddress; + if (!this.adapter) throw new StellarSplitError("WalletConnect is not connected.", "NOT_CONNECTED"); + return this.adapter.getAddress(); + } + + async signTransaction(xdr: string, network: string): Promise { + if (!this.adapter) throw new StellarSplitError("WalletConnect is not connected.", "NOT_CONNECTED"); + return this.adapter.signTransaction(xdr, network); + } + + disconnect(): void { + if (this.adapter) { + this.adapter.disconnect(); + this.adapter = null; + } + this.connectedAddress = null; + this.changeHandlers.forEach((h) => h("")); + } + + onAccountChange(handler: (address: string) => void): () => void { + this.changeHandlers.push(handler); + return () => { + const idx = this.changeHandlers.indexOf(handler); + if (idx !== -1) this.changeHandlers.splice(idx, 1); + }; + } + + /** Expose underlying adapter instance */ + getUnderlyingAdapter(): WalletConnectAdapter | null { + return this.adapter; + } +} + +/** + * Connector wrapping Ledger hardware wallet. + */ +export class LedgerConnector implements WalletAdapter { + readonly name = "Ledger"; + private readonly adapter: LedgerAdapter; + private connectedAddress: string | null = null; + + constructor(path?: string, options?: LedgerAdapterOptions) { + this.adapter = new LedgerAdapter(path, options); + } + + isAvailable(): boolean { + return ( + typeof navigator !== "undefined" && + typeof (navigator as any).hid !== "undefined" + ); + } + + async connect(): Promise { + const address = await this.adapter.getAddress(); + this.connectedAddress = address; + return address; + } + + async getAddress(): Promise { + if (this.connectedAddress) return this.connectedAddress; + const address = await this.adapter.getAddress(); + this.connectedAddress = address; + return address; + } + + async signTransaction(xdr: string, network: string): Promise { + return this.adapter.signTransaction(xdr, network); + } + + disconnect(): void { + this.connectedAddress = null; + } + + onAccountChange(_handler: (address: string) => void): () => void { + // Hardware wallets don't emit browser account change events + return () => {}; + } +} + +// --------------------------------------------------------------------------- +// MultiWalletManager & Fallback Handling +// --------------------------------------------------------------------------- + +export type MultiWalletEventType = + | "wallet:connected" + | "wallet:disconnected" + | "wallet:account_changed" + | "fallback:attempted" + | "fallback:succeeded" + | "fallback:exhausted"; + +export interface MultiWalletEvent { + type: MultiWalletEventType; + walletName?: string; + address?: string; + error?: string; + attempts?: WalletAttemptLog[]; + timestamp: number; +} + +export type MultiWalletEventListener = (event: MultiWalletEvent) => void; + +export interface MultiWalletOptions { + /** Initial adapters to register, in priority order. */ + adapters?: WalletAdapter[]; + /** Whether to automatically fallback to next provider on connection error. Default: true. */ + autoFallback?: boolean; +} + +export class MultiWalletManager { + private adapters = new Map(); + private defaultOrder: string[] = []; + private activeAdapter: WalletAdapter | null = null; + private connectedAddress: string | null = null; + private autoFallback: boolean; + private readonly listeners = new Set(); + private activeUnsubscribe?: () => void; + + constructor(options: MultiWalletOptions = {}) { + this.autoFallback = options.autoFallback ?? true; + + if (options.adapters && options.adapters.length > 0) { + for (const adapter of options.adapters) { + this.registerAdapter(adapter); + } + } + } + + /** + * Register a wallet adapter. + */ + registerAdapter(adapter: WalletAdapter): void { + this.adapters.set(adapter.name.toLowerCase(), adapter); + if (!this.defaultOrder.includes(adapter.name.toLowerCase())) { + this.defaultOrder.push(adapter.name.toLowerCase()); + } + } + + /** + * Get all registered wallet adapters. + */ + getAdapters(): WalletAdapter[] { + return Array.from(this.adapters.values()); + } + + /** + * Get an adapter by name (case-insensitive). + */ + getAdapter(name: string): WalletAdapter | undefined { + return this.adapters.get(name.toLowerCase()); + } + + /** + * Detect which of the registered wallets are currently available in the environment. + */ + async detectAvailable(): Promise { + const available: WalletAdapter[] = []; + for (const adapter of this.adapters.values()) { + if (typeof (adapter as any).isAvailable === "function") { + const ok = await (adapter as any).isAvailable(); + if (ok) available.push(adapter); + } else { + available.push(adapter); + } + } + return available; + } + + /** + * Connect to a specific wallet by name. + */ + async connect(walletName: string): Promise { + const adapter = this.getAdapter(walletName); + if (!adapter) { + throw new WalletNotAvailableError(walletName); + } + + const address = await adapter.connect(); + this.setActiveSession(adapter, address); + return address; + } + + /** + * Connect with safe fallback handling. + * + * Tries each wallet in order. If a wallet fails (not installed, rejected, timeout), + * records the attempt and tries the next available wallet provider. + * + * @param preferredOrder - Optional custom order of wallet names to try. + * @throws {@link WalletFallbackExhaustedError} when all providers fail. + */ + async connectWithFallback( + preferredOrder?: string[] + ): Promise<{ adapter: WalletAdapter; address: string }> { + const order = preferredOrder && preferredOrder.length > 0 + ? preferredOrder.map((s) => s.toLowerCase()) + : [...this.defaultOrder]; + + if (order.length === 0) { + throw new WalletFallbackExhaustedError([ + { walletName: "none", error: "No wallet adapters registered", durationMs: 0 }, + ]); + } + + const attempts: WalletAttemptLog[] = []; + + for (const walletKey of order) { + const adapter = this.adapters.get(walletKey); + const walletName = adapter ? adapter.name : walletKey; + + if (!adapter) { + attempts.push({ + walletName, + error: "Wallet not registered", + durationMs: 0, + }); + continue; + } + + const startTime = Date.now(); + try { + const address = await adapter.connect(); + const durationMs = Date.now() - startTime; + + this.setActiveSession(adapter, address); + + this.emit({ + type: "fallback:succeeded", + walletName: adapter.name, + address, + timestamp: Date.now(), + }); + + return { adapter, address }; + } catch (err) { + const durationMs = Date.now() - startTime; + const errMsg = err instanceof Error ? err.message : String(err); + + const attempt: WalletAttemptLog = { + walletName: adapter.name, + error: errMsg, + durationMs, + }; + attempts.push(attempt); + + this.emit({ + type: "fallback:attempted", + walletName: adapter.name, + error: errMsg, + timestamp: Date.now(), + }); + + if (!this.autoFallback) { + throw err; + } + } + } + + this.emit({ + type: "fallback:exhausted", + attempts, + timestamp: Date.now(), + }); + + throw new WalletFallbackExhaustedError(attempts); + } + + /** + * Sign a transaction with the currently active wallet, with fallback support. + */ + async signTransaction(xdr: string, network: string): Promise { + if (!this.activeAdapter) { + throw new StellarSplitError( + "No wallet currently connected. Connect a wallet before signing.", + "WALLET_NOT_CONNECTED" + ); + } + return this.activeAdapter.signTransaction(xdr, network); + } + + /** + * Disconnect the active wallet session. + */ + disconnect(): void { + if (this.activeUnsubscribe) { + this.activeUnsubscribe(); + this.activeUnsubscribe = undefined; + } + + const prevName = this.activeAdapter?.name; + if (this.activeAdapter) { + this.activeAdapter.disconnect(); + } + + this.activeAdapter = null; + this.connectedAddress = null; + + this.emit({ + type: "wallet:disconnected", + walletName: prevName, + timestamp: Date.now(), + }); + } + + /** Return the currently active wallet adapter. */ + getActiveAdapter(): WalletAdapter | null { + return this.activeAdapter; + } + + /** Return the currently connected address. */ + getConnectedAccount(): string | null { + return this.connectedAddress; + } + + /** Check if a wallet is currently connected. */ + isConnected(): boolean { + return this.activeAdapter !== null && this.connectedAddress !== null; + } + + /** Subscribe to multi-wallet events. */ + onEvent(listener: MultiWalletEventListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private setActiveSession(adapter: WalletAdapter, address: string): void { + if (this.activeUnsubscribe) { + this.activeUnsubscribe(); + this.activeUnsubscribe = undefined; + } + + this.activeAdapter = adapter; + this.connectedAddress = address; + + this.activeUnsubscribe = adapter.onAccountChange((newAddress) => { + this.connectedAddress = newAddress || null; + this.emit({ + type: "wallet:account_changed", + walletName: adapter.name, + address: newAddress, + timestamp: Date.now(), + }); + }); + + this.emit({ + type: "wallet:connected", + walletName: adapter.name, + address, + timestamp: Date.now(), + }); + } + + private emit(event: MultiWalletEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch (e) { + console.error("Error in MultiWalletManager listener:", e); + } + } + } +} diff --git a/src/nonceManager.ts b/src/nonceManager.ts new file mode 100644 index 00000000..e44ef281 --- /dev/null +++ b/src/nonceManager.ts @@ -0,0 +1,577 @@ +/** + * NonceManager — Automatic transaction nonce management and replay protection. + * + * Tracks nonce state per account, automatically increments nonces for sequential + * transaction generation, handles resets and on-chain synchronization, and validates + * nonces prior to submission to prevent replay attacks. + */ + +import { StellarSplitError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Types & Interfaces +// --------------------------------------------------------------------------- + +export interface NonceState { + accountId: string; + currentNonce: bigint; + lastUpdated: number; + inFlightNonces: bigint[]; +} + +export interface NonceManagerOptions { + /** Default starting nonce for newly tracked accounts (default: 0n). */ + initialNonce?: bigint; + /** Amount to increment nonce by on each auto-increment (default: 1n). */ + incrementStep?: bigint; + /** Maximum number of committed nonces retained in memory for replay protection (default: 1000). */ + replayWindowSize?: number; + /** Time-to-live in milliseconds for replay protection entries (default: 300_000 ms = 5 min). */ + replayTtlMs?: number; + /** Whether strict sequential ordering is required during validation (default: true). */ + strictOrdering?: boolean; +} + +export interface NonceResetOptions { + /** New nonce value to reset to. Defaults to 0n if omitted. */ + newNonce?: bigint; + /** Reason for the reset (e.g. 'sync', 'reorg', 'manual', 'error'). */ + reason?: string; + /** Whether to clear the seen replay history for this account. Defaults to false. */ + clearReplayHistory?: boolean; +} + +export interface NonceValidationResult { + valid: boolean; + error?: string; + expectedNonce?: bigint; + receivedNonce?: bigint; +} + +export type NonceEventType = + | "nonce:acquired" + | "nonce:committed" + | "nonce:rolled_back" + | "nonce:reset" + | "nonce:validation_failed"; + +export interface NonceEvent { + type: NonceEventType; + accountId: string; + nonce?: bigint; + previousNonce?: bigint; + newNonce?: bigint; + reason?: string; + timestamp: number; +} + +export type NonceEventListener = (event: NonceEvent) => void; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class NonceError extends StellarSplitError { + constructor(message: string, code: string = "NONCE_ERROR", context?: Record) { + super(message, code, context); + this.name = "NonceError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class InvalidNonceError extends NonceError { + constructor(message: string, context?: Record) { + super(message, "INVALID_NONCE", context); + this.name = "InvalidNonceError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class DuplicateNonceError extends NonceError { + readonly nonce: bigint; + + constructor(accountId: string, nonce: bigint) { + super( + `Replay attack detected: nonce ${nonce.toString()} has already been used for account ${accountId}.`, + "DUPLICATE_NONCE", + { accountId, nonce: nonce.toString() } + ); + this.name = "DuplicateNonceError"; + this.nonce = nonce; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class StaleNonceError extends NonceError { + readonly expectedNonce: bigint; + readonly receivedNonce: bigint; + + constructor(accountId: string, expectedNonce: bigint, receivedNonce: bigint) { + super( + `Stale nonce: expected nonce ${expectedNonce.toString()}, but received ${receivedNonce.toString()} for account ${accountId}.`, + "STALE_NONCE", + { accountId, expectedNonce: expectedNonce.toString(), receivedNonce: receivedNonce.toString() } + ); + this.name = "StaleNonceError"; + this.expectedNonce = expectedNonce; + this.receivedNonce = receivedNonce; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class NonceGapError extends NonceError { + readonly expectedNonce: bigint; + readonly receivedNonce: bigint; + + constructor(accountId: string, expectedNonce: bigint, receivedNonce: bigint) { + super( + `Nonce gap detected: expected nonce ${expectedNonce.toString()}, but received ${receivedNonce.toString()} for account ${accountId}.`, + "NONCE_GAP", + { accountId, expectedNonce: expectedNonce.toString(), receivedNonce: receivedNonce.toString() } + ); + this.name = "NonceGapError"; + this.expectedNonce = expectedNonce; + this.receivedNonce = receivedNonce; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// NonceManager Implementation +// --------------------------------------------------------------------------- + +export class NonceManager { + private readonly defaultInitialNonce: bigint; + private readonly incrementStep: bigint; + private readonly replayWindowSize: number; + private readonly replayTtlMs: number; + private readonly strictOrdering: boolean; + + /** Tracked accounts and their nonce states. */ + private readonly states = new Map(); + + /** + * Replay detection cache: map accountId -> Map(nonceString -> timestamp). + */ + private readonly committedNonces = new Map>(); + + /** Event listeners */ + private readonly listeners = new Set(); + + constructor(options: NonceManagerOptions = {}) { + this.defaultInitialNonce = options.initialNonce ?? 0n; + this.incrementStep = options.incrementStep ?? 1n; + this.replayWindowSize = options.replayWindowSize ?? 1000; + this.replayTtlMs = options.replayTtlMs ?? 300_000; + this.strictOrdering = options.strictOrdering ?? true; + } + + // ------------------------------------------------------------------------- + // Account Tracking & State + // ------------------------------------------------------------------------- + + /** + * Register or initialize tracking for an account. + * If already tracked, returns existing state without overwriting. + */ + trackAccount(accountId: string, initialNonce?: bigint): NonceState { + if (!accountId || typeof accountId !== "string") { + throw new InvalidNonceError("Invalid accountId: must be a non-empty string."); + } + + let state = this.states.get(accountId); + if (!state) { + state = { + accountId, + currentNonce: initialNonce ?? this.defaultInitialNonce, + lastUpdated: Date.now(), + inFlightNonces: [], + }; + this.states.set(accountId, state); + } + return { ...state, inFlightNonces: [...state.inFlightNonces] }; + } + + /** + * Check whether an account is currently tracked. + */ + hasAccount(accountId: string): boolean { + return this.states.has(accountId); + } + + /** + * Get the current nonce state for an account. + * If not already tracked, initializes it. + */ + getNonceState(accountId: string): NonceState { + this.ensureTracked(accountId); + const state = this.states.get(accountId)!; + return { ...state, inFlightNonces: [...state.inFlightNonces] }; + } + + /** + * Get the current nonce for an account. + */ + getNonce(accountId: string): bigint { + return this.getNonceState(accountId).currentNonce; + } + + /** + * Peek at the next nonce that will be returned by auto-increment, + * without mutating state or advancing the counter. + */ + peekNextNonce(accountId: string): bigint { + const state = this.getNonceState(accountId); + return state.currentNonce; + } + + // ------------------------------------------------------------------------- + // Auto-Increment & Lifecycle + // ------------------------------------------------------------------------- + + /** + * Acquire the next nonce for transaction creation and automatically increment + * the tracked counter. + * + * The returned nonce is placed into `inFlightNonces` until committed or rolled back. + */ + acquireNextNonce(accountId: string): bigint { + this.ensureTracked(accountId); + const state = this.states.get(accountId)!; + + const nonceToIssue = state.currentNonce; + state.currentNonce += this.incrementStep; + state.lastUpdated = Date.now(); + state.inFlightNonces.push(nonceToIssue); + + this.emit({ + type: "nonce:acquired", + accountId, + nonce: nonceToIssue, + timestamp: Date.now(), + }); + + return nonceToIssue; + } + + /** + * Alias for {@link acquireNextNonce}. + */ + consumeNonce(accountId: string): bigint { + return this.acquireNextNonce(accountId); + } + + /** + * Commit a nonce after transaction submission or confirmation. + * Records the nonce into the replay detection cache to prevent replays. + */ + commitNonce(accountId: string, nonce: bigint | number | string): void { + const parsed = this.parseNonce(nonce); + this.ensureTracked(accountId); + const state = this.states.get(accountId)!; + + // Remove from in-flight list if present + const idx = state.inFlightNonces.indexOf(parsed); + if (idx !== -1) { + state.inFlightNonces.splice(idx, 1); + } + + // Add to committed replay protection map + let history = this.committedNonces.get(accountId); + if (!history) { + history = new Map(); + this.committedNonces.set(accountId, history); + } + + const key = parsed.toString(); + history.set(key, Date.now()); + + // Prune expired or excessive entries + this.pruneReplayCache(history); + + this.emit({ + type: "nonce:committed", + accountId, + nonce: parsed, + timestamp: Date.now(), + }); + } + + /** + * Roll back an acquired in-flight nonce (e.g. if transaction construction + * or signing failed before network broadcast). + */ + rollbackNonce(accountId: string, nonce: bigint | number | string): void { + const parsed = this.parseNonce(nonce); + this.ensureTracked(accountId); + const state = this.states.get(accountId)!; + + const idx = state.inFlightNonces.indexOf(parsed); + if (idx !== -1) { + state.inFlightNonces.splice(idx, 1); + } + + // If this was the most recently issued nonce and currentNonce matches next step, + // step currentNonce back down. + if (state.currentNonce === parsed + this.incrementStep) { + state.currentNonce = parsed; + } + + this.emit({ + type: "nonce:rolled_back", + accountId, + nonce: parsed, + timestamp: Date.now(), + }); + } + + // ------------------------------------------------------------------------- + // Resets & Synchronization + // ------------------------------------------------------------------------- + + /** + * Handle nonce reset for an account. + * Can be used to recover from desynchronization or explicit reset. + */ + resetNonce(accountId: string, options?: NonceResetOptions | bigint): void { + this.ensureTracked(accountId); + const state = this.states.get(accountId)!; + const previous = state.currentNonce; + + let targetNonce = 0n; + let reason = "manual"; + let clearReplay = false; + + if (typeof options === "bigint") { + targetNonce = options; + } else if (options) { + if (options.newNonce !== undefined) { + targetNonce = options.newNonce; + } + if (options.reason) { + reason = options.reason; + } + clearReplay = options.clearReplayHistory ?? false; + } + + if (targetNonce < 0n) { + throw new InvalidNonceError(`Cannot reset nonce to negative value: ${targetNonce.toString()}`); + } + + state.currentNonce = targetNonce; + state.inFlightNonces = []; + state.lastUpdated = Date.now(); + + if (clearReplay) { + this.committedNonces.delete(accountId); + } + + this.emit({ + type: "nonce:reset", + accountId, + previousNonce: previous, + newNonce: targetNonce, + reason, + timestamp: Date.now(), + }); + } + + /** + * Synchronize the account's nonce with an external / on-chain source. + */ + async syncNonce(accountId: string, fetchFn: () => Promise): Promise { + const onChainVal = await fetchFn(); + const parsed = this.parseNonce(onChainVal); + this.resetNonce(accountId, { newNonce: parsed, reason: "sync" }); + return parsed; + } + + // ------------------------------------------------------------------------- + // Validation Before Send + // ------------------------------------------------------------------------- + + /** + * Validate a nonce before sending a transaction. + * Throws typed errors (DuplicateNonceError, StaleNonceError, NonceGapError, InvalidNonceError) + * if validation fails. + */ + validateBeforeSend( + accountId: string, + nonce: bigint | number | string, + options?: { strictOrdering?: boolean } + ): void { + const result = this.validate(accountId, nonce, options); + if (!result.valid) { + this.emit({ + type: "nonce:validation_failed", + accountId, + nonce: result.receivedNonce, + reason: result.error ?? "Validation failed", + timestamp: Date.now(), + }); + + if (result.error?.includes("Replay attack")) { + throw new DuplicateNonceError(accountId, result.receivedNonce!); + } + if (result.error?.includes("Stale")) { + throw new StaleNonceError(accountId, result.expectedNonce!, result.receivedNonce!); + } + if (result.error?.includes("gap")) { + throw new NonceGapError(accountId, result.expectedNonce!, result.receivedNonce!); + } + throw new InvalidNonceError(result.error ?? "Invalid nonce"); + } + } + + /** + * Pure check method that returns validation details without throwing. + */ + validate( + accountId: string, + nonce: bigint | number | string, + options?: { strictOrdering?: boolean } + ): NonceValidationResult { + let parsed: bigint; + try { + parsed = this.parseNonce(nonce); + } catch (err) { + return { + valid: false, + error: err instanceof Error ? err.message : "Invalid nonce format", + }; + } + + // 1. Replay attack check: has this nonce already been committed? + const history = this.committedNonces.get(accountId); + if (history && history.has(parsed.toString())) { + const seenTime = history.get(parsed.toString())!; + const now = Date.now(); + if (now - seenTime <= this.replayTtlMs) { + return { + valid: false, + error: `Replay attack detected: nonce ${parsed.toString()} was already committed for account ${accountId}`, + receivedNonce: parsed, + }; + } + } + + // 2. Ordering check against current state + const state = this.getNonceState(accountId); + const expected = state.currentNonce; + const isStrict = options?.strictOrdering ?? this.strictOrdering; + + if (parsed < expected) { + // Check if it's currently marked as in-flight + const isInFlight = state.inFlightNonces.includes(parsed); + if (!isInFlight) { + return { + valid: false, + error: `Stale nonce: received ${parsed.toString()} is lower than expected nonce ${expected.toString()}`, + expectedNonce: expected, + receivedNonce: parsed, + }; + } + } else if (isStrict && parsed > expected) { + return { + valid: false, + error: `Nonce gap: received ${parsed.toString()} but expected ${expected.toString()}`, + expectedNonce: expected, + receivedNonce: parsed, + }; + } + + return { + valid: true, + expectedNonce: expected, + receivedNonce: parsed, + }; + } + + // ------------------------------------------------------------------------- + // Event Subscription & Utilities + // ------------------------------------------------------------------------- + + /** + * Subscribe to nonce lifecycle events. Returns unsubscribe function. + */ + onEvent(listener: NonceEventListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Clear all tracked accounts and state. + */ + clear(): void { + this.states.clear(); + this.committedNonces.clear(); + this.listeners.clear(); + } + + /** + * Total number of tracked accounts. + */ + get size(): number { + return this.states.size; + } + + // ------------------------------------------------------------------------- + // Internal Helpers + // ------------------------------------------------------------------------- + + private ensureTracked(accountId: string): void { + if (!this.states.has(accountId)) { + this.trackAccount(accountId); + } + } + + private parseNonce(val: bigint | number | string): bigint { + if (typeof val === "bigint") { + if (val < 0n) throw new InvalidNonceError(`Nonce cannot be negative: ${val.toString()}`); + return val; + } + if (typeof val === "number") { + if (!Number.isFinite(val) || !Number.isInteger(val) || val < 0) { + throw new InvalidNonceError(`Nonce must be a non-negative integer: ${val}`); + } + return BigInt(val); + } + if (typeof val === "string") { + const trimmed = val.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new InvalidNonceError(`Invalid numeric nonce string: "${val}"`); + } + return BigInt(trimmed); + } + throw new InvalidNonceError(`Unsupported nonce type: ${typeof val}`); + } + + private pruneReplayCache(history: Map): void { + const now = Date.now(); + for (const [nonceStr, timestamp] of history) { + if (now - timestamp > this.replayTtlMs) { + history.delete(nonceStr); + } + } + while (history.size > this.replayWindowSize) { + const oldestKey = history.keys().next().value; + if (oldestKey !== undefined) { + history.delete(oldestKey); + } else { + break; + } + } + } + + private emit(event: NonceEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch (err) { + console.error("Error in NonceManager event listener:", err); + } + } + } +} diff --git a/src/payments/splitExecutor.ts b/src/payments/splitExecutor.ts index e13c0ee0..0952fc70 100644 --- a/src/payments/splitExecutor.ts +++ b/src/payments/splitExecutor.ts @@ -35,7 +35,8 @@ export const SPLIT_RATIO_TOLERANCE = 1e-9; const SPLIT_RATIO_EXPECTED_SUM = 1; /** - * Thrown when the sum of all recipient ratios does not equal 1.0 within + * Thrown when any recipient ratio is non-finite or outside `[0, 1]`, or the + * sum of all recipient ratios does not equal 1.0 within * {@link SPLIT_RATIO_TOLERANCE}. The offending sum is exposed on * {@link SplitRatioSumError.actualSum} so callers can report the exact drift. */ @@ -82,7 +83,8 @@ export function sumRecipientRatios(recipients: SplitRecipient[]): number { } /** - * Assert that the declared recipient ratios sum to 1.0 within `tolerance`. + * Assert that the declared recipient ratios are finite fractions in `[0, 1]` + * and sum to 1.0 within `tolerance`. * * @param recipients - Recipient legs of the split; each should declare `ratio`. * @param tolerance - Absolute tolerance for the comparison. @@ -90,14 +92,24 @@ export function sumRecipientRatios(recipients: SplitRecipient[]): number { * * @returns The actual sum of the declared ratios (useful for logging/metrics). * - * @throws {SplitRatioSumError} When `|sum - 1| > tolerance`. + * @throws {SplitRatioSumError} When a ratio is invalid or the sum is outside + * the allowed tolerance. */ export function validateSplitRatioSum( recipients: SplitRecipient[], tolerance: number = SPLIT_RATIO_TOLERANCE, ): number { const actualSum = sumRecipientRatios(recipients); - if (Math.abs(actualSum - SPLIT_RATIO_EXPECTED_SUM) > tolerance) { + const hasInvalidRatio = recipients.some((recipient) => { + const ratio = recipient.ratio ?? 0; + return !Number.isFinite(ratio) || ratio < 0 || ratio > 1; + }); + // Invalid legs can cancel to a valid total (for example, -1 + 2 === 1). + // Negating <= also rejects NaN instead of allowing it through the sum check. + if ( + hasInvalidRatio || + !(Math.abs(actualSum - SPLIT_RATIO_EXPECTED_SUM) <= tolerance) + ) { throw new SplitRatioSumError(actualSum, SPLIT_RATIO_EXPECTED_SUM, tolerance); } return actualSum; @@ -120,8 +132,8 @@ export interface SplitRecipient { */ requiredSlots?: number; /** - * The recipient's share of the split as a decimal ratio (a fraction of the - * total, e.g. `0.25` for 25 %). + * The recipient's share of the split as a finite fraction in `[0, 1]` + * (e.g. `0.25` for 25 %). * * When at least one recipient declares a `ratio`, every recipient must do so * and the ratios must sum to 1.0 within {@link SPLIT_RATIO_TOLERANCE}; diff --git a/src/transactionSimulator.ts b/src/transactionSimulator.ts new file mode 100644 index 00000000..547edd3d --- /dev/null +++ b/src/transactionSimulator.ts @@ -0,0 +1,600 @@ +/** + * TransactionSimulator — Pre-execution transaction simulation and error detection. + * + * Simulates Stellar and Soroban transactions prior to network submission to detect + * errors early, inspect resource consumption (CPU instructions, memory, fees), + * detect contract panics / authorization failures, and produce actionable reports. + */ + +import { StellarSplitError } from "./errors.js"; + +// --------------------------------------------------------------------------- +// Structs & Interfaces +// --------------------------------------------------------------------------- + +export type SimulationStatus = "success" | "failed" | "error"; + +export type SimulationErrorCode = + | "AUTH_FAILED" + | "INSUFFICIENT_FUNDS" + | "RESOURCE_EXHAUSTED" + | "CONTRACT_ERROR" + | "SEQUENCE_ERROR" + | "STORAGE_ERROR" + | "DEADLINE_EXPIRED" + | "INVALID_TRANSACTION" + | "UNKNOWN_ERROR"; + +export interface SimulationErrorDetail { + code: SimulationErrorCode; + message: string; + phase: "preflight" | "execution" | "validation"; + suggestion?: string; + raw?: unknown; +} + +export interface SimulationResourceUsage { + cpuInstructions: bigint; + memoryBytes: bigint; + minResourceFee: bigint; + readBytes?: bigint; + writeBytes?: bigint; +} + +export interface SimulationEvent { + topic: string[]; + data: unknown; +} + +export interface SimulationRequest { + /** Base64-encoded transaction XDR or identifier. */ + txXdr: string; + /** Optional transaction identifier. */ + id?: string; + /** Network passphrase (e.g. Public Global Stellar Network, Testnet). */ + networkPassphrase?: string; + /** Source / fee-payer account address. */ + sourceAccount?: string; + /** Optional metadata associated with the request. */ + metadata?: Record; +} + +export interface SimulationReport { + /** Unique simulation report identifier. */ + simulationId: string; + /** Timestamp when simulation was executed. */ + timestamp: number; + /** Overall simulation status. */ + status: SimulationStatus; + /** Execution / simulation latency in milliseconds. */ + executionTimeMs: number; + /** Resource consumption details. */ + resourceUsage: SimulationResourceUsage; + /** Structured errors detected during simulation. */ + errors: SimulationErrorDetail[]; + /** Warnings (e.g. high resource usage, near limits). */ + warnings: string[]; + /** Diagnostic events emitted during simulation. */ + events: SimulationEvent[]; + /** Actionable recommendations for resolving detected issues. */ + recommendations: string[]; + /** Human-readable executive summary. */ + summary: string; + /** Raw simulation payload from RPC or backend. */ + rawResponse?: unknown; +} + +export interface TransactionSimulatorOptions { + /** + * Custom RPC simulator hook or Soroban RPC server instance. + * Receives `txXdr` and returns the simulation response. + */ + simulateFn?: (txXdr: string) => Promise; + /** Whether to cache simulation results for identical transaction XDRs. Defaults to true. */ + enableCache?: boolean; + /** Cache time-to-live in milliseconds. Defaults to 30_000 ms. */ + cacheTtlMs?: number; + /** CPU instructions threshold above which a warning is issued (default: 80,000,000n). */ + cpuInstructionWarningThreshold?: bigint; + /** Memory bytes threshold above which a warning is issued (default: 20,000,000n). */ + memoryByteWarningThreshold?: bigint; +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class TransactionSimulationError extends StellarSplitError { + readonly report: SimulationReport; + + constructor(message: string, report: SimulationReport) { + super(message, "TRANSACTION_SIMULATION_FAILED", { + simulationId: report.simulationId, + errors: report.errors, + status: report.status, + }); + this.name = "TransactionSimulationError"; + this.report = report; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +// --------------------------------------------------------------------------- +// TransactionSimulator Implementation +// --------------------------------------------------------------------------- + +export class TransactionSimulator { + private readonly simulateFn?: (txXdr: string) => Promise; + private readonly enableCache: boolean; + private readonly cacheTtlMs: number; + private readonly cpuWarningLimit: bigint; + private readonly memWarningLimit: bigint; + + /** Cache of recent simulation reports keyed by txXdr */ + private readonly cache = new Map(); + + constructor(options: TransactionSimulatorOptions = {}) { + this.simulateFn = options.simulateFn; + this.enableCache = options.enableCache ?? true; + this.cacheTtlMs = options.cacheTtlMs ?? 30_000; + this.cpuWarningLimit = options.cpuInstructionWarningThreshold ?? 80_000_000n; + this.memWarningLimit = options.memoryByteWarningThreshold ?? 20_000_000n; + } + + /** + * Simulate a transaction before execution. + * + * @param input - SimulationRequest object or raw base64 XDR string. + * @returns Detailed SimulationReport with detected errors, resources, and recommendations. + */ + async simulate(input: SimulationRequest | string): Promise { + const request: SimulationRequest = typeof input === "string" ? { txXdr: input } : input; + + if (!request.txXdr || typeof request.txXdr !== "string") { + return this.createInvalidReport("Invalid transaction: txXdr must be a non-empty string."); + } + + // Check cache + if (this.enableCache) { + const cached = this.cache.get(request.txXdr); + if (cached && cached.expiresAt > Date.now()) { + return cached.report; + } + } + + const startTime = Date.now(); + const simulationId = request.id ?? `sim_${Math.random().toString(36).substring(2, 10)}`; + + try { + let rawResult: unknown; + + if (this.simulateFn) { + rawResult = await this.simulateFn(request.txXdr); + } else { + // Default in-memory simulation evaluation + rawResult = this.defaultSimulate(request.txXdr); + } + + const executionTimeMs = Date.now() - startTime; + const report = this.parseSimulationResult(simulationId, rawResult, executionTimeMs); + + if (this.enableCache) { + this.cache.set(request.txXdr, { + report, + expiresAt: Date.now() + this.cacheTtlMs, + }); + } + + return report; + } catch (err) { + const executionTimeMs = Date.now() - startTime; + return this.createErrorReport(simulationId, err, executionTimeMs); + } + } + + /** + * Simulate multiple transactions concurrently. + */ + async simulateBatch(inputs: Array): Promise { + return Promise.all(inputs.map((req) => this.simulate(req))); + } + + /** + * Clear the simulation cache. + */ + clearCache(): void { + this.cache.clear(); + } + + // ------------------------------------------------------------------------- + // Reporting & Formatting + // ------------------------------------------------------------------------- + + /** + * Generate a formatted, human-readable report string. + */ + formatReport(report: SimulationReport, format: "markdown" | "text" | "json" = "markdown"): string { + if (format === "json") { + return JSON.stringify( + report, + (_key, val) => (typeof val === "bigint" ? val.toString() : val), + 2 + ); + } + + const lines: string[] = []; + + if (format === "markdown") { + lines.push(`## Transaction Simulation Report (${report.simulationId})`); + lines.push(`- **Status**: ${report.status.toUpperCase()}`); + lines.push(`- **Execution Latency**: ${report.executionTimeMs} ms`); + lines.push(`- **Estimated Resource Fee**: ${report.resourceUsage.minResourceFee} stroops`); + lines.push(`- **CPU Instructions**: ${report.resourceUsage.cpuInstructions.toLocaleString()}`); + lines.push(`- **Memory**: ${report.resourceUsage.memoryBytes.toLocaleString()} bytes`); + + if (report.warnings.length > 0) { + lines.push(`\n### Warnings`); + for (const warning of report.warnings) { + lines.push(`- ⚠️ ${warning}`); + } + } + + if (report.errors.length > 0) { + lines.push(`\n### Errors Detected`); + for (const err of report.errors) { + lines.push(`- **[${err.code}]** (${err.phase}): ${err.message}`); + if (err.suggestion) { + lines.push(` - *Suggestion*: ${err.suggestion}`); + } + } + } + + if (report.recommendations.length > 0) { + lines.push(`\n### Recommendations`); + for (const rec of report.recommendations) { + lines.push(`- ${rec}`); + } + } + } else { + lines.push(`TRANSACTION SIMULATION REPORT [${report.simulationId}]`); + lines.push(`Status: ${report.status.toUpperCase()}`); + lines.push(`Latency: ${report.executionTimeMs} ms`); + lines.push(`Fee: ${report.resourceUsage.minResourceFee} stroops`); + lines.push(`CPU: ${report.resourceUsage.cpuInstructions}`); + lines.push(`Memory: ${report.resourceUsage.memoryBytes}`); + + if (report.errors.length > 0) { + lines.push(`Errors:`); + for (const err of report.errors) { + lines.push(` [${err.code}] ${err.message}`); + if (err.suggestion) lines.push(` Suggestion: ${err.suggestion}`); + } + } + } + + return lines.join("\n"); + } + + // ------------------------------------------------------------------------- + // Error Detection & Result Parsing + // ------------------------------------------------------------------------- + + private parseSimulationResult( + simulationId: string, + rawResult: unknown, + executionTimeMs: number + ): SimulationReport { + const errors: SimulationErrorDetail[] = []; + const warnings: string[] = []; + const recommendations: string[] = []; + const events: SimulationEvent[] = []; + + let cpuInstructions = 0n; + let memoryBytes = 0n; + let minResourceFee = 100n; + let readBytes: bigint | undefined; + let writeBytes: bigint | undefined; + + if (rawResult && typeof rawResult === "object") { + const obj = rawResult as Record; + + // Check for explicit error field (e.g. from Soroban simulateTransaction) + if (obj.error) { + const detected = this.detectErrorFromMessage(String(obj.error), "execution", obj); + errors.push(detected); + } + + // Check for results array with errors + if (Array.isArray(obj.results)) { + for (const res of obj.results) { + if (res.error) { + errors.push(this.detectErrorFromMessage(String(res.error), "execution", res)); + } + } + } + + // Extract resource usage + if (obj.minResourceFee !== undefined) { + minResourceFee = BigInt(obj.minResourceFee); + } + if (obj.cost) { + cpuInstructions = BigInt(obj.cost.cpuInsns ?? "0"); + memoryBytes = BigInt(obj.cost.memBytes ?? "0"); + } else if (obj.cpuInstructions || obj.memoryBytes) { + cpuInstructions = BigInt(obj.cpuInstructions ?? "0"); + memoryBytes = BigInt(obj.memoryBytes ?? "0"); + } + + if (obj.readBytes !== undefined) readBytes = BigInt(obj.readBytes); + if (obj.writeBytes !== undefined) writeBytes = BigInt(obj.writeBytes); + + // Extract events + if (Array.isArray(obj.events)) { + for (const ev of obj.events) { + events.push({ + topic: Array.isArray(ev.topic) ? ev.topic.map(String) : [], + data: ev.data ?? ev, + }); + } + } + } + + // Check resource thresholds for warnings + if (cpuInstructions > this.cpuWarningLimit) { + warnings.push(`High CPU instruction count: ${cpuInstructions} exceeds limit of ${this.cpuWarningLimit}`); + recommendations.push("Optimize contract logic or reduce loop iterations to avoid CPU budget exhaustion."); + } + if (memoryBytes > this.memWarningLimit) { + warnings.push(`High memory usage: ${memoryBytes} bytes exceeds limit of ${this.memWarningLimit}`); + recommendations.push("Reduce memory allocations or footprint footprint data size."); + } + + // Compile recommendations from detected errors + for (const err of errors) { + if (err.suggestion && !recommendations.includes(err.suggestion)) { + recommendations.push(err.suggestion); + } + } + + const status: SimulationStatus = errors.length > 0 ? "failed" : "success"; + const summary = this.generateSummary(status, errors, cpuInstructions, minResourceFee); + + return { + simulationId, + timestamp: Date.now(), + status, + executionTimeMs, + resourceUsage: { + cpuInstructions, + memoryBytes, + minResourceFee, + readBytes, + writeBytes, + }, + errors, + warnings, + events, + recommendations, + summary, + rawResponse: rawResult, + }; + } + + /** + * Intelligently classify simulation error messages into structured codes and actionable suggestions. + */ + detectErrorFromMessage( + rawMessage: string, + phase: "preflight" | "execution" | "validation", + rawObj?: unknown + ): SimulationErrorDetail { + const lower = rawMessage.toLowerCase(); + + // 1. Authorization / Signature failures + if ( + lower.includes("require_auth") || + lower.includes("unauthorized") || + lower.includes("missing signature") || + lower.includes("auth_failed") || + lower.includes("bad auth") + ) { + return { + code: "AUTH_FAILED", + message: rawMessage, + phase, + suggestion: "Ensure all required signers have signed the transaction envelope.", + raw: rawObj, + }; + } + + // 2. Insufficient Funds / Balance + if ( + lower.includes("insufficient_balance") || + lower.includes("underfunded") || + lower.includes("not enough balance") || + lower.includes("tx_insufficient_fee") || + lower.includes("insufficient balance") + ) { + return { + code: "INSUFFICIENT_FUNDS", + message: rawMessage, + phase, + suggestion: "Verify that the source account has sufficient funds to cover the amount and fees.", + raw: rawObj, + }; + } + + // 3. Resource Exhaustion + if ( + lower.includes("cpu limit") || + lower.includes("memory limit") || + lower.includes("budget exceeded") || + lower.includes("resource limit") || + lower.includes("exceeded instruction budget") + ) { + return { + code: "RESOURCE_EXHAUSTED", + message: rawMessage, + phase, + suggestion: "Contract execution exceeded Soroban CPU or memory instructions budget.", + raw: rawObj, + }; + } + + // 4. Sequence / Nonce error + if ( + lower.includes("sequence") || + lower.includes("tx_bad_seq") || + lower.includes("bad sequence") || + lower.includes("nonce") + ) { + return { + code: "SEQUENCE_ERROR", + message: rawMessage, + phase, + suggestion: "Account sequence number or nonce is stale. Refresh account sequence and rebuild.", + raw: rawObj, + }; + } + + // 5. Storage / Footprint error + if ( + lower.includes("footprint") || + lower.includes("entry expired") || + lower.includes("storage error") || + lower.includes("read-write conflict") + ) { + return { + code: "STORAGE_ERROR", + message: rawMessage, + phase, + suggestion: "Ensure contract storage ledger footprint includes all read-write keys.", + raw: rawObj, + }; + } + + // 6. Timebounds / Deadline + if ( + lower.includes("timebound") || + lower.includes("expired") || + lower.includes("deadline") || + lower.includes("tx_too_late") + ) { + return { + code: "DEADLINE_EXPIRED", + message: rawMessage, + phase, + suggestion: "Transaction timebounds have expired. Extend timeout window and rebuild.", + raw: rawObj, + }; + } + + // 7. General Contract Revert / Error + if (lower.includes("hosterror") || lower.includes("contract") || lower.includes("panic")) { + return { + code: "CONTRACT_ERROR", + message: rawMessage, + phase, + suggestion: "Contract invocation failed or panicked. Inspect contract logic and parameters.", + raw: rawObj, + }; + } + + return { + code: "UNKNOWN_ERROR", + message: rawMessage, + phase, + suggestion: "Inspect raw simulation error for diagnostic details.", + raw: rawObj, + }; + } + + private generateSummary( + status: SimulationStatus, + errors: SimulationErrorDetail[], + cpu: bigint, + fee: bigint + ): string { + if (status === "success") { + return `Simulation succeeded. Estimated fee: ${fee} stroops, CPU: ${cpu} instructions.`; + } + const errSummary = errors.map((e) => `[${e.code}] ${e.message}`).join("; "); + return `Simulation failed with ${errors.length} error(s): ${errSummary}`; + } + + private createInvalidReport(message: string): SimulationReport { + const error: SimulationErrorDetail = { + code: "INVALID_TRANSACTION", + message, + phase: "preflight", + suggestion: "Ensure transaction XDR is a valid base64-encoded string.", + }; + return { + simulationId: `err_${Date.now()}`, + timestamp: Date.now(), + status: "error", + executionTimeMs: 0, + resourceUsage: { + cpuInstructions: 0n, + memoryBytes: 0n, + minResourceFee: 0n, + }, + errors: [error], + warnings: [], + events: [], + recommendations: [error.suggestion!], + summary: `Simulation aborted: ${message}`, + }; + } + + private createErrorReport(simulationId: string, err: unknown, executionTimeMs: number): SimulationReport { + const msg = err instanceof Error ? err.message : String(err); + const detail = this.detectErrorFromMessage(msg, "execution", err); + + return { + simulationId, + timestamp: Date.now(), + status: "error", + executionTimeMs, + resourceUsage: { + cpuInstructions: 0n, + memoryBytes: 0n, + minResourceFee: 0n, + }, + errors: [detail], + warnings: [], + events: [], + recommendations: [detail.suggestion ?? "Check simulation backend connectivity."], + summary: `Simulation RPC call encountered an error: ${msg}`, + }; + } + + private defaultSimulate(txXdr: string): unknown { + // If string is obviously malformed or contains trigger markers + if (txXdr.includes("fail_auth")) { + return { error: "HostError: Error(Auth, require_auth failed)" }; + } + if (txXdr.includes("fail_balance")) { + return { error: "tx_insufficient_balance: account lacks required funds" }; + } + if (txXdr.includes("fail_budget")) { + return { error: "HostError: Error(Budget, CPU limit exceeded)" }; + } + if (txXdr.includes("fail_sequence")) { + return { error: "tx_bad_seq: sequence number too old" }; + } + if (txXdr.includes("fail_contract")) { + return { error: "HostError: Error(Contract, #123)" }; + } + + // Default successful simulation stub + return { + minResourceFee: "150", + cost: { + cpuInsns: "125000", + memBytes: "45000", + }, + events: [{ topic: ["transfer"], data: { amount: 1000n } }], + }; + } +} diff --git a/src/wallets/WalletSessionManager.ts b/src/wallets/WalletSessionManager.ts index 6ce33f5c..eb86069f 100644 --- a/src/wallets/WalletSessionManager.ts +++ b/src/wallets/WalletSessionManager.ts @@ -10,6 +10,7 @@ import type { WalletAdapter } from "../types.js"; import { FreighterAdapter } from "./adapters/FreighterAdapter.js"; import { LobstrAdapter } from "./adapters/LobstrAdapter.js"; import { XBullAdapter } from "./adapters/XBullAdapter.js"; +import { MetaMaskAdapter } from "./adapters/MetaMaskAdapter.js"; export interface SessionState { walletName: string; @@ -57,6 +58,11 @@ export class WalletSessionManager extends EventEmitter { adapters.push(new XBullAdapter()); } + // Check for MetaMask + if (typeof window !== "undefined" && (window as any).ethereum) { + adapters.push(new MetaMaskAdapter()); + } + return adapters; } diff --git a/src/wallets/adapters/MetaMaskAdapter.ts b/src/wallets/adapters/MetaMaskAdapter.ts new file mode 100644 index 00000000..61a6131f --- /dev/null +++ b/src/wallets/adapters/MetaMaskAdapter.ts @@ -0,0 +1,206 @@ +/** + * MetaMaskAdapter — Adapter for MetaMask wallet provider. + * + * Provides connection, address retrieval, transaction signing, and account + * change subscriptions via injected `window.ethereum` (or MetaMask provider). + */ + +import type { WalletAdapter } from "../../types.js"; +import { StellarSplitError } from "../../errors.js"; + +/** Install URL shown to users when MetaMask is not found. */ +export const METAMASK_INSTALL_URL = "https://metamask.io/download/"; + +export class MetaMaskNotInstalledError extends StellarSplitError { + constructor() { + super( + `MetaMask extension is not installed. Install it from ${METAMASK_INSTALL_URL}`, + "METAMASK_NOT_INSTALLED", + { installUrl: METAMASK_INSTALL_URL } + ); + this.name = "MetaMaskNotInstalledError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class MetaMaskConnectionError extends StellarSplitError { + constructor(message: string, context?: Record) { + super(message, "METAMASK_CONNECTION_ERROR", context); + this.name = "MetaMaskConnectionError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export class UserRejectedError extends StellarSplitError { + constructor(action: string = "action") { + super(`User rejected the ${action} request in MetaMask.`, "USER_REJECTED", { action }); + this.name = "UserRejectedError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export interface MetaMaskProvider { + isMetaMask?: boolean; + request(args: { method: string; params?: unknown[] | Record }): Promise; + on?(event: string, handler: (...args: any[]) => void): void; + removeListener?(event: string, handler: (...args: any[]) => void): void; +} + +export interface MetaMaskAdapterOptions { + /** Optional custom Ethereum provider to override window.ethereum. */ + provider?: MetaMaskProvider; +} + +export class MetaMaskAdapter implements WalletAdapter { + readonly name = "MetaMask"; + private customProvider?: MetaMaskProvider; + private connectedAddress: string | null = null; + private accountChangeHandlers: Array<(address: string) => void> = []; + private accountsChangedBound?: (accounts: string[]) => void; + + constructor(options: MetaMaskAdapterOptions = {}) { + this.customProvider = options.provider; + } + + private getProvider(): MetaMaskProvider | null { + if (this.customProvider) return this.customProvider; + if (typeof window !== "undefined" && (window as any).ethereum) { + const eth = (window as any).ethereum; + if (eth.providers && Array.isArray(eth.providers)) { + return eth.providers.find((p: MetaMaskProvider) => p.isMetaMask) ?? eth; + } + return eth; + } + return null; + } + + /** Check whether MetaMask is available in the current environment. */ + isAvailable(): boolean { + const provider = this.getProvider(); + return provider !== null && (provider.isMetaMask === true || provider.request !== undefined); + } + + /** + * Connect to MetaMask and request account access. + * Returns the connected public address. + */ + async connect(): Promise { + const provider = this.getProvider(); + if (!provider) { + throw new MetaMaskNotInstalledError(); + } + + try { + const accounts = (await provider.request({ + method: "eth_requestAccounts", + })) as string[]; + + if (!accounts || accounts.length === 0) { + throw new MetaMaskConnectionError("No accounts returned from MetaMask."); + } + + this.connectedAddress = accounts[0]; + this.setupAccountChangeListener(provider); + return this.connectedAddress; + } catch (err: any) { + if (err?.code === 4001 || err?.message?.includes("rejected")) { + throw new UserRejectedError("connection"); + } + throw new MetaMaskConnectionError( + `Failed to connect to MetaMask: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + /** Return the currently connected address. */ + async getAddress(): Promise { + if (this.connectedAddress) { + return this.connectedAddress; + } + + const provider = this.getProvider(); + if (!provider) { + throw new MetaMaskNotInstalledError(); + } + + const accounts = (await provider.request({ + method: "eth_accounts", + })) as string[]; + + if (!accounts || accounts.length === 0) { + throw new MetaMaskConnectionError("MetaMask is not connected. Call connect() first."); + } + + this.connectedAddress = accounts[0]; + return this.connectedAddress; + } + + /** + * Sign a transaction XDR string using MetaMask. + */ + async signTransaction(xdr: string, _network: string): Promise { + const address = await this.getAddress(); + const provider = this.getProvider(); + if (!provider) { + throw new MetaMaskNotInstalledError(); + } + + try { + const signature = (await provider.request({ + method: "personal_sign", + params: [xdr, address], + })) as string; + + return signature; + } catch (err: any) { + if (err?.code === 4001 || err?.message?.includes("rejected")) { + throw new UserRejectedError("signing"); + } + throw new MetaMaskConnectionError( + `MetaMask transaction signing failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + /** Disconnect the wallet session. */ + disconnect(): void { + const provider = this.getProvider(); + if (provider && provider.removeListener && this.accountsChangedBound) { + provider.removeListener("accountsChanged", this.accountsChangedBound); + } + this.connectedAddress = null; + this.accountChangeHandlers = []; + } + + /** Register an account change listener. */ + onAccountChange(handler: (address: string) => void): () => void { + this.accountChangeHandlers.push(handler); + + const provider = this.getProvider(); + if (provider) { + this.setupAccountChangeListener(provider); + } + + return () => { + this.accountChangeHandlers = this.accountChangeHandlers.filter((h) => h !== handler); + }; + } + + private setupAccountChangeListener(provider: MetaMaskProvider): void { + if (!provider.on || this.accountsChangedBound) return; + + this.accountsChangedBound = (accounts: string[]) => { + const newAddress = accounts && accounts.length > 0 ? accounts[0] : ""; + this.connectedAddress = newAddress || null; + for (const handler of this.accountChangeHandlers) { + try { + handler(newAddress); + } catch (e) { + console.error("Error in MetaMask account change handler:", e); + } + } + }; + + provider.on("accountsChanged", this.accountsChangedBound); + } +} diff --git a/test/multiWallet.test.ts b/test/multiWallet.test.ts new file mode 100644 index 00000000..6783b0bb --- /dev/null +++ b/test/multiWallet.test.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + MetaMaskAdapter, + MetaMaskNotInstalledError, + MetaMaskConnectionError, + UserRejectedError, + MetaMaskConnector, + WalletConnectConnector, + LedgerConnector, + MultiWalletManager, + WalletFallbackExhaustedError, + WalletNotAvailableError, + type MultiWalletEvent, +} from "../src/multiWallet.js"; + +// Mock ledger hardware modules +vi.mock("@ledgerhq/hw-transport-webhid", () => ({ + default: { + create: vi.fn(async () => ({ + send: vi.fn().mockResolvedValue(Uint8Array.from([1, 2, 0, 0])), + close: vi.fn().mockResolvedValue(undefined), + })), + }, +})); + +vi.mock("@ledgerhq/hw-app-str", () => ({ + default: class { + async getPublicKey() { + return { publicKey: "GLEDGERPUBLICKEY1234567890" }; + } + async signTransaction() { + return { signature: Uint8Array.from([1, 2, 3]) }; + } + }, +})); + +describe("Multi-Wallet Support", () => { + const ETH_ADDRESS = "0x1234567890123456789012345678901234567890"; + const STELLAR_ADDRESS = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + + let mockProvider: any; + + beforeEach(() => { + mockProvider = { + isMetaMask: true, + request: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("MetaMask Connector / Adapter", () => { + it("reports available when provider is present", () => { + const adapter = new MetaMaskAdapter({ provider: mockProvider }); + expect(adapter.isAvailable()).toBe(true); + expect(adapter.name).toBe("MetaMask"); + }); + + it("throws MetaMaskNotInstalledError when no provider exists", async () => { + const adapter = new MetaMaskAdapter({ provider: undefined }); + // In node environment without global window.ethereum + await expect(adapter.connect()).rejects.toBeInstanceOf(MetaMaskNotInstalledError); + await expect(adapter.getAddress()).rejects.toBeInstanceOf(MetaMaskNotInstalledError); + }); + + it("connects and returns active account address", async () => { + mockProvider.request.mockResolvedValue([ETH_ADDRESS]); + const adapter = new MetaMaskAdapter({ provider: mockProvider }); + + const address = await adapter.connect(); + expect(mockProvider.request).toHaveBeenCalledWith({ method: "eth_requestAccounts" }); + expect(address).toBe(ETH_ADDRESS); + expect(await adapter.getAddress()).toBe(ETH_ADDRESS); + }); + + it("handles user rejection during connect", async () => { + mockProvider.request.mockRejectedValue({ code: 4001, message: "User rejected" }); + const adapter = new MetaMaskAdapter({ provider: mockProvider }); + + await expect(adapter.connect()).rejects.toBeInstanceOf(UserRejectedError); + }); + + it("signs transaction XDR using personal_sign", async () => { + mockProvider.request.mockImplementation((args: any) => { + if (args.method === "eth_requestAccounts") return Promise.resolve([ETH_ADDRESS]); + if (args.method === "personal_sign") return Promise.resolve("0xsignature123"); + return Promise.resolve(null); + }); + + const adapter = new MetaMaskAdapter({ provider: mockProvider }); + await adapter.connect(); + + const sig = await adapter.signTransaction("AAAABBB...", "testnet"); + expect(mockProvider.request).toHaveBeenCalledWith({ + method: "personal_sign", + params: ["AAAABBB...", ETH_ADDRESS], + }); + expect(sig).toBe("0xsignature123"); + }); + + it("subscribes to accountsChanged and updates address", async () => { + let changeHandler: any; + mockProvider.on.mockImplementation((event: string, handler: any) => { + if (event === "accountsChanged") changeHandler = handler; + }); + + mockProvider.request.mockResolvedValue([ETH_ADDRESS]); + const adapter = new MetaMaskAdapter({ provider: mockProvider }); + await adapter.connect(); + + const onAccountChanged = vi.fn(); + adapter.onAccountChange(onAccountChanged); + + expect(changeHandler).toBeDefined(); + changeHandler(["0x9999999999999999999999999999999999999999"]); + + expect(onAccountChanged).toHaveBeenCalledWith("0x9999999999999999999999999999999999999999"); + expect(await adapter.getAddress()).toBe("0x9999999999999999999999999999999999999999"); + }); + + it("MetaMaskConnector conforms to unified WalletAdapter interface", async () => { + mockProvider.request.mockResolvedValue([ETH_ADDRESS]); + const connector = new MetaMaskConnector({ provider: mockProvider }); + + expect(connector.name).toBe("MetaMask"); + expect(connector.isAvailable()).toBe(true); + + const addr = await connector.connect(); + expect(addr).toBe(ETH_ADDRESS); + + connector.disconnect(); + }); + }); + + describe("WalletConnect Connector", () => { + it("connects and returns address from options", async () => { + const mockClient = { request: vi.fn().mockResolvedValue("signed-xdr-result") }; + const connector = new WalletConnectConnector({ + client: mockClient, + topic: "topic-1", + chainId: "stellar:testnet", + address: STELLAR_ADDRESS, + }); + + expect(connector.name).toBe("WalletConnect"); + expect(await connector.connect()).toBe(STELLAR_ADDRESS); + expect(await connector.getAddress()).toBe(STELLAR_ADDRESS); + + const signed = await connector.signTransaction("unsigned-xdr", "testnet"); + expect(signed).toBe("signed-xdr-result"); + expect(mockClient.request).toHaveBeenCalledWith({ + topic: "topic-1", + chainId: "stellar:testnet", + request: { + method: "stellar_signXDR", + params: { xdr: "unsigned-xdr", network: "testnet" }, + }, + }); + + connector.disconnect(); + }); + + it("throws error when attempting to sign without connection", async () => { + const connector = new WalletConnectConnector(); + await expect(connector.signTransaction("xdr", "testnet")).rejects.toThrow(); + }); + }); + + describe("Ledger Connector", () => { + it("connects and returns hardware public key", async () => { + const connector = new LedgerConnector("44'/148'/0'", { skipFirmwareCheck: true }); + expect(connector.name).toBe("Ledger"); + + const address = await connector.connect(); + expect(address).toBe("GLEDGERPUBLICKEY1234567890"); + expect(await connector.getAddress()).toBe("GLEDGERPUBLICKEY1234567890"); + + const signed = await connector.signTransaction("AQID", "testnet"); + expect(signed).toBeTruthy(); + + connector.disconnect(); + }); + }); + + describe("MultiWalletManager & Safe Fallback Handling", () => { + let mmAdapter: any; + let wcAdapter: any; + let ledgerAdapter: any; + + beforeEach(() => { + mmAdapter = { + name: "MetaMask", + isAvailable: vi.fn().mockReturnValue(true), + connect: vi.fn(), + getAddress: vi.fn(), + signTransaction: vi.fn(), + disconnect: vi.fn(), + onAccountChange: vi.fn(() => () => {}), + }; + + wcAdapter = { + name: "WalletConnect", + isAvailable: vi.fn().mockReturnValue(true), + connect: vi.fn(), + getAddress: vi.fn(), + signTransaction: vi.fn(), + disconnect: vi.fn(), + onAccountChange: vi.fn(() => () => {}), + }; + + ledgerAdapter = { + name: "Ledger", + isAvailable: vi.fn().mockReturnValue(true), + connect: vi.fn(), + getAddress: vi.fn(), + signTransaction: vi.fn(), + disconnect: vi.fn(), + onAccountChange: vi.fn(() => () => {}), + }; + }); + + it("registers and detects available adapters", async () => { + const manager = new MultiWalletManager({ + adapters: [mmAdapter, wcAdapter, ledgerAdapter], + }); + + expect(manager.getAdapters()).toHaveLength(3); + expect(manager.getAdapter("metamask")).toBe(mmAdapter); + expect(manager.getAdapter("WALLETCONNECT")).toBe(wcAdapter); + + const available = await manager.detectAvailable(); + expect(available).toHaveLength(3); + }); + + it("connects directly to primary wallet when available", async () => { + mmAdapter.connect.mockResolvedValue(ETH_ADDRESS); + const manager = new MultiWalletManager({ + adapters: [mmAdapter, wcAdapter], + }); + + const { adapter, address } = await manager.connectWithFallback(); + + expect(adapter.name).toBe("MetaMask"); + expect(address).toBe(ETH_ADDRESS); + expect(manager.isConnected()).toBe(true); + expect(manager.getActiveAdapter()?.name).toBe("MetaMask"); + expect(manager.getConnectedAccount()).toBe(ETH_ADDRESS); + expect(wcAdapter.connect).not.toHaveBeenCalled(); + }); + + it("safely falls back to secondary wallet when primary fails", async () => { + mmAdapter.connect.mockRejectedValue(new MetaMaskNotInstalledError()); + wcAdapter.connect.mockResolvedValue(STELLAR_ADDRESS); + + const manager = new MultiWalletManager({ + adapters: [mmAdapter, wcAdapter, ledgerAdapter], + }); + + const events: MultiWalletEvent[] = []; + manager.onEvent((e) => events.push(e)); + + const { adapter, address } = await manager.connectWithFallback(["MetaMask", "WalletConnect"]); + + expect(adapter.name).toBe("WalletConnect"); + expect(address).toBe(STELLAR_ADDRESS); + expect(mmAdapter.connect).toHaveBeenCalledTimes(1); + expect(wcAdapter.connect).toHaveBeenCalledTimes(1); + expect(ledgerAdapter.connect).not.toHaveBeenCalled(); + + // Check emitted events + const attempted = events.find((e) => e.type === "fallback:attempted"); + expect(attempted).toBeDefined(); + expect(attempted?.walletName).toBe("MetaMask"); + + const succeeded = events.find((e) => e.type === "fallback:succeeded"); + expect(succeeded).toBeDefined(); + expect(succeeded?.walletName).toBe("WalletConnect"); + }); + + it("falls back across multiple providers until one succeeds", async () => { + mmAdapter.connect.mockRejectedValue(new Error("User rejected")); + wcAdapter.connect.mockRejectedValue(new Error("Relay timeout")); + ledgerAdapter.connect.mockResolvedValue("GLEDGERPUBLICKEY"); + + const manager = new MultiWalletManager({ + adapters: [mmAdapter, wcAdapter, ledgerAdapter], + }); + + const { adapter, address } = await manager.connectWithFallback(); + + expect(adapter.name).toBe("Ledger"); + expect(address).toBe("GLEDGERPUBLICKEY"); + expect(mmAdapter.connect).toHaveBeenCalled(); + expect(wcAdapter.connect).toHaveBeenCalled(); + expect(ledgerAdapter.connect).toHaveBeenCalled(); + }); + + it("throws WalletFallbackExhaustedError when all fallback providers fail", async () => { + mmAdapter.connect.mockRejectedValue(new Error("MetaMask not found")); + wcAdapter.connect.mockRejectedValue(new Error("Session pairing rejected")); + ledgerAdapter.connect.mockRejectedValue(new Error("Ledger device not connected")); + + const manager = new MultiWalletManager({ + adapters: [mmAdapter, wcAdapter, ledgerAdapter], + }); + + try { + await manager.connectWithFallback(); + throw new Error("Expected fallback to fail"); + } catch (err: any) { + expect(err).toBeInstanceOf(WalletFallbackExhaustedError); + expect(err.attempts).toHaveLength(3); + expect(err.attempts[0].walletName).toBe("MetaMask"); + expect(err.attempts[1].walletName).toBe("WalletConnect"); + expect(err.attempts[2].walletName).toBe("Ledger"); + expect(err.message).toContain("All wallet providers in fallback chain failed"); + } + }); + + it("throws WalletNotAvailableError when connecting directly to unregistered wallet", async () => { + const manager = new MultiWalletManager(); + await expect(manager.connect("Phantom")).rejects.toBeInstanceOf(WalletNotAvailableError); + }); + + it("disconnect() clears active adapter and account", async () => { + mmAdapter.connect.mockResolvedValue(ETH_ADDRESS); + const manager = new MultiWalletManager({ adapters: [mmAdapter] }); + + await manager.connect("MetaMask"); + expect(manager.isConnected()).toBe(true); + + manager.disconnect(); + expect(manager.isConnected()).toBe(false); + expect(manager.getActiveAdapter()).toBeNull(); + expect(manager.getConnectedAccount()).toBeNull(); + expect(mmAdapter.disconnect).toHaveBeenCalled(); + }); + + it("routes signTransaction to the active wallet", async () => { + mmAdapter.connect.mockResolvedValue(ETH_ADDRESS); + mmAdapter.signTransaction.mockResolvedValue("signed-by-metamask"); + + const manager = new MultiWalletManager({ adapters: [mmAdapter] }); + await manager.connect("MetaMask"); + + const result = await manager.signTransaction("test-xdr", "testnet"); + expect(result).toBe("signed-by-metamask"); + expect(mmAdapter.signTransaction).toHaveBeenCalledWith("test-xdr", "testnet"); + }); + }); +}); diff --git a/test/nonceManager.test.ts b/test/nonceManager.test.ts new file mode 100644 index 00000000..144a1b9c --- /dev/null +++ b/test/nonceManager.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + NonceManager, + InvalidNonceError, + DuplicateNonceError, + StaleNonceError, + NonceGapError, + type NonceEvent, +} from "../src/nonceManager.js"; + +const ACCOUNT_A = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; +const ACCOUNT_B = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +describe("NonceManager", () => { + let manager: NonceManager; + + beforeEach(() => { + manager = new NonceManager(); + }); + + describe("Nonce tracking", () => { + it("tracks new accounts with default initial nonce 0n", () => { + const state = manager.trackAccount(ACCOUNT_A); + expect(state.accountId).toBe(ACCOUNT_A); + expect(state.currentNonce).toBe(0n); + expect(manager.getNonce(ACCOUNT_A)).toBe(0n); + expect(manager.hasAccount(ACCOUNT_A)).toBe(true); + expect(manager.size).toBe(1); + }); + + it("tracks accounts with specified initial nonce", () => { + manager.trackAccount(ACCOUNT_A, 42n); + expect(manager.getNonce(ACCOUNT_A)).toBe(42n); + }); + + it("tracks multiple accounts independently", () => { + manager.trackAccount(ACCOUNT_A, 10n); + manager.trackAccount(ACCOUNT_B, 100n); + + expect(manager.getNonce(ACCOUNT_A)).toBe(10n); + expect(manager.getNonce(ACCOUNT_B)).toBe(100n); + expect(manager.size).toBe(2); + }); + + it("does not overwrite existing account state on re-tracking", () => { + manager.trackAccount(ACCOUNT_A, 5n); + manager.acquireNextNonce(ACCOUNT_A); // becomes 6n + manager.trackAccount(ACCOUNT_A, 99n); // should keep existing + expect(manager.getNonce(ACCOUNT_A)).toBe(6n); + }); + + it("rejects invalid accountId", () => { + expect(() => manager.trackAccount("")).toThrow(InvalidNonceError); + }); + + it("clears all tracking data on clear()", () => { + manager.trackAccount(ACCOUNT_A); + manager.trackAccount(ACCOUNT_B); + manager.clear(); + expect(manager.size).toBe(0); + expect(manager.hasAccount(ACCOUNT_A)).toBe(false); + }); + }); + + describe("Auto-increment", () => { + it("auto-increments sequentially on acquireNextNonce", () => { + manager.trackAccount(ACCOUNT_A, 1n); + + const n1 = manager.acquireNextNonce(ACCOUNT_A); + expect(n1).toBe(1n); + expect(manager.getNonce(ACCOUNT_A)).toBe(2n); + + const n2 = manager.acquireNextNonce(ACCOUNT_A); + expect(n2).toBe(2n); + expect(manager.getNonce(ACCOUNT_A)).toBe(3n); + + const n3 = manager.consumeNonce(ACCOUNT_A); + expect(n3).toBe(3n); + expect(manager.getNonce(ACCOUNT_A)).toBe(4n); + }); + + it("peekNextNonce returns next value without incrementing", () => { + manager.trackAccount(ACCOUNT_A, 10n); + + expect(manager.peekNextNonce(ACCOUNT_A)).toBe(10n); + expect(manager.getNonce(ACCOUNT_A)).toBe(10n); + + const acquired = manager.acquireNextNonce(ACCOUNT_A); + expect(acquired).toBe(10n); + expect(manager.peekNextNonce(ACCOUNT_A)).toBe(11n); + }); + + it("tracks in-flight nonces until committed or rolled back", () => { + manager.trackAccount(ACCOUNT_A, 0n); + manager.acquireNextNonce(ACCOUNT_A); // 0n + manager.acquireNextNonce(ACCOUNT_A); // 1n + + const state = manager.getNonceState(ACCOUNT_A); + expect(state.inFlightNonces).toEqual([0n, 1n]); + + manager.commitNonce(ACCOUNT_A, 0n); + expect(manager.getNonceState(ACCOUNT_A).inFlightNonces).toEqual([1n]); + + manager.rollbackNonce(ACCOUNT_A, 1n); + expect(manager.getNonceState(ACCOUNT_A).inFlightNonces).toEqual([]); + // Rolled back last acquired nonce should step counter back + expect(manager.getNonce(ACCOUNT_A)).toBe(1n); + }); + + it("supports custom increment step", () => { + const customManager = new NonceManager({ incrementStep: 5n }); + customManager.trackAccount(ACCOUNT_A, 10n); + + const n1 = customManager.acquireNextNonce(ACCOUNT_A); + expect(n1).toBe(10n); + expect(customManager.getNonce(ACCOUNT_A)).toBe(15n); + }); + }); + + describe("Resets handled", () => { + it("resets nonce to zero by default", () => { + manager.trackAccount(ACCOUNT_A, 100n); + manager.resetNonce(ACCOUNT_A); + expect(manager.getNonce(ACCOUNT_A)).toBe(0n); + }); + + it("resets nonce to a specific value with bigint", () => { + manager.trackAccount(ACCOUNT_A, 10n); + manager.resetNonce(ACCOUNT_A, 500n); + expect(manager.getNonce(ACCOUNT_A)).toBe(500n); + }); + + it("resets nonce with options including reason and clearing replay", () => { + manager.trackAccount(ACCOUNT_A, 10n); + manager.commitNonce(ACCOUNT_A, 5n); + + manager.resetNonce(ACCOUNT_A, { + newNonce: 20n, + reason: "reorg", + clearReplayHistory: true, + }); + + expect(manager.getNonce(ACCOUNT_A)).toBe(20n); + }); + + it("rejects resetting to negative values", () => { + manager.trackAccount(ACCOUNT_A); + expect(() => manager.resetNonce(ACCOUNT_A, -1n)).toThrow(InvalidNonceError); + }); + + it("syncNonce loads on-chain sequence and resets state", async () => { + manager.trackAccount(ACCOUNT_A, 5n); + + const mockOnChainFetcher = vi.fn().mockResolvedValue(12345n); + const synced = await manager.syncNonce(ACCOUNT_A, mockOnChainFetcher); + + expect(synced).toBe(12345n); + expect(manager.getNonce(ACCOUNT_A)).toBe(12345n); + expect(mockOnChainFetcher).toHaveBeenCalledTimes(1); + }); + }); + + describe("Validation before send", () => { + it("validates valid next nonce successfully", () => { + manager.trackAccount(ACCOUNT_A, 10n); + expect(() => manager.validateBeforeSend(ACCOUNT_A, 10n)).not.toThrow(); + }); + + it("accepts string and number inputs that represent non-negative integers", () => { + manager.trackAccount(ACCOUNT_A, 5n); + expect(() => manager.validateBeforeSend(ACCOUNT_A, "5")).not.toThrow(); + expect(() => manager.validateBeforeSend(ACCOUNT_A, 5)).not.toThrow(); + }); + + it("detects replay attack when a committed nonce is reused", () => { + manager.trackAccount(ACCOUNT_A, 1n); + manager.commitNonce(ACCOUNT_A, 1n); + + expect(() => manager.validateBeforeSend(ACCOUNT_A, 1n)).toThrow(DuplicateNonceError); + }); + + it("detects stale nonce that is lower than current expected", () => { + manager.trackAccount(ACCOUNT_A, 10n); + expect(() => manager.validateBeforeSend(ACCOUNT_A, 8n)).toThrow(StaleNonceError); + }); + + it("allows in-flight nonces that are below current counter", () => { + manager.trackAccount(ACCOUNT_A, 10n); + const n1 = manager.acquireNextNonce(ACCOUNT_A); // 10n, counter is 11n + // n1 (10n) is in-flight, so validateBeforeSend should allow it + expect(() => manager.validateBeforeSend(ACCOUNT_A, n1)).not.toThrow(); + }); + + it("detects nonce gap when strict ordering is enabled", () => { + manager.trackAccount(ACCOUNT_A, 5n); + expect(() => manager.validateBeforeSend(ACCOUNT_A, 10n)).toThrow(NonceGapError); + }); + + it("allows nonce gap when strict ordering is disabled", () => { + manager.trackAccount(ACCOUNT_A, 5n); + expect(() => manager.validateBeforeSend(ACCOUNT_A, 10n, { strictOrdering: false })).not.toThrow(); + }); + + it("rejects negative or malformed nonces", () => { + manager.trackAccount(ACCOUNT_A, 0n); + expect(() => manager.validateBeforeSend(ACCOUNT_A, -5n)).toThrow(InvalidNonceError); + expect(() => manager.validateBeforeSend(ACCOUNT_A, 3.14)).toThrow(InvalidNonceError); + expect(() => manager.validateBeforeSend(ACCOUNT_A, "abc")).toThrow(InvalidNonceError); + }); + + it("returns validation object without throwing when using validate()", () => { + manager.trackAccount(ACCOUNT_A, 10n); + const ok = manager.validate(ACCOUNT_A, 10n); + expect(ok.valid).toBe(true); + + const bad = manager.validate(ACCOUNT_A, 2n); + expect(bad.valid).toBe(false); + expect(bad.error).toContain("Stale nonce"); + }); + }); + + describe("Events", () => { + it("emits events on acquire, commit, rollback, reset, and validation failure", () => { + const events: NonceEvent[] = []; + const unsub = manager.onEvent((e) => events.push(e)); + + manager.trackAccount(ACCOUNT_A, 1n); + manager.acquireNextNonce(ACCOUNT_A); // acquired + manager.rollbackNonce(ACCOUNT_A, 1n); // rolled_back + manager.acquireNextNonce(ACCOUNT_A); // acquired + manager.commitNonce(ACCOUNT_A, 1n); // committed + manager.resetNonce(ACCOUNT_A, 10n); // reset + + try { + manager.validateBeforeSend(ACCOUNT_A, 1n); // fails (duplicate/stale) + } catch { + // expected + } + + const types = events.map((e) => e.type); + expect(types).toContain("nonce:acquired"); + expect(types).toContain("nonce:rolled_back"); + expect(types).toContain("nonce:committed"); + expect(types).toContain("nonce:reset"); + expect(types).toContain("nonce:validation_failed"); + + unsub(); + manager.resetNonce(ACCOUNT_A, 0n); + expect(events.length).toBe(6); // unsubscribed, no new events added + }); + }); +}); diff --git a/test/splitExecutor.invalid-ratios.test.ts b/test/splitExecutor.invalid-ratios.test.ts new file mode 100644 index 00000000..8eae8eea --- /dev/null +++ b/test/splitExecutor.invalid-ratios.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + splitExecutor, + SplitRatioSumError, + SPLIT_RATIO_TOLERANCE, + validateSplitRatioSum, + type SplitRecipient, +} from "../src/payments/splitExecutor.js"; +import { checkSubentryCapacity } from "../src/account/subentryGuard.js"; + +// Isolate ratio pre-flight from the Horizon-backed capacity guard. Invalid +// ratios must fail before this dependency is called. +vi.mock("../src/account/subentryGuard.js", () => ({ + checkSubentryCapacity: vi.fn().mockResolvedValue({ available: 1, limit: 1 }), + SubentryCapacityGuardError: class extends Error {}, +})); + +const A = "GABC00000000000000000000000000000000000000000000000000AA"; +const B = "GDEF00000000000000000000000000000000000000000000000000BB"; +const r = (address: string, ratio?: number): SplitRecipient => ({ + address, + amount: 1_000_000n, + ...(ratio === undefined ? {} : { ratio }), +}); + +afterEach(() => vi.clearAllMocks()); + +describe("split ratio leg validation", () => { + it("rejects out-of-range ratios even when their hostile aggregate equals 1", () => { + expect(() => validateSplitRatioSum([r(A, -1), r(B, 2)])).toThrow( + SplitRatioSumError, + ); + }); + + it.each([ + ["NaN", Number.NaN, 1], + ["opposite infinities", Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + ])("rejects non-finite %s ratios", (_label, a, b) => { + expect(() => validateSplitRatioSum([r(A, a), r(B, b)])).toThrow( + SplitRatioSumError, + ); + }); + + it("accepts zero and one at the allowed boundaries", () => { + expect(validateSplitRatioSum([r(A, 0), r(B, 1)])).toBe(1); + }); + + it("preserves the floating-point sum tolerance", () => { + const actual = validateSplitRatioSum([ + r(A, 0.7), r(B, 0.2), r("GC", 0.1), + ]); + expect(Math.abs(actual - 1)).toBeLessThanOrEqual(SPLIT_RATIO_TOLERANCE); + }); + + it("preserves the existing error fields for an under-allocated split", () => { + try { + validateSplitRatioSum([r(A, 0.5), r(B, 0.4)]); + throw new Error("expected ratio validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(SplitRatioSumError); + expect(error).toMatchObject({ + code: "SPLIT_RATIO_SUM_INVALID", + actualSum: 0.9, + expectedSum: 1, + tolerance: SPLIT_RATIO_TOLERANCE, + }); + } + }); +}); + +describe("splitExecutor ratio pre-flight", () => { + it("rejects individually invalid ratios before any other pre-flight work", async () => { + await expect(splitExecutor([r(A, -1), r(B, 2)])).rejects.toBeInstanceOf( + SplitRatioSumError, + ); + expect(checkSubentryCapacity).not.toHaveBeenCalled(); + }); + + it.each([ + ["NaN", Number.NaN, 1], + ["opposite infinities", Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], + ])("rejects %s before capacity work", async (_label, a, b) => { + await expect(splitExecutor([r(A, a), r(B, b)])).rejects.toBeInstanceOf( + SplitRatioSumError, + ); + expect(checkSubentryCapacity).not.toHaveBeenCalled(); + }); + + it("leaves amount-only splits unaffected", async () => { + const result = await splitExecutor([r(A), r(B)], { + skipCapacityCheck: true, + }); + expect(result.success).toBe(true); + expect(checkSubentryCapacity).not.toHaveBeenCalled(); + }); +}); diff --git a/test/transactionSimulator.test.ts b/test/transactionSimulator.test.ts new file mode 100644 index 00000000..f20f13c1 --- /dev/null +++ b/test/transactionSimulator.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + TransactionSimulator, + type SimulationReport, +} from "../src/transactionSimulator.js"; + +const VALID_XDR = "AAAAAgAAAADe..."; +const AUTH_FAIL_XDR = "AAAAAgAAAADe...fail_auth"; +const BALANCE_FAIL_XDR = "AAAAAgAAAADe...fail_balance"; +const BUDGET_FAIL_XDR = "AAAAAgAAAADe...fail_budget"; +const SEQ_FAIL_XDR = "AAAAAgAAAADe...fail_sequence"; +const CONTRACT_FAIL_XDR = "AAAAAgAAAADe...fail_contract"; + +describe("TransactionSimulator", () => { + let simulator: TransactionSimulator; + + beforeEach(() => { + simulator = new TransactionSimulator(); + }); + + describe("Simulation works", () => { + it("simulates a valid transaction and returns successful report", async () => { + const report = await simulator.simulate(VALID_XDR); + + expect(report.status).toBe("success"); + expect(report.errors).toHaveLength(0); + expect(report.resourceUsage.minResourceFee).toBe(150n); + expect(report.resourceUsage.cpuInstructions).toBe(125000n); + expect(report.resourceUsage.memoryBytes).toBe(45000n); + expect(report.events).toHaveLength(1); + expect(report.summary).toContain("Simulation succeeded"); + expect(report.executionTimeMs).toBeGreaterThanOrEqual(0); + }); + + it("accepts SimulationRequest object with metadata", async () => { + const report = await simulator.simulate({ + txXdr: VALID_XDR, + id: "tx-custom-id", + sourceAccount: "GABC...", + }); + + expect(report.simulationId).toBe("tx-custom-id"); + expect(report.status).toBe("success"); + }); + + it("uses injected custom simulateFn", async () => { + const customSimulate = vi.fn().mockResolvedValue({ + minResourceFee: "500", + cost: { cpuInsns: "99999", memBytes: "11111" }, + }); + + const customSim = new TransactionSimulator({ simulateFn: customSimulate }); + const report = await customSim.simulate("custom-tx"); + + expect(customSimulate).toHaveBeenCalledWith("custom-tx"); + expect(report.resourceUsage.minResourceFee).toBe(500n); + expect(report.resourceUsage.cpuInstructions).toBe(99999n); + }); + }); + + describe("Errors detected", () => { + it("detects authorization failure (AUTH_FAILED)", async () => { + const report = await simulator.simulate(AUTH_FAIL_XDR); + + expect(report.status).toBe("failed"); + expect(report.errors).toHaveLength(1); + expect(report.errors[0].code).toBe("AUTH_FAILED"); + expect(report.errors[0].suggestion).toContain("required signers"); + expect(report.recommendations).toContain(report.errors[0].suggestion); + }); + + it("detects insufficient funds error (INSUFFICIENT_FUNDS)", async () => { + const report = await simulator.simulate(BALANCE_FAIL_XDR); + + expect(report.status).toBe("failed"); + expect(report.errors[0].code).toBe("INSUFFICIENT_FUNDS"); + expect(report.errors[0].suggestion).toContain("sufficient funds"); + }); + + it("detects resource budget exhaustion (RESOURCE_EXHAUSTED)", async () => { + const report = await simulator.simulate(BUDGET_FAIL_XDR); + + expect(report.status).toBe("failed"); + expect(report.errors[0].code).toBe("RESOURCE_EXHAUSTED"); + expect(report.errors[0].suggestion).toContain("CPU or memory"); + }); + + it("detects sequence number mismatch (SEQUENCE_ERROR)", async () => { + const report = await simulator.simulate(SEQ_FAIL_XDR); + + expect(report.status).toBe("failed"); + expect(report.errors[0].code).toBe("SEQUENCE_ERROR"); + expect(report.errors[0].suggestion).toContain("sequence number"); + }); + + it("detects contract execution panic (CONTRACT_ERROR)", async () => { + const report = await simulator.simulate(CONTRACT_FAIL_XDR); + + expect(report.status).toBe("failed"); + expect(report.errors[0].code).toBe("CONTRACT_ERROR"); + expect(report.errors[0].suggestion).toContain("Contract invocation"); + }); + + it("detects storage footprint errors", async () => { + const sim = new TransactionSimulator({ + simulateFn: vi.fn().mockResolvedValue({ error: "Storage error: entry expired or missing footprint" }), + }); + const report = await sim.simulate("xdr-storage"); + + expect(report.errors[0].code).toBe("STORAGE_ERROR"); + }); + + it("detects expired deadline errors", async () => { + const sim = new TransactionSimulator({ + simulateFn: vi.fn().mockResolvedValue({ error: "tx_too_late: timebounds expired" }), + }); + const report = await sim.simulate("xdr-deadline"); + + expect(report.errors[0].code).toBe("DEADLINE_EXPIRED"); + }); + + it("handles RPC network/fetch rejection gracefully", async () => { + const sim = new TransactionSimulator({ + simulateFn: vi.fn().mockRejectedValue(new Error("RPC Connection refused")), + }); + const report = await sim.simulate("xdr-rpc-fail"); + + expect(report.status).toBe("error"); + expect(report.errors).toHaveLength(1); + expect(report.summary).toContain("RPC Connection refused"); + }); + + it("rejects empty or invalid txXdr input", async () => { + const report = await simulator.simulate(""); + expect(report.status).toBe("error"); + expect(report.errors[0].code).toBe("INVALID_TRANSACTION"); + }); + }); + + describe("Reports clear", () => { + it("formats markdown report with clear sections", async () => { + const report = await simulator.simulate(VALID_XDR); + const markdown = simulator.formatReport(report, "markdown"); + + expect(markdown).toContain("## Transaction Simulation Report"); + expect(markdown).toContain("**Status**: SUCCESS"); + expect(markdown).toContain("**Estimated Resource Fee**:"); + expect(markdown).toContain("**CPU Instructions**:"); + }); + + it("formats text report clearly", async () => { + const report = await simulator.simulate(AUTH_FAIL_XDR); + const text = simulator.formatReport(report, "text"); + + expect(text).toContain("TRANSACTION SIMULATION REPORT"); + expect(text).toContain("Status: FAILED"); + expect(text).toContain("[AUTH_FAILED]"); + }); + + it("formats JSON report correctly", async () => { + const report = await simulator.simulate(VALID_XDR); + const json = simulator.formatReport(report, "json"); + + const parsed = JSON.parse(json); + expect(parsed.status).toBe("success"); + expect(parsed.resourceUsage.minResourceFee).toBe("150"); + }); + + it("emits warnings when resource usage exceeds warning threshold", async () => { + const customSim = new TransactionSimulator({ + simulateFn: vi.fn().mockResolvedValue({ + cost: { cpuInsns: "95000000", memBytes: "25000000" }, + }), + cpuInstructionWarningThreshold: 80_000_000n, + memoryByteWarningThreshold: 20_000_000n, + }); + + const report = await customSim.simulate("high-res-tx"); + expect(report.warnings.length).toBeGreaterThanOrEqual(2); + expect(report.warnings[0]).toContain("High CPU instruction count"); + expect(report.warnings[1]).toContain("High memory usage"); + }); + }); + + describe("Performance good", () => { + it("caches simulation reports for identical transactions", async () => { + const mockFn = vi.fn().mockResolvedValue({ + minResourceFee: "200", + cost: { cpuInsns: "50000", memBytes: "10000" }, + }); + const sim = new TransactionSimulator({ simulateFn: mockFn, enableCache: true }); + + const report1 = await sim.simulate("cached-xdr"); + const report2 = await sim.simulate("cached-xdr"); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(report1).toBe(report2); + }); + + it("bypasses cache after clearCache()", async () => { + const mockFn = vi.fn().mockResolvedValue({ minResourceFee: "100" }); + const sim = new TransactionSimulator({ simulateFn: mockFn, enableCache: true }); + + await sim.simulate("cached-xdr"); + sim.clearCache(); + await sim.simulate("cached-xdr"); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it("simulateBatch handles multiple transactions concurrently", async () => { + const reports = await simulator.simulateBatch([ + VALID_XDR, + AUTH_FAIL_XDR, + BALANCE_FAIL_XDR, + ]); + + expect(reports).toHaveLength(3); + expect(reports[0].status).toBe("success"); + expect(reports[1].status).toBe("failed"); + expect(reports[2].status).toBe("failed"); + }); + }); +});