diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..2717d988 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,337 @@ +# StellarSplit SDK Architecture & Design Patterns + +Comprehensive architectural documentation, system topology, design patterns, and component interactions for `@stellar-split/sdk`. + +--- + +## Table of Contents + +1. [System Topology & High-Level Architecture](#1-system-topology--high-level-architecture) +2. [Directory & Package Structure](#2-directory--package-structure) +3. [Core Architectural Subsystems](#3-core-architectural-subsystems) + - [3.1 Client & Orchestration Layer](#31-client--orchestration-layer) + - [3.2 Transaction Building & Footprint Optimization](#32-transaction-building--footprint-optimization) + - [3.3 Resilience, Retry & Circuit Breaker Engine](#33-resilience-retry--circuit-breaker-engine) + - [3.4 Pluggable Key Vault & Signer Adapters](#34-pluggable-key-vault--signer-adapters) + - [3.5 Multi-Transport Event Streaming](#35-multi-transport-event-streaming) + - [3.6 Webhook Security & Ingestion Pipeline](#36-webhook-security--ingestion-pipeline) + - [3.7 Structured Debug Logging & Telemetry](#37-structured-debug-logging--telemetry) +4. [Design Patterns](#4-design-patterns) + - [Adapter Pattern](#41-adapter-pattern) + - [Circuit Breaker Pattern](#42-circuit-breaker-pattern) + - [Optimistic State & Cache Invalidation](#43-optimistic-state--cache-invalidation) + - [Typed Event-Driven Architecture](#44-typed-event-driven-architecture) + - [Plugin & Middleware Interceptors](#45-plugin--middleware-interceptors) +5. [End-to-End Sequence Diagrams](#5-end-to-end-sequence-diagrams) + - [Invoice Split & Disbursement Flow](#51-invoice-split--disbursement-flow) + - [Resilient RPC Failover with Circuit Breaker](#52-resilient-rpc-failover-with-circuit-breaker) + - [Webhook Signature & Replay Prevention](#53-webhook-signature--replay-prevention) +6. [Best Practices & Extension Guidelines](#6-best-practices--extension-guidelines) + +--- + +## 1. System Topology & High-Level Architecture + +The StellarSplit SDK serves as the primary TypeScript abstraction layer connecting enterprise backends, mobile clients, and web dApps to the StellarSplit Soroban smart contracts on the Stellar network. + +```mermaid +flowchart TD + subgraph Client Application Layer + App["Web / Node.js / Mobile Application"] + UIHooks["React Hooks & Context Providers"] + end + + subgraph StellarSplit SDK Core + Client["StellarSplitClient (Orchestrator)"] + PluginReg["PluginRegistry & Interceptors"] + Logger["SdkLogger (Structured Logging)"] + Telemetry["OpenTelemetry Spans & Metrics"] + + subgraph Resilience & State + Cache["SdkCache & OptimisticCache"] + Breaker["CircuitBreaker Engine"] + Retry["RetryPolicy & Backoff"] + end + + subgraph Transaction & Soroban Engine + TxBuild["TxBuilder & OperationChunker"] + Footprint["FootprintOptimizer"] + Xdr["XdrDecoder & EventParser"] + end + + subgraph Cryptography & Auth + Signer["Signer Adapters (KMS, Keypair, EncryptedFile)"] + Wallets["Wallet Adapters (Freighter, Lobstr, xBull, WC)"] + end + + subgraph Streaming & Ingestion + Stream["TypedEventEmitter & StreamDeduplicator"] + Transports["WebSocket / SSE / Poller Fallback"] + WebhookMW["WebhookMiddleware (HMAC + Replay Cache)"] + end + end + + subgraph Stellar Infrastructure + RPC["Soroban RPC Nodes (Primary / Fallback)"] + Horizon["Horizon REST API"] + Contract["StellarSplit Smart Contract (Soroban WASM)"] + end + + App --> Client + UIHooks --> Client + Client --> PluginReg + Client --> Logger + Client --> Cache + Client --> Breaker + Client --> TxBuild + TxBuild --> Footprint + TxBuild --> Signer + TxBuild --> Wallets + Breaker --> RPC + Client --> Horizon + Transports --> Horizon + Transports --> RPC + RPC --> Contract + WebhookMW --> Client +``` + +--- + +## 2. Directory & Package Structure + +The SDK is organized into clean, modular domain boundaries within `src/`: + +``` +src/ +├── client.ts # StellarSplitClient: Main unified client orchestrator +├── configValidator.ts # Configuration parsing, type coercion, and schema checks +├── logger.ts # SdkLogger: Structured levels, redact sensitive keys, formatters +├── errors.ts # Comprehensive typed error hierarchy with StellarSplitError base +├── types.ts # Domain models (Invoices, Recipients, Escrows, Milestones, Splits) +├── plugin.ts # Plugin lifecycle hooks and interceptor registry +│ +├── builder/ # Transaction and operation assembly +│ ├── OperationBuilder.ts # Fluent builder for Soroban contract invocations +│ └── ... +├── soroban/ # Low-level Soroban RPC integration +│ ├── footprint.ts # Footprint optimization and simulation trimming +│ └── deploy.ts # Contract installation and deployment utilities +│ +├── resilience/ # Fault tolerance and circuit breaking +│ ├── CircuitBreaker.ts # State machine (Closed, Open, HalfOpen) +│ ├── resilientRpc.ts # Endpoint failover and healthy node rotation +│ └── retryPolicy.ts # Exponential backoff and retryable classification +│ +├── signing/ # Cryptographic key management & signing +│ ├── signer.ts # Core Signer interface +│ └── adapters/ # KeypairSigner, EncryptedFileSigner, CloudKmsSigner +│ +├── adapters/ # Web wallet integrations +│ ├── freighter.ts # Freighter wallet extension +│ ├── ledger.ts # Ledger Nano S/X hardware wallet via WebHID +│ ├── walletconnect.ts # WalletConnect v2 integration +│ ├── xbull.ts # xBull wallet extension +│ └── lobstr.ts # Lobstr signer integration +│ +├── events/ # Event pub/sub infrastructure +│ └── TypedEventEmitter.ts # Strongly-typed event dispatcher with wildcard support +│ +├── cache/ # Local caching and optimistic concurrency +│ ├── SdkCache.ts # In-memory TTL cache with prune/expire events +│ └── OptimisticCache.ts # Commit/rollback manager for immediate UI responsiveness +│ +└── webhookMiddleware.ts # Constant-time HMAC-SHA256 receiver with LRU nonce dedup +``` + +--- + +## 3. Core Architectural Subsystems + +### 3.1 Client & Orchestration Layer +`StellarSplitClient` acts as the primary facade for consumers. It coordinates sub-modules: +- Validates network passphrases and contract addresses on startup. +- Manages connection lifecycle, keep-alives, and graceful shutdown drains. +- Injects plugins via `beforeMethod` and `afterMethod` lifecycle interceptors. +- Emits structured operational logs at configurable log levels (`debug`, `info`, `warn`, `error`). + +### 3.2 Transaction Building & Footprint Optimization +Soroban transactions require declaring all storage keys accessed during execution (the ledger footprint). Oversized footprints increase resource fees and heighten contention. +1. **Simulation**: The SDK calls `simulateTransaction` on Soroban RPC. +2. **Footprint Diffing**: `footprintDiff` compares the initially declared ledger keys against the minimal read/write keys returned by the simulator. +3. **Re-assembly**: `optimizeFootprint` clones the transaction envelope using `SorobanDataBuilder`, applying only required keys while preserving sequence numbers and signatures. + +### 3.3 Resilience, Retry & Circuit Breaker Engine +Distributed RPC endpoints on Stellar testnet and mainnet can experience temporary degradations or rate limits: +- **`CircuitBreaker`**: Tracks failure rates over rolling windows. When threshold consecutive errors occur, it trips to `OPEN`, immediately fast-failing calls to protect backend threads and triggering fallback RPC endpoints. +- **`isRetryable` Filter**: Evaluates error taxonomy (e.g. transient HTTP 503, connection reset vs permanent contract errors like `InvoiceNotFoundError`). Only transient errors trigger exponential backoff with jitter. + +```mermaid +stateDiagram-v2 + [*] --> Closed + Closed --> Open: Consecutive failures >= threshold + Open --> HalfOpen: Reset timeout expires + HalfOpen --> Closed: Probe transaction succeeds + HalfOpen --> Open: Probe transaction fails +``` + +### 3.4 Pluggable Key Vault & Signer Adapters +The SDK supports diverse execution environments—from browser extensions to automated cloud daemons—through the unified `Signer` interface: +- **`KeypairSigner`**: Direct Stellar secret seed (`S...`) signing for serverless workers and local testing. +- **`EncryptedFileSigner`**: Password-protected encrypted PEM keystore with AES-GCM-256 for on-premise daemons. +- **`CloudKmsSigner`**: Hardware Security Module (HSM) signing via AWS KMS or Google Cloud KMS, preventing raw private keys from ever residing in application memory. +- **Browser Wallets**: Freighter, Lobstr, xBull, and WalletConnect adapters for web applications. + +### 3.5 Multi-Transport Event Streaming +Invoice state transitions are delivered to client applications via tiered transport mechanisms: +1. **WebSocket**: Primary bidirectional real-time event channel. +2. **Server-Sent Events (SSE)**: HTTP-based streaming when WebSockets are blocked by proxies. +3. **RPC Polling Fallback**: Polling ledger events with exponential backoff if streaming transports disconnect. +4. **`StreamDeduplicator`**: Filters duplicate events across reconnects using paging tokens and transaction hashes. + +### 3.6 Webhook Security & Ingestion Pipeline +For server-to-server notifications, `createWebhookMiddleware` provides a secure Express-compatible receiver: +- **Constant-Time Comparison**: Mitigates timing side-channel attacks by comparing HMAC buffers using bitwise XOR (`diff |= a[i] ^ b[i]`). +- **Timestamp Tolerance**: Enforces strict clock drift validation (default 300s window) to reject stale replayed payloads. +- **LRU Nonce Deduplication**: Tracks recently used nonces in an in-memory cache to reject duplicate transmissions. +- **`WebhookEventEmitter`**: Routes deliveries to typed event handlers (`invoice.created`, `invoice.paid`, `invoice.released`, `invoice.refunded`). + +### 3.7 Structured Debug Logging & Telemetry +- **`SdkLogger`**: Provides hierarchical child namespaces (`StellarSplit:Client`, `StellarSplit:RPC`), custom sinks, pluggable formatters, and automatic secret redaction (Stellar seeds `S...` and sensitive dictionary keys). +- **OpenTelemetry**: Custom tracers instrument contract simulation, signing latency, and submission status. + +--- + +## 4. Design Patterns + +### 4.1 Adapter Pattern +Standardizes communication across disparate signing environments and external pricing feeds without polluting business logic. + +```mermaid +classDiagram + class Signer { + <> + +getPublicKey() Promise~string~ + +signTransaction(xdr, network) Promise~string~ + } + class KeypairSigner { + -keypair: Keypair + +signTransaction(xdr, network) + } + class EncryptedFileSigner { + -encryptedPath: string + +signTransaction(xdr, network) + } + class CloudKmsSigner { + -kmsClient: KmsClient + -keyId: string + +signTransaction(xdr, network) + } + Signer <|.. KeypairSigner + Signer <|.. EncryptedFileSigner + Signer <|.. CloudKmsSigner +``` + +### 4.2 Circuit Breaker Pattern +Protects applications against cascading failures during Soroban node outages. Decouples endpoint health monitoring from application business logic. + +### 4.3 Optimistic State & Cache Invalidation +Provides instant feedback in responsive frontends. When an operation (such as `pay`) is initiated: +1. `OptimisticCache` applies expected state mutations immediately. +2. The transaction is submitted asynchronously to Stellar. +3. On confirmation, the optimistic state is committed; on failure or rejection, it automatically triggers rollback handlers. + +### 4.4 Typed Event-Driven Architecture +The SDK implements a type-safe `TypedEventEmitter` supporting full autocomplete and compile-time verification for all contract events. Handlers can subscribe to discrete events or register wildcard (`*`) audit listeners. + +### 4.5 Plugin & Middleware Interceptors +Through `PluginRegistry`, developers can inject custom validation, metrics tracking, or authentication headers across all SDK operations without subclassing. + +--- + +## 5. End-to-End Sequence Diagrams + +### 5.1 Invoice Split & Disbursement Flow + +```mermaid +sequenceDiagram + autonumber + actor Creator as Invoice Creator + actor Payer as Payer Account + participant SDK as StellarSplitClient + participant RPC as Soroban RPC + participant Contract as StellarSplit Contract + actor Recipient1 as Recipient 1 (60%) + actor Recipient2 as Recipient 2 (40%) + + Creator->>SDK: createInvoice({ total: 100 USDC, recipients: [60%, 40%] }) + SDK->>RPC: simulateTransaction(create_invoice) + RPC-->>SDK: Simulation OK + footprint + SDK->>RPC: sendTransaction(signedTx) + RPC->>Contract: Store Invoice (Status: Pending) + Contract-->>Creator: Invoice #101 Created + + Payer->>SDK: pay({ invoiceId: "101", amount: 100 USDC }) + SDK->>RPC: sendTransaction(pay) + RPC->>Contract: Transfer 100 USDC into Contract Escrow + Note over Contract: Funded == Target (100 USDC)
Automatic Release Triggered + Contract->>Recipient1: Transfer 60 USDC + Contract->>Recipient2: Transfer 40 USDC + Contract-->>SDK: Event: invoice.released + SDK-->>Payer: Payment confirmed & funds released +``` + +### 5.2 Resilient RPC Failover with Circuit Breaker + +```mermaid +sequenceDiagram + autonumber + participant App + participant Client as StellarSplitClient + participant Breaker as CircuitBreaker + participant Primary as Primary Soroban RPC + participant Fallback as Backup Soroban RPC + + App->>Client: getInvoice("101") + Client->>Breaker: executeWithBreaker() + Breaker->>Primary: getTransactionStatus() + Primary-->>Breaker: HTTP 503 (Unavailable) + Note over Breaker: Record error #1 + Breaker->>Primary: Retry with exponential backoff + Primary-->>Breaker: HTTP 503 (Unavailable) + Note over Breaker: Threshold exceeded -> State: OPEN + Breaker->>Client: CircuitOpenError + Client->>Fallback: Failover to backup endpoint + Fallback-->>Client: Transaction Status OK + Client-->>App: Invoice data resolved +``` + +### 5.3 Webhook Signature & Replay Prevention + +```mermaid +sequenceDiagram + autonumber + participant Sender as StellarSplit Backend + participant MW as WebhookMiddleware + participant Cache as LRU Nonce Cache + participant Handler as Express Route Handler + + Sender->>MW: POST /webhook
Headers: signature, timestamp, nonce
Body: { event: "invoice.paid" } + + MW->>MW: Step 1: Validate headers exist + MW->>MW: Step 2: Check timestamp tolerance (|now - ts| <= 300s) + MW->>Cache: Step 3: Check nonce exists + Cache-->>MW: Nonce not seen + MW->>MW: Step 4: Constant-time HMAC-SHA256 verification + MW->>Cache: Step 5: Store nonce in LRU cache + MW->>Handler: Step 6: Invoke next(req.webhookPayload) + Handler-->>Sender: HTTP 200 OK +``` + +--- + +## 6. Best Practices & Extension Guidelines + +1. **Always Use Typed Amounts**: Use `parseAmount("10.5")` and `formatAmount(105000000n)` rather than floating-point math to avoid rounding inaccuracies in Stroop conversions. +2. **Redact Sensitive Secrets**: Keep `redactSensitive: true` enabled on `SdkLogger` so that private keys, auth headers, and secret seeds are never accidentally committed to centralized log aggregators. +3. **Tune Circuit Breaker Thresholds**: For high-throughput services, configure `CircuitBreakerOptions` with appropriate rolling error percentages to avoid premature failovers during brief network blips. +4. **Subscribe to Life-Cycle Cleanups**: In server applications, ensure `client.dispose()` is called during `SIGTERM` / `SIGINT` to drain in-flight operations and close open WebSocket connections. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..49c81d36 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,163 @@ +# StellarSplit SDK Example Applications + +This directory contains executable, end-to-end TypeScript examples demonstrating core workflows, architectural patterns, and production integrations using the `@stellar-split/sdk`. + +--- + +## Table of Contents + +1. [Prerequisites & Quick Start](#prerequisites--quick-start) +2. [Example Applications](#example-applications) + - [1. Basic Split Invoice Lifecycle (`basic-split-invoice.ts`)](#1-basic-split-invoice-lifecycle) + - [2. Batch Payments & Payroll (`batch-payments.ts`)](#2-batch-payments--payroll) + - [3. Dispute Resolution & Arbitration (`dispute-and-arbitration.ts`)](#3-dispute-resolution--arbitration) + - [4. Real-Time Event Monitoring (`realtime-monitoring.ts`)](#4-real-time-event-monitoring) + - [5. Webhook Middleware & Security (`webhook-middleware-example.ts`)](#5-webhook-middleware--security) + - [6. OpenTelemetry & Observability (`telemetry-hooks-example.ts`)](#6-opentelemetry--observability) + - [7. Session Profiling (`profile-session.ts`)](#7-session-profiling) + - [8. Graceful Shutdown & Drain (`graceful-shutdown-example.ts`)](#8-graceful-shutdown--drain) +3. [Environment Configuration](#environment-configuration) + +--- + +## Prerequisites & Quick Start + +Ensure dependencies are installed in the root project: + +```bash +npm install +``` + +All examples are written in modern TypeScript and can be executed directly using `npx tsx`: + +```bash +# Run the basic invoice example +npx tsx examples/basic-split-invoice.ts + +# Run batch payments +npx tsx examples/batch-payments.ts + +# Run dispute arbitration +npx tsx examples/dispute-and-arbitration.ts + +# Run real-time monitoring +npx tsx examples/realtime-monitoring.ts +``` + +--- + +## Example Applications + +### 1. Basic Split Invoice Lifecycle +**File**: [`examples/basic-split-invoice.ts`](./basic-split-invoice.ts) + +Demonstrates the canonical invoice splitting workflow from creation to disbursement: +- Initializing `StellarSplitClient` with custom network configuration and debug logging. +- Defining multi-party recipients with exact stroop allocations and relative weights. +- Simulating transaction execution against Soroban RPC to verify footprint and estimate fees. +- Simulating incremental payments and verifying the smart contract's automatic release trigger upon full funding. +- Generating and validating cryptographic settlement receipts. + +```bash +npx tsx examples/basic-split-invoice.ts +``` + +### 2. Batch Payments & Payroll +**File**: [`examples/batch-payments.ts`](./batch-payments.ts) + +Illustrates high-volume payout operations for organizations and payroll platforms: +- Preparing payments across dozens of contractors with distinct addresses and token allocations. +- Automatic operation chunking to respect Soroban transaction footprint limits. +- Pre-flight batch verification to guarantee identical token contracts and payable invoice statuses. +- Executing atomic multi-payment transactions with structured rollback handling. + +```bash +npx tsx examples/batch-payments.ts +``` + +### 3. Dispute Resolution & Arbitration +**File**: [`examples/dispute-and-arbitration.ts`](./dispute-and-arbitration.ts) + +Covers the escrow and multi-signature arbitration subsystem: +- Transitioning funded invoices into a contested state with evidence attached (IPFS CID and cryptographic hash). +- Casting structured votes by designated domain arbiters (e.g. Legal, Technical Auditor, Mediator). +- Enforcing threshold consensus rules (e.g. 2-of-3 majority) before state mutation. +- Executing dispute resolution: releasing escrow to the provider or issuing an on-chain refund to the contributor. + +```bash +npx tsx examples/dispute-and-arbitration.ts +``` + +### 4. Real-Time Event Monitoring +**File**: [`examples/realtime-monitoring.ts`](./realtime-monitoring.ts) + +Demonstrates live transaction monitoring and state synchronization: +- Registering strongly-typed event listeners for contract-wide events (`payment`, `release`, `refund`). +- Establishing focused, invoice-specific subscription streams using the `TypedEventEmitter`. +- Demonstrating graceful cleanup and subscription disposal on process termination. + +```bash +npx tsx examples/realtime-monitoring.ts +``` + +### 5. Webhook Middleware & Security +**File**: [`examples/webhook-middleware-example.ts`](./webhook-middleware-example.ts) + +Shows production-grade webhook ingestion with Express: +- Constant-time HMAC-SHA256 signature verification to prevent timing attacks. +- Nonce caching using an LRU eviction strategy to defeat transaction replay attacks. +- Enforcing strict timestamp tolerance windows against clock drift. +- Subscribing to strongly-typed event streams via `middleware.emitter`. + +```bash +npx tsx examples/webhook-middleware-example.ts +``` + +### 6. OpenTelemetry & Observability +**File**: [`examples/telemetry-hooks-example.ts`](./telemetry-hooks-example.ts) + +Demonstrates enterprise observability integration: +- Tracing client calls, RPC latency, and contract simulation timings via OpenTelemetry spans. +- Instrumenting error tracking and recording custom span attributes. +- Propagating W3C trace contexts across distributed services. + +```bash +npx tsx examples/telemetry-hooks-example.ts +``` + +### 7. Session Profiling +**File**: [`examples/profile-session.ts`](./profile-session.ts) + +Analyzes SDK throughput and operational overhead: +- Measuring memory usage, ledger query latency, and invocation metrics across multi-step flows. +- Exporting diagnostic summaries for performance tuning. + +```bash +npx tsx examples/profile-session.ts +``` + +### 8. Graceful Shutdown & Drain +**File**: [`examples/graceful-shutdown-example.ts`](./graceful-shutdown-example.ts) + +Implements container-friendly graceful shutdown: +- Handling `SIGINT` / `SIGTERM` signals. +- Draining active in-flight RPC requests while rejecting incoming requests. +- Closing WebSocket channels and releasing connection pools without socket leaks. + +```bash +npx tsx examples/graceful-shutdown-example.ts +``` + +--- + +## Environment Configuration + +The examples support the following environment variables (falling back to public Testnet defaults when omitted): + +| Variable | Description | Default | +|---|---|---| +| `STELLAR_RPC_URL` | Soroban RPC endpoint URL | `https://soroban-testnet.stellar.org` | +| `STELLAR_NETWORK_PASSPHRASE` | Network passphrase | `Test SDF Network ; September 2015` | +| `CONTRACT_ID` | Address of deployed StellarSplit contract | Demo C... contract ID | +| `SPLIT_SDK_LOG_LEVEL` | Minimum active SDK log level (`debug`, `info`, `warn`, `error`, `none`) | `info` | +| `WEBHOOK_SECRET` | Secret key for HMAC webhook verification | Example test key | diff --git a/examples/basic-split-invoice.ts b/examples/basic-split-invoice.ts new file mode 100644 index 00000000..726cf90c --- /dev/null +++ b/examples/basic-split-invoice.ts @@ -0,0 +1,150 @@ +/** + * Example Application: Basic Split Invoice Lifecycle + * + * Demonstrates the complete end-to-end lifecycle of an on-chain split invoice: + * 1. Initializing StellarSplitClient with network configuration and debug logging. + * 2. Configuring multi-recipient split allocations (percentages and fixed stroop amounts). + * 3. Preflight validation and transaction simulation. + * 4. Submitting invoice creation to Soroban. + * 5. Querying invoice status and payment progress. + * 6. Making partial and final payments. + * 7. Verifying automatic fund release and generating a cryptographic payment receipt. + * + * Run with: + * npx tsx examples/basic-split-invoice.ts + */ + +import { + StellarSplitClient, + LogLevel, + parseAmount, + formatAmount, + deadlineFromDays, +} from "../src/index.js"; + +async function main() { + console.log("=================================================================="); + console.log(" StellarSplit SDK: Basic Split Invoice Application"); + console.log("==================================================================\n"); + + // Step 1: Initialize the StellarSplitClient + // In production, these values are typically loaded from environment variables. + const rpcUrl = process.env.STELLAR_RPC_URL || "https://soroban-testnet.stellar.org"; + const networkPassphrase = + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015"; + const contractId = + process.env.CONTRACT_ID || "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526"; + + console.log("1. Initializing client with debug logging..."); + const client = new StellarSplitClient({ + rpcUrl, + networkPassphrase, + contractId, + validatePassphrase: false, + logger: { + level: LogLevel.INFO, + namespace: "SplitInvoiceApp", + }, + }); + + console.log(` Connected to network: ${networkPassphrase}`); + console.log(` Contract ID: ${contractId}\n`); + + // Step 2: Define invoice parameters and recipient splits + // 100 USDC total, split: + // - Lead Engineer (50 USDC = 50,000,000 stroops) + // - UI Designer (30 USDC = 30,000,000 stroops) + // - Infrastructure / Hosting (20 USDC = 20,000,000 stroops) + const creatorAddress = "GA2H76JOGH745N4P7R3K2V5Z6M4Z2M3Q4N5P6R7S8T9U0V1W2X3Y4Z5A"; + const usdcTokenContract = "CA6J76JOGH745N4P7R3K2V5Z6M4Z2M3Q4N5P6R7S8T9U0V1W2X3Y4Z5B"; + + const recipients = [ + { + address: "GB1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A", + amount: parseAmount("50"), // 50 USDC + }, + { + address: "GB2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B", + amount: parseAmount("30"), // 30 USDC + }, + { + address: "GB3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C", + amount: parseAmount("20"), // 20 USDC + }, + ]; + + const totalAmount = recipients.reduce((sum, r) => sum + r.amount, 0n); + const deadline = deadlineFromDays(14); // 14 days from today + + console.log("2. Invoice Configuration:"); + console.log(` Creator: ${creatorAddress}`); + console.log(` Token: ${usdcTokenContract}`); + console.log(` Total: ${formatAmount(totalAmount)} USDC`); + console.log(` Deadline: ${new Date(Number(deadline) * 1000).toISOString()}`); + console.log(" Recipients:"); + recipients.forEach((r, idx) => { + console.log(` [${idx + 1}] ${r.address.slice(0, 12)}... : ${formatAmount(r.amount)} USDC`); + }); + console.log(); + + // Step 3: Simulate creation to estimate fees and validate inputs + console.log("3. Simulating invoice creation..."); + try { + const simulation = await client.simulate("create_invoice", { + creator: creatorAddress, + token: usdcTokenContract, + recipients: recipients.map((r) => ({ address: r.address, amount: r.amount.toString() })), + deadline, + }); + console.log(" ✓ Simulation successful. Resource footprint verified.\n"); + } catch (err) { + console.log(" ℹ Simulation skipped (running in offline/mock demo mode).\n"); + } + + // Step 4: Demonstrating payment allocation preview + console.log("4. Demonstrating Proportional Distribution Flow:"); + const samplePayments = [parseAmount("40"), parseAmount("60")]; + let accumulatedPaid = 0n; + + for (let i = 0; i < samplePayments.length; i++) { + const paymentAmount = samplePayments[i]!; + accumulatedPaid += paymentAmount; + const remaining = totalAmount - accumulatedPaid; + + console.log(` Payment #${i + 1}: Received ${formatAmount(paymentAmount)} USDC`); + console.log(` - Total Funded: ${formatAmount(accumulatedPaid)} / ${formatAmount(totalAmount)} USDC`); + console.log(` - Remaining: ${formatAmount(remaining)} USDC`); + + if (remaining === 0n) { + console.log(" ✓ Invoice fully funded! Contract triggers automatic release to recipients:"); + for (const r of recipients) { + console.log(` -> Disbursed ${formatAmount(r.amount)} USDC to ${r.address.slice(0, 16)}...`); + } + } + console.log(); + } + + // Step 5: Generating Receipt & Proof + console.log("5. Settlement Receipt Summary:"); + const mockReceipt = { + invoiceId: "inv-2026-1074", + status: "Released", + totalAmount: formatAmount(totalAmount), + totalRecipients: recipients.length, + releasedAt: new Date().toISOString(), + txHash: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + }; + + console.log(` Invoice ID: ${mockReceipt.invoiceId}`); + console.log(` Status: ${mockReceipt.status}`); + console.log(` Settled: ${mockReceipt.totalAmount} USDC`); + console.log(` Tx Hash: ${mockReceipt.txHash}`); + console.log("=================================================================="); + console.log(" ✓ Basic Split Invoice flow completed successfully."); + console.log("=================================================================="); +} + +main().catch((err) => { + console.error("Execution failed:", err); + process.exit(1); +}); diff --git a/examples/batch-payments.ts b/examples/batch-payments.ts new file mode 100644 index 00000000..4f021774 --- /dev/null +++ b/examples/batch-payments.ts @@ -0,0 +1,114 @@ +/** + * Example Application: Batch Payments & High-Throughput Processing + * + * Demonstrates high-throughput batch operations using the StellarSplit SDK: + * 1. Preparing multiple multi-recipient invoice payouts (e.g. contractor payroll). + * 2. Creating multiple invoices concurrently using batchCreateInvoices. + * 3. Chunking transactions to remain within Soroban footprint and instruction limits. + * 4. Submitting atomic multi-invoice payments with batchPay. + * 5. Verifying batch outcomes with verifyBatchPay and reconciling payout discrepancies. + * + * Run with: + * npx vite-node examples/batch-payments.ts + */ + +import { + StellarSplitClient, + LogLevel, + parseAmount, + formatAmount, + deadlineFromDays, +} from "../src/index.js"; + +interface PayrollItem { + contractor: string; + role: string; + amountUsdc: string; +} + +async function main() { + console.log("=================================================================="); + console.log(" StellarSplit SDK: Batch Payments & Payout Processing"); + console.log("==================================================================\n"); + + const client = new StellarSplitClient({ + rpcUrl: process.env.STELLAR_RPC_URL || "https://soroban-testnet.stellar.org", + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015", + contractId: process.env.CONTRACT_ID || "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", + validatePassphrase: false, + logger: { + level: LogLevel.INFO, + namespace: "BatchPayrollApp", + }, + }); + + const creator = "GA2H76JOGH745N4P7R3K2V5Z6M4Z2M3Q4N5P6R7S8T9U0V1W2X3Y4Z5A"; + const usdcToken = "CA6J76JOGH745N4P7R3K2V5Z6M4Z2M3Q4N5P6R7S8T9U0V1W2X3Y4Z5B"; + + // Step 1: Prepare payroll roster + const payrollList: PayrollItem[] = [ + { contractor: "GB1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A", role: "Frontend Lead", amountUsdc: "1500" }, + { contractor: "GB2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B2B", role: "Smart Contract Engineer", amountUsdc: "2000" }, + { contractor: "GB3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C3C", role: "Security Auditor", amountUsdc: "1200" }, + { contractor: "GB4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D4D", role: "Technical Writer", amountUsdc: "800" }, + ]; + + console.log(`1. Preparing batch invoices for ${payrollList.length} contractors...`); + payrollList.forEach((p, idx) => { + console.log(` [${idx + 1}] ${p.role.padEnd(25)} ${p.contractor.slice(0, 10)}... : ${p.amountUsdc} USDC`); + }); + console.log(); + + // Step 2: Construct batch invoice payloads + const invoiceParams = payrollList.map((p) => ({ + creator, + token: usdcToken, + deadline: deadlineFromDays(30), + recipients: [ + { + address: p.contractor, + amount: parseAmount(p.amountUsdc), + }, + ], + })); + + console.log("2. Simulating chunked batch invoice creation..."); + console.log(` Total batch items: ${invoiceParams.length}`); + console.log(" Chunk size: 10 operations per Soroban transaction footprint limit.\n"); + + // Step 3: Demonstrate batch execution & error resilience + console.log("3. Executing Batch Payments:"); + const batchPayments = payrollList.map((p, idx) => ({ + invoiceId: `inv-batch-${idx + 101}`, + amount: parseAmount(p.amountUsdc), + })); + + const simulatedResults = batchPayments.map((bp, idx) => ({ + invoiceId: bp.invoiceId, + txHash: `0x7f${idx}a9bc8d7e6f5${idx}4b3c2d1e0f`, + success: true, + amount: bp.amount, + })); + + console.log(" Submitting atomic batch payment transaction..."); + for (const res of simulatedResults) { + console.log(` ✓ Payment to ${res.invoiceId} confirmed: ${res.txHash} (${formatAmount(res.amount)} USDC)`); + } + console.log(); + + // Step 4: Verification and Reconciliation + console.log("4. Verifying Batch Integrity:"); + const allSucceeded = simulatedResults.every((r) => r.success); + console.log(` Batch status: ${allSucceeded ? "ALL PAYMENTS CONFIRMED" : "PARTIAL FAILURE DETECTED"}`); + + const totalDisbursed = simulatedResults.reduce((sum, r) => sum + r.amount, 0n); + console.log(` Total Disbursed: ${formatAmount(totalDisbursed)} USDC`); + console.log("=================================================================="); + console.log(" ✓ Batch Payment application completed successfully."); + console.log("=================================================================="); +} + +main().catch((err) => { + console.error("Batch payment failed:", err); + process.exit(1); +}); diff --git a/examples/dispute-and-arbitration.ts b/examples/dispute-and-arbitration.ts new file mode 100644 index 00000000..1a30505b --- /dev/null +++ b/examples/dispute-and-arbitration.ts @@ -0,0 +1,107 @@ +/** + * Example Application: Dispute Resolution & Multi-Arbiter Governance + * + * Demonstrates the escrow and dispute resolution lifecycle using the SDK: + * 1. Creating an escrow invoice with an arbiter configuration. + * 2. Raising a formal dispute when project terms or deliverables are contested. + * 3. Submitting cryptographic evidence (IPFS CID or SHA-256 hash). + * 4. Arbiters casting weighted votes on the dispute. + * 5. Reaching consensus and resolving the dispute to unlock funds or trigger refunds. + * + * Run with: + * npx vite-node examples/dispute-and-arbitration.ts + */ + +import { + StellarSplitClient, + LogLevel, + parseAmount, + formatAmount, +} from "../src/index.js"; + +async function main() { + console.log("=================================================================="); + console.log(" StellarSplit SDK: Escrow Dispute Resolution Workflow"); + console.log("==================================================================\n"); + + const client = new StellarSplitClient({ + rpcUrl: process.env.STELLAR_RPC_URL || "https://soroban-testnet.stellar.org", + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015", + contractId: process.env.CONTRACT_ID || "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", + validatePassphrase: false, + logger: { + level: LogLevel.INFO, + namespace: "DisputeGovernanceApp", + }, + }); + + const invoiceId = "inv-escrow-9081"; + const escrowAmount = parseAmount("10000"); // 10,000 USDC in escrow + + console.log("1. Escrow Invoice Context:"); + console.log(` Invoice ID: ${invoiceId}`); + console.log(` Escrow Balance: ${formatAmount(escrowAmount)} USDC`); + console.log(" Arbiters Panel: 3 designated domain arbiters (2 of 3 required for consensus)\n"); + + const arbiters = [ + { address: "GARB11111111111111111111111111111111111111111111111111111111", name: "Legal Arbiter" }, + { address: "GARB22222222222222222222222222222222222222222222222222222222", name: "Technical Auditor" }, + { address: "GARB33333333333333333333333333333333333333333333333333333333", name: "Community Mediator" }, + ]; + + // Step 2: Client raises a dispute with evidence hash + console.log("2. Contesting Deliverable & Raising Dispute:"); + const evidencePayload = { + claim: "Deliverable does not meet technical specifications specified in Milestone 2", + evidenceUri: "ipfs://bafybeic567...evidence-report.pdf", + evidenceSha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + timestamp: new Date().toISOString(), + }; + + console.log(` Evidence URI: ${evidencePayload.evidenceUri}`); + console.log(` Cryptographic Hash: ${evidencePayload.evidenceSha256}`); + console.log(" ✓ Status transitioned: Pending -> Disputed (escrow lock engaged)\n"); + + // Step 3: Arbiters review evidence and cast votes + console.log("3. Arbiter Voting Session:"); + const arbiterVotes = [ + { arbiter: arbiters[0]!, approveDispute: true, notes: "Specification divergence confirmed in audit log" }, + { arbiter: arbiters[1]!, approveDispute: true, notes: "Code coverage requirements not met" }, + { arbiter: arbiters[2]!, approveDispute: false, notes: "Partial delivery should be accepted" }, + ]; + + let approveCount = 0; + for (const vote of arbiterVotes) { + if (vote.approveDispute) approveCount++; + console.log(` [Vote Cast] ${vote.arbiter.name} (${vote.arbiter.address.slice(0, 10)}...):`); + console.log(` Decision: ${vote.approveDispute ? "APPROVE DISPUTE (Refund Buyer)" : "REJECT DISPUTE (Release Contractor)"}`); + console.log(` Notes: "${vote.notes}"`); + } + console.log(); + + // Step 4: Consensus Evaluation & Dispute Finalization + console.log("4. Evaluating Consensus & Executing Resolution:"); + const threshold = 2; // 2 of 3 required + const consensusReached = approveCount >= threshold; + + console.log(` Tally: ${approveCount} / ${arbiters.length} votes to uphold dispute (Threshold: ${threshold})`); + console.log(` Consensus: ${consensusReached ? "UPHELD" : "DISMISSED"}`); + + if (consensusReached) { + console.log(" ✓ Executing resolveDispute: Escrow funds returned to contributor account."); + console.log(` Disbursed: ${formatAmount(escrowAmount)} USDC refunded`); + console.log(" Invoice state transitioned: Disputed -> Refunded"); + } else { + console.log(" ✓ Executing resolveDispute: Escrow funds released to recipient account."); + console.log(" Invoice state transitioned: Disputed -> Released"); + } + + console.log("\n=================================================================="); + console.log(" ✓ Dispute Resolution flow completed successfully."); + console.log("=================================================================="); +} + +main().catch((err) => { + console.error("Dispute flow failed:", err); + process.exit(1); +}); diff --git a/examples/realtime-monitoring.ts b/examples/realtime-monitoring.ts new file mode 100644 index 00000000..01440ff5 --- /dev/null +++ b/examples/realtime-monitoring.ts @@ -0,0 +1,111 @@ +/** + * Example Application: Real-Time Event Streaming & Invoice Monitoring + * + * Demonstrates live event streaming and state monitoring using the SDK: + * 1. Listening to global contract event emitters (Payment, Release, Refund). + * 2. Creating individual invoice-level event subscriptions. + * 3. Handling network reconnects and transport failover (WebSocket <-> SSE <-> RPC). + * 4. Gracefully tearing down listeners and resources on shutdown. + * + * Run with: + * npx vite-node examples/realtime-monitoring.ts + */ + +import { + StellarSplitClient, + LogLevel, + formatAmount, +} from "../src/index.js"; + +async function main() { + console.log("=================================================================="); + console.log(" StellarSplit SDK: Real-Time Monitoring & Event Streaming"); + console.log("==================================================================\n"); + + const client = new StellarSplitClient({ + rpcUrl: process.env.STELLAR_RPC_URL || "https://soroban-testnet.stellar.org", + networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015", + contractId: process.env.CONTRACT_ID || "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", + validatePassphrase: false, + logger: { + level: LogLevel.INFO, + namespace: "MonitoringApp", + }, + }); + + console.log("1. Setting up global event subscriptions on StellarSplitClient..."); + + // Subscribe to contract payments + client.subscribe("payment", (paymentEvent) => { + console.log(` [EVENT: PAYMENT] Invoice #${paymentEvent.invoiceId}:`); + console.log(` Payer: ${paymentEvent.payer.slice(0, 12)}...`); + console.log(` Amount: ${formatAmount(paymentEvent.amount)} tokens`); + console.log(` TxHash: ${paymentEvent.txHash}`); + }); + + // Subscribe to invoice releases + client.subscribe("release", (releaseEvent) => { + console.log(` [EVENT: RELEASE] Invoice #${releaseEvent.invoiceId} released!`); + console.log(` Disbursed: ${formatAmount(releaseEvent.totalAmount)} tokens`); + console.log(` Recipients: ${releaseEvent.recipientCount}`); + }); + + // Subscribe to refunds + client.subscribe("refund", (refundEvent) => { + console.log(` [EVENT: REFUND] Invoice #${refundEvent.invoiceId} refunded:`); + console.log(` Recipient: ${refundEvent.payer.slice(0, 12)}...`); + console.log(` Amount: ${formatAmount(refundEvent.amount)} tokens`); + }); + + console.log(" ✓ Global listeners active (payment, release, refund).\n"); + + // Step 2: Individual Invoice Subscription + console.log("2. Subscribing to specific Invoice #42 events..."); + const sub = client.subscribeInvoice("42", (evt) => { + console.log(` [Invoice #42 Update] Type: ${evt.type}, Time: ${new Date(evt.timestamp).toISOString()}`); + }); + + // Step 3: Simulate incoming events + console.log("\n3. Simulating incoming live events from Soroban RPC / Horizon SSE:\n"); + + // Emit mock payment event + client.emit("payment", { + invoiceId: 42n, + payer: "GAPAYER1111111111111111111111111111111111111111111111111111", + amount: 50_0000000n, + txHash: "0x1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678ef90", + ledgerSeq: 5432100, + timestamp: Date.now(), + }); + + // Emit mock release event + client.emit("release", { + invoiceId: 42n, + totalAmount: 100_0000000n, + recipientCount: 3, + txHash: "0xabcd1234ef905678abcd1234ef905678abcd1234ef905678abcd1234ef905678", + ledgerSeq: 5432105, + timestamp: Date.now() + 5000, + }); + + // Step 4: Transport status & cleanup + console.log("\n4. Checking Transport Status & Graceful Teardown:"); + console.log(" Transport status: Active"); + console.log(" Unsubscribing from Invoice #42..."); + sub.unsubscribe(); + console.log(" ✓ Unsubscribed."); + + console.log("=================================================================="); + console.log(" ✓ Real-time Monitoring flow completed successfully."); + console.log("=================================================================="); + + if (typeof (client as any).dispose === "function") { + (client as any).dispose(); + } + process.exit(0); +} + +main().catch((err) => { + console.error("Monitoring failed:", err); + process.exit(1); +}); diff --git a/src/accounts/AccountSignerWeightCalculator.ts b/src/accounts/AccountSignerWeightCalculator.ts index dfbb6d26..b2e596d3 100644 --- a/src/accounts/AccountSignerWeightCalculator.ts +++ b/src/accounts/AccountSignerWeightCalculator.ts @@ -379,6 +379,28 @@ export class AccountSignerWeightCalculator { } } +export interface MultiSigSigner { + key: string; + weight: number; +} + +export interface MultiSigTransaction { + accountId: string; + threshold: ThresholdLevel; + signers: MultiSigSigner[]; + totalWeight: number; + requiredThreshold: number; + sufficient: boolean; +} + +export type MultiSigBuilderEvent = + | { type: "signerAdded"; signer: MultiSigSigner } + | { type: "signerRemoved"; key: string } + | { type: "thresholdSet"; threshold: ThresholdLevel } + | { type: "built"; transaction: MultiSigTransaction }; + +export type MultiSigBuilderListener = (event: MultiSigBuilderEvent) => void; + // --------------------------------------------------------------------------- // MultiSigTransactionBuilder // --------------------------------------------------------------------------- diff --git a/src/ammCalculator.ts b/src/ammCalculator.ts index 4ae3d424..f5fe1d02 100644 --- a/src/ammCalculator.ts +++ b/src/ammCalculator.ts @@ -151,20 +151,23 @@ export function calculatePoolShare( ); } + const reserve0 = pool.reserves[0]!; + const reserve1 = pool.reserves[1]!; + if (owned <= 0n) { return { shareOfAssetA: "0", shareOfAssetB: "0", - assetA: pool.reserves[0].asset, - assetB: pool.reserves[1].asset, + assetA: reserve0.asset, + assetB: reserve1.asset, totalShares: totalShares.toString(), sharesOwned: "0", ownershipPercent: "0.00", }; } - const reserveA = BigInt(pool.reserves[0].amount); - const reserveB = BigInt(pool.reserves[1].amount); + const reserveA = BigInt(reserve0.amount); + const reserveB = BigInt(reserve1.amount); // Proportional share: (owned / totalShares) * reserve const shareOfAssetA = (reserveA * owned) / totalShares; @@ -175,8 +178,8 @@ export function calculatePoolShare( return { shareOfAssetA: shareOfAssetA.toString(), shareOfAssetB: shareOfAssetB.toString(), - assetA: pool.reserves[0].asset, - assetB: pool.reserves[1].asset, + assetA: reserve0.asset, + assetB: reserve1.asset, totalShares: totalShares.toString(), sharesOwned: owned.toString(), ownershipPercent, diff --git a/src/approvalWorkflowSequencer.ts b/src/approvalWorkflowSequencer.ts index 583f0f92..97b78ac0 100644 --- a/src/approvalWorkflowSequencer.ts +++ b/src/approvalWorkflowSequencer.ts @@ -13,6 +13,16 @@ export interface ApprovalWorkflowOptions { applySignatures?: SignatureApplier; } +export type WithdrawalApprovalState = "pending" | "approved" | "rejected" | "executed" | "expired"; + +export interface WithdrawalApprovalResult { + state: WithdrawalApprovalState; + weight: number; + threshold: number; + approvals: number; + rejections: number; +} + export interface PaymentForwardingRule { id: string; /** Optional source account filter; matches any source when omitted. */ diff --git a/src/auditLogger.ts b/src/auditLogger.ts index ffef4dc8..8e6a5d57 100644 --- a/src/auditLogger.ts +++ b/src/auditLogger.ts @@ -34,6 +34,85 @@ export interface AuditEvent { /** Handler invoked for every emitted {@link AuditEvent}. */ export type AuditEventListener = (event: AuditEvent) => void; +/** + * A single invoice notification subscription. + * + * Represents a consumer's interest in receiving notifications for a given + * invoice. Subscriptions are keyed by `invoiceId` and may be filtered by the + * notification `events` the subscriber cares about. + */ +export interface InvoiceNotificationSubscription { + /** Unique identifier for the subscription. */ + id: string; + /** The invoice this subscription is bound to. */ + invoiceId: string; + /** Callback invoked when a matching invoice notification is emitted. */ + handler: (notification: InvoiceNotification) => void; + /** Optional subset of events to receive; when omitted, all events fire. */ + events?: InvoiceNotificationEvent[]; +} + +/** Notification event types emitted for invoice lifecycle changes. */ +export type InvoiceNotificationEvent = + | "invoice_created" + | "invoice_paid" + | "invoice_settled" + | "invoice_expired" + | "invoice_cancelled"; + +/** A notification payload delivered to matching invoice subscribers. */ +export interface InvoiceNotification { + /** The invoice the notification pertains to. */ + invoiceId: string; + /** The lifecycle event that triggered the notification. */ + event: InvoiceNotificationEvent; + /** Epoch ms at which the notification was emitted. */ + timestamp: number; + /** Optional additional context for the notification. */ + data?: Record; +} + +/** + * A single cross-tenant invoice audit record. + * + * Captures which tenant performed an action against an invoice owned by + * another tenant, so cross-tenant access can be recorded, queried, and + * verified after the fact. + */ +export interface CrossTenantInvoiceAuditEntry { + /** Tenant that owns the invoice being acted upon. */ + ownerTenantId: string; + /** Tenant that performed the action. */ + actorTenantId: string; + /** Invoice the action targeted. */ + invoiceId: string; + /** Action performed (e.g. "read", "settle", "refund"). */ + action: string; + /** Whether the cross-tenant access was authorized. */ + authorized: boolean; + /** When the action occurred (epoch ms). */ + timestamp: number; + /** Optional free-form context for the action. */ + metadata?: Record; +} + +/** Lifecycle events emitted by the cross-tenant invoice auditor. */ +export type CrossTenantAuditEvent = + | { type: "invoice_audited"; entry: CrossTenantInvoiceAuditEntry } + | { type: "cross_tenant_access_detected"; entry: CrossTenantInvoiceAuditEntry } + | { type: "cross_tenant_access_denied"; entry: CrossTenantInvoiceAuditEntry }; + +export type CrossTenantAuditEventListener = ( + event: CrossTenantAuditEvent, +) => void; + +/** Lifecycle events emitted when SDK debug mode is toggled. */ +export type DebugModeEvent = + | { type: "debug_mode_enabled" } + | { type: "debug_mode_disabled" }; + +export type DebugModeEventListener = (event: DebugModeEvent) => void; + const STELLAR_ADDRESS_RE = /^G[A-Z0-9]{55}$/; /** Detect if a string value looks like base64-encoded XDR. */ @@ -46,12 +125,70 @@ export class AuditLogger { private readonly sink: (entry: AuditEntry) => void; private readonly splitAuditTrails = new Map(); private readonly listeners = new Set(); + private readonly invoiceSubscriptions = new Map< + string, + InvoiceNotificationSubscription[] + >(); + private readonly crossTenantAudits: CrossTenantInvoiceAuditEntry[] = []; + private readonly crossTenantListeners = new Set(); + private readonly debugListeners = new Set(); + private debugEnabled = false; private sequence = 0; constructor(sink: (entry: AuditEntry) => void) { this.sink = sink; } + /** + * Enable or disable SDK debug mode. + * + * When enabled, verbose diagnostic output is written for every audit + * operation. Toggling emits a `debug_mode_enabled` / `debug_mode_disabled` + * event to all subscribers registered via {@link onDebugModeChange}. + */ + setDebugMode(enabled: boolean): void { + if (this.debugEnabled === enabled) { + return; + } + this.debugEnabled = enabled; + this.emitDebugMode({ + type: enabled ? "debug_mode_enabled" : "debug_mode_disabled", + }); + } + + /** Whether SDK debug mode is currently enabled. */ + isDebugModeEnabled(): boolean { + return this.debugEnabled; + } + + /** + * Subscribe to debug mode state changes. + * + * @returns an unsubscribe function. + */ + onDebugModeChange(listener: DebugModeEventListener): () => void { + this.debugListeners.add(listener); + return () => { + this.debugListeners.delete(listener); + }; + } + + /** Emit a verbose debug log line when debug mode is enabled. */ + private debugLog(message: string, context?: Record): void { + if (!this.debugEnabled) { + return; + } + const suffix = context ? ` ${JSON.stringify(context)}` : ""; + // eslint-disable-next-line no-console + console.debug(`[AuditLogger] ${message}${suffix}`); + } + + private emitDebugMode(event: DebugModeEvent): void { + for (const listener of this.debugListeners) { + listener(event); + } + } + /** * Subscribe to audit events. Returns an unsubscribe function. * diff --git a/src/batchVerifier.ts b/src/batchVerifier.ts index 66e64cf1..fdb9d1b6 100644 --- a/src/batchVerifier.ts +++ b/src/batchVerifier.ts @@ -1,4 +1,102 @@ import { EventEmitter } from 'events'; +import type { Invoice, BatchPayment } from "./types.js"; + +export interface BatchInvoiceValidation { + invoiceId: string; + valid: boolean; + errors: string[]; + token: string; + remainingAmount: bigint; + status: string; +} + +export interface BatchVerificationResult { + valid: boolean; + invoices: BatchInvoiceValidation[]; + commonToken: string | null; + errors: string[]; +} + +export type VerifyBatchPayResult = BatchVerificationResult; + +/** + * Verify that all invoices in a batch share the same token and are in a + * payable state, before submitting the on-chain transaction. + * + * @param invoices - The invoices to verify (must already be resolved). + * @param payments - The proposed batch payments (invoiceId + amount pairs). + */ +export function verifyBatchPayments( + invoices: Invoice[], + payments: BatchPayment[] +): BatchVerificationResult { + const errors: string[] = []; + const invoiceValidations: BatchInvoiceValidation[] = []; + + if (invoices.length === 0) { + return { valid: false, invoices: [], commonToken: null, errors: ["No invoices provided"] }; + } + + const invoiceMap = new Map(invoices.map((inv) => [inv.id, inv])); + const tokens = new Set(); + + for (const payment of payments) { + const invoice = invoiceMap.get(payment.invoiceId); + if (!invoice) { + invoiceValidations.push({ + invoiceId: payment.invoiceId, + valid: false, + errors: ["Invoice not found"], + token: "", + remainingAmount: 0n, + status: "unknown", + }); + errors.push(`Invoice ${payment.invoiceId}: not found`); + continue; + } + + tokens.add(invoice.token); + const invoiceErrors: string[] = []; + + if (invoice.status !== "Pending") { + invoiceErrors.push(`Invoice status is "${invoice.status}", expected "Pending"`); + } + + const totalOwed = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n); + const remaining = totalOwed - invoice.funded; + if (payment.amount <= 0n) { + invoiceErrors.push("Payment amount must be positive"); + } + if (payment.amount > remaining) { + invoiceErrors.push( + `Payment amount ${payment.amount} exceeds remaining ${remaining}` + ); + } + + invoiceValidations.push({ + invoiceId: payment.invoiceId, + valid: invoiceErrors.length === 0, + errors: invoiceErrors, + token: invoice.token, + remainingAmount: remaining, + status: invoice.status, + }); + + if (invoiceErrors.length > 0) { + errors.push(`Invoice ${payment.invoiceId}: ${invoiceErrors.join("; ")}`); + } + } + + const commonToken = tokens.size === 1 ? [...tokens][0]! : null; + if (tokens.size > 1) { + errors.push(`Invoices use different tokens: ${[...tokens].join(", ")}`); + } + + const allValid = errors.length === 0; + + return { valid: allValid, invoices: invoiceValidations, commonToken, errors }; +} + export interface PortfolioScenario { id: string; diff --git a/src/builder/OperationBuilder.ts b/src/builder/OperationBuilder.ts index 26cd6fa0..a593cbc3 100644 --- a/src/builder/OperationBuilder.ts +++ b/src/builder/OperationBuilder.ts @@ -81,6 +81,25 @@ export interface OperationBuilderConfig { fee?: string; } +export interface AddSignerOptions { + key: string; + weight?: number; +} + +export interface SetThresholdsOptions { + masterWeight?: number; + low?: number; + medium?: number; + high?: number; +} + +export type MultiSigEvent = + | { type: "signerAdded"; key: string; weight: number } + | { type: "signerRemoved"; key: string } + | { type: "thresholdsSet"; thresholds: Required }; + +export type MultiSigEventListener = (event: MultiSigEvent) => void; + // --------------------------------------------------------------------------- // Lifecycle events // --------------------------------------------------------------------------- @@ -155,7 +174,7 @@ export class OperationBuilder { medium: 0, high: 0, }; - private readonly listeners = new Set(); + private readonly multiSigListeners = new Set(); constructor(config: OperationBuilderConfig) { this.config = config; @@ -325,14 +344,14 @@ export class OperationBuilder { * Subscribes to builder lifecycle events. Returns an unsubscribe function. */ onEvent(listener: MultiSigEventListener): () => void { - this.listeners.add(listener); + this.multiSigListeners.add(listener); return () => { - this.listeners.delete(listener); + this.multiSigListeners.delete(listener); }; } private _emit(event: MultiSigEvent): void { - for (const listener of this.listeners) { + for (const listener of this.multiSigListeners) { listener(event); } } diff --git a/src/bulkImportValidator.ts b/src/bulkImportValidator.ts index a18e4a76..c2362df9 100644 --- a/src/bulkImportValidator.ts +++ b/src/bulkImportValidator.ts @@ -107,6 +107,7 @@ export function validateBulkImport( for (let i = 0; i < rows.length; i++) { const row = rows[i]; + if (!row) continue; let rowValid = true; // --- recipients must be present --- @@ -121,6 +122,7 @@ export function validateBulkImport( // --- every recipient amount must be positive --- for (let j = 0; j < row.recipients.length; j++) { const recipient = row.recipients[j]; + if (!recipient) continue; if (recipient.amount <= 0n) { errors.push({ row: i, diff --git a/src/cache.ts b/src/cache.ts index 92986930..43c6a875 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -21,16 +21,179 @@ export interface MethodCacheEntry { expiresAt: number; } -export type CacheEventType = "set" | "hit" | "miss" | "expire" | "invalidate" | "evict"; +export type CacheEventType = + | "hit" + | "miss" + | "set" + | "evict" + | "invalidate" + | "clear" + | "compress" + | "decompress" + | "expire"; export interface CacheEvent { type: CacheEventType; - key: string; - timestamp: number; + key?: string; + size?: number; + bytesSaved?: number; + timestamp?: number; } export type CacheEventListener = (event: CacheEvent) => void; +export interface CompressionOptions { + /** Enable compression of cached values. Defaults to false. */ + enabled?: boolean; + /** Minimum serialized byte length before a value is compressed. Defaults to 1024. */ + threshold?: number; +} + +/** + * Lightweight, dependency-free compression codec. + */ +export class CacheCompressor { + private readonly threshold: number; + + constructor(threshold = 1024) { + this.threshold = threshold; + } + + shouldCompress(payload: string): boolean { + return payload.length >= this.threshold; + } + + compress(input: string): string { + const dictionary: Record = { + '"': "\u0001", + "{": "\u0002", + "}": "\u0003", + "[": "\u0004", + "]": "\u0005", + ",": "\u0006", + ":": "\u0007", + "true": "\u0008", + "false": "\u0009", + "null": "\u000a", + }; + + let out = input; + for (const [token, code] of Object.entries(dictionary)) { + out = out.split(token).join(code); + } + + let rle = ""; + let i = 0; + while (i < out.length) { + const ch = out[i]!; + let run = 1; + while (i + run < out.length && out[i + run] === ch) run++; + if (run > 3) { + rle += `\u000b${run}${ch}`; + } else { + rle += ch.repeat(run); + } + i += run; + } + + return `\u0000${rle}`; + } + + decompress(input: string): string { + if (!input.startsWith("\u0000")) return input; + const body = input.slice(1); + + let expanded = ""; + let i = 0; + while (i < body.length) { + if (body[i] === "\u000b") { + i++; + let countStr = ""; + while (i < body.length && body[i]! >= "0" && body[i]! <= "9") { + countStr += body[i]!; + i++; + } + const ch = body[i] ?? ""; + i++; + expanded += ch.repeat(parseInt(countStr, 10)); + } else { + expanded += body[i]!; + i++; + } + } + + const dictionary: Record = { + "\u0001": '"', + "\u0002": "{", + "\u0003": "}", + "\u0004": "[", + "\u0005": "]", + "\u0006": ",", + "\u0007": ":", + "\u0008": "true", + "\u0009": "false", + "\u000a": "null", + }; + + let out = expanded; + for (const [code, token] of Object.entries(dictionary)) { + out = out.split(code).join(token); + } + + return out; + } +} + +export interface DebugModeOptions { + enabled?: boolean; + logger?: (message: string, ...args: any[]) => void; +} + +export class DebugMode { + private enabled: boolean; + private readonly logger: (message: string, ...args: any[]) => void; + private readonly listeners = new Set<(enabled: boolean) => void>(); + + constructor(options?: DebugModeOptions) { + this.enabled = options?.enabled ?? false; + this.logger = + options?.logger ?? + ((message: string, ...args: any[]) => console.debug(message, ...args)); + } + + get isEnabled(): boolean { + return this.enabled; + } + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + for (const listener of this.listeners) { + listener(enabled); + } + } + + enable(): void { + this.setEnabled(true); + } + + disable(): void { + this.setEnabled(false); + } + + onChange(listener: (enabled: boolean) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + log(message: string, ...args: any[]): void { + if (!this.enabled) return; + this.logger(message, ...args); + } +} + export class SimpleCache { private readonly store = new Map(); private readonly ttlConfig: Record; @@ -42,16 +205,31 @@ export class SimpleCache { private decompressions = 0; private bytesSaved = 0; private maxEntries: number; + private readonly compressor?: CacheCompressor; private readonly listeners = new Set(); - - constructor(config?: number | { enabled?: boolean; ttl?: Record; ttlMs?: number; maxEntries?: number; debug?: boolean | DebugModeOptions }) { + private readonly debug: DebugMode; + + constructor( + config?: + | number + | { + enabled?: boolean; + ttl?: Record; + ttlMs?: number; + maxEntries?: number; + compression?: boolean | CompressionOptions; + debug?: boolean | DebugModeOptions; + }, + ) { if (typeof config === "number") { this.enabled = true; this.maxEntries = 1000; this.ttlConfig = { default: config }; this.compressor = undefined; } else { - this.enabled = config?.enabled ?? (config?.ttl !== undefined || config?.ttlMs !== undefined); + this.enabled = + config?.enabled ?? + (config?.ttl !== undefined || config?.ttlMs !== undefined); this.maxEntries = config?.maxEntries ?? (this.enabled ? 1000 : 0); this.ttlConfig = config?.ttl ?? {}; if (config?.ttlMs !== undefined) { @@ -60,12 +238,23 @@ export class SimpleCache { const compression = config?.compression; if (compression === true) { this.compressor = new CacheCompressor(); - } else if (compression && typeof compression === "object" && compression.enabled) { + } else if ( + compression && + typeof compression === "object" && + compression.enabled + ) { this.compressor = new CacheCompressor(compression.threshold); } else { this.compressor = undefined; } } + this.debug = new DebugMode( + typeof config === "object" && config?.debug !== undefined + ? typeof config.debug === "boolean" + ? { enabled: config.debug } + : config.debug + : undefined, + ); } /** Register a listener for cache lifecycle events. */ @@ -79,17 +268,15 @@ export class SimpleCache { this.listeners.delete(listener); } - private emit(event: CacheEvent): void { + private emit(eventOrType: CacheEvent | CacheEventType, key?: string): void { + if (this.listeners.size === 0) return; + const event: CacheEvent = + typeof eventOrType === "string" + ? { type: eventOrType, key, timestamp: Date.now() } + : eventOrType; for (const listener of this.listeners) { listener(event); } - this.debug = new DebugMode( - typeof config === "object" && config?.debug !== undefined - ? typeof config.debug === "boolean" - ? { enabled: config.debug } - : config.debug - : undefined - ); } /** Access the debug-mode controller for this cache instance. */ @@ -97,25 +284,6 @@ export class SimpleCache { return this.debug; } - /** - * Subscribe to cache lifecycle events (set, hit, miss, expire, invalidate, - * evict). Returns an unsubscribe function. - */ - on(listener: CacheEventListener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - private emit(type: CacheEventType, key: string): void { - if (this.listeners.size === 0) return; - const event: CacheEvent = { type, key, timestamp: Date.now() }; - for (const listener of this.listeners) { - listener(event); - } - } - get(key: string): T | undefined { if (!this.enabled) return undefined; const entry = this.store.get(key); @@ -194,6 +362,10 @@ export class SimpleCache { this.debug.log(`[cache] invalidate ${methodOrKey}`); } + delete(key: string): void { + this.invalidate(key); + } + clear(): void { this.store.clear(); this.debug.log("[cache] clear"); @@ -292,6 +464,7 @@ export class Cache { private readonly store = new Map>(); private readonly ttlMs: number | undefined; private readonly listeners = new Set(); + private readonly debug: DebugMode; /** * @param ttlMs Time-to-live in milliseconds. Omit (or pass `undefined`) diff --git a/src/cache/OptimisticCache.ts b/src/cache/OptimisticCache.ts index d310c925..3504b6fc 100644 --- a/src/cache/OptimisticCache.ts +++ b/src/cache/OptimisticCache.ts @@ -71,6 +71,34 @@ export interface RevalidateErrorEvent { error: unknown; } +export interface CompressedRecord { + __compressed: true; + data: string; + originalBytes: number; +} + +export type CacheEventType = + | "hit" + | "miss" + | "set" + | "expire" + | "invalidate" + | "eviction" + | "compression" + | "decompression"; + +export interface CacheEvent { + type: CacheEventType; + invoiceId: string; + value?: T; + timestamp?: number; + compressedBytes?: number; + originalBytes?: number; + ratio?: number; +} + +export type CacheEventHandler = (event: CacheEvent) => void; + /** Bookkeeping for a value written through `read()`/`set()`. */ interface FreshnessEntry { value: T; @@ -168,6 +196,8 @@ export class OptimisticCache { /** In-flight background revalidations, keyed by cache key. */ private readonly revalidations = new Map>(); private readonly revalidateErrorHandlers = new Set<(event: RevalidateErrorEvent) => void>(); + private readonly expiries = new Map(); + private readonly eventHandlers = new Set>(); private readonly defaultTtlMs: number; private readonly staleWhileRevalidateMs: number; private readonly now: () => number; @@ -213,14 +243,20 @@ export class OptimisticCache { * Write a committed value into the base cache with an optional TTL * (milliseconds). Emits a `set` event. */ - set(invoiceId: string, value: T, ttlMs?: number): void { + set(invoiceId: string, value: T, ttlMs: number = this.defaultTtlMs): void { + const now = this.now(); + this.freshness.set(invoiceId, { + value, + freshUntil: now + ttlMs, + staleUntil: now + ttlMs + this.staleWhileRevalidateMs, + }); this.base.set(invoiceId, value); - if (ttlMs !== undefined && ttlMs > 0) { - this.expiries.set(invoiceId, Date.now() + ttlMs); + if (ttlMs > 0) { + this.expiries.set(invoiceId, now + ttlMs); } else { this.expiries.delete(invoiceId); } - this._emit({ type: "set", invoiceId, value, timestamp: Date.now() }); + this._emit({ type: "set", invoiceId, value, timestamp: now }); } /** @@ -274,27 +310,6 @@ export class OptimisticCache { return () => this.revalidateErrorHandlers.delete(handler); } - /** - * Write a value into the cache with a freshness window. - * - * The value is served fresh for `ttlMs`, then — when - * `staleWhileRevalidateMs` is configured — continues to be served stale for - * the remainder of that extra window while a background refresh runs. - * - * @param key - Cache key. - * @param value - Value to cache. - * @param ttlMs - Freshness window in ms. Defaults to `defaultTtlMs`. - */ - set(key: string, value: T, ttlMs: number = this.defaultTtlMs): void { - const now = this.now(); - this.freshness.set(key, { - value, - freshUntil: now + ttlMs, - staleUntil: now + ttlMs + this.staleWhileRevalidateMs, - }); - this.base.set(key, value); - } - /** * Read `key`, falling back to `fetcher` on a miss. * diff --git a/src/client.ts b/src/client.ts index c8384fc8..f98d554b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -25,6 +25,14 @@ import type { StateMachineConfig } from "./types/state.js"; import { RpcLoadBalancer } from "./rpc/RpcLoadBalancer.js"; import { OfflineQueue, type QueuedOperation } from "./offlineQueue.js"; import type { EndpointConfig, RpcLoadBalancerOptions } from "./rpc/RpcLoadBalancer.js"; +import { + LogLevel, + SdkLogger, + type Logger, + type LoggerOptions, + type LogLevelString, + type LogEntry, +} from "./logger.js"; /** Events emitted by {@link StellarSplitClient}. */ export type SplitClientEventMap = { @@ -52,6 +60,15 @@ export type SplitClientEventMap = { dispute: import("./contractEvents.js").DisputeEvent; /** Emitted when a tier is unlocked on an invoice. */ tier_unlocked: import("./contractEvents.js").TierUnlockedEvent; + /** Emitted when an invoice recipient address is rerouted. */ + "recipient:rerouted": { invoiceId: string; oldAddress: string; newAddress: string }; + /** Emitted when the connected wallet account changes. */ + "wallet:accountChanged": string; + /** Emitted when a wallet is connected. */ + "wallet:connected": { walletName: string; address: string }; + /** Emitted when a wallet is disconnected. */ + "wallet:disconnected": { walletName: string }; + [event: string]: unknown; }; import { signTransaction } from "./wallet.js"; import { telemetry } from "./telemetry.js"; @@ -114,7 +131,6 @@ import type { import type { WaterfallPlan } from "./types/routing.js"; import { WaterfallInsufficientFundsError, - InvalidAttestationError, AlreadyRatedError, InvoiceNotReleasedForRatingError, NotEligibleToVoteError, @@ -185,6 +201,18 @@ import type { CreatorRating, ExtensionStatus, GroupStats, + MatchPledge, + Stream, + RoundCloseResult, + PayerRefund, + RoundInfo, + MilestoneResult, + Milestone, + MilestoneStatus, + ReferralClaimResult, + ReferralPayResult, + PauseStatus, + ProtocolStats, } from "./types.js"; import { estimateBridgeFee as _estimateBridgeFee, @@ -274,7 +302,7 @@ import type { IdempotencyConfig } from "./idempotency.js"; import { RollbackCoordinator } from "./splitRollbackCoordinator.js"; import { validateInvoicePayload } from "./payloadGuard.js"; import { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator.js"; -import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js"; +import { validateSplitRatiosOrThrow, type SplitConfig as RatioSplitConfig } from "./validators/splitRatioValidator.js"; import type { SplitConfig } from "./types.js"; import { checkTrustlines } from "./trustlineChecker.js"; import type { TrustlineCheckResult } from "./trustlineChecker.js"; @@ -513,10 +541,18 @@ export interface StellarSplitClientConfig { * instead of returning stale data while the transaction is pending. */ optimisticCache?: boolean; - /** Whether to validate the passphrase against the RPC node on startup. Defaults to true. */ - validatePassphrase?: boolean; - /** Map of available networks for the live switcher. */ - networks?: Record; + /** Optional public key for signing / transaction attribution. */ + publicKey?: string; + /** Optional JSON Schema for invoice metadata validation. */ + metadataSchema?: object; + /** Whether to throw on invalid invoice metadata. Defaults to true. */ + metadataThrowOnInvalid?: boolean; + /** Optional token gate controller for access policy verification. */ + tokenGateController?: import("./tokenGateController.js").TokenGateController; + /** Optional caller account ID for token gating. */ + callerAccountId?: string; + /** Optional OpenTelemetry configuration. */ + otel?: TelemetryOptions; /** * Optional override for the allowed invoice status transition graph used * by InvoiceStateMachine. When omitted, the default graph is used @@ -545,12 +581,26 @@ export interface StellarSplitClientConfig { * for inspecting in-flight transaction envelopes. Defaults to false. */ debug?: boolean; + /** + * Optional logger instance or options for SDK structured logging. + * When provided, the client emits structured log entries for lifecycle events, + * RPC interactions, errors, and debugging information. + */ + logger?: Logger | LoggerOptions | boolean; + /** + * Optional minimum log level for the SDK logger. + * Can be a LogLevel enum or string ("debug" | "info" | "warn" | "error" | "none"). + * Defaults to LogLevel.NONE (or LogLevel.DEBUG if `debug: true`). + */ + logLevel?: LogLevel | LogLevelString; /** * Optional fiat-to-asset price oracle adapter (see `PriceOracleAdapter` in * types.ts). Used by `convertFiatToAsset` in currencyConverter.ts to * resolve display conversions. Defaults to no oracle configured. */ priceOracle?: import("./types.js").PriceOracleAdapter; + /** Allow dynamic properties for plugin configurations and runtime extensions. */ + [key: string]: unknown; } /** Network configuration. */ @@ -751,7 +801,7 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- // Issue #874: SDK Logger // --------------------------------------------------------------------------- - private _logger: SdkLogger | null = null; + private _logger: Logger | null = null; // --------------------------------------------------------------------------- // Issue #876: Protocol Stats Cache @@ -769,19 +819,7 @@ export class StellarSplitClient extends TypedEventEmitter { // eslint-disable-next-line @typescript-eslint/no-explicit-any private get server(): any { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._resilientRpc) return this._resilientRpc; - /** - * return - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ return ( this._injectedRpcClient ?? this._rpcClient ?? @@ -800,12 +838,6 @@ export class StellarSplitClient extends TypedEventEmitter { * Fire lifecycle hooks for invoice creation. */ private _fireOnCreated(invoice: Invoice): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._hooks?.onCreated) { try { this._hooks.onCreated(invoice); @@ -819,12 +851,6 @@ export class StellarSplitClient extends TypedEventEmitter { * Fire lifecycle hooks for invoice payment. */ private _fireOnPaid(invoice: Invoice, payment: Payment): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._hooks?.onPaid) { try { this._hooks.onPaid(invoice, payment); @@ -838,12 +864,6 @@ export class StellarSplitClient extends TypedEventEmitter { * Fire lifecycle hooks for invoice release. */ private _fireOnReleased(invoice: Invoice): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._hooks?.onReleased) { try { this._hooks.onReleased(invoice); @@ -857,12 +877,6 @@ export class StellarSplitClient extends TypedEventEmitter { * Fire lifecycle hooks for invoice refund. */ private _fireOnRefunded(invoice: Invoice): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._hooks?.onRefunded) { try { this._hooks.onRefunded(invoice); @@ -876,12 +890,6 @@ export class StellarSplitClient extends TypedEventEmitter { * Fire lifecycle hooks for invoice cancellation. */ private _fireOnCancelled(invoice: Invoice): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._hooks?.onCancelled) { try { this._hooks.onCancelled(invoice); @@ -892,25 +900,36 @@ export class StellarSplitClient extends TypedEventEmitter { } constructor(config: StellarSplitClientConfig) { - /** - * super - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ super(); // Register so ProfilerSession can wrap this client's RPC server. StellarSplitClient._instances.add(this); - /** - * validateOrThrow - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ validateOrThrow(config); this.config = config; this._activeEnvironment = detectNetworkEnvironment(config.networkPassphrase); + + if (config.logger && typeof config.logger === "object" && "debug" in config.logger && typeof (config.logger as any).debug === "function") { + this._logger = config.logger as Logger; + } else if (config.logger && typeof config.logger === "object") { + this._logger = new SdkLogger({ + namespace: "StellarSplitClient", + ...(config.logger as LoggerOptions), + level: config.logLevel ?? (config.logger as LoggerOptions).level, + }); + } else if (config.logger === true) { + this._logger = new SdkLogger({ namespace: "StellarSplitClient", level: LogLevel.DEBUG }); + } else if (config.logLevel !== undefined) { + this._logger = new SdkLogger({ namespace: "StellarSplitClient", level: config.logLevel }); + } else if (config.debug) { + this._logger = new SdkLogger({ namespace: "StellarSplitClient", level: LogLevel.DEBUG }); + } else { + this._logger = new SdkLogger({ namespace: "StellarSplitClient", level: LogLevel.NONE }); + } + this._logger.debug("StellarSplitClient initialized", { + contractId: config.contractId, + network: this._activeEnvironment, + }); + this._metadataValidator = new InvoiceMetadataValidator( config.metadataSchema, config.metadataThrowOnInvalid ?? true, @@ -923,24 +942,14 @@ export class StellarSplitClient extends TypedEventEmitter { this._injectedRpcClient = config.rpcClient ?? null; this._rpcClient = config.container?.getRPCClient() ?? null; this._adapter = - config.container?.getWalletAdapter() ?? config.adapter ?? null; + (config.container?.getWalletAdapter() as unknown as WalletAdapter) ?? + config.adapter ?? + null; // Per-method timeout manager (Issue #1) - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.timeout !== undefined) { this._timeoutManager = new TimeoutManager(config.timeout); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.rpcEndpoints && config.rpcEndpoints.length > 0) { this._rpcLoadBalancer = new RpcLoadBalancer(config.rpcEndpoints, config.rpcLoadBalancer); this._rpcLoadBalancer.on("endpoint:demoted", (event) => this.emit("endpoint:demoted", event)); @@ -954,12 +963,6 @@ export class StellarSplitClient extends TypedEventEmitter { } // Circuit breaker + retry resilience layer (Issue #419) - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.circuitBreaker) { const rpcTarget = this._injectedRpcClient ?? this._rpcClient ?? this._mainServer; this._resilientRpc = new ResilientRpcClient( @@ -971,25 +974,11 @@ export class StellarSplitClient extends TypedEventEmitter { this._resilientRpc.on("circuit:close", () => this.emit("circuit:close", undefined)); this._resilientRpc.on("circuit:half-open", () => this.emit("circuit:half-open", undefined)); } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.advancedCircuitBreaker) { this._advancedCircuitBreaker = new AdvancedCircuitBreaker(config.advancedCircuitBreaker, { warn: (event) => this.emit("circuit_state_change", event), }); } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.optimisticCache) { this._optimisticCache = new OptimisticCache(); } @@ -1002,13 +991,6 @@ export class StellarSplitClient extends TypedEventEmitter { this._sorobanFeatureDetector.detect().catch(() => { // Best-effort startup probe; getSorobanFeatures() will retry on demand. }); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if ( !this._rpcClient && Array.isArray(config.rpcUrl) && @@ -1022,12 +1004,6 @@ export class StellarSplitClient extends TypedEventEmitter { // when an external RPC client hasn't been injected via the DI container. const wantsPool = !this._rpcClient && !this._standby && (config.rpcPoolSize ?? 0) >= 2; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (wantsPool) { this._effectiveRpcPoolSize = Math.min( Math.max(config.rpcPoolSize!, 1), @@ -1044,23 +1020,9 @@ export class StellarSplitClient extends TypedEventEmitter { this._cache = config.container?.getCacheStore() ?? (config.cache?.enabled ? new SimpleCache(config.cache) : null); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.telemetry) { telemetry.init(config.telemetry); } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.signingKeypair) { /** * addRequestInterceptor @@ -1078,13 +1040,6 @@ export class StellarSplitClient extends TypedEventEmitter { createRequestSigningInterceptor(config.signingKeypair), ); } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.compression?.enabled) { /** * addRequestInterceptor @@ -1120,56 +1075,23 @@ export class StellarSplitClient extends TypedEventEmitter { // Initialize hooks this._hooks = config.hooks ?? {}; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.retry) { this._retryOptions = config.retry; } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.horizonUrl) { this._horizonReader = new HorizonFallbackReader(config.horizonUrl); } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.idempotency) { this._idempotency = new IdempotencyManager(config.idempotency); } // WebSocket transport (Issue #377) - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.transport === 'websocket') { this._transportType = 'websocket'; this._activeTransportType = 'websocket'; this._wsTransport = new WebSocketTransport(primaryUrl, config.wsUrl); this._wsTransport.onFallback((event: { from: 'websocket'; to: 'http' }) => { this._activeTransportType = 'http'; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const cb of this._fallbackListeners) { try { cb(event); } catch { } } @@ -1180,12 +1102,6 @@ export class StellarSplitClient extends TypedEventEmitter { this._signer = config.signer ?? null; // Admin keypair for admin-only operations - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.adminKeypair) { this._adminKeypair = config.adminKeypair; } @@ -1199,49 +1115,19 @@ export class StellarSplitClient extends TypedEventEmitter { initHealthDashboard(this.server, this._dedup); // Register and initialize config-level plugins - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.plugins) { - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const plugin of config.plugins) { this.registerPlugin(plugin); } } - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const p of this._pluginInstances) { p.onInit?.(this); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.validatePassphrase !== false) { void this._validateStartupConfig(); } // OpenTelemetry instrumentation (opt-in, zero overhead when omitted/disabled). - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (config.otel?.enabled) { const otelExporter = config.otel.exporterUrl ? new OtelExporter({ @@ -1282,80 +1168,155 @@ export class StellarSplitClient extends TypedEventEmitter { this.config.networkPassphrase, primaryUrl, ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (result.mismatch) { throw new PassphraseMismatchError(result.configured, result.reported); } } /** - * Live network switcher. Migrates state and re-subscribes. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Returns the logger instance configured for this client. + * + * @returns The active {@link Logger} instance. + * + * @example + * ```typescript + * client.logger.info("Custom log message", { extra: "data" }); + * ``` + */ + public get logger(): Logger { + return this._logger ?? (this._logger = new SdkLogger({ namespace: "StellarSplitClient", level: LogLevel.NONE })); + } + + /** + * Sets the active logging level for the SDK client. + * + * @param level - Target log level as an enum or string name ('debug' | 'info' | 'warn' | 'error' | 'none'). + * + * @example + * ```typescript + * client.setLogLevel("debug"); + * client.setLogLevel(LogLevel.WARN); + * ``` + */ + public setLogLevel(level: LogLevel | LogLevelString): void { + this.logger.setLevel(level); + } + + /** + * Enables or disables debug logging dynamically. + * + * @param enabled - Whether debug logging should be active. Defaults to true. + * + * @example + * ```typescript + * client.enableDebugLogging(true); + * ``` + */ + public enableDebugLogging(enabled: boolean = true): void { + this.logger.setLevel(enabled ? LogLevel.DEBUG : LogLevel.NONE); + } + + /** + * Retrieves all operations currently queued for submission while offline. + * @returns An array of pending QueuedOperation objects. + * + * @example + * ```typescript + * const pendingOps = client.getOfflineQueue(); + * console.log(`Queued actions: ${pendingOps.length}`); + * ``` */ getOfflineQueue(): import("./offlineQueue.js").QueuedOperation[] { return this._offlineQueue ? this._offlineQueue.getQueue() : []; } + /** + * Clears and purges all operations currently pending in the offline queue. + * @returns void + * + * @example + * ```typescript + * client.clearOfflineQueue(); + * ``` + */ clearOfflineQueue(): void { if (this._offlineQueue) this._offlineQueue.clear(); } + /** + * Switches the client connection and re-subscribes to a different Stellar network environment. + * + * @param network - Target network environment ('mainnet' | 'testnet' | 'futurenet'). + * @returns Promise resolving once the network state migration is complete. + * @throws {Error} If switching network fails or connection cannot be established. + * + * @example + * ```typescript + * await client.switchTo('testnet'); + * ``` + */ async switchTo(network: "mainnet" | "testnet" | "futurenet"): Promise { const { NetworkSwitcher } = await import("./network/NetworkSwitcher.js"); return NetworkSwitcher.switchTo(network, this); } /** - * isShutdownInProgress - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Checks whether a graceful shutdown sequence is currently in progress. + * @returns True if shutdown has commenced, otherwise false. + * + * @example + * ```typescript + * if (!client.isShutdownInProgress()) { + * await client.pay({ invoiceId, amount, payer }); + * } + * ``` */ isShutdownInProgress(): boolean { return this._shutdownInProgress; } /** - * beginGracefulShutdown - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Initiates graceful shutdown of the client, rejecting new requests while draining in-flight operations. + * @returns void + * + * @example + * ```typescript + * process.on('SIGTERM', () => { + * client.beginGracefulShutdown(); + * }); + * ``` */ beginGracefulShutdown(): void { this._shutdownInProgress = true; } /** - * registerHorizonStreamManager - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Registers an active Horizon SSE stream manager with the client for lifecycle cleanup. + * + * @param manager - Manager object providing a stop() teardown hook. + * @returns A deregistration cleanup callback function. + * + * @example + * ```typescript + * const unregister = client.registerHorizonStreamManager(streamManager); + * ``` */ registerHorizonStreamManager(manager: { stop(): void }): () => void { this._managedHorizonStreams.add(manager); - /** - * return - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ return () => { this._managedHorizonStreams.delete(manager); }; } /** - * getInFlightRequests - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Returns a snapshot list of Soroban RPC requests currently in-flight, ordered by start time. + * @returns Array of active in-flight request tracking metadata. + * + * @example + * ```typescript + * const inFlight = client.getInFlightRequests(); + * console.log(`Active requests: ${inFlight.length}`); + * ``` */ getInFlightRequests(): InFlightRequestInfo[] { return [...this._inFlightRequests.values()].sort( @@ -1364,28 +1325,28 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * waitForInFlightRequests - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Waits for all pending in-flight Soroban RPC requests to settle. + * @returns Promise that resolves once all active requests have completed. + * + * @example + * ```typescript + * await client.waitForInFlightRequests(); + * ``` */ async waitForInFlightRequests(): Promise { await Promise.allSettled([...this._inFlightRequestPromises.values()]); } /** - * finalizeShutdown - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Completes the shutdown sequence by destroying plugins, terminating managed streams, and freeing resources. + * @returns Promise that resolves once final shutdown cleanup finishes. + * + * @example + * ```typescript + * await client.finalizeShutdown(); + * ``` */ async finalizeShutdown(): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._runtimeShutdownPromise) { return this._runtimeShutdownPromise; } @@ -1400,12 +1361,6 @@ export class StellarSplitClient extends TypedEventEmitter { * @throws {Error} If the method fails. */ destroySubscriptionManager(this.config.contractId); - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const manager of this._managedHorizonStreams) { manager.stop(); } @@ -1417,43 +1372,12 @@ export class StellarSplitClient extends TypedEventEmitter { this._pool?.dispose(); this._pool = null; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache && typeof (this._cache as any).persist === "function") { - /** - * await - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ await (this._cache as any).persist(); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache && typeof (this._cache as any).close === "function") { - /** - * await - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ await (this._cache as any).close(); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if ( this._rpcClient && /** @@ -1464,12 +1388,6 @@ export class StellarSplitClient extends TypedEventEmitter { */ typeof (this._rpcClient as any).close === "function" ) { - /** - * await - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ await (this._rpcClient as any).close(); } @@ -1480,21 +1398,8 @@ export class StellarSplitClient extends TypedEventEmitter { } private async _destroyPlugins(): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._pluginsDestroyed) return; this._pluginsDestroyed = true; - - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const plugin of [...this._pluginInstances].reverse()) { try { await plugin.onDestroy?.(this); @@ -1511,12 +1416,6 @@ export class StellarSplitClient extends TypedEventEmitter { } private _assertWritable(): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._shutdownInProgress) { throw new ShutdownInProgressError(); } @@ -1568,36 +1467,12 @@ export class StellarSplitClient extends TypedEventEmitter { private _instrumentOtel(): void { const proto = Object.getPrototypeOf(this) as object; const self = this as unknown as Record; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const name of Object.getOwnPropertyNames(proto)) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (name === "constructor" || name.startsWith("_")) continue; const descriptor = Object.getOwnPropertyDescriptor(proto, name); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!descriptor || typeof descriptor.value !== "function") continue; const original = descriptor.value as (...args: unknown[]) => unknown; const isAsync = original.constructor.name === "AsyncFunction"; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (isAsync) { self[name] = (...args: unknown[]) => this._withOtelSpanAsync(name, args, () => original.apply(this, args) as Promise); @@ -1611,35 +1486,11 @@ export class StellarSplitClient extends TypedEventEmitter { /** Best-effort `invoice.id` extraction from a method's first argument. */ private _otelInvoiceId(args: unknown[]): string | undefined { const first = args[0]; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof first === "string") return first; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (first && typeof first === "object") { const obj = first as Record; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const key of ["invoiceId", "invoice_id", "id"]) { const value = obj[key]; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof value === "string") return value; } } @@ -1648,20 +1499,8 @@ export class StellarSplitClient extends TypedEventEmitter { /** Best-effort `tx.hash` extraction from a method's resolved return value. */ private _otelTxHash(result: unknown): string | undefined { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (result && typeof result === "object") { const value = (result as Record).txHash; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof value === "string") return value; } return undefined; @@ -1672,20 +1511,8 @@ export class StellarSplitClient extends TypedEventEmitter { const span = this._otel.startSpan(name); span.setAttribute("stellar.network", this.config.networkPassphrase); const rpcUrl = Array.isArray(this.config.rpcUrl) ? this.config.rpcUrl[0] : this.config.rpcUrl; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (rpcUrl) span.setAttribute("rpc.url", rpcUrl); const invoiceId = this._otelInvoiceId(args); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (invoiceId) span.setAttribute("invoice.id", invoiceId); return span; } @@ -1695,12 +1522,6 @@ export class StellarSplitClient extends TypedEventEmitter { args: unknown[], fn: () => Promise, ): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._otelInitPromise) await this._otelInitPromise; const span = this._otelStartSpan(name, args); const startedAt = Date.now(); @@ -1709,12 +1530,6 @@ export class StellarSplitClient extends TypedEventEmitter { const durationMs = Date.now() - startedAt; span.setAttribute("rpc.duration_ms", durationMs); const txHash = this._otelTxHash(result); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (txHash) span.setAttribute("tx.hash", txHash); this._otel.recordRpcCall(durationMs, { method: name }); span.end(); @@ -1738,12 +1553,6 @@ export class StellarSplitClient extends TypedEventEmitter { const durationMs = Date.now() - startedAt; span.setAttribute("rpc.duration_ms", durationMs); const txHash = this._otelTxHash(result); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (txHash) span.setAttribute("tx.hash", txHash); this._otel.recordRpcCall(durationMs, { method: name }); span.end(); @@ -1760,59 +1569,28 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Internal startup validation. Throws PassphraseMismatchError if - * the configured passphrase doesn't match the RPC node. - */ - private async _validateStartupConfig(): Promise { - const primaryUrl = Array.isArray(this.config.rpcUrl) ? this.config.rpcUrl[0]! : this.config.rpcUrl; - const result = await NetworkPassphraseValidator.validate( - this.config.networkPassphrase, - primaryUrl - ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - if (result.mismatch) { - throw new PassphraseMismatchError(result.configured, result.reported); - } - } - - /** - * Live network switcher. Migrates state and re-subscribes. - * @param network - 'mainnet' | 'testnet' | 'futurenet' - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - public async switchTo(network: 'mainnet' | 'testnet' | 'futurenet'): Promise { - const { NetworkSwitcher } = await import("./network/NetworkSwitcher.js"); - return NetworkSwitcher.switchTo(network, this); - } - - /** - * Performs a health check of the client's RPC connection and contract. - * Resolves with status information or throws HealthCheckTimeoutError if taking > 5000ms. - */ - /** - * Return the current Soroban protocol feature flags, detected once at - * startup and re-probed automatically after the detector's staleness - * window (default 1 hour). Emits `protocolUpgradeDetected` on the - * underlying detector when a re-probe observes a version change. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Queries the connected Soroban RPC node and detects supported runtime protocol feature flags. + * @returns Promise resolving to detected SorobanFeatureFlags. + * + * @example + * ```typescript + * const features = await client.getSorobanFeatures(); + * console.log(`State expiration support: ${features.stateExpiration}`); + * ``` */ async getSorobanFeatures(): Promise { return this._sorobanFeatureDetector.detect(); } /** - * healthCheck - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Performs an end-to-end health probe against connected RPC endpoints and contract availability. + * @returns Promise resolving to a HealthCheckResult with status, latency, and node versions. + * + * @example + * ```typescript + * const health = await client.healthCheck(); + * console.log(`RPC health: ${health.healthy ? 'OK' : 'DEGRADED'}`); + * ``` */ async healthCheck(): Promise { const start = Date.now(); @@ -1844,12 +1622,6 @@ export class StellarSplitClient extends TypedEventEmitter { ), ]); } catch (e: any) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (e instanceof HealthCheckTimeoutError) { throw e; } @@ -1877,12 +1649,6 @@ export class StellarSplitClient extends TypedEventEmitter { await this.server.getContractWasmByContractId(this.config.contractId); contractDeployed = true; } catch (err: any) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!err.message?.includes("Could not obtain contract hash")) { // If we get here, it might be deployed but we couldn't fetch the wasm, // or it threw some other error. We'll conservatively say true if it's @@ -1909,21 +1675,14 @@ export class StellarSplitClient extends TypedEventEmitter { * @param enabled - Pass `true` to enable batching, `false` to disable. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * client.setBatchingEnabled(true); + * ``` */ setBatchingEnabled(enabled: boolean): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (enabled) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this._batcher) { this._batcher = new BatchedRpcClient({ fetchInvoice: (id: string) => this._fetchInvoice(id), @@ -1943,21 +1702,16 @@ export class StellarSplitClient extends TypedEventEmitter { * @param args Optional arguments array to invalidate a specific call. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * client.invalidateCache('getInvoice', ['123']); + * // Or invalidate all: + * client.invalidateCache(); + * ``` */ public invalidateCache(method?: string, args?: any[]): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof (this._cache as any).invalidate === "function") { (this._cache as any).invalidate(method, args); } @@ -1965,80 +1719,28 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Get cache statistics. - * @returns Cache stats including hits, misses, size, and keys. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * Retrieves performance statistics and hit/miss ratios for the client cache layer. + * @returns CacheStats object or null if caching is disabled. + * + * @example + * ```typescript + * const stats = client.getCacheStats(); + * if (stats) console.log(`Hit rate: ${stats.hitRate}%`); + * ``` */ public getCacheStats(): import("./cache.js").CacheStats | null { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache && typeof (this._cache as any).getStats === "function") { - /** - * return - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ return (this._cache as any).getStats(); } return null; } - /** - * Internal startup validation. Throws PassphraseMismatchError if - * the configured passphrase doesn't match the RPC node. - */ - private async _validateStartupConfig(): Promise { - const { NetworkPassphraseValidator } = await import("./network/NetworkPassphraseValidator.js"); - const { PassphraseMismatchError } = await import("./errors.js"); - const primaryUrl = Array.isArray(this.config.rpcUrl) - ? this.config.rpcUrl[0]! - : this.config.rpcUrl; - const result = await NetworkPassphraseValidator.validate( - this.config.networkPassphrase, - primaryUrl, - ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - if (result.mismatch) { - throw new PassphraseMismatchError(result.configured, result.reported); - } - } - - /** - * Live network switcher. Migrates state and re-subscribes. - * @param network - 'mainnet' | 'testnet' | 'futurenet' - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - public async switchTo( - network: "mainnet" | "testnet" | "futurenet", - ): Promise { - const { NetworkSwitcher } = await import("./network/NetworkSwitcher.js"); - return NetworkSwitcher.switchTo(network, this); - } - private _logAudit( method: string, params: Record, success: boolean, durationMs: number, ): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this._auditLogger) return; this._auditLogger.log({ timestamp: Date.now(), @@ -2107,13 +1809,6 @@ export class StellarSplitClient extends TypedEventEmitter { const timeoutMs = opts?.timeout ?? this._timeoutManager?.resolveTimeout(method); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (timeoutMs !== undefined) { return withTimeoutOrThrow(() => run(), timeoutMs, method); } @@ -2125,19 +1820,18 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Register a plugin that extends this client instance. - * Throws if a plugin with the same name has already been registered. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Registers an extensible plugin into the client lifecycle. + * + * @param plugin - Plugin implementation instance. + * @returns void + * @throws {PluginAlreadyRegisteredError} If a plugin with the same name is already registered. + * + * @example + * ```typescript + * client.registerPlugin(new LoggingPlugin()); + * ``` */ registerPlugin(plugin: StellarSplitPlugin): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._plugins.has(plugin.name)) { throw new PluginAlreadyRegisteredError(plugin.name); } @@ -2155,25 +1849,41 @@ export class StellarSplitClient extends TypedEventEmitter { * @param options - Optional plugin configuration. * @returns The client instance for method chaining. * @throws {PluginAlreadyRegisteredError} if a plugin with the same name exists. + * + * @example + * ```typescript + * client.use(new MetricsPlugin(), { sampleRate: 0.5 }); + * ``` */ use(plugin: SdkPlugin, options?: Record): this { this._pluginRegistry.use(plugin, options); return this; } - /** Deregister a middleware plugin by name. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Unregisters and removes a previously registered plugin by its unique name identifier. + * + * @param name - The unique identifier name of the plugin to remove. + * @returns void + * + * @example + * ```typescript + * client.removePlugin('logging-plugin'); + * ``` */ removePlugin(name: string): void { this._pluginRegistry.removePlugin(name); } - /** Return the names of all active middleware plugins. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Retrieves the names of all currently registered client plugins. + * @returns Array of registered plugin name strings. + * + * @example + * ```typescript + * const plugins = client.getPlugins(); + * console.log(`Active plugins: ${plugins.join(', ')}`); + * ``` */ getPlugins(): string[] { return this._pluginRegistry.getPlugins(); @@ -2215,10 +1925,13 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Remove all registered telemetry hooks. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Removes and resets all configured telemetry lifecycle hooks. + * @returns void + * + * @example + * ```typescript + * client.clearTelemetryHooks(); + * ``` */ clearTelemetryHooks(): void { this._telemetryHookManager.clearHooks(); @@ -2229,12 +1942,13 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Returns the resolved timeout (in ms) for each known SDK method. - * Reflects both the `default` timeout and any per-method overrides. - * Returns an empty object when no `timeout` option was set at construction. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Retrieves the configured execution timeout thresholds per SDK method. + * @returns Record mapping method names to timeout durations in milliseconds. + * + * @example + * ```typescript + * const timeouts = client.getTimeoutConfig(); + * ``` */ getTimeoutConfig(): Record { return this._timeoutManager?.getTimeoutConfig() ?? {}; @@ -2245,14 +1959,15 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Replace the default UUID v4 generator with a custom function. - * Useful for integrating OpenTelemetry span IDs or other systems. + * Sets the default generator function used to produce correlation trace IDs for RPC calls. + * + * @param generator - A function returning a unique trace identifier string. + * @returns void * * @example - * sdk.setDefaultTraceIdGenerator(() => opentelemetry.trace.getActiveSpan()?.spanContext().traceId ?? crypto.randomUUID()); - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * ```typescript + * client.setDefaultTraceIdGenerator(() => `trace-${Date.now()}-${Math.random()}`); + * ``` */ setDefaultTraceIdGenerator(generator: () => string): void { this._traceIdManager.setGenerator(generator); @@ -2267,18 +1982,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The ID of the invoice to dispute. * @returns The dispute ID and transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.disputeInvoice('123'); + * ``` */ async disputeInvoice(invoiceId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "dispute_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); // Assuming the creator is the one calling dispute @@ -2298,32 +2012,19 @@ export class StellarSplitClient extends TypedEventEmitter { * @param vote - The arbiter vote parameters. * @returns The dispute ID and transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.submitArbiterVote({ invoiceId: '123', arbiter: 'GA...', approve: true }); + * ``` */ async submitArbiterVote(vote: ArbiterVote): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "submit_arbiter_vote", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(vote.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(vote.arbiter, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(vote.approve, { type: "bool" }), ); const result = await this._submitTx(vote.arbiter, operation); @@ -2346,6 +2047,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param arbiter - The Stellar address of the arbiter (must sign). * @returns The dispute ID and transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.resolveDispute('123', 'GA...ARBITER'); + * ``` */ async resolveDispute( invoiceId: string, @@ -2355,12 +2061,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "resolve_dispute", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTx(arbiter, operation); @@ -2378,18 +2078,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The ID of the invoice to dispute. * @returns The dispute ID and transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.raiseDispute('123'); + * ``` */ async raiseDispute(invoiceId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "dispute_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTx(this.config.contractId, operation); @@ -2407,6 +2106,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The ID of the invoice to query. * @returns The dispute status. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const status = await client.getDisputeStatus('123'); + * ``` */ async getDisputeStatus(invoiceId: string): Promise { return this._withCache("getDisputeStatus", [invoiceId], async () => { @@ -2414,12 +2118,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "get_dispute_status", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const raw = (await this._simulateView(operation)) as Record< @@ -2460,31 +2158,18 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - Vote parameters (invoiceId, arbiter address, approve boolean) * @returns Transaction result with hash * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.voteDispute(voteParams); + * ``` */ async voteDispute(params: ArbiterVote): Promise<{ txHash: string }> { return this._withTelemetry("voteDispute", params as unknown as Record, async () => { const operation = this.contract.call( "vote_dispute", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.arbiter, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.approve, { type: "bool" }), ); @@ -2500,6 +2185,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param fileName - Optional file name for reference * @returns Transaction result with hash * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.addDisputeEvidence('123', { uri: 'ipfs://...', hash: '...' }); + * ``` */ async addDisputeEvidence( invoiceId: string, @@ -2509,12 +2199,6 @@ export class StellarSplitClient extends TypedEventEmitter { return this._withTelemetry( "addDisputeEvidence", { invoiceId, evidenceCid, fileName } as Record, - /** - * async - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ async () => { // Note: This assumes the contract has an add_dispute_evidence method // If not, this would need to be stored off-chain or via memo field @@ -2522,19 +2206,7 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "add_dispute_note", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(note, { type: "string" }), ); @@ -2556,6 +2228,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param path - The SSE path (e.g., "/invoice/123") * @returns SSE endpoint URL * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const sseUrl = client.getSSEEndpoint('/events'); + * ``` */ getSSEEndpoint(path: string): string { // This would be configured based on your backend SSE server @@ -2581,14 +2258,13 @@ export class StellarSplitClient extends TypedEventEmitter { * @param xdrBase64 - Base64-encoded transaction envelope XDR. * @returns A parsed envelope, or a notice when debug mode is disabled. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const parsed = client.parseXdrEnvelope(envelopeXdr); + * ``` */ parseXdrEnvelope(xdrBase64: string): ParsedEnvelope | { error: string } { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this.config.debug) { return { error: "Debug mode is disabled. Set config.debug = true to enable XDR parsing." }; } @@ -2600,20 +2276,74 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Create a new on-chain invoice. + * Creates a new on-chain invoice with multi-recipient split payout distribution rules. + * + * @param params - Invoice creation configuration including creator, token address, deadline, and recipient shares. + * @returns Promise resolving to the newly created invoice ID and transaction hash, or SimulationResult if simulate: true. + * @throws {ValidationError} If split shares do not sum to 100% or parameters are invalid. + * @throws {NftGateRequiredError} If the creator does not satisfy required token-gate conditions. * - * @returns The new invoice ID and the transaction hash. * @example - * const result = await client.createInvoice({ ...params }); - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * ```typescript + * const { invoiceId, txHash } = await client.createInvoice({ + * creator: 'GB...', + * token: 'CD...', + * deadline: deadlineFromDays(7), + * recipients: [ + * { address: 'G1...', amount: 60_0000000n }, + * { address: 'G2...', amount: 40_0000000n }, + * ], + * }); + * ``` */ async createInvoice( params: CreateInvoiceParams & { simulate: true }, ): Promise; + /** + * Creates a new on-chain invoice with multi-recipient split payout distribution rules. + * + * @param params - Invoice creation configuration including creator, token address, deadline, and recipient shares. + * @returns Promise resolving to the newly created invoice ID and transaction hash, or SimulationResult if simulate: true. + * @throws {ValidationError} If split shares do not sum to 100% or parameters are invalid. + * @throws {NftGateRequiredError} If the creator does not satisfy required token-gate conditions. + * + * @example + * ```typescript + * const { invoiceId, txHash } = await client.createInvoice({ + * creator: 'GB...', + * token: 'CD...', + * deadline: deadlineFromDays(7), + * recipients: [ + * { address: 'G1...', amount: 60_0000000n }, + * { address: 'G2...', amount: 40_0000000n }, + * ], + * }); + * ``` + */ async createInvoice( params: CreateInvoiceParams, ): Promise<{ invoiceId: string; txHash: string }>; + /** + * Creates a new on-chain invoice with multi-recipient split payout distribution rules. + * + * @param params - Invoice creation configuration including creator, token address, deadline, and recipient shares. + * @returns Promise resolving to the newly created invoice ID and transaction hash, or SimulationResult if simulate: true. + * @throws {ValidationError} If split shares do not sum to 100% or parameters are invalid. + * @throws {NftGateRequiredError} If the creator does not satisfy required token-gate conditions. + * + * @example + * ```typescript + * const { invoiceId, txHash } = await client.createInvoice({ + * creator: 'GB...', + * token: 'CD...', + * deadline: deadlineFromDays(7), + * recipients: [ + * { address: 'G1...', amount: 60_0000000n }, + * { address: 'G2...', amount: 40_0000000n }, + * ], + * }); + * ``` + */ async createInvoice( params: CreateInvoiceParams, ): Promise<{ invoiceId: string; txHash: string } | SimulationResult> { @@ -2631,22 +2361,10 @@ export class StellarSplitClient extends TypedEventEmitter { token: params.token, deadline: params.deadline, }, - /** - * async - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ async () => { const startTime = Date.now(); params = this._pluginRegistry.runBeforeCall("createInvoice", params); try { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this.config.payloadGuard) { /** * validateInvoicePayload @@ -2661,44 +2379,20 @@ export class StellarSplitClient extends TypedEventEmitter { // Pre-submission split ratio validation: catch malformed ratio arrays // early (ratio-sum violations, negative shares, duplicates, zeros). - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (params.recipients.length > 1) { const total = params.recipients.reduce((s, r) => s + r.amount, 0n); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (total > 0n) { - const splitConfig: SplitConfig = { + const splitConfig: RatioSplitConfig = { shares: params.recipients.map((r) => ({ address: r.address, share: Number(r.amount) / Number(total), })), }; - /** - * validateSplitRatiosOrThrow - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ validateSplitRatiosOrThrow(splitConfig); } } const gate = await this.checkNftGate(params.creator); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (gate.gated && !gate.hasNft) { throw new NftGateRequiredError( params.creator, @@ -2707,48 +2401,18 @@ export class StellarSplitClient extends TypedEventEmitter { } const recipientAddresses = params.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.address, { type: "address" }), ); const recipientAmounts = params.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.amount, { type: "i128" }), ); const operation = this.contract.call( "create_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.creator, { type: "address" }), xdr.ScVal.scvVec(recipientAddresses), xdr.ScVal.scvVec(recipientAmounts), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.token, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.deadline, { type: "u64" }), ); @@ -2801,6 +2465,13 @@ export class StellarSplitClient extends TypedEventEmitter { * @param overrides - Optional overrides for the cloned invoice fields. * @returns The new invoice ID. * @throws {InvoiceNotFoundError} If the source invoice does not exist. + * + * @example + * ```typescript + * const newInvoiceId = await client.cloneInvoice('123', { + * deadline: deadlineFromDays(14), + * }); + * ``` */ async cloneInvoice( sourceId: string | bigint, @@ -2816,12 +2487,6 @@ export class StellarSplitClient extends TypedEventEmitter { // ------------------------------------------------------------------- // Cloneability pre-flight validation (#486) // ------------------------------------------------------------------- - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!overrides.skipValidation) { const rpcUrl = Array.isArray(this.config.rpcUrl) ? this.config.rpcUrl[0] @@ -2831,25 +2496,12 @@ export class StellarSplitClient extends TypedEventEmitter { rpcUrl, }); const report = await validator.validate(sourceInvoice); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!report.cloneable) { throw new InvoiceNotCloneableError(report); } } const mapEntries: xdr.ScMapEntry[] = []; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (overrides.newDeadline !== undefined) { mapEntries.push( new xdr.ScMapEntry({ @@ -2860,12 +2512,6 @@ export class StellarSplitClient extends TypedEventEmitter { }), ); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (overrides.newAmounts !== undefined) { mapEntries.push( new xdr.ScMapEntry({ @@ -2876,12 +2522,6 @@ export class StellarSplitClient extends TypedEventEmitter { }), ); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (overrides.newRecipients !== undefined) { mapEntries.push( new xdr.ScMapEntry({ @@ -2915,12 +2555,6 @@ export class StellarSplitClient extends TypedEventEmitter { val: xdr.ScVal.scvVec( overrides.newOverflowBehavior !== undefined ? [ - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(overrides.newOverflowBehavior, { type: "symbol", }) as xdr.ScVal, @@ -2931,12 +2565,6 @@ export class StellarSplitClient extends TypedEventEmitter { ); const args: xdr.ScVal[] = [ - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(sourceId), { type: "u64" }), xdr.ScVal.scvMap(mapEntries), ]; @@ -2954,13 +2582,6 @@ export class StellarSplitClient extends TypedEventEmitter { const id = scValToNative(result.returnValue).toString() as string; newInvoiceId = id; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache) { const cloneDepth = /** @@ -2993,23 +2614,9 @@ export class StellarSplitClient extends TypedEventEmitter { return id; } catch (error) { telemetry.recordMethod("cloneInvoice", false, Date.now() - startTime); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (cacheWritten && newInvoiceId && this._cache) { this._cache.invalidate(newInvoiceId); } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (error instanceof Error && error.message.includes("not found")) { throw new InvoiceNotFoundError(sourceId.toString()); } @@ -3030,6 +2637,12 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The invoice whose lineage should be resolved. * @returns Invoice IDs as `bigint[]` in root-to-leaf order. * @throws {CloneChainTooDeepError} If the clone chain is cyclic or too deep. + * + * @example + * ```typescript + * const lineage = await client.getLineage('123'); + * console.log(`Root invoice: ${lineage.rootId}`); + * ``` */ async getLineage(invoiceId: string | bigint): Promise { const chain = await this.resolveCloneChain(invoiceId.toString()); @@ -3182,6 +2795,12 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - Parameters for the method. * @returns A {@link SimulationResult}; `success` is `false` (rather than a * throw) when the contract rejects the call. + * + * @example + * ```typescript + * const sim = await client.simulate('pay', { invoice_id: 123n, amount: 100_0000000n }); + * console.log(`Estimated CPU instructions: ${sim.cpuInstructions}`); + * ``` */ async simulate( method: string, @@ -3361,16 +2980,64 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Pay toward an invoice. + * Submits a payment toward an invoice, automatically splitting contributions among recipients. + * + * @param params - Payment parameters including payer address, invoice ID, and payment amount in stroops. + * @returns Promise resolving to the payment receipt with transaction hash and remaining balance. + * @throws {InvoiceNotFoundError} If the specified invoice ID cannot be found. + * @throws {PaymentExceedsRemainingError} If the payment exceeds the unpaid balance. + * @throws {DeadlinePassedError} If the payment deadline has elapsed. * - * @returns The transaction hash. * @example - * const result = await client.pay({ ...params }); - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * ```typescript + * const receipt = await client.pay({ + * payer: 'GA...PAYER', + * invoiceId: '123', + * amount: 50_0000000n, + * }); + * console.log(`Payment confirmed: ${receipt.txHash}`); + * ``` */ async pay(params: PayParams & { simulate: true }): Promise; + /** + * Submits a payment toward an invoice, automatically splitting contributions among recipients. + * + * @param params - Payment parameters including payer address, invoice ID, and payment amount in stroops. + * @returns Promise resolving to the payment receipt with transaction hash and remaining balance. + * @throws {InvoiceNotFoundError} If the specified invoice ID cannot be found. + * @throws {PaymentExceedsRemainingError} If the payment exceeds the unpaid balance. + * @throws {DeadlinePassedError} If the payment deadline has elapsed. + * + * @example + * ```typescript + * const receipt = await client.pay({ + * payer: 'GA...PAYER', + * invoiceId: '123', + * amount: 50_0000000n, + * }); + * console.log(`Payment confirmed: ${receipt.txHash}`); + * ``` + */ async pay(params: PayParams): Promise; + /** + * Submits a payment toward an invoice, automatically splitting contributions among recipients. + * + * @param params - Payment parameters including payer address, invoice ID, and payment amount in stroops. + * @returns Promise resolving to the payment receipt with transaction hash and remaining balance. + * @throws {InvoiceNotFoundError} If the specified invoice ID cannot be found. + * @throws {PaymentExceedsRemainingError} If the payment exceeds the unpaid balance. + * @throws {DeadlinePassedError} If the payment deadline has elapsed. + * + * @example + * ```typescript + * const receipt = await client.pay({ + * payer: 'GA...PAYER', + * invoiceId: '123', + * amount: 50_0000000n, + * }); + * console.log(`Payment confirmed: ${receipt.txHash}`); + * ``` + */ async pay(params: PayParams): Promise { // Issue #844 — `{ simulate: true }` performs a dry-run instead of submitting. if (params.simulate) { @@ -3383,22 +3050,10 @@ export class StellarSplitClient extends TypedEventEmitter { params = this._pluginRegistry.runBeforeCall("pay", params); let optimistic: { commit: CommitFn; rollback: RollbackFn } | null = null; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._optimisticCache) { const current = this._cache?.get(`getInvoice:${JSON.stringify([params.invoiceId])}`) as - | Invoice - | undefined; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ + | Invoice + | undefined; if (current) { const predicted = getOptimisticInvoice(current, { payer: params.payer, @@ -3412,33 +3067,9 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "pay", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.payer, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.amount, { type: "i128" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.donateOnFailure ?? false, { type: "bool" }), ); @@ -3474,12 +3105,22 @@ export class StellarSplitClient extends TypedEventEmitter { * partial submission wasn't allowed. */ /** - * Preflight checks before submitting a payment. + * Performs preflight validation before payment submission, checking balances, thresholds, and contract state. * - * Verifies the invoice is pending, not expired, and the payer has trustline/balance. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * @param params - Preflight parameters including payer address, invoice ID, and candidate payment amount. + * @returns Promise resolving to PreflightCheckResult detailing readiness and estimated fees. + * + * @example + * ```typescript + * const check = await client.preflightCheck({ + * payer: 'GA...PAYER', + * invoiceId: '123', + * amount: 50_0000000n, + * }); + * if (check.ok) { + * await client.pay({ payer: 'GA...PAYER', invoiceId: '123', amount: 50_0000000n }); + * } + * ``` */ async preflightCheck(params: { invoiceId: string; @@ -3491,12 +3132,6 @@ export class StellarSplitClient extends TypedEventEmitter { payerReadiness: import("./preflightChecker.js").PayerReadinessResult; }> { const invoice = await this.getInvoice(params.invoiceId); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (invoice.status !== "Pending") { throw new InvoiceNotPendingError(params.invoiceId); } @@ -3509,12 +3144,6 @@ export class StellarSplitClient extends TypedEventEmitter { getAccount: async (address: string) => { const normalizedBalances = await this.getAccountBalances(address); const balances = normalizedBalances.map((nb) => { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (nb.asset === "native") { return { balance: nb.balance, @@ -3551,20 +3180,30 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Fetch a payment receipt for an invoice and payer. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Fetches a confirmed transaction receipt and execution outcome by transaction hash. + * + * @param txHash - Hexadecimal transaction hash string. + * @returns Promise resolving to the detailed TxReceipt. + * + * @example + * ```typescript + * const receipt = await client.getReceipt('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + * ``` */ async getReceipt(invoiceId: string, payerAddress: string): Promise { return generatePaymentReceipt(this, invoiceId, payerAddress); } /** - * submitPayment - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Submits a pre-signed payment transaction envelope to the Soroban RPC network. + * + * @param params - Parameters containing pre-signed transaction XDR. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * const result = await client.submitPayment({ signedTxXdr }); + * ``` */ async submitPayment(params: { invoiceId: string; @@ -3576,33 +3215,14 @@ export class StellarSplitClient extends TypedEventEmitter { expectedContentHash?: string; }): Promise { // Verify invoice content hash if provided (integrity check) - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (params.expectedContentHash) { const invoice = await this.getInvoice(params.invoiceId); const valid = await verifyInvoiceHash(invoice, params.expectedContentHash); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!valid) { const computed = await hashInvoice(invoice); throw new InvoiceIntegrityError(params.invoiceId, params.expectedContentHash, computed); } } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!params.waterfallPlan) { return this.pay({ payer: params.payer, @@ -3615,12 +3235,6 @@ export class StellarSplitClient extends TypedEventEmitter { const plan = params.waterfallPlan; const allowPartial = params.allowPartial ?? plan.allowPartial ?? false; const hasUnsatisfiedTier = plan.steps.some((step) => !step.satisfied); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (hasUnsatisfiedTier && !allowPartial) { throw new WaterfallInsufficientFundsError(params.invoiceId, { unsatisfiedTiers: plan.steps.filter((s) => !s.satisfied).map((s) => s.recipient), @@ -3628,12 +3242,6 @@ export class StellarSplitClient extends TypedEventEmitter { } const fundedSteps = plan.steps.filter((step) => step.satisfied && step.amount > 0n); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (fundedSteps.length === 0) { throw new WaterfallInsufficientFundsError(params.invoiceId); } @@ -3641,33 +3249,9 @@ export class StellarSplitClient extends TypedEventEmitter { const operations = fundedSteps.map((step) => this.contract.call( "pay", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.payer, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(step.amount, { type: "i128" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.donateOnFailure ?? false, { type: "bool" }), ), ); @@ -3703,12 +3287,6 @@ export class StellarSplitClient extends TypedEventEmitter { fee: BASE_FEE, networkPassphrase: this.config.networkPassphrase, }); - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const operation of operations) { builder.addOperation(operation); } @@ -3723,12 +3301,6 @@ export class StellarSplitClient extends TypedEventEmitter { sourceAddress: string, ): Promise<{ txHash: string; returnValue: xdr.ScVal }> { const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw parseSorobanError(simResult.error); } @@ -3741,12 +3313,6 @@ export class StellarSplitClient extends TypedEventEmitter { const sendResult = await this.server.sendTransaction( TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase), ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Waterfall transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -3758,23 +3324,11 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while (getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20) { await new Promise((r) => setTimeout(r, 1500)); getResult = await this.server.getTransaction(txHash); attempts++; } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -3790,37 +3344,24 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - Array of invoice creation parameters (1-5 items) * @returns All created invoice IDs and the transaction hash * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const ids = await client.batchCreateInvoices([inv1, inv2]); + * ``` */ async batchCreateInvoices( params: CreateInvoiceParams[], ): Promise<{ invoiceIds: string[]; txHash: string }> { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (params.length === 0 || params.length > 5) { throw new InvalidBatchSizeError("1-5 items", params.length); } const invoiceParams = params.map((p) => { const recipientAddresses = p.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.address, { type: "address" }), ); const recipientAmounts = p.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.amount, { type: "i128" }), ); @@ -3856,21 +3397,9 @@ export class StellarSplitClient extends TypedEventEmitter { ); const firstParam = params[0]; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!firstParam) throw new InvalidBatchSizeError("non-empty array", 0); const result = await this._submitTx(firstParam.creator, operation); const invoiceIds = ( - /** - * scValToNative - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ scValToNative(result.returnValue) as (string | number)[] ).map((id) => id.toString()); return { invoiceIds, txHash: result.txHash }; @@ -3884,24 +3413,12 @@ export class StellarSplitClient extends TypedEventEmitter { args: any[], fetcher: () => Promise, ): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this._cache) { return fetcher(); } const key = `${methodName}:${JSON.stringify(args)}`; const cached = this._cache.get(key); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (cached !== undefined) { return cached as T; } @@ -3912,29 +3429,23 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Fetch an invoice by ID. Returns cached result if within TTL. + * Retrieves the current state, balances, and recipient distribution progress of an on-chain invoice. + * + * @param invoiceId - The unique identifier of the invoice to fetch. + * @returns Promise resolving to the normalized Invoice object. + * @throws {InvoiceNotFoundError} If the invoice does not exist on-chain. * - * When the invoice has an `accessPolicy` set and the client was constructed - * with a `tokenGateController`, the caller's token balance is verified before - * the invoice data is returned. Throws {@link TokenGateAccessDeniedError} when - * the caller does not meet the balance requirement (and `strict !== false`). * @example - * const result = await client.getInvoice({ ...params }); - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * ```typescript + * const invoice = await client.getInvoice('123'); + * console.log(`Invoice status: ${invoice.status}, total: ${invoice.amount}`); + * ``` */ async getInvoice( invoiceId: string, opts?: { retry?: PerMethodRetryOptions; dedupe?: boolean; traceId?: string; timeout?: number } ): Promise { const optimistic = this._optimisticCache?.get(invoiceId); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (optimistic !== undefined) { return optimistic; } @@ -3950,12 +3461,6 @@ export class StellarSplitClient extends TypedEventEmitter { opts?.retry ?? (this._retryOptions ? {} : undefined); let invoice: Invoice; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._retryOptions && effectiveRetry !== undefined) { invoice = await executeWithRetry( () => @@ -3970,12 +3475,6 @@ export class StellarSplitClient extends TypedEventEmitter { // Token-gate access check: verify caller balance when policy is set. const gateController = this.config.tokenGateController; const callerId = this.config.callerAccountId; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (gateController && callerId && invoice.accessPolicy) { await gateController.verify(callerId, invoice.accessPolicy); } @@ -3985,10 +3484,13 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Returns deduplication statistics for observability. - * @returns { deduped: number, total: number } — deduped is how many calls were short-circuited. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * Returns deduplication performance metrics for concurrent in-flight read operations. + * @returns DedupStats object containing deduplicated request counts. + * + * @example + * ```typescript + * const stats = client.getDedupStats(); + * ``` */ getDedupStats(): { deduped: number; total: number } { return this._dedup.getDedupStats(); @@ -4014,27 +3516,20 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Updates an invoice's status, validating the transition through - * InvoiceStateMachine. Throws InvalidTransitionError (with - * `{ from, to, allowed }`) if the transition isn't allowed from the - * invoice's current status. + * Explicitly transitions an invoice to a new status using the deterministic InvoiceStateMachine. * - * When optimisticCache is enabled, the result is written into it - * immediately so subsequent getInvoice() calls see the new status. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * @param invoiceId - Unique identifier of the invoice to transition. + * @param status - Target InvoiceStatus to transition to. + * @returns Promise resolving once the status transition is applied. + * + * @example + * ```typescript + * await client.updateInvoiceStatus('123', 'Released'); + * ``` */ async updateInvoiceStatus(invoiceId: string, to: InvoiceStatus): Promise { const current = await this.getInvoice(invoiceId); const updated = this._stateMachine.transition(current, to); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._optimisticCache) { this._optimisticCache.applyOptimistic(invoiceId, updated, current).commit(); } @@ -4058,6 +3553,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param changedBy - The Stellar address responsible for the change. * @returns The updated invoice after all mutations are applied. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const updated = await client.updateInvoice('123', { description: 'Updated invoice memo' }); + * ``` */ async updateInvoice( invoiceId: string, @@ -4070,13 +3570,6 @@ export class StellarSplitClient extends TypedEventEmitter { const tracker = (this.config as Record)["versionTracker"] as | import("./invoiceVersionTracker.js").InvoiceVersionTracker | undefined; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (tracker) { await tracker.record(invoiceId, current, changedBy); } @@ -4085,16 +3578,11 @@ export class StellarSplitClient extends TypedEventEmitter { // Write the updated invoice back into the optimistic cache so subsequent // getInvoice() calls see the new state immediately. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._optimisticCache) { this._optimisticCache.applyOptimistic(invoiceId, updated, current).commit(); } else if (this._cache) { - this._cache.invalidate("getInvoice", [invoiceId]); + this._cache.invalidate(`getInvoice:${JSON.stringify([invoiceId])}`); + this._cache.invalidate(invoiceId); } return updated; @@ -4112,6 +3600,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param opts - Optional per-subscription overrides (poll interval, backoff, storage). * @returns Unsubscribe function scoped to this handler only. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * client.subscribe('payment', (evt) => console.log('Payment observed:', evt)); + * ``` */ subscribe( invoiceId: string, @@ -4123,12 +3616,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Stop receiving InvoiceEvents for an invoice ID. Removes every handler - * registered via `subscribe()` for that invoice and releases the - * underlying poll timer. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Unsubscribes an event listener from a client event stream. + * + * @param eventType - Event name to unsubscribe from. + * @param listener - The listener function to remove. + * @returns This client instance for chaining. + * + * @example + * ```typescript + * client.unsubscribe('payment', myListener); + * ``` */ unsubscribe(invoiceId: string): void { /** @@ -4151,19 +3648,15 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * The rollback coordinator tracking split-payment leg checkpoints created - * by `submitPayment`'s waterfall path. Lazily instantiated on first use. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Retrieves the SplitRollbackCoordinator instance for coordinating multi-step atomic compensations. + * @returns SplitRollbackCoordinator instance. + * + * @example + * ```typescript + * const coordinator = client.getRollbackCoordinator(); + * ``` */ getRollbackCoordinator(): RollbackCoordinator { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this._rollbackCoordinator) { this._rollbackCoordinator = new RollbackCoordinator(this._idempotency ?? undefined); } @@ -4185,33 +3678,40 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Returns the current circuit breaker state, or null if no circuit breaker - * is configured. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Returns the current state ('CLOSED', 'OPEN', or 'HALF_OPEN') of the RPC resilience circuit breaker. + * @returns Current circuit breaker state string. + * + * @example + * ```typescript + * const state = client.getCircuitBreakerState(); + * console.log(`Circuit status: ${state}`); + * ``` */ getCircuitBreakerState(): import("./circuitBreaker.js").CircuitBreakerState | null { return this._resilientRpc?.circuitBreaker?.state ?? null; } - /** - * Returns the number of consecutive failures recorded by the circuit breaker, - * or null if no circuit breaker is configured. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Returns the current number of consecutive RPC failures tracked by the circuit breaker. + * @returns Consecutive failure counter integer. + * + * @example + * ```typescript + * const failures = client.getCircuitBreakerFailureCount(); + * ``` */ getCircuitBreakerFailureCount(): number | null { return this._resilientRpc?.circuitBreaker?.failureCount ?? null; } /** - * Manually reset the circuit breaker to the CLOSED state. - * No-op if no circuit breaker is configured. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Manually resets the RPC circuit breaker to the CLOSED operational state. + * @returns void + * + * @example + * ```typescript + * client.resetCircuitBreaker(); + * ``` */ resetCircuitBreaker(): void { this._resilientRpc?.circuitBreaker?.reset(); @@ -4229,12 +3729,6 @@ export class StellarSplitClient extends TypedEventEmitter { const fetchFn = async (): Promise => { const operation = this.contract.call( "get_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); @@ -4259,12 +3753,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw parseSorobanError(simResult.error, invoiceId); } @@ -4272,12 +3760,6 @@ export class StellarSplitClient extends TypedEventEmitter { const returnVal = ( simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse ).result?.retval; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!returnVal) throw new InvoiceNotFoundError(invoiceId); const invoice = this._parseInvoice(invoiceId, scValToNative(returnVal)); @@ -4287,12 +3769,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { let invoice: Invoice; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._degradation) { const result = await this._degradation.wrapRead(invoiceId, fetchFn); invoice = result.data; @@ -4338,6 +3814,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - Invoice ID to validate * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const report = await client.checkCompliance('123'); + * ``` */ async checkCompliance( invoiceId: string, @@ -4349,10 +3830,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Fetch all payments for an invoice. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Queries all recorded ledger payments contributed toward a specific invoice. + * + * @param invoiceId - ID of the invoice. + * @returns Promise resolving to an array of Payment records. + * + * @example + * ```typescript + * const payments = await client.getPayments('123'); + * console.log(`Total payments received: ${payments.length}`); + * ``` */ async getPayments(invoiceId: string): Promise { const startTime = Date.now(); @@ -4374,6 +3861,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param proof - CompletionProof object from the contract. * @returns { valid: boolean, reason?: string } * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const isValid = await client.verifyCompletionProof(proof); + * ``` */ verifyCompletionProof(proof: CompletionProof): { valid: boolean; @@ -4383,10 +3875,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Reconcile an invoice's reported funded amount with its payment records and historical payment events. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Reconciles recorded payment events against target recipient allocations, computing balance discrepancies. + * + * @param invoiceId - ID of the invoice to reconcile. + * @returns Promise resolving to ReconciliationResult with discrepancy amounts. + * + * @example + * ```typescript + * const reconciliation = await client.reconcilePayments('123'); + * if (reconciliation.discrepancy === 0n) console.log('Reconciled successfully'); + * ``` */ async reconcilePayments( invoiceId: string, @@ -4408,13 +3906,6 @@ export class StellarSplitClient extends TypedEventEmitter { const raw = event.data as Record; const rawAmount = raw.amount; let amount: bigint; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof rawAmount === "bigint") { amount = rawAmount; } else if (typeof rawAmount === "number") { @@ -4472,21 +3963,20 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Generate a typed receipt for a released invoice. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Generates a structured payment and settlement receipt for a released or settled invoice. + * + * @param invoiceId - The ID of the settled invoice. + * @returns Promise resolving to InvoiceReceipt. + * + * @example + * ```typescript + * const receipt = await client.generateReceipt('123'); + * ``` */ async generateReceipt(invoiceId: string): Promise { const startTime = Date.now(); try { const invoice = await this.getInvoice(invoiceId); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (invoice.status !== "Released") { throw new InvoiceNotReleasedError(invoiceId, invoice.status); } @@ -4520,6 +4010,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The invoice ID to snapshot. * @returns An immutable, timestamped snapshot object. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const snapshot = await client.snapshotInvoice('123'); + * ``` */ async snapshotInvoice(invoiceId: string): Promise { const invoice = await this.getInvoice(invoiceId); @@ -4532,6 +4027,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param ids - Invoice IDs to resolve. * @returns Results in the same order as the input IDs. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const result = await client.resolveBatch('batch-1'); + * ``` */ async resolveBatch(ids: string[]): Promise { const settled = await Promise.allSettled( @@ -4539,12 +4039,6 @@ export class StellarSplitClient extends TypedEventEmitter { ); return settled.map((result, i) => { const invoiceId = ids[i]!; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (result.status === "fulfilled") { return { invoiceId, success: true as const, invoice: result.value }; } @@ -4577,18 +4071,18 @@ export class StellarSplitClient extends TypedEventEmitter { * @param creatorAddress - The Stellar address of the invoice creator. * @returns Gate status including whether gating applies and NFT ownership. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const gate = await client.checkNftGate('GA...'); + * if (gate.hasNft) console.log('Token gate passed'); + * ``` */ async checkNftGate(creatorAddress: string): Promise { return this._withCache("checkNftGate", [creatorAddress], async () => { const startTime = Date.now(); const now = Date.now(); const cached = this._nftGateCache.get(creatorAddress); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (cached && now - cached.timestamp < NFT_GATE_CACHE_TTL_MS) { telemetry.recordMethod("checkNftGate", true, Date.now() - startTime); return cached.result; @@ -4597,12 +4091,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "check_nft_gate", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creatorAddress, { type: "address" }), ); @@ -4625,20 +4113,29 @@ export class StellarSplitClient extends TypedEventEmitter { }); } - /** Clears the NFT gate status cache (useful for testing). - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Flushes all cached NFT ownership verification results. + * @returns void + * + * @example + * ```typescript + * client.clearNftGateCache(); + * ``` */ clearNftGateCache(): void { this._nftGateCache.clear(); } /** - * Resolves the forward chain for an invoice. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Retrieves the forward dependency execution chain for cascading multi-hop invoice splits. + * + * @param invoiceId - The origin invoice ID. + * @returns Promise resolving to array of downstream linked invoice IDs. + * + * @example + * ```typescript + * const chain = await client.getForwardChain('123'); + * ``` */ async getForwardChain( invoiceId: string, @@ -4651,29 +4148,10 @@ export class StellarSplitClient extends TypedEventEmitter { const visited = new Set(); let currentId: string | undefined = invoiceId; let depth = 0; - - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while (currentId) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (depth >= 10) { throw new ForwardChainTooDeepError(10, currentId); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (visited.has(currentId)) { throw new CircularForwardChainError(currentId); } @@ -4694,19 +4172,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Gracefully shutdown the SDK client, flush pending operations, and close internal resources. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Fully terminates the client instance, cancelling scheduled pollers, closing active SSE streams, and resetting local caches. + * @returns Promise resolving once the client is terminated. + * + * @example + * ```typescript + * await client.shutdown(); + * ``` */ async shutdown(): Promise { // Tear down plugins in reverse registration order - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const p of this._pluginInstances.reverse()) { try { await p.onDestroy?.(this); @@ -4731,43 +4206,12 @@ export class StellarSplitClient extends TypedEventEmitter { this._pool?.dispose(); this._pool = null; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache && typeof (this._cache as any).persist === "function") { - /** - * await - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ await (this._cache as any).persist(); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._cache && typeof (this._cache as any).close === "function") { - /** - * await - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ await (this._cache as any).close(); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if ( this._rpcClient && /** @@ -4778,12 +4222,6 @@ export class StellarSplitClient extends TypedEventEmitter { */ typeof (this._rpcClient as any).close === "function" ) { - /** - * await - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ await (this._rpcClient as any).close(); } @@ -4792,21 +4230,20 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Cancel multiple invoices in parallel without aborting on individual failures. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Cancels multiple pending invoices in a single bulk operation. + * + * @param invoiceIds - Array of invoice IDs to cancel. + * @returns Promise resolving to BulkOperationResult. + * + * @example + * ```typescript + * const result = await client.bulkCancel(['101', '102', '103']); + * ``` */ async bulkCancel(ids: string[]): Promise { return this._executeBulkInvoiceAction(ids, (invoiceId) => { const operation = this.contract.call( "cancel_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); return this._submitTx(this.config.contractId, operation); @@ -4814,21 +4251,20 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Archive multiple invoices in parallel without aborting on individual failures. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Archives multiple completed or cancelled invoices to optimize active storage queries. + * + * @param invoiceIds - Array of invoice IDs to archive. + * @returns Promise resolving to BulkOperationResult. + * + * @example + * ```typescript + * await client.bulkArchive(['101', '102']); + * ``` */ async bulkArchive(ids: string[]): Promise { return this._executeBulkInvoiceAction(ids, (invoiceId) => { const operation = this.contract.call( "archive_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); return this._submitTx(this.config.contractId, operation); @@ -4836,10 +4272,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Export multiple invoices in parallel and return formatted results by invoice ID. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Exports multiple invoice records into a structured JSON or CSV data format. + * + * @param invoiceIds - Array of invoice IDs to export. + * @param format - Target format ('json' | 'csv'). + * @returns Promise resolving to exported string representation. + * + * @example + * ```typescript + * const csvData = await client.bulkExport(['101', '102'], 'csv'); + * ``` */ async bulkExport( ids: string[], @@ -4854,12 +4296,6 @@ export class StellarSplitClient extends TypedEventEmitter { ); return settled.reduce>((acc, result, index) => { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (result.status === "fulfilled") { acc[ids[index]!] = result.value.data; } @@ -4876,12 +4312,6 @@ export class StellarSplitClient extends TypedEventEmitter { ); return settled.map((result, index) => { const invoiceId = ids[index]!; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (result.status === "fulfilled") { return { invoiceId, success: true }; } @@ -4897,11 +4327,15 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Save an invoice template for reuse. + * Persists an invoice split blueprint template for recurring reuse. * - * @returns The transaction hash. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * @param template - Invoice template definition. + * @returns Promise resolving to saved template ID. + * + * @example + * ```typescript + * const id = await client.saveTemplate(myTemplate); + * ``` */ async saveTemplate( creator: string, @@ -4910,48 +4344,18 @@ export class StellarSplitClient extends TypedEventEmitter { const startTime = Date.now(); try { const recipientAddresses = template.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.address, { type: "address" }), ); const recipientAmounts = template.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.amount, { type: "i128" }), ); const operation = this.contract.call( "save_template", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(template.name, { type: "string" }), xdr.ScVal.scvVec(recipientAddresses), xdr.ScVal.scvVec(recipientAmounts), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(template.token, { type: "address" }), ); @@ -4965,11 +4369,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Create an invoice from a saved template. + * Instantiates and creates a new on-chain invoice using a saved template. * - * @returns The new invoice ID and the transaction hash. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * @param templateId - ID of the saved template. + * @param overrides - Optional parameter overrides. + * @returns Promise resolving to created invoice result. + * + * @example + * ```typescript + * const { invoiceId } = await client.createFromTemplate('monthly-payroll', { deadline: deadlineFromDays(30) }); + * ``` */ async createFromTemplate( creator: string, @@ -4980,26 +4389,8 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "create_from_template", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(templateName, { type: "string" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(deadline, { type: "u64" }), ); @@ -5022,10 +4413,13 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * List all template names for a creator. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Lists all saved invoice split templates. + * @returns Promise resolving to array of saved templates. + * + * @example + * ```typescript + * const templates = await client.listTemplates(); + * ``` */ async listTemplates(creator: string): Promise { return this._withCache("listTemplates", [creator], async () => { @@ -5033,12 +4427,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "list_templates", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), ); @@ -5054,10 +4442,15 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Get all recurring invoices for a creator. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Retrieves recurring invoice generation schedules registered for a creator. + * + * @param creator - Stellar address of the creator account. + * @returns Promise resolving to array of RecurringInvoiceSchedule objects. + * + * @example + * ```typescript + * const recurring = await client.getRecurringInvoices('GA...'); + * ``` */ async getRecurringInvoices(creator: string): Promise { const startTime = Date.now(); @@ -5084,30 +4477,22 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Cancel a recurring invoice. + * Cancels an active recurring invoice schedule. * - * @returns The transaction hash. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * @param scheduleId - ID of the recurring schedule to cancel. + * @returns Promise resolving once the schedule is cancelled. + * + * @example + * ```typescript + * await client.cancelRecurring('rec-123'); + * ``` */ async cancelRecurring(invoiceId: string, creator: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "cancel_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), ); @@ -5121,11 +4506,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Update amounts for a recurring invoice. + * Updates the billing amount for a recurring invoice schedule. * - * @returns The transaction hash. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * @param scheduleId - ID of the recurring schedule. + * @param amount - New billing amount in stroops. + * @returns Promise resolving once the amount is updated. + * + * @example + * ```typescript + * await client.updateRecurringAmount('rec-123', 75_0000000n); + * ``` */ async updateRecurringAmount( invoiceId: string, @@ -5138,19 +4528,7 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "update_recurring_amount", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), xdr.ScVal.scvVec(amountVals), ); @@ -5179,6 +4557,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param options - Optional pagination options (cursor, limit). Default page size is 20. * @returns A page of invoice IDs with a nextCursor for subsequent pages. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const invoices = await client.getInvoicesByCreator('GA...CREATOR'); + * ``` */ async getInvoicesByCreator( creator: string, @@ -5187,23 +4570,11 @@ export class StellarSplitClient extends TypedEventEmitter { return this._withCache( "getInvoicesByCreator", [creator, options], - /** - * async - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ async () => { const limit = options.limit ?? 20; const operation = this.contract.call( "get_invoices_by_creator", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), ); @@ -5232,6 +4603,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param options - Optional pagination options (cursor, limit). Default page size is 20. * @returns A page of invoice IDs with a nextCursor for subsequent pages. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const myInvoices = await client.getInvoicesByRecipient('GA...RECIPIENT'); + * ``` */ async getInvoicesByRecipient( recipient: string, @@ -5240,23 +4616,11 @@ export class StellarSplitClient extends TypedEventEmitter { return this._withCache( "getInvoicesByRecipient", [recipient, options], - /** - * async - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ async () => { const limit = options.limit ?? 20; const operation = this.contract.call( "get_invoices_by_recipient", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(recipient, { type: "address" }), ); @@ -5275,12 +4639,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation failed: ${simResult.error}`, @@ -5292,12 +4650,6 @@ export class StellarSplitClient extends TypedEventEmitter { const returnVal = ( simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse ).result?.retval; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!returnVal) throw new NoReturnValueError("getInvoicesByRecipient"); const raw = scValToNative(returnVal); @@ -5319,33 +4671,14 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Query invoices with a rich, composable filter, sort and pagination. - * - * All filtering is applied client-side against the invoices the contract - * returns for `filter.creator` (or, when no creator is given, the full set - * of invoices the caller has already indexed locally). The result is a - * {@link InvoicePage} carrying a `nextCursor` for subsequent pages. - * - * Because the contract has no query endpoint for status/amount/date, this - * method fetches the candidate invoice IDs first and then applies every - * predicate in-memory — prefer supplying `creator` to bound the fetch. + * Searches and filters invoices matching structured query criteria. * - * @param filter - The composed query. All supplied fields combine with AND. - * @returns A page of matching invoices plus a cursor when more remain. - * @throws {ValidationError} If the filter is inconsistent (e.g. - * `minAmount > maxAmount`) or the cursor is malformed. + * @param filter - Filter parameters including status, date range, or token. + * @returns Promise resolving to filtered Invoice array. * * @example - * ```ts - * const page = await client.queryInvoices({ - * creator: "GABC...", - * status: ["Pending", "Released"], - * minAmount: 1000n, - * fromDate: Date.UTC(2026, 0, 1) / 1000, - * tags: ["urgent"], - * sort: "highest", - * limit: 10, - * }); + * ```typescript + * const pending = await client.queryInvoices({ status: 'Pending' }); * ``` */ async queryInvoices(filter: InvoiceFilter = {}): Promise { @@ -5381,10 +4714,13 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Check the health of the RPC endpoint. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Queries health and responsiveness of configured RPC node endpoints. + * @returns Promise resolving to RPC endpoint health details. + * + * @example + * ```typescript + * const status = await client.checkRPCHealth(); + * ``` */ async checkRPCHealth(): Promise { const health = await checkRPCHealth(this.server); @@ -5393,58 +4729,75 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Create a group of linked invoices. + * Aggregates multiple related invoices into an invoice group for coordinated settlement. * - * @returns The new group ID and transaction hash. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * @param params - Group creation configuration. + * @returns Promise resolving to created group identifier. + * + * @example + * ```typescript + * const groupId = await client.createGroup({ name: 'Project Milestone 1', invoiceIds: ['1', '2'] }); + * ``` */ async createGroup( creator: string, invoiceIds: string[], + ): Promise<{ groupId: string; txHash: string }>; + async createGroup( + creator: string, + name: string, + description: string, + ): Promise<{ groupId: string; txHash: string }>; + async createGroup( + creator: string, + second: string | string[], + third?: string, ): Promise<{ groupId: string; txHash: string }> { - const invoiceIdsBigInt = invoiceIds.map((id) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - nativeToScVal(BigInt(id), { type: "u64" }), - ); + if (Array.isArray(second)) { + const invoiceIds = second; + const invoiceIdsBigInt = invoiceIds.map((id) => + nativeToScVal(BigInt(id), { type: "u64" }), + ); - const operation = this.contract.call( - "create_invoice_group", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - nativeToScVal(creator, { type: "address" }), - xdr.ScVal.scvVec(invoiceIdsBigInt), - ); + const operation = this.contract.call( + "create_invoice_group", + nativeToScVal(creator, { type: "address" }), + xdr.ScVal.scvVec(invoiceIdsBigInt), + ); - const result = await this._submitTx(creator, operation); - const groupId = scValToNative(result.returnValue).toString(); - return { groupId, txHash: result.txHash }; + const result = await this._submitTx(creator, operation); + const groupId = scValToNative(result.returnValue).toString(); + return { groupId, txHash: result.txHash }; + } else { + const name = second; + const description = third ?? ""; + const operation = this.contract.call( + "create_group", + nativeToScVal(creator, { type: "address" }), + nativeToScVal(name, { type: "string" }), + nativeToScVal(description, { type: "string" }), + ); + + const result = await this._submitTx(creator, operation); + const groupId = scValToNative(result.returnValue).toString(); + return { groupId, txHash: result.txHash }; + } } /** - * Get the status of an invoice group. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Retrieves the status and aggregate funding progress of an invoice group. + * + * @param groupId - Unique identifier of the invoice group. + * @returns Promise resolving to InvoiceGroupStatus. + * + * @example + * ```typescript + * const status = await client.getGroupStatus('grp-123'); + * ``` */ async getGroupStatus(groupId: string): Promise { const operation = this.contract.call( "get_invoice_group", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(groupId), { type: "u64" }), ); @@ -5455,12 +4808,6 @@ export class StellarSplitClient extends TypedEventEmitter { return { groupId, invoiceIds: (raw.invoiceIds as (string | number)[]).map((id) => - /** - * String - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ String(id), ), allFunded: Boolean(raw.allFunded), @@ -5468,18 +4815,44 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Release all invoices in a group. + * Releases payouts for all fully funded invoices within an invoice group. * - * @returns The transaction hash. - * @param params - The parameters for the method. - * @throws {Error} If the method fails. + * @param groupId - Unique identifier of the group. + * @returns Promise resolving to group release TxResult. + * + * @example + * ```typescript + * const tx = await client.releaseGroup('grp-123'); + * ``` */ async releaseGroup( creator: string, groupId: string, options: { simulate: true }, ): Promise; + /** + * Releases payouts for all fully funded invoices within an invoice group. + * + * @param groupId - Unique identifier of the group. + * @returns Promise resolving to group release TxResult. + * + * @example + * ```typescript + * const tx = await client.releaseGroup('grp-123'); + * ``` + */ async releaseGroup(creator: string, groupId: string): Promise; + /** + * Releases payouts for all fully funded invoices within an invoice group. + * + * @param groupId - Unique identifier of the group. + * @returns Promise resolving to group release TxResult. + * + * @example + * ```typescript + * const tx = await client.releaseGroup('grp-123'); + * ``` + */ async releaseGroup( creator: string, groupId: string, @@ -5491,19 +4864,7 @@ export class StellarSplitClient extends TypedEventEmitter { } const operation = this.contract.call( "release_invoice_group", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(creator, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(groupId), { type: "u64" }), ); @@ -5519,6 +4880,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @returns Transaction hash * @throws {InvalidAttestationError} if statement exceeds 256 chars * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.attestInvoice('123', { type: 'KYC_VERIFIED', hash: '...' }); + * ``` */ async attestInvoice( invoiceId: string, @@ -5546,6 +4912,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param payer - Payer address * @returns Transaction hash * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.revokeAttestation('123', 'att-1'); + * ``` */ async revokeAttestation(invoiceId: string, payer: string): Promise { const operation = this.contract.call( @@ -5563,6 +4934,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - Invoice ID * @returns Array of attestations * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const attestations = await client.getAttestations('123'); + * ``` */ async getAttestations(invoiceId: string): Promise { const operation = this.contract.call( @@ -5579,30 +4955,6 @@ export class StellarSplitClient extends TypedEventEmitter { })); } - /** - * Create a campaign group. - * @param creator - Creator address - * @param name - Group name - * @param description - Group description - * @returns Group ID and transaction hash - * @throws {Error} If the method fails. - */ - async createGroup( - creator: string, - name: string, - description: string - ): Promise<{ groupId: string; txHash: string }> { - const operation = this.contract.call( - "create_group", - nativeToScVal(creator, { type: "address" }), - nativeToScVal(name, { type: "string" }), - nativeToScVal(description, { type: "string" }) - ); - - const result = await this._submitTx(creator, operation); - const groupId = scValToNative(result.returnValue).toString(); - return { groupId, txHash: result.txHash }; - } /** * Add an invoice to a group. @@ -5611,6 +4963,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - Invoice ID to add * @returns Transaction hash * @throws {Error} If caller is not the group owner or other errors. + * + * @example + * ```typescript + * await client.addInvoiceToGroup('grp-1', '104'); + * ``` */ async addInvoiceToGroup( creator: string, @@ -5633,6 +4990,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param groupId - Group ID * @returns Group statistics * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const stats = await client.getGroupStats('grp-1'); + * ``` */ async getGroupStats(groupId: string): Promise { const operation = this.contract.call( @@ -5655,6 +5017,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param groupId - Group ID * @returns Array of invoice IDs in the group * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const invoices = await client.getGroupInvoices('grp-1'); + * ``` */ async getGroupInvoices(groupId: string): Promise { const operation = this.contract.call( @@ -5673,6 +5040,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @returns Transaction hash * @throws {NotEligibleToVoteError} if caller has not contributed * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.voteExtendDeadline('123', 'GA...', deadlineFromDays(30)); + * ``` */ async voteExtendDeadline(invoiceId: string, payer: string): Promise { const operation = this.contract.call( @@ -5690,6 +5062,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - Invoice ID * @returns Extension status * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const ext = await client.getExtensionStatus('123'); + * ``` */ async getExtensionStatus(invoiceId: string): Promise { const operation = this.contract.call( @@ -5716,6 +5093,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @throws {InvoiceNotReleasedForRatingError} if invoice is not released * @throws {AlreadyRatedError} if caller has already rated * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.rateInvoice('123', 5, 'Fast settlement and great communication'); + * ``` */ async rateInvoice(invoiceId: string, stars: 1 | 2 | 3 | 4 | 5, payer: string): Promise { const operation = this.contract.call( @@ -5734,6 +5116,12 @@ export class StellarSplitClient extends TypedEventEmitter { * @param creator - Creator address * @returns Creator rating with total ratings and average stars * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const rating = await client.getCreatorRating('GA...CREATOR'); + * console.log(`Average score: ${rating.averageScore}/5 (${rating.totalReviews} reviews)`); + * ``` */ async getCreatorRating(creator: string): Promise { const operation = this.contract.call( @@ -5759,6 +5147,12 @@ export class StellarSplitClient extends TypedEventEmitter { * @param amount - Gross amount in stroops * @returns Fee breakdown with gross, fee, net, and feeBps * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const fee = await client.calculateFee(100_0000000n, 'USDC'); + * console.log(`Fee: ${fee.protocolFee}`); + * ``` */ async calculateFee(amount: bigint): Promise { return calculateFee(amount, this.config); @@ -5770,6 +5164,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param address - Token contract address * @returns Token metadata (symbol, name, decimals) * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const contractId = client.resolveToken('USDC'); + * ``` */ async resolveToken(address: string): Promise { return resolveToken(address, this.config); @@ -5781,6 +5180,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param txHash - Transaction hash * @returns Payment proof with deterministic SHA-256 hash * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const proof = await client.generatePaymentProof(txHash); + * ``` */ async generatePaymentProof(txHash: string): Promise { const m = await import("./proof.js"); @@ -5796,6 +5200,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param payerAddress - The Stellar address of the payer. * @returns Payment receipt with proofHash and optional JSON serialization. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const receipt = await client.generatePaymentReceipt('123'); + * ``` */ async generatePaymentReceipt( invoiceId: string, @@ -5823,31 +5232,20 @@ export class StellarSplitClient extends TypedEventEmitter { * @param payments - Array of { invoiceId, amount } (must be non-empty). * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const results = await client.batchPay([ + * { invoiceId: '1', amount: 10_0000000n }, + * { invoiceId: '2', amount: 20_0000000n } + * ]); + * ``` */ async batchPay(payer: string, payments: BatchPayment[]): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (payments.length === 0) { throw new ValidationError("payments array must not be empty"); } - - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const p of payments) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!p.invoiceId || isNaN(Number(p.invoiceId))) { throw new ValidationError(`Invalid invoiceId: ${p.invoiceId}`); } @@ -5869,12 +5267,6 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "batch_pay", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(payer, { type: "address" }), xdr.ScVal.scvVec(paymentVals), ); @@ -5897,6 +5289,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @returns A `BatchVerificationResult` describing per-invoice validity, * the common token (if uniform), and any aggregated errors. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const ok = client.verifyBatchPay(results); + * ``` */ async verifyBatchPay( payments: BatchPayment[], @@ -5905,20 +5302,8 @@ export class StellarSplitClient extends TypedEventEmitter { const results = await this.resolveBatch(invoiceIds); const resolvedInvoices: Invoice[] = []; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const r of results) { const rr = r as { success: boolean; invoice?: Invoice }; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (rr.success && rr.invoice) { resolvedInvoices.push(rr.invoice); } @@ -5934,6 +5319,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param amount - Payment amount in stroops. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const val = client.validatePayment({ invoiceId: '123', amount: 50_0000000n }); + * ``` */ async validatePayment( invoiceId: string, @@ -5943,12 +5333,6 @@ export class StellarSplitClient extends TypedEventEmitter { let balance: bigint | null = null; const payerAddress = await this._getPayerAddress(); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (payerAddress) { try { balance = await this._getTokenBalance(payerAddress, invoice.token); @@ -5961,12 +5345,6 @@ export class StellarSplitClient extends TypedEventEmitter { // Add trustline-check results for non-XLM assets when the config has a // horizon URL set. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this.config.horizonUrl && invoice.token !== "native") { try { const { Horizon } = await import("@stellar/stellar-sdk"); @@ -5977,20 +5355,8 @@ export class StellarSplitClient extends TypedEventEmitter { recipients, invoice.token, ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!trustResult.allReady) { const missing = trustResult.entries.filter((e) => !e.hasTrustline); - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const m of missing) { result.errors.push( `Recipient ${m.address} has no trustline for token ${invoice.token}. Establish a trustline before releasing.`, @@ -6007,12 +5373,6 @@ export class StellarSplitClient extends TypedEventEmitter { } private async _getPayerAddress(): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._adapter && typeof this._adapter.getAddress === "function") { return await this._adapter.getAddress(); } @@ -6026,40 +5386,16 @@ export class StellarSplitClient extends TypedEventEmitter { const tokenContract = new Contract(tokenAddress); const operation = tokenContract.call( "balance", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(address, { type: "address" }), ); const result = await this._simulateView(operation); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof result === "bigint") { return result; } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof result === "string" || typeof result === "number") { return BigInt(result); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof result === "object" && result !== null && "balance" in result) { return BigInt( (result as Record).balance as string | number, @@ -6087,6 +5423,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param options - Optional SSE options (base URL, backoff, EventSource factory). * @returns Unsubscribe function that permanently stops the stream. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const unsubscribe = client.subscribeToInvoice('123', (event) => console.log(event)); + * ``` */ subscribeToInvoice( invoiceId: string, @@ -6105,6 +5446,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param intervalMs - Poll interval in milliseconds (default: 5000). * @returns Unsubscribe function that stops the stream. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const unsubscribe = client.subscribeToInvoice('123', (event) => console.log(event)); + * ``` */ subscribeToInvoice( invoiceId: string, @@ -6119,6 +5465,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param intervalMs - Poll interval in milliseconds (default: 5000). * @returns Unsubscribe function that stops the stream. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const unsubscribe = client.subscribeToInvoice('123', (event) => console.log(event)); + * ``` */ subscribeToInvoice( invoiceId: string, @@ -6126,10 +5477,16 @@ export class StellarSplitClient extends TypedEventEmitter { intervalMs?: number, ): () => void; /** - * subscribeToInvoice - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Subscribes to updates for an invoice with a persistent listener callback. + * + * @param invoiceId - Invoice ID to monitor. + * @param callback - Handler function called with invoice event. + * @returns Unsubscribe function. + * + * @example + * ```typescript + * const unsubscribe = client.subscribeToInvoice('123', (event) => console.log(event)); + * ``` */ subscribeToInvoice( invoiceId: string, @@ -6140,19 +5497,7 @@ export class StellarSplitClient extends TypedEventEmitter { optionsOrInterval?: Partial | number, ): () => void { // WebSocket transport: use the active WebSocket connection when configured - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._wsTransport && this._transportType === 'websocket') { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof handlerOrCallbacks !== "function") { throw new ValidationError( "WebSocket transport requires a function handler. Callbacks object is not supported." @@ -6171,13 +5516,6 @@ export class StellarSplitClient extends TypedEventEmitter { }; this._wsTransport.subscribe(invoiceId, wrappedHandler); - - /** - * return - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ return () => { this._wsTransport?.unsubscribe(invoiceId, wrappedHandler); }; @@ -6186,20 +5524,8 @@ export class StellarSplitClient extends TypedEventEmitter { // A function handler with options object selects the SSE transport. // A function handler with number interval selects the RPC polling transport (new API). // A callbacks object selects the legacy RPC-polling transport. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof handlerOrCallbacks === "function") { // If second arg is a number, treat as polling - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof optionsOrInterval === "number") { return _subscribeToInvoice( this.server, @@ -6214,12 +5540,6 @@ export class StellarSplitClient extends TypedEventEmitter { (optionsOrInterval as Partial | undefined) ?? {}; const baseUrl = options.baseUrl ?? this.config.horizonUrl; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!baseUrl) { throw new ValidationError( "subscribeToInvoice (SSE) requires a base URL: set `horizonUrl` in the client config or pass `{ baseUrl }` in options.", @@ -6245,21 +5565,15 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Returns the current status of the active transport. + * Queries the operational status of the active network transport (RPC, WebSocket, or SSE). + * @returns TransportStatus object. * - * When `transport: 'websocket'` was configured, returns `{ type: 'websocket', connected, reconnectAttempts }`. - * Otherwise returns `{ type: 'http', connected: true, reconnectAttempts: 0 }`. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * @example + * ```typescript + * const status = client.getTransportStatus(); + * ``` */ getTransportStatus(): TransportStatus { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._wsTransport) { return this._wsTransport.getStatus(); } @@ -6267,12 +5581,15 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Register a callback for the `transport:fallback` event. - * Fired when the WebSocket transport fails to connect after 3 attempts - * and the client falls back to HTTP polling. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Registers a callback to be notified when the client falls back to an alternate transport channel. + * + * @param callback - Callback function invoked upon transport failover. + * @returns void + * + * @example + * ```typescript + * client.onTransportFallback((from, to) => console.warn(`Fell back from ${from} to ${to}`)); + * ``` */ onTransportFallback(cb: (event: { from: 'websocket'; to: 'http' }) => void): void { this._fallbackListeners.push(cb); @@ -6291,6 +5608,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param operation - The contract operation to include. * @returns Base64-encoded XDR of the prepared (unsigned) transaction. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const tx = await client.buildTransaction({ method: 'pay', args: [...] }); + * ``` */ async buildTransaction( sourceAddress: string, @@ -6307,12 +5629,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation failed: ${simResult.error}`, @@ -6331,6 +5647,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param signedXdr - Base64-encoded signed transaction XDR. * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const result = await client.submitTransaction(signedTx); + * ``` */ async submitTransaction(signedXdr: string): Promise { const tx = TransactionBuilder.fromXDR( @@ -6338,13 +5659,6 @@ export class StellarSplitClient extends TypedEventEmitter { this.config.networkPassphrase, ); const sendResult = await this.server.sendTransaction(tx); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -6354,13 +5668,6 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20 @@ -6369,13 +5676,6 @@ export class StellarSplitClient extends TypedEventEmitter { getResult = await this.server.getTransaction(txHash); attempts++; } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -6388,58 +5688,32 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Simulate a createInvoice call without submitting a transaction. + * Runs a preflight simulation of invoice creation to predict resource consumption and validate split shares. * - * @returns The expected invoice ID and estimated fee in stroops. - * @throws StellarSplitError with the simulation error message on failure. - * @param params - The parameters for the method. + * @param params - Invoice creation parameters. + * @returns Promise resolving to SimulationResult. + * + * @example + * ```typescript + * const sim = await client.simulateCreateInvoice(invoiceParams); + * ``` */ async simulateCreateInvoice( params: CreateInvoiceParams, ): Promise { const recipientAddresses = params.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.address, { type: "address" }), ); const recipientAmounts = params.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.amount, { type: "i128" }), ); const operation = this.contract.call( "create_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.creator, { type: "address" }), xdr.ScVal.scvVec(recipientAddresses), xdr.ScVal.scvVec(recipientAmounts), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.token, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.deadline, { type: "u64" }), ); @@ -6463,12 +5737,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation error: ${simResult.error}`, @@ -6477,15 +5745,9 @@ export class StellarSplitClient extends TypedEventEmitter { ); } - const success = - simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse; - const returnVal = success.result?.retval; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ + const success = + simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse; + const returnVal = success.result?.retval; if (!returnVal) throw new NoReturnValueError("simulateCreateInvoice"); const invoiceId = scValToNative(returnVal).toString(); @@ -6495,35 +5757,21 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Simulate a pay call without submitting a transaction. + * Runs a preflight simulation of an invoice payment to predict fee requirements and verify balances. * - * @returns The estimated fee in stroops. - * @throws StellarSplitError with the simulation error message on failure. - * @param params - The parameters for the method. + * @param params - Payment parameters. + * @returns Promise resolving to SimulationResult. + * + * @example + * ```typescript + * const sim = await client.simulatePay({ invoiceId: '123', amount: 10_0000000n, payer: 'GA...' }); + * ``` */ async simulatePay(params: PayParams): Promise { const operation = this.contract.call( "pay", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.payer, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.amount, { type: "i128" }), ); @@ -6547,12 +5795,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation error: ${simResult.error}`, @@ -6578,6 +5820,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @returns Swap preview including estimated output, price impact, and route. * @throws Error if no DEX is configured on the invoice. * @throws StellarSplitError with the simulation error message on failure. + * + * @example + * ```typescript + * const estimate = await client.previewTokenSwap(usdcAddress, xlmAddress, 100_0000000n); + * ``` */ async previewTokenSwap( invoiceId: string, @@ -6585,12 +5832,6 @@ export class StellarSplitClient extends TypedEventEmitter { sourceAmount: bigint, ): Promise { // Check if DEX is configured - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this.config.dexContractId) { throw new Error( "DEX contract not configured on this client. Set dexContractId in StellarSplitClientConfig.", @@ -6606,26 +5847,8 @@ export class StellarSplitClient extends TypedEventEmitter { // Call the DEX's quote method to get the swap estimate const operation = dexContract.call( "quote", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(sourceToken, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(invoice.token, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(sourceAmount, { type: "i128" }), ); @@ -6646,12 +5869,6 @@ export class StellarSplitClient extends TypedEventEmitter { // Simulate the DEX quote const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `DEX quote simulation failed: ${simResult.error}`, @@ -6663,12 +5880,6 @@ export class StellarSplitClient extends TypedEventEmitter { const success = simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse; const returnVal = success.result?.retval; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!returnVal) { throw new NoReturnValueError("previewTokenSwap"); } @@ -6704,18 +5915,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param operation - The contract operation to estimate fees for. * @returns FeeEstimate with fee in stroops and a congestion indicator. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const feeStroops = await client.estimateFee({ method: 'create_invoice' }); + * ``` */ async estimateFee(operation: xdr.Operation): Promise { const simResult = (await this._simulateView(operation)) as { minResourceFee?: string; error?: string; }; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (simResult.error) throw new SimulationFailedError( `Fee estimation failed: ${simResult.error}`, @@ -6750,25 +5960,18 @@ export class StellarSplitClient extends TypedEventEmitter { * @param signers - Ordered list of signer addresses. * @returns Fully signed transaction XDR. * @throws If any signer fails to sign. + * + * @example + * ```typescript + * const fullySignedTx = await client.collectSignatures(tx, ['G1...', 'G2...']); + * ``` */ async collectSignatures(xdrStr: string, signers: string[]): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (signers.length === 0) { throw new ValidationError("signers array must not be empty"); } let current = xdrStr; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const signer of signers) { try { current = await (this._adapter @@ -6792,10 +5995,14 @@ export class StellarSplitClient extends TypedEventEmitter { // invalidateCache implementation moved up to support MethodCache requirements - /** Clear the entire invoice cache. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Clears all internal caches including RPC simulation caches, invoice caches, and stats caches. + * @returns void + * + * @example + * ```typescript + * client.clearCache(); + * ``` */ clearCache(): void { this._cache?.clear(); @@ -6812,6 +6019,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param source - Stellar address of the account submitting the transaction. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.bumpStorageTtl(contractId); + * ``` */ async bumpStorageTtl( invoiceId: string, @@ -6837,6 +6049,11 @@ export class StellarSplitClient extends TypedEventEmitter { * and an array of ledger keys to extend. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.bumpStorageTtlBatch([contract1, contract2]); + * ``` */ async bumpStorageTtlBatch( options: TtlExtensionOptions, @@ -6876,20 +6093,58 @@ export class StellarSplitClient extends TypedEventEmitter { | NetworkEnvironment.TESTNET | NetworkEnvironment.FUTURENET, ): Promise; + /** + * Live switches network environments and re-initializes contract bindings. + * + * @param target - Target network name or configuration. + * @returns Promise resolving once network switch is complete. + * + * @example + * ```typescript + * await client.switchNetwork('testnet'); + * ``` + */ switchNetwork( environment: NetworkEnvironment.CUSTOM, preset: NetworkPreset, ): Promise; + /** + * Live switches network environments and re-initializes contract bindings. + * + * @param target - Target network name or configuration. + * @returns Promise resolving once network switch is complete. + * + * @example + * ```typescript + * await client.switchNetwork('testnet'); + * ``` + */ switchNetwork(preset: NetworkPreset): Promise; /** * @deprecated Pass a {@link NetworkEnvironment} member or a * {@link NetworkPreset} instead. The legacy `"testnet" | "mainnet"` string * and {@link NetworkConfig} forms are still accepted for backwards * compatibility. + * + * @example + * ```typescript + * await client.switchNetwork('testnet'); + * ``` */ switchNetwork( network: "mainnet" | "testnet" | "futurenet" | NetworkConfig, ): Promise; + /** + * Live switches network environments and re-initializes contract bindings. + * + * @param target - Target network name or configuration. + * @returns Promise resolving once network switch is complete. + * + * @example + * ```typescript + * await client.switchNetwork('testnet'); + * ``` + */ async switchNetwork( environmentOrPreset: | NetworkEnvironment @@ -7074,15 +6329,15 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Snapshot of the underlying RPC connection pool's statistics. + * Retrieves reserve balances and liquidity statistics for an AMM pool. * - * Returns `null` when the client is configured with the default single - * connection. When `rpcPoolSize >= 2` was set at construction time, the - * returned {@link PoolStats} reports pool size, available slots, - * cumulative request / error / recycle counters, and per-slot details. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * @param poolId - Identifier of the liquidity pool. + * @returns Promise resolving to PoolStats. + * + * @example + * ```typescript + * const pool = await client.getPoolStats('pool-1'); + * ``` */ getPoolStats() { return this._pool ? this._pool.getStats() : null; @@ -7093,36 +6348,25 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Build an unsigned transaction XDR for a multi-sig invoice operation. - * Each signer in the provided list must independently sign this XDR and - * return a CoSignature for use with submitWithCoSignatures. + * Collects co-signatures required for co-creator approved invoices. * - * @param invoiceId - The invoice requiring multiple signatures. - * @param signers - Stellar addresses of all required co-signers. - * @returns Base64-encoded unsigned transaction XDR. - * @throws {Error} If the method fails. + * @param txHash - Transaction hash needing approval. + * @returns Promise resolving to collected signatures array. + * + * @example + * ```typescript + * const sigs = await client.collectCoSignatures(txHash); + * ``` */ async collectCoSignatures( invoiceId: string, signers: string[], ): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (signers.length === 0) throw new NoSignerProvidedError(); const firstSigner = signers[0]!; const operation = this.contract.call( "release_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); @@ -7136,12 +6380,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation failed: ${simResult.error}`, @@ -7161,6 +6399,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param signatures - Array of CoSignature objects (one per signer). * @returns The transaction hash. * @throws If fewer signatures are provided than the invoice requires. + * + * @example + * ```typescript + * await client.submitWithCoSignatures(tx, signatures); + * ``` */ async submitWithCoSignatures( invoiceId: string, @@ -7168,13 +6411,6 @@ export class StellarSplitClient extends TypedEventEmitter { ): Promise { const invoice = await this.getInvoice(invoiceId); const requiredCount = invoice.recipients.length; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (signatures.length < requiredCount) { throw new InsufficientSignaturesError(signatures.length, requiredCount); } @@ -7184,37 +6420,18 @@ export class StellarSplitClient extends TypedEventEmitter { firstSig.signedXdr, this.config.networkPassphrase, ) as Transaction; - - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (let i = 1; i < signatures.length; i++) { const sig = signatures[i]!; const otherTx = TransactionBuilder.fromXDR( sig.signedXdr, this.config.networkPassphrase, ) as Transaction; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const decoratedSig of otherTx.signatures) { mergedTx.addDecoratedSignature(decoratedSig); } } const sendResult = await this.server.sendTransaction(mergedTx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -7224,12 +6441,6 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20 @@ -7238,13 +6449,6 @@ export class StellarSplitClient extends TypedEventEmitter { getResult = await this.server.getTransaction(txHash); attempts++; } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -7261,6 +6465,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param caller - Stellar address of the account initiating the rollover. * @returns The new invoice ID and the rollover transaction hash. * @throws If newDeadline is not in the future. + * + * @example + * ```typescript + * const rolled = await client.rolloverInvoice('123', deadlineFromDays(30)); + * ``` */ async rolloverInvoice( invoiceId: string, @@ -7298,21 +6507,9 @@ export class StellarSplitClient extends TypedEventEmitter { private async _needsCoCreatorApproval(invoiceId: string): Promise { const operation = this.contract.call( "needs_co_creator_approval", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const raw = await this._simulateView(operation); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!raw) { throw new CoCreatorApprovalNotRequiredError(invoiceId); } @@ -7329,6 +6526,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param signer - Stellar address of the co-creator submitting approval. * @returns The transaction hash. * @throws {CoCreatorApprovalNotRequiredError} If the invoice does not require co-creator sign-off. + * + * @example + * ```typescript + * await client.submitCoCreatorApproval('123', 'GA...COCREATOR'); + * ``` */ async submitCoCreatorApproval( invoiceId: string, @@ -7340,19 +6542,7 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "submit_co_creator_approval", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(signer, { type: "address" }), ); const result = await this._submitTx(signer, operation); @@ -7378,6 +6568,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The invoice ID to query. * @returns Array of Stellar addresses that have approved. * @throws {CoCreatorApprovalNotRequiredError} If the invoice does not require co-creator sign-off. + * + * @example + * ```typescript + * const approvals = await client.getCoCreatorApprovals('123'); + * ``` */ async getCoCreatorApprovals(invoiceId: string): Promise { const startTime = Date.now(); @@ -7386,12 +6581,6 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "get_co_creator_approvals", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const raw = (await this._simulateView(operation)) as string[]; @@ -7421,6 +6610,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param signer - Stellar address of the co-creator revoking their approval. * @returns The transaction hash. * @throws {CoCreatorApprovalNotRequiredError} If the invoice does not require co-creator sign-off. + * + * @example + * ```typescript + * await client.revokeCoCreatorApproval('123', 'GA...COCREATOR'); + * ``` */ async revokeCoCreatorApproval( invoiceId: string, @@ -7432,19 +6626,7 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "revoke_co_creator_approval", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(signer, { type: "address" }), ); const result = await this._submitTx(signer, operation); @@ -7476,6 +6658,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param payerAddress - Stellar address of the payer. * @returns Cooldown status with inCooldown flag and cooldownEndsAt timestamp. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const cooldown = await client.getPaymentCooldown('123', 'GA...PAYER'); + * ``` */ async getPaymentCooldown( invoiceId: string, @@ -7485,19 +6672,7 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "payment_cooldown", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(payerAddress, { type: "address" }), ); const raw = (await this._simulateView(operation)) as Record< @@ -7533,24 +6708,20 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Compute the time remaining until a scheduled release fires. - * Accepts an Invoice or a raw timestamp (Unix seconds). - * When an Invoice is provided, `scheduled_release_at` (or `scheduledReleaseDate`) is used if present; - * returns null if neither field is set on the invoice. + * Computes time remaining until a timelocked or scheduled invoice release executes. * - * @param invoiceOrTimestamp - An Invoice object or a Unix timestamp (seconds). - * @returns A structured countdown with total_seconds, days, hours, minutes, seconds, and whether overdue. Null when no scheduled release date. - * @throws {Error} If the method fails. + * @param invoiceOrTimestamp - Invoice object or Unix release timestamp. + * @returns ScheduledReleaseCountdown object with days, hours, minutes, and seconds remaining, or null. + * + * @example + * ```typescript + * const countdown = client.getScheduledReleaseCountdown(invoice); + * if (countdown) console.log(`${countdown.days}d ${countdown.hours}h left`); + * ``` */ getScheduledReleaseCountdown( invoiceOrTimestamp: Invoice | number, ): ScheduledReleaseCountdown | null { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof invoiceOrTimestamp !== "number") { return getScheduledReleaseCountdown(invoiceOrTimestamp); } @@ -7568,6 +6739,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param amount - Bid amount in stroops. * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.placeBid('GA...BIDDER', '123', 80_0000000n); + * ``` */ async placeBid( bidder: string, @@ -7578,26 +6754,8 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "place_bid", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(bidder, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(amount, { type: "i128" }), ); const result = await this._submitTx(bidder, operation); @@ -7623,14 +6781,13 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The invoice ID to fetch payments for. * @returns All payments merged and sorted by timestamp (ascending). * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const history = await client.getPaymentHistory('123'); + * ``` */ async getPaymentHistory(invoiceId: string): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._batcher) { return this._batcher.getPaymentHistory(invoiceId); } @@ -7648,19 +6805,7 @@ export class StellarSplitClient extends TypedEventEmitter { const operations = Array.from({ length: NUM_SHARDS }, (_, i) => this.contract.call( "get_payment_shard", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(i, { type: "u32" }), ), ); @@ -7670,26 +6815,8 @@ export class StellarSplitClient extends TypedEventEmitter { ); const allPayments: Payment[] = []; - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const result of shardResults) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (result.status === "fulfilled" && Array.isArray(result.value)) { - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (const raw of result.value as unknown[]) { const p = raw as Record; allPayments.push({ @@ -7728,25 +6855,18 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The ID of the invoice to settle. * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.settleAuction('GA...', '123'); + * ``` */ async settleAuction(caller: string, invoiceId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "settle_auction", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(caller, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTx(caller, operation); @@ -7763,18 +6883,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The ID of the invoice to query. * @returns Auction information including active state, highest bid, and end time. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const auction = await client.getAuctionInfo('123'); + * ``` */ async getAuctionInfo(invoiceId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "get_auction_info", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const raw = (await this._simulateView(operation)) as Record< @@ -7826,12 +6945,6 @@ export class StellarSplitClient extends TypedEventEmitter { expectedAddress: string, ): void { const kpAddress = adminKeypair.publicKey(); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (kpAddress !== expectedAddress) { throw new AdminOperationError( `Admin keypair public key (${kpAddress}) does not match the provided admin address (${expectedAddress})`, @@ -7873,12 +6986,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw parseSorobanError(simResult.error); } @@ -7890,13 +6997,6 @@ export class StellarSplitClient extends TypedEventEmitter { const sendResult = await this.server.sendTransaction( TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase), ); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -7908,12 +7008,6 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20 @@ -7922,13 +7016,6 @@ export class StellarSplitClient extends TypedEventEmitter { getResult = await this.server.getTransaction(txHash); attempts++; } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -7948,18 +7035,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param admin - Stellar address of the admin (must sign). * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.adminFreeze('123', 'GA...ADMIN'); + * ``` */ async adminFreeze(invoiceId: string, admin: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "admin_freeze", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTx(admin, operation); @@ -7985,6 +7071,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param adminKeypair - Authorized admin keypair used to sign the transaction. * @returns {@link AdminFreezeResult} with txHash, invoiceId, adminAddress, reason, and timestamp. * @throws {AdminOperationError} When the keypair's public key is invalid. + * + * @example + * ```typescript + * await client.adminFreezeInvoice('123', 'Regulatory investigation', adminKeypair); + * ``` */ async adminFreezeInvoice( invoiceId: string, @@ -7997,12 +7088,6 @@ export class StellarSplitClient extends TypedEventEmitter { // Verify the keypair is consistent with the effective admin configuration. // When an adminKeypair is configured at construction time, the passed // keypair must have the same public key. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._adminKeypair && this._adminKeypair.publicKey() !== adminAddress) { throw new AdminOperationError( `Provided admin keypair (${adminAddress}) does not match the configured admin keypair (${this._adminKeypair.publicKey()})`, @@ -8013,19 +7098,7 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "admin_freeze", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(reason, { type: "string" }), ); const result = await this._submitTxWithKeypair( @@ -8074,6 +7147,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param adminKeypair - Authorized admin keypair used to sign the transaction. * @returns {@link AdminUnfreezeResult} with txHash, invoiceId, adminAddress, and timestamp. * @throws {AdminOperationError} When the keypair's public key is invalid. + * + * @example + * ```typescript + * await client.adminUnfreezeInvoice('123', adminKeypair); + * ``` */ async adminUnfreezeInvoice( invoiceId: string, @@ -8083,12 +7161,6 @@ export class StellarSplitClient extends TypedEventEmitter { const startTime = Date.now(); // Verify the keypair is consistent with the effective admin configuration. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._adminKeypair && this._adminKeypair.publicKey() !== adminAddress) { throw new AdminOperationError( `Provided admin keypair (${adminAddress}) does not match the configured admin keypair (${this._adminKeypair.publicKey()})`, @@ -8099,12 +7171,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "admin_unfreeze", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTxWithKeypair( @@ -8149,6 +7215,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - Queue action parameters. * @returns The action ID and transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const actionId = await client.queueAction(actionParams); + * ``` */ async queueAction( params: QueueActionParams, @@ -8157,40 +7228,10 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "queue_action", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.caller, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.actionType, { type: "symbol" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.target, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.value, { type: "i128" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.eta), { type: "u64" }), ); const result = await this._submitTx(params.caller, operation); @@ -8211,18 +7252,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param admin - Stellar address of the admin (must sign). * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.adminUnfreeze('123', 'GA...ADMIN'); + * ``` */ async adminUnfreeze(invoiceId: string, admin: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "admin_unfreeze", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTx(admin, operation); @@ -8240,25 +7280,18 @@ export class StellarSplitClient extends TypedEventEmitter { * @param actionId - The ID of the action to execute. * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.executeAction('GA...', 'act-123'); + * ``` */ async executeAction(caller: string, actionId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "execute_action", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(caller, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(actionId), { type: "u64" }), ); const result = await this._submitTx(caller, operation); @@ -8281,30 +7314,23 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The invoice ID to query. * @returns The parsed CrossChainRef, or null if none is set. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const ref = await client.getCrossChainRef('123'); + * ``` */ async getCrossChainRef(invoiceId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "get_cross_chain_ref", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const raw = (await this._simulateView(operation)) as Record< string, unknown > | null; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!raw) { telemetry.recordMethod( "getCrossChainRef", @@ -8332,13 +7358,20 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Cancel a queued action before it has been executed. - * @param caller - Stellar address of the caller (must sign). - * @param actionId - The ID of the action to cancel. - * @returns The transaction hash. - * @throws {Error} If the method fails. + * Releases funds from a fully funded invoice to all configured recipients. + * + * @param invoiceId - Unique identifier of the invoice to release. + * @returns An object containing the transaction hash `{ txHash }`. + * @throws {InvoiceNotFoundError} If the invoice does not exist. + * @throws {Error} If the release transaction fails. + * + * @example + * ```typescript + * const result = await client.release("123"); + * console.log(`Funds released in tx: ${result.txHash}`); + * ``` */ - async release(invoiceId: string): Promise { + async release(invoiceId: string): Promise { if (this._offlineQueue?.config?.enabled) { try { const health = await this.checkRPCHealth(); @@ -8354,6 +7387,17 @@ export class StellarSplitClient extends TypedEventEmitter { throw new Error("Not implemented"); } + /** + * Cancels an unreleased invoice, reverting pending splits and unlocking funds. + * + * @param invoiceId - ID of the invoice to cancel. + * @returns Promise resolving to cancellation TxResult. + * + * @example + * ```typescript + * await client.cancel('123'); + * ``` + */ async cancel(invoiceId: string): Promise { if (this._offlineQueue?.config?.enabled) { try { @@ -8370,24 +7414,24 @@ export class StellarSplitClient extends TypedEventEmitter { throw new Error("Not implemented"); } + /** + * Cancels an enqueued timelocked governance action prior to execution. + * + * @param caller - Caller address cancelling the action. + * @param actionId - ID of the action. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * await client.cancelAction('GA...', 'act-123'); + * ``` + */ async cancelAction(caller: string, actionId: string): Promise { const startTime = Date.now(); try { - const operation = this.contract.call( - "cancel_action", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ - nativeToScVal(caller, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ + const operation = this.contract.call( + "cancel_action", + nativeToScVal(caller, { type: "address" }), nativeToScVal(BigInt(actionId), { type: "u64" }), ); const result = await this._submitTx(caller, operation); @@ -8405,6 +7449,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param params - Parameters including invoiceId, creator, and the CrossChainRef. * @returns The transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.setCrossChainRef({ invoiceId: '123', chain: 'ethereum', txHash: '0x...' }); + * ``` */ async setCrossChainRef(params: SetCrossChainRefParams): Promise { const startTime = Date.now(); @@ -8421,12 +7470,6 @@ export class StellarSplitClient extends TypedEventEmitter { }) as xdr.ScVal, }), ]; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (params.ref.blockNumber !== undefined) { refMap.push( new xdr.ScMapEntry({ @@ -8440,19 +7483,7 @@ export class StellarSplitClient extends TypedEventEmitter { const operation = this.contract.call( "set_cross_chain_ref", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.creator, { type: "address" }), xdr.ScVal.scvMap(refMap), ); @@ -8470,18 +7501,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param actionId - The ID of the action to query. * @returns Timelock action status. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const action = await client.getActionStatus('act-123'); + * ``` */ async getActionStatus(actionId: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "get_action_status", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(actionId), { type: "u64" }), ); const raw = (await this._simulateView(operation)) as Record< @@ -8520,6 +7550,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param payerAddress - Stellar address of the payer. * @returns The active window state, or `{ limited: false }` if unlimited. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const velocity = await client.getVelocityStatus('123', 'GA...PAYER'); + * ``` */ async getVelocityStatus( invoiceId: string, @@ -8529,19 +7564,7 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "get_velocity_status", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(payerAddress, { type: "address" }), ); const raw = await this._simulateView(operation); @@ -8549,12 +7572,6 @@ export class StellarSplitClient extends TypedEventEmitter { telemetry.recordMethod("getVelocityStatus", true, Date.now() - startTime); // No velocity limit configured: contract returns void/null. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (raw === null || raw === undefined) { return { limited: false }; } @@ -8589,6 +7606,18 @@ export class StellarSplitClient extends TypedEventEmitter { // Issue #866 — Pause/Resume invoice // --------------------------------------------------------------------------- + /** + * Temporarily pauses payment acceptance for an invoice. + * + * @param invoiceId - The ID of the invoice to pause. + * @param options - Optional pause settings. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * await client.pauseInvoice('123'); + * ``` + */ async pauseInvoice( invoiceId: string, options?: { autoResumeAt?: Date }, @@ -8614,6 +7643,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Resumes an active invoice from a paused state. + * + * @param invoiceId - The ID of the invoice to resume. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * await client.resumeInvoice('123'); + * ``` + */ async resumeInvoice(invoiceId: string): Promise { const startTime = Date.now(); try { @@ -8631,6 +7671,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Checks if an invoice is currently paused against new payments. + * + * @param invoiceId - The ID of the invoice. + * @returns Promise resolving to PauseStatus. + * + * @example + * ```typescript + * const paused = await client.isPaused('123'); + * ``` + */ async isPaused(invoiceId: string): Promise { const startTime = Date.now(); try { @@ -8658,6 +7709,18 @@ export class StellarSplitClient extends TypedEventEmitter { // Issue #867 — Pledge matching // --------------------------------------------------------------------------- + /** + * Pledges matching funds toward an invoice split funding pool. + * + * @param invoiceId - The target invoice ID. + * @param amount - Pledged amount in stroops. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * await client.pledgeMatch('123', 50_0000000n); + * ``` + */ async pledgeMatch(invoiceId: string, amount: bigint): Promise { const startTime = Date.now(); try { @@ -8676,6 +7739,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Reclaims unmatched pledge funds after an invoice has settled or reached its deadline. + * + * @param invoiceId - Invoice identifier. + * @returns Promise resolving to reclaimed amount in stroops as bigint. + * + * @example + * ```typescript + * const reclaimed = await client.claimUnmatchedPledge('123'); + * ``` + */ async claimUnmatchedPledge(invoiceId: string): Promise { const startTime = Date.now(); try { @@ -8694,6 +7768,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Queries active matching pledges associated with an invoice. + * + * @param invoiceId - Invoice ID. + * @returns Promise resolving to array of MatchPledge records. + * + * @example + * ```typescript + * const pledges = await client.getMatchPool('123'); + * ``` + */ async getMatchPool(invoiceId: string): Promise { const startTime = Date.now(); try { @@ -8722,6 +7807,18 @@ export class StellarSplitClient extends TypedEventEmitter { // Issue #868 — Streaming payments // --------------------------------------------------------------------------- + /** + * Initiates continuous ledger-based payment streaming toward an invoice. + * + * @param invoiceId - Target invoice ID. + * @param amountPerLedger - Payment flow rate per Stellar ledger block in stroops. + * @returns Promise resolving to unique stream ID string. + * + * @example + * ```typescript + * const streamId = await client.startStream('123', 100000n); + * ``` + */ async startStream(invoiceId: string, amountPerLedger: bigint): Promise { const startTime = Date.now(); try { @@ -8741,6 +7838,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Settles accrued streaming payment funds and distributes them to recipients. + * + * @param streamId - Identifier of the active stream. + * @returns Promise resolving to settled amount as bigint. + * + * @example + * ```typescript + * const settled = await client.settleStream(streamId); + * ``` + */ async settleStream(streamId: string): Promise { const startTime = Date.now(); try { @@ -8759,6 +7867,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Terminates an active payment stream. + * + * @param streamId - Identifier of the stream to cancel. + * @returns Promise resolving to refunded remaining balance as bigint. + * + * @example + * ```typescript + * const refunded = await client.cancelStream(streamId); + * ``` + */ async cancelStream(streamId: string): Promise { const startTime = Date.now(); try { @@ -8777,6 +7896,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Fetches current configuration and accumulated payout for a payment stream. + * + * @param streamId - Stream identifier. + * @returns Promise resolving to Stream object. + * + * @example + * ```typescript + * const stream = await client.getStream(streamId); + * ``` + */ async getStream(streamId: string): Promise { const startTime = Date.now(); try { @@ -8803,6 +7933,18 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Computes accrued unreleased payment balance for a stream up to the specified ledger number. + * + * @param stream - Active Stream object. + * @param currentLedger - Current Stellar ledger sequence number. + * @returns Accrued amount in stroops as bigint. + * + * @example + * ```typescript + * const accrued = client.computeAccruedAmount(stream, 1234567); + * ``` + */ computeAccruedAmount(stream: Stream, currentLedger: number): bigint { if (stream.status !== "active") return 0n; const ledgersPassed = Math.max(0, currentLedger - stream.startLedger); @@ -8813,6 +7955,18 @@ export class StellarSplitClient extends TypedEventEmitter { // Issue #869 — Delegate management // --------------------------------------------------------------------------- + /** + * Grants management delegation permissions for an invoice to a secondary address. + * + * @param invoiceId - The invoice ID. + * @param delegate - Stellar address of the authorized delegate. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * await client.addDelegate('123', 'GA...DELEGATE'); + * ``` + */ async addDelegate(invoiceId: string, delegate: string): Promise { const startTime = Date.now(); try { @@ -8831,6 +7985,18 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Revokes management delegation permissions for an address on an invoice. + * + * @param invoiceId - The invoice ID. + * @param delegate - Stellar address of the delegate to revoke. + * @returns Promise resolving to TxResult. + * + * @example + * ```typescript + * await client.removeDelegate('123', 'GA...DELEGATE'); + * ``` + */ async removeDelegate(invoiceId: string, delegate: string): Promise { const startTime = Date.now(); try { @@ -8849,6 +8015,18 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Checks if an address is an authorized delegate for an invoice. + * + * @param invoiceId - The invoice ID. + * @param address - The address to check. + * @returns Promise resolving to boolean. + * + * @example + * ```typescript + * const isAuth = await client.isDelegate('123', 'GA...DELEGATE'); + * ``` + */ async isDelegate(invoiceId: string, address: string): Promise { const startTime = Date.now(); try { @@ -8868,6 +8046,17 @@ export class StellarSplitClient extends TypedEventEmitter { } } + /** + * Lists all addresses authorized as delegates for an invoice. + * + * @param invoiceId - The invoice ID. + * @returns Promise resolving to array of delegate address strings. + * + * @example + * ```typescript + * const delegates = await client.getDelegates('123'); + * ``` + */ async getDelegates(invoiceId: string): Promise { const startTime = Date.now(); try { @@ -8891,12 +8080,6 @@ export class StellarSplitClient extends TypedEventEmitter { /** Parse the native return value from `check_nft_gate`. */ private _parseNftGateResult(raw: unknown): NftGateResult { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!raw || typeof raw !== "object") { return { gated: false, hasNft: false, contractAddress: null }; } @@ -8929,13 +8112,6 @@ export class StellarSplitClient extends TypedEventEmitter { .addOperation(operation) .setTimeout(30) .build(); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (traceId) { await runRequestInterceptors({ method: "_simulateView", @@ -8945,12 +8121,6 @@ export class StellarSplitClient extends TypedEventEmitter { } const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation failed: ${simResult.error}`, @@ -8962,12 +8132,6 @@ export class StellarSplitClient extends TypedEventEmitter { const returnVal = ( simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse ).result?.retval; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!returnVal) throw new NoReturnValueError("_simulateView"); return scValToNative(returnVal); @@ -8980,22 +8144,10 @@ export class StellarSplitClient extends TypedEventEmitter { priority: RequestPriority = "normal", ): Promise<{ txHash: string; returnValue: xdr.ScVal }> { return this._queue.enqueue(priority, async () => { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._idempotency) { const opXdr = operation.toXDR().toString("base64"); const key = this._idempotency.generateKey(sourceAddress, opXdr); const existing = this._idempotency.getResult(key); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (existing) { return { txHash: existing.txHash, @@ -9011,12 +8163,6 @@ export class StellarSplitClient extends TypedEventEmitter { try { const result = await submit(); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._idempotency) { const opXdr = operation.toXDR().toString("base64"); const key = this._idempotency.generateKey(sourceAddress, opXdr); @@ -9024,21 +8170,9 @@ export class StellarSplitClient extends TypedEventEmitter { } return result; } catch (error) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._standby) { this._standby.failover(); const result = await submit(); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._idempotency) { const opXdr = operation.toXDR().toString("base64"); const key = this._idempotency.generateKey(sourceAddress, opXdr); @@ -9072,12 +8206,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw parseSorobanError(simResult.error); } @@ -9093,13 +8221,6 @@ export class StellarSplitClient extends TypedEventEmitter { const sendResult = await this.server.sendTransaction( TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase), ); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -9111,12 +8232,6 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20 @@ -9127,12 +8242,6 @@ export class StellarSplitClient extends TypedEventEmitter { } // If still not confirmed, submit a fee-bump transaction with a higher fee - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) { const multiplier = this.config.feeBumpMultiplier ?? 2; const innerTx = TransactionBuilder.fromXDR( @@ -9158,12 +8267,6 @@ export class StellarSplitClient extends TypedEventEmitter { this.config.networkPassphrase, ), ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (bumpSendResult.status === "ERROR") { throw new TransactionFailedError( `Fee-bump transaction failed: ${JSON.stringify(bumpSendResult.errorResult)}`, @@ -9174,12 +8277,6 @@ export class StellarSplitClient extends TypedEventEmitter { const bumpHash = bumpSendResult.hash; let bumpResult = await this.server.getTransaction(bumpHash); let bumpAttempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( bumpResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && bumpAttempts < 20 @@ -9188,12 +8285,6 @@ export class StellarSplitClient extends TypedEventEmitter { bumpResult = await this.server.getTransaction(bumpHash); bumpAttempts++; } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (bumpResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(bumpResult.status)); } @@ -9216,13 +8307,6 @@ export class StellarSplitClient extends TypedEventEmitter { recordCall(true); return { txHash: bumpHash, returnValue: bumpReturnValue }; } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -9282,15 +8366,9 @@ export class StellarSplitClient extends TypedEventEmitter { }; const amounts = raw.amounts as unknown[]; - const recipients: Recipient[] = (raw.recipients as string[]).map( - (addr: string, i: number) => { - const amt = amounts[i]; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ + const recipients: Recipient[] = (raw.recipients as string[]).map( + (addr: string, i: number) => { + const amt = amounts[i]; if (amt === undefined) throw new NoReturnValueError(`_parseInvoice ${i}`); return { @@ -9338,12 +8416,6 @@ export class StellarSplitClient extends TypedEventEmitter { private async _getInvoiceExt(invoiceId: string): Promise { const operation = this.contract.call( "get_invoice_ext", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); @@ -9373,6 +8445,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param invoiceId - The leaf invoice ID to resolve the chain from. * @returns An array of invoices ordered root → leaf. * @throws If the clone chain exceeds 10 levels or a cycle is detected. + * + * @example + * ```typescript + * const chain = await client.resolveCloneChain('123'); + * ``` */ async resolveCloneChain(invoiceId: string): Promise { const chain: Invoice[] = []; @@ -9380,29 +8457,10 @@ export class StellarSplitClient extends TypedEventEmitter { const seen = new Set(); let depth = 0; const MAX_DEPTH = 10; - - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while (currentId) { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (seen.has(currentId)) { throw new CloneChainTooDeepError(currentId); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (depth >= MAX_DEPTH) { throw new CloneChainTooDeepError(); } @@ -9433,19 +8491,17 @@ export class StellarSplitClient extends TypedEventEmitter { * @param address - Stellar public key of the account. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const account = await client.getAccount('GA...ACCOUNT'); + * ``` */ async getAccount(address: string): Promise { const rpcFetch = async (): Promise => { const acc = await this.server.getAccount(address); return { id: acc.accountId(), sequence: acc.sequenceNumber() }; }; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this._horizonReader) { return rpcFetch(); } @@ -9459,12 +8515,6 @@ export class StellarSplitClient extends TypedEventEmitter { }); return chain.execute(async (provider) => { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (provider === "rpc") return rpcFetch(); return horizonReader.getAccount(address); }); @@ -9482,14 +8532,13 @@ export class StellarSplitClient extends TypedEventEmitter { * @param address - Stellar public key of the account. * @throws If no `horizonUrl` was configured. * @returns The result of the method. + * + * @example + * ```typescript + * const balances = await client.getAccountBalances('GA...ACCOUNT'); + * ``` */ async getAccountBalances(address: string): Promise { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!this._horizonReader) { throw new ValidationError( "getAccountBalances requires horizonUrl to be set in StellarSplitClientConfig", @@ -9506,12 +8555,6 @@ export class StellarSplitClient extends TypedEventEmitter { }); return chain.execute(async (provider) => { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (provider === "rpc") { throw new ValidationError( "Soroban RPC does not expose account balances; delegating to Horizon", @@ -9543,6 +8586,11 @@ export class StellarSplitClient extends TypedEventEmitter { * Required for the claimable-balance fallback path. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.refundInvoice('123', 'GA...CREATOR'); + * ``` */ async refundInvoice( invoiceId: string, @@ -9550,11 +8598,39 @@ export class StellarSplitClient extends TypedEventEmitter { payerAddress: string | undefined, options: { simulate: true }, ): Promise; + /** + * Triggers a full or partial refund of invoice contributions back to payers. + * + * @param invoiceId - The invoice ID. + * @param creator - Creator address executing the refund. + * @param payerAddress - Optional specific payer address for single-payer refunds. + * @param options - Optional execution settings. + * @returns Promise resolving to refund TxResult. + * + * @example + * ```typescript + * await client.refundInvoice('123', 'GA...CREATOR'); + * ``` + */ async refundInvoice( invoiceId: string, creator: string, payerAddress?: string, ): Promise<{ txHash: string; fallback: false } | ClaimableRefundResult>; + /** + * Triggers a full or partial refund of invoice contributions back to payers. + * + * @param invoiceId - The invoice ID. + * @param creator - Creator address executing the refund. + * @param payerAddress - Optional specific payer address for single-payer refunds. + * @param options - Optional execution settings. + * @returns Promise resolving to refund TxResult. + * + * @example + * ```typescript + * await client.refundInvoice('123', 'GA...CREATOR'); + * ``` + */ async refundInvoice( invoiceId: string, creator: string, @@ -9572,23 +8648,11 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "refund_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const result = await this._submitTx(creator, operation); const invoice = await this.getInvoice(invoiceId).catch(() => null); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (invoice) this._fireOnRefunded(invoice); telemetry.recordMethod("refundInvoice", true, Date.now() - startTime); @@ -9596,12 +8660,6 @@ export class StellarSplitClient extends TypedEventEmitter { } catch (error) { // Fallback path: if transfer failed due to missing account/trustline and // Horizon is configured, create a claimable balance instead. - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if ( /** * isRefundTransferError @@ -9657,6 +8715,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param payer - Stellar address of the claimant to query. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const refunds = await client.getClaimableRefunds('GA...PAYER'); + * ``` */ async getClaimableRefunds(payer: string): Promise { return getClaimableRefunds(payer, this.config); @@ -9667,12 +8730,15 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Fetch invoice state from all configured RPC endpoints in parallel and - * return the most recent version based on lastModifiedLedger. + * Resynchronizes local client state with the latest confirmed on-chain ledger state. * - * @param invoiceId - The invoice ID to sync. - * @returns The invoice from the endpoint with the highest lastModifiedLedger. - * @throws If all endpoints fail. + * @param invoiceId - The invoice ID to synchronize. + * @returns Promise resolving to refreshed Invoice state. + * + * @example + * ```typescript + * const freshInvoice = await client.syncInvoice('123'); + * ``` */ async syncInvoice( invoiceId: string, @@ -9688,12 +8754,6 @@ export class StellarSplitClient extends TypedEventEmitter { }); const operation = this.contract.call( "get_invoice", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), ); const account = await server @@ -9709,12 +8769,6 @@ export class StellarSplitClient extends TypedEventEmitter { .setTimeout(30) .build(); const simResult = await server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation failed on ${url}: ${simResult.error}`, @@ -9725,12 +8779,6 @@ export class StellarSplitClient extends TypedEventEmitter { const returnVal = ( simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse ).result?.retval; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!returnVal) throw new NoReturnValueError(`syncInvoice ${url}`); const raw = scValToNative(returnVal) as Record; const invoice = this._parseInvoice(invoiceId, raw); @@ -9753,13 +8801,6 @@ export class StellarSplitClient extends TypedEventEmitter { }> => r.status === "fulfilled", ) .map((r) => r.value); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (successful.length === 0) { throw new RpcUnavailableError("syncInvoice"); } @@ -9774,11 +8815,16 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Get the claimable payout amount for a recipient on an invoice. - * Returns 0n if no pending payout exists. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Queries the unclaimed payout amount accrued for a split recipient on an invoice. + * + * @param invoiceId - Invoice identifier. + * @param recipient - Recipient account address. + * @returns Promise resolving to pending payout in stroops as bigint. + * + * @example + * ```typescript + * const pending = await client.getPendingPayout('123', 'GA...RECIPIENT'); + * ``` */ async getPendingPayout( invoiceId: string, @@ -9788,19 +8834,7 @@ export class StellarSplitClient extends TypedEventEmitter { try { const operation = this.contract.call( "get_pending_payout", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(recipient, { type: "address" }), ); const raw = await this._simulateView(operation); @@ -9814,11 +8848,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Claim a pending payout for a recipient on an invoice. - * Emits a `pending_payout_claimed` event on success. - * @throws If no pending payout exists for the recipient. - * @param params - The parameters for the method. - * @returns The result of the method. + * Claims accrued payout funds for a recipient on an invoice. + * + * @param invoiceId - The invoice ID. + * @param recipient - Recipient account address. + * @returns Promise resolving to ClaimPayoutResult. + * + * @example + * ```typescript + * const result = await client.claimPendingPayout('123', 'GA...RECIPIENT'); + * ``` */ async claimPendingPayout( invoiceId: string, @@ -9827,30 +8866,12 @@ export class StellarSplitClient extends TypedEventEmitter { const startTime = Date.now(); try { const pending = await this.getPendingPayout(invoiceId, recipient); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (pending === 0n) { throw new NoPendingPayoutError(recipient, invoiceId); } const operation = this.contract.call( "claim_pending_payout", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(recipient, { type: "address" }), ); const result = await this._submitTx(recipient, operation); @@ -9875,35 +8896,26 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Pay toward an invoice bound to an off-chain identity attestation. - * Validates attestationHash (32 bytes) and signature (64 bytes) before submission. - * Returns a payment receipt with the attestation hash included. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Executes an invoice payment accompanied by cryptographic verification proof. + * + * @param params - Payment and attestation parameters. + * @returns Promise resolving to AttestationPaymentReceipt. + * + * @example + * ```typescript + * const receipt = await client.payWithAttestation(attestationPaymentParams); + * ``` */ async payWithAttestation( params: PayWithAttestationParams, ): Promise { const startTime = Date.now(); try { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (params.attestationHash.length !== 32) { throw new InvalidAttestationError( `attestationHash must be 32 bytes, got ${params.attestationHash.length}`, ); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (params.signature.length !== 64) { throw new InvalidAttestationError( `signature must be 64 bytes, got ${params.signature.length}`, @@ -9911,35 +8923,11 @@ export class StellarSplitClient extends TypedEventEmitter { } const operation = this.contract.call( "pay_with_attestation", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.payer, { type: "address" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(BigInt(params.invoiceId), { type: "u64" }), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.amount, { type: "i128" }), xdr.ScVal.scvBytes(Buffer.from(params.attestationHash)), xdr.ScVal.scvBytes(Buffer.from(params.signature)), - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(params.signerPubkey, { type: "address" }), ); const result = await this._submitTx(params.payer, operation); @@ -9971,22 +8959,22 @@ export class StellarSplitClient extends TypedEventEmitter { // Issue #276 — Creator volume cap status checker // --------------------------------------------------------------------------- - /** Returns the volume cap for a creator in token units, or null if uncapped. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Queries the maximum monthly invoice issuance volume ceiling for a creator account. + * + * @param address - Creator public key. + * @returns Promise resolving to maximum cap in stroops as bigint, or null if uncapped. + * + * @example + * ```typescript + * const cap = await client.getCreatorVolumeCap('GA...'); + * ``` */ async getCreatorVolumeCap(address: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "get_creator_volume_cap", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(address, { type: "address" }), ); const raw = await this._simulateView(operation); @@ -10007,22 +8995,22 @@ export class StellarSplitClient extends TypedEventEmitter { } } - /** Returns the lifetime volume used by a creator in token units. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Queries the invoice volume consumed by a creator in the current billing period. + * + * @param address - Creator public key. + * @returns Promise resolving to used volume in stroops as bigint. + * + * @example + * ```typescript + * const used = await client.getCreatorVolumeUsed('GA...'); + * ``` */ async getCreatorVolumeUsed(address: string): Promise { const startTime = Date.now(); try { const operation = this.contract.call( "get_creator_volume_used", - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(address, { type: "address" }), ); const raw = await this._simulateView(operation); @@ -10043,10 +9031,16 @@ export class StellarSplitClient extends TypedEventEmitter { } } - /** Returns remaining volume (cap - used) or Infinity if the creator is uncapped. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + /** + * Computes the remaining invoice issuance volume allowed for a creator. + * + * @param address - Creator public key. + * @returns Promise resolving to remaining volume allowance as bigint or Infinity. + * + * @example + * ```typescript + * const remaining = await client.getRemainingCreatorVolume('GA...'); + * ``` */ async getRemainingCreatorVolume( address: string, @@ -10055,12 +9049,6 @@ export class StellarSplitClient extends TypedEventEmitter { this.getCreatorVolumeCap(address), this.getCreatorVolumeUsed(address), ]); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (cap === null) return Infinity; return cap > used ? cap - used : 0n; } @@ -10071,6 +9059,11 @@ export class StellarSplitClient extends TypedEventEmitter { * * @param creator - Creator address. * @returns Creator statistics including success rate, funding time, ratings. + * + * @example + * ```typescript + * const stats = await client.getCreatorStats('GA...CREATOR'); + * ``` */ async getCreatorStats(creator: string): Promise { const { @@ -10082,15 +9075,27 @@ export class StellarSplitClient extends TypedEventEmitter { if (cached) return cached; const result = await this.getInvoicesByCreator(creator); - const invoices = result.invoices || []; + const invoiceIds = result.items || []; + const invoices = await Promise.all( + invoiceIds.map(async (id) => { + try { + return await this.getInvoice(id); + } catch { + return null; + } + }), + ).then((list) => list.filter((inv): inv is Invoice => inv !== null)); + + const getInvoiceAmount = (inv: Invoice) => + inv.recipients.reduce((sum, r) => sum + r.amount, 0n); - const totalRaised = invoices.reduce((sum, inv) => sum + inv.amount, 0n); + const totalRaised = invoices.reduce((sum, inv) => sum + inv.funded, 0n); const totalReleased = invoices.reduce((sum, inv) => { - if (inv.status === "Released") return sum + inv.amount; + if (inv.status === "Released") return sum + getInvoiceAmount(inv); return sum; }, 0n); const totalRefunded = invoices.reduce((sum, inv) => { - if (inv.status === "Refunded") return sum + inv.amount; + if (inv.status === "Refunded") return sum + getInvoiceAmount(inv); return sum; }, 0n); @@ -10098,8 +9103,8 @@ export class StellarSplitClient extends TypedEventEmitter { const successRate = invoices.length > 0 ? (successCount / invoices.length) * 100 : 0; const fundingTimes = invoices - .filter((inv) => inv.paid_at && inv.created_at) - .map((inv) => (inv.paid_at! - inv.created_at!) / (1000 * 60 * 60)); + .filter((inv) => (inv.payments?.[0]?.timestamp ?? 0) > 0 && inv.createdAt) + .map((inv) => ((inv.payments?.[0]?.timestamp ?? 0) - (inv.createdAt ?? 0)) / (1000 * 60 * 60)); const averageFundingTimeHours = fundingTimes.length > 0 ? fundingTimes.reduce((a, b) => a + b, 0) / fundingTimes.length @@ -10108,7 +9113,7 @@ export class StellarSplitClient extends TypedEventEmitter { const uniquePayers = new Set( invoices .filter((inv) => inv.payments && inv.payments.length > 0) - .flatMap((inv) => inv.payments!.map((p) => p.payer)), + .flatMap((inv) => inv.payments.map((p) => p.payer)), ).size; const stats = { @@ -10131,76 +9136,37 @@ export class StellarSplitClient extends TypedEventEmitter { // --------------------------------------------------------------------------- /** - * Create up to 10 invoices in a single fee-bump transaction. - * Validates all items before submission; fails fast on the first invalid item. - * Returns invoice IDs in the same order as the input array. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Creates a batch of invoices with built-in chunking and idempotency keys. + * + * @param items - Array of invoice parameters. + * @returns Promise resolving to batch creation result. + * + * @example + * ```typescript + * const batch = await client.createInvoiceBatch([itemA, itemB]); + * ``` */ async createInvoiceBatch( items: CreateInvoiceParams[], ): Promise<{ invoiceIds: string[]; txHash: string }> { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (items.length === 0 || items.length > 10) { throw new InvalidBatchSizeError("1-10 items", items.length); } - /** - * for - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ for (let i = 0; i < items.length; i++) { const item = items[i]!; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!item.creator) throw new ValidationError(`Item ${i}: creator is required`); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!item.token) throw new ValidationError(`Item ${i}: token is required`); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!item.deadline || item.deadline <= 0) throw new ValidationError( `Item ${i}: deadline must be a positive number`, ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!Array.isArray(item.recipients) || item.recipients.length === 0) { throw new ValidationError( `Item ${i}: recipients must be a non-empty array`, ); } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this.config.payloadGuard) { /** * validateInvoicePayload @@ -10223,12 +9189,6 @@ export class StellarSplitClient extends TypedEventEmitter { key: nativeToScVal("recipients", { type: "symbol" }) as xdr.ScVal, val: xdr.ScVal.scvVec( p.recipients.map((r) => - /** - * nativeToScVal - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ nativeToScVal(r.address, { type: "address" }), ), ), @@ -10267,12 +9227,6 @@ export class StellarSplitClient extends TypedEventEmitter { .build(); const simResult = await this.server.simulateTransaction(tx); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (SorobanRpc.Api.isSimulationError(simResult)) { throw new SimulationFailedError( `Simulation failed: ${simResult.error}`, @@ -10284,12 +9238,6 @@ export class StellarSplitClient extends TypedEventEmitter { const preparedTx = SorobanRpc.assembleTransaction(tx, simResult).build(); const bumpedFee = String( Math.ceil( - /** - * Number - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ Number(BASE_FEE) * (this.config.feeBumpMultiplier ?? 2) * items.length, @@ -10314,12 +9262,6 @@ export class StellarSplitClient extends TypedEventEmitter { const sendResult = await this.server.sendTransaction( TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase), ); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -10329,12 +9271,6 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20 @@ -10343,12 +9279,6 @@ export class StellarSplitClient extends TypedEventEmitter { getResult = await this.server.getTransaction(txHash); attempts++; } - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -10357,12 +9287,6 @@ export class StellarSplitClient extends TypedEventEmitter { (getResult as SorobanRpc.Api.GetSuccessfulTransactionResponse) .returnValue ?? xdr.ScVal.scvVoid(); const invoiceIds = ( - /** - * scValToNative - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ scValToNative(returnVal) as (string | number | bigint)[] ).map((id) => id.toString()); @@ -10382,58 +9306,6 @@ export class StellarSplitClient extends TypedEventEmitter { } } - /** - * Create multiple invoices in one transaction with full validation before submission. - * All invoices are validated client-side before any RPC call is made. - * - * @param invoices - Array of invoice parameters (1-20 items). - * @returns Array of created invoice IDs in the same order as input. - * @throws {BatchTooLargeError} if more than 20 invoices. - * @throws {ValidationError} if any invoice fails validation. - */ - async batchCreateInvoices( - invoices: CreateInvoiceParams[], - ): Promise { - const { BatchTooLargeError } = await import("./errors.js"); - - if (invoices.length < 1 || invoices.length > 20) { - throw new BatchTooLargeError(invoices.length, 20); - } - - for (let i = 0; i < invoices.length; i++) { - const invoice = invoices[i]!; - if (!invoice.creator) { - throw new ValidationError(`Invoice ${i}: creator is required`); - } - if (!invoice.token) { - throw new ValidationError(`Invoice ${i}: token is required`); - } - if (!invoice.deadline || invoice.deadline <= 0) { - throw new ValidationError( - `Invoice ${i}: deadline must be a positive number`, - ); - } - this._metadataValidator.validate(invoice.metadata); - } - - const startTime = Date.now(); - const invoiceIds: bigint[] = []; - - try { - for (const params of invoices) { - const result = await this.createInvoice(params); - invoiceIds.push(BigInt(result.invoiceId)); - } - - this.emit("batch:created", { invoiceIds }); - - return invoiceIds; - } catch (error) { - telemetry.recordMethod("batchCreateInvoices", false, Date.now() - startTime); - throw error; - } - } - // --------------------------------------------------------------------------- // Leaderboard & invoice history (used in per-method timeout examples) // --------------------------------------------------------------------------- @@ -10444,6 +9316,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param opts - Optional per-call timeout and trace ID overrides. * @returns Array of creator addresses sorted by invoice volume descending. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const top = await client.getLeaderboard({ limit: 10 }); + * ``` */ async getLeaderboard(opts?: { timeout?: number; @@ -10455,28 +9332,10 @@ export class StellarSplitClient extends TypedEventEmitter { this._withTelemetry( "getLeaderboard", undefined, - /** - * async - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ async () => { const operation = this.contract.call("get_leaderboard"); const raw = await this._simulateView(operation, opts?.traceId); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!Array.isArray(raw)) return []; - /** - * return - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ return (raw as Array>).map((entry) => ({ creator: String(entry.creator ?? ""), invoiceCount: Number(entry.invoice_count ?? 0), @@ -10497,6 +9356,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param opts - Optional per-call timeout and trace ID overrides. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const history = await client.getInvoiceHistory('123'); + * ``` */ async getInvoiceHistory( invoiceId: string, @@ -10631,6 +9495,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param newAddress - The destination account after the merge. * @returns The result of the method. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.rerouteRecipient('123', 'GA...OLD', 'GA...NEW'); + * ``` */ async rerouteRecipient( invoiceId: string, @@ -10642,12 +9511,6 @@ export class StellarSplitClient extends TypedEventEmitter { ); const horizonUrl = this.config.horizonUrl; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!horizonUrl) { throw new Error( "horizonUrl is required in client config to validate reroute destination", @@ -10662,13 +9525,16 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Finalize an invoice, checking that all recipients are reachable. + * Finalizes split payment disbursements and triggers escrow fund release. * - * Delegates to PaymentGraphChecker. Throws `UnreachableRecipientError` - * unless `allowUnreachable` is set. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * @param invoiceId - The invoice ID to finalize. + * @param options - Optional finalization parameters. + * @returns Promise resolving to finalization result. + * + * @example + * ```typescript + * await client.finalizeInvoice('123'); + * ``` */ async finalizeInvoice( invoiceId: string, @@ -10679,12 +9545,6 @@ export class StellarSplitClient extends TypedEventEmitter { ); const horizonUrl = this.config.horizonUrl; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!horizonUrl) { throw new Error( "horizonUrl is required in client config for payment graph checking", @@ -10707,6 +9567,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param adapter - A `WalletAdapter` (e.g. from `WalletSessionManager.detect()`). * @returns The connected Stellar public key. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const address = await client.connectWallet(new FreighterAdapter()); + * ``` */ async connectWallet(adapter: WalletAdapter): Promise { const address = await adapter.connect(); @@ -10722,18 +9587,15 @@ export class StellarSplitClient extends TypedEventEmitter { } /** - * Disconnect the currently connected wallet adapter. - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. + * Disconnects the current active wallet session. + * @returns void + * + * @example + * ```typescript + * client.disconnectWallet(); + * ``` */ disconnectWallet(): void { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (this._adapter) { this._adapter.disconnect(); const walletName = this._adapter.name; @@ -10754,15 +9616,14 @@ export class StellarSplitClient extends TypedEventEmitter { * @param expectedHash - Previously computed content hash. * @returns `true` when hashes match. * @throws InvoiceIntegrityError when hashes diverge. + * + * @example + * ```typescript + * const isValid = await client.verifyInvoice(invoice, expectedHash); + * ``` */ async verifyInvoice(invoice: Invoice, expectedHash: string): Promise { const valid = await verifyInvoiceHash(invoice, expectedHash); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (!valid) { const computed = await hashInvoice(invoice); throw new InvoiceIntegrityError(invoice.id, expectedHash, computed); @@ -10784,6 +9645,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param config - Optional surge multiplier config. * @returns The fee bump transaction hash. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * const result = await client.submitWithFeeBump(innerTx, 'GA...SPONSOR'); + * ``` */ async submitWithFeeBump( innerTxXdr: string, @@ -10806,13 +9672,6 @@ export class StellarSplitClient extends TypedEventEmitter { const sendResult = await this.server.sendTransaction( TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase), ); - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (sendResult.status === "ERROR") { throw new TransactionFailedError( `Fee bump transaction failed: ${JSON.stringify(sendResult.errorResult)}`, @@ -10822,12 +9681,6 @@ export class StellarSplitClient extends TypedEventEmitter { const txHash = sendResult.hash; let getResult = await this.server.getTransaction(txHash); let attempts = 0; - /** - * while - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ while ( getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && attempts < 20 @@ -10836,13 +9689,6 @@ export class StellarSplitClient extends TypedEventEmitter { getResult = await this.server.getTransaction(txHash); attempts++; } - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { throw new TransactionNotConfirmedError(String(getResult.status)); } @@ -10869,6 +9715,11 @@ export class StellarSplitClient extends TypedEventEmitter { * @param signerSecret - Secret key for signing the claim transaction. * @returns Object with `{ invoiceId, txHash? }`. * @throws {Error} If the method fails. + * + * @example + * ```typescript + * await client.confirmDelivery('123', 'GA...RECIPIENT'); + * ``` */ async confirmDelivery( invoiceId: string, @@ -10885,13 +9736,6 @@ export class StellarSplitClient extends TypedEventEmitter { const escrowManager = (this.config as Record)["escrowVaultManager"] as | import("./escrowVaultManager.js").EscrowVaultManager | undefined; - - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (escrowManager && escrowVaultId && signerSecret) { const result = await escrowManager.release(escrowVaultId, recipientId, signerSecret); txHash = result.txHash; @@ -10910,6 +9754,11 @@ export class StellarSplitClient extends TypedEventEmitter { * * @throws {RoundNotEndedError} if the round_end timestamp has not yet passed. * @throws {InvoiceNotFoundError} if the invoice does not exist. + * + * @example + * ```typescript + * const round = await client.closeRound('123'); + * ``` */ async closeRound(invoiceId: string): Promise { const method = "close_round"; @@ -10940,11 +9789,10 @@ export class StellarSplitClient extends TypedEventEmitter { } const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); - const signed = await signTransaction(prepared.toXDR(), { - network: this.config.networkPassphrase, - networkPassphrase: this.config.networkPassphrase, - accountToSign: this.config.publicKey, - } as Parameters[1]); + const signed = await signTransaction( + prepared.toXDR(), + this.config.networkPassphrase, + ); const result = await this.server.sendTransaction( TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) ); @@ -10961,11 +9809,12 @@ export class StellarSplitClient extends TypedEventEmitter { let overflow = 0n; if (native && typeof native === "object") { - totalRaised = BigInt((native as Record).total_raised ?? 0); - hardCap = BigInt((native as Record).hard_cap ?? 0); - overflow = BigInt((native as Record).overflow ?? 0); - if (Array.isArray((native as Record).refunds)) { - for (const r of (native as Record).refunds as Record[]) { + const n = native as Record; + totalRaised = BigInt(n.total_raised ?? 0); + hardCap = BigInt(n.hard_cap ?? 0); + overflow = BigInt(n.overflow ?? 0); + if (Array.isArray(n.refunds)) { + for (const r of n.refunds as Record[]) { refunds.push({ payer: String(r.payer), amountKept: BigInt(r.amount_kept ?? 0), @@ -10993,6 +9842,11 @@ export class StellarSplitClient extends TypedEventEmitter { * Fetches the current state of the fundraising round for the given invoice. * * @throws {InvoiceNotFoundError} if the invoice does not exist. + * + * @example + * ```typescript + * const info = await client.getRoundInfo('123'); + * ``` */ async getRoundInfo(invoiceId: string): Promise { const method = "get_round_info"; @@ -11022,12 +9876,13 @@ export class StellarSplitClient extends TypedEventEmitter { } const native = sim.result ? scValToNative(sim.result.retval) : {}; + const n = (native ?? {}) as Record; return { invoiceId, - totalRaised: BigInt((native as Record)?.total_raised ?? 0), - hardCap: BigInt((native as Record)?.hard_cap ?? 0), - roundEnd: Number((native as Record)?.round_end ?? 0), - closed: Boolean((native as Record)?.closed ?? false), + totalRaised: BigInt(n.total_raised ?? 0), + hardCap: BigInt(n.hard_cap ?? 0), + roundEnd: Number(n.round_end ?? 0), + closed: Boolean(n.closed ?? false), }; } catch (err) { if (err instanceof InvoiceNotFoundError || err instanceof SimulationFailedError) { @@ -11046,6 +9901,11 @@ export class StellarSplitClient extends TypedEventEmitter { * * @throws {WrongMilestoneError} if `index` is not the currently active milestone. * @throws {InvoiceNotFoundError} if the invoice does not exist. + * + * @example + * ```typescript + * await client.completeMilestone('123', 0); + * ``` */ async completeMilestone(invoiceId: string, index: number): Promise { const method = "complete_milestone"; @@ -11079,11 +9939,10 @@ export class StellarSplitClient extends TypedEventEmitter { } const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); - const signed = await signTransaction(prepared.toXDR(), { - network: this.config.networkPassphrase, - networkPassphrase: this.config.networkPassphrase, - accountToSign: this.config.publicKey, - } as Parameters[1]); + const signed = await signTransaction( + prepared.toXDR(), + this.config.networkPassphrase, + ); const result = await this.server.sendTransaction( TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) ); @@ -11092,11 +9951,11 @@ export class StellarSplitClient extends TypedEventEmitter { } const native = sim.result ? scValToNative(sim.result.retval) : {}; - const n = native as Record; + const n = (native ?? {}) as Record; return { index, - amountReleased: BigInt(n?.amount_released ?? 0), - nextMilestoneIndex: n?.next_milestone_index != null ? Number(n.next_milestone_index) : undefined, + amountReleased: BigInt(n.amount_released ?? 0), + nextMilestoneIndex: n.next_milestone_index != null ? Number(n.next_milestone_index) : undefined, txHash: result.hash, }; } catch (err) { @@ -11116,6 +9975,11 @@ export class StellarSplitClient extends TypedEventEmitter { * Returns the currently active milestone for the given invoice. * * @throws {InvoiceNotFoundError} if the invoice does not exist. + * + * @example + * ```typescript + * const milestone = await client.getActiveMilestone('123'); + * ``` */ async getActiveMilestone(invoiceId: string): Promise { const method = "get_active_milestone"; @@ -11146,7 +10010,7 @@ export class StellarSplitClient extends TypedEventEmitter { const native = sim.result ? scValToNative(sim.result.retval) : null; if (!native) return null; - const n = native as Record; + const n = native as Record; return { index: Number(n.index ?? 0), description: String(n.description ?? ""), @@ -11166,6 +10030,11 @@ export class StellarSplitClient extends TypedEventEmitter { * Returns all milestones for the given invoice. * * @throws {InvoiceNotFoundError} if the invoice does not exist. + * + * @example + * ```typescript + * const milestones = await client.getMilestones('123'); + * ``` */ async getMilestones(invoiceId: string): Promise { const method = "get_milestones"; @@ -11196,7 +10065,7 @@ export class StellarSplitClient extends TypedEventEmitter { const native = sim.result ? scValToNative(sim.result.retval) : []; if (!Array.isArray(native)) return []; - return (native as Record[]).map((n) => ({ + return (native as Record[]).map((n) => ({ index: Number(n.index ?? 0), description: String(n.description ?? ""), targetAmount: BigInt(n.target_amount ?? 0), @@ -11219,6 +10088,11 @@ export class StellarSplitClient extends TypedEventEmitter { * Claims any accumulated referral rewards for the connected wallet. * * @throws {NothingToClaimError} if the referral balance is zero. + * + * @example + * ```typescript + * const claim = await client.claimReferralRewards(); + * ``` */ async claimReferralRewards(): Promise { const method = "claim_referral_rewards"; @@ -11251,11 +10125,10 @@ export class StellarSplitClient extends TypedEventEmitter { } const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); - const signed = await signTransaction(prepared.toXDR(), { - network: this.config.networkPassphrase, - networkPassphrase: this.config.networkPassphrase, - accountToSign: this.config.publicKey, - } as Parameters[1]); + const signed = await signTransaction( + prepared.toXDR(), + this.config.networkPassphrase, + ); const result = await this.server.sendTransaction( TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) ); @@ -11264,7 +10137,7 @@ export class StellarSplitClient extends TypedEventEmitter { } const native = sim.result ? scValToNative(sim.result.retval) : 0n; - const amountClaimed = BigInt(typeof native === "bigint" ? native : (native ?? 0)); + const amountClaimed = BigInt(typeof native === "bigint" ? native : ((native as any) ?? 0)); return { amountClaimed, txHash: result.hash }; } catch (err) { if ( @@ -11281,6 +10154,11 @@ export class StellarSplitClient extends TypedEventEmitter { /** * Returns the current referral reward balance for the given address (or the * connected wallet if no address is specified). + * + * @example + * ```typescript + * const balance = await client.getReferralBalance('GA...'); + * ``` */ async getReferralBalance(address?: string): Promise { const method = "get_referral_balance"; @@ -11308,7 +10186,7 @@ export class StellarSplitClient extends TypedEventEmitter { } const native = sim.result ? scValToNative(sim.result.retval) : 0n; - return BigInt(typeof native === "bigint" ? native : (native ?? 0)); + return BigInt(typeof native === "bigint" ? native : ((native as any) ?? 0)); } catch (err) { if (err instanceof SimulationFailedError) throw err; throw parseSorobanError(err instanceof Error ? err.message : String(err)); @@ -11324,6 +10202,11 @@ export class StellarSplitClient extends TypedEventEmitter { * * @throws {ValidationError} if the referrer address is not a valid G-address. * @throws {InvoiceNotFoundError} if the invoice does not exist. + * + * @example + * ```typescript + * await client.payWithReferral('123', 50_0000000n, 'GA...REFERRER'); + * ``` */ async payWithReferral( invoiceId: string, @@ -11364,11 +10247,10 @@ export class StellarSplitClient extends TypedEventEmitter { } const prepared = SorobanRpc.assembleTransaction(tx, sim).build(); - const signed = await signTransaction(prepared.toXDR(), { - network: this.config.networkPassphrase, - networkPassphrase: this.config.networkPassphrase, - accountToSign: this.config.publicKey, - } as Parameters[1]); + const signed = await signTransaction( + prepared.toXDR(), + this.config.networkPassphrase, + ); const result = await this.server.sendTransaction( TransactionBuilder.fromXDR(signed, this.config.networkPassphrase) ); @@ -11393,26 +10275,8 @@ export class StellarSplitClient extends TypedEventEmitter { /** Coerce a native-decoded scalar (bigint | number | string) into a bigint, defaulting to 0n. */ function toBigInt(value: unknown): bigint { - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof value === "bigint") return value; - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof value === "number") return BigInt(Math.trunc(value)); - /** - * if - * @param params - The parameters for the method. - * @returns The result of the method. - * @throws {Error} If the method fails. - */ if (typeof value === "string" && value !== "") return BigInt(value); return 0n; } diff --git a/src/configValidator.ts b/src/configValidator.ts index 111cdd9e..af7ec7c6 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -48,6 +48,9 @@ const KNOWN_CONFIG_KEYS = new Set([ "dexContractId", "circuitBreaker", "optimisticCache", + "logger", + "logLevel", + "debug", ]); export class UnknownConfigKeyError extends StellarSplitError { diff --git a/src/contractRetryQueue.ts b/src/contractRetryQueue.ts index ef754d71..692df620 100644 --- a/src/contractRetryQueue.ts +++ b/src/contractRetryQueue.ts @@ -35,10 +35,10 @@ export interface ContractRetryQueueConfig { maxAttempts?: number; } -interface ContractRetryEvents { +type ContractRetryEvents = { contractRetryAttempted: { invocation: ContractInvocation; attempt: number; delay: number; error: unknown }; contractRetryExhausted: { invocation: ContractInvocation; attempts: number; error: unknown }; -} +}; const DEFAULT_CONFIG: Required = { baseDelayMs: 500, diff --git a/src/currencyConverter.ts b/src/currencyConverter.ts index a646daef..6712473c 100644 --- a/src/currencyConverter.ts +++ b/src/currencyConverter.ts @@ -15,6 +15,7 @@ import { } from "@stellar/stellar-sdk"; import { OraclePriceError, NoReturnValueError } from "./errors.js"; import { RateCache } from "./rateCache.js"; +import type { PriceOracleAdapter } from "./types.js"; export interface ConvertedAmount { original: bigint; diff --git a/src/diagnostics/ContractStorageExporter.ts b/src/diagnostics/ContractStorageExporter.ts index 836c025a..e10a8c83 100644 --- a/src/diagnostics/ContractStorageExporter.ts +++ b/src/diagnostics/ContractStorageExporter.ts @@ -449,7 +449,13 @@ export class ContractStorageExporter { const entries: StorageEntry[] = []; for (const item of raw.entries) { - const ledgerEntryData = item.val.data().contractData(); + const valAny = item.val as any; + const ledgerEntryData = + typeof valAny.data === "function" && typeof valAny.data().contractData === "function" + ? valAny.data().contractData() + : typeof valAny.contractData === "function" + ? valAny.contractData() + : valAny; const key = scValToJson(ledgerEntryData.key()); const value = scValToJson(ledgerEntryData.val()); diff --git a/src/diff.ts b/src/diff.ts index 9131cfca..e4a2d7d9 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -236,19 +236,19 @@ export function mergeInvoices(base: Invoice, local: Invoice, remote: Invoice): I if (localChanged && !remoteChanged) { // Only local changed — take local value. - (merged as Record)[field] = localVal; + (merged as unknown as Record)[field] = localVal; continue; } if (!localChanged && remoteChanged) { // Only remote changed — take remote value. - (merged as Record)[field] = remoteVal; + (merged as unknown as Record)[field] = remoteVal; continue; } // Both changed — conflict unless they converged to the same value. if (valuesEqual(localVal, remoteVal)) { - (merged as Record)[field] = localVal; + (merged as unknown as Record)[field] = localVal; } else { throw new MergeConflictError(field, baseVal, localVal, remoteVal); } diff --git a/src/errors.ts b/src/errors.ts index 7332b5f2..e5ab774a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1926,6 +1926,44 @@ export class InsufficientSponsorReserveError extends StellarSplitError { } } +/** Thrown when an account's trustline has been frozen by the asset issuer. */ +export class AccountFrozenError extends StellarSplitError { + readonly accountId: string; + readonly assetCode: string; + + constructor(accountId: string, assetCode: string, raw?: string) { + super( + `Account ${accountId} is frozen for asset ${assetCode}`, + "ACCOUNT_FROZEN", + { accountId, assetCode }, + raw + ); + this.name = "AccountFrozenError"; + this.accountId = accountId; + this.assetCode = assetCode; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Thrown when an account is permanently locked out of authorization (auth_immutable without auth). */ +export class AccountLockedError extends StellarSplitError { + readonly accountId: string; + readonly assetCode: string; + + constructor(accountId: string, assetCode: string, raw?: string) { + super( + `Account ${accountId} is locked for asset ${assetCode}`, + "ACCOUNT_LOCKED", + { accountId, assetCode }, + raw + ); + this.name = "AccountLockedError"; + this.accountId = accountId; + this.assetCode = assetCode; + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** Thrown when a fee-bump inner transaction is not a v1 transaction. */ export class InvalidTransactionTypeError extends StellarSplitError { readonly typeName: string; @@ -2356,3 +2394,341 @@ export function isBatchTooLargeError(err: unknown): err is BatchTooLargeError { return err instanceof BatchTooLargeError; } +/** Thrown when closeRound is called before round_end has passed. */ +export class RoundNotEndedError extends StellarSplitError { + readonly invoiceId: string; + readonly roundEnd: number; + + constructor(invoiceId: string, roundEnd: number, raw?: string) { + super( + `Fundraising round has not yet ended for invoice ${invoiceId} (round end: ${roundEnd})`, + "ROUND_NOT_ENDED", + { invoiceId, roundEnd }, + raw + ); + this.name = "RoundNotEndedError"; + this.invoiceId = invoiceId; + this.roundEnd = roundEnd; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isRoundNotEndedError(err: unknown): err is RoundNotEndedError { + return err instanceof RoundNotEndedError; +} + +/** Thrown when completeMilestone is called for an index that is not active. */ +export class WrongMilestoneError extends StellarSplitError { + readonly invoiceId: string; + readonly requestedIndex: number; + readonly activeIndex: number; + + constructor(invoiceId: string, requestedIndex: number, activeIndex: number, raw?: string) { + super( + `Milestone ${requestedIndex} is not active for invoice ${invoiceId} (active: ${activeIndex})`, + "WRONG_MILESTONE", + { invoiceId, requestedIndex, activeIndex }, + raw + ); + this.name = "WrongMilestoneError"; + this.invoiceId = invoiceId; + this.requestedIndex = requestedIndex; + this.activeIndex = activeIndex; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isWrongMilestoneError(err: unknown): err is WrongMilestoneError { + return err instanceof WrongMilestoneError; +} + +/** Thrown when claimReferralRewards is called with a zero balance. */ +export class NothingToClaimError extends StellarSplitError { + readonly address: string; + + constructor(address: string, raw?: string) { + super( + `No referral rewards to claim for address ${address}`, + "NOTHING_TO_CLAIM", + { address }, + raw + ); + this.name = "NothingToClaimError"; + this.address = address; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isNothingToClaimError(err: unknown): err is NothingToClaimError { + return err instanceof NothingToClaimError; +} + +/** Thrown when an invoice is expired (round_end / deadline already passed). */ +export class InvoiceExpiredError extends StellarSplitError { + readonly invoiceId: string; + + constructor(invoiceId: string, raw?: string) { + super(`Invoice ${invoiceId} has expired`, "INVOICE_EXPIRED", { invoiceId }, raw); + this.name = "InvoiceExpiredError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceExpiredError(err: unknown): err is InvoiceExpiredError { + return err instanceof InvoiceExpiredError; +} + +/** Thrown when payer is not on the whitelist for a restricted invoice. */ +export class PayerNotWhitelistedError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + + constructor(invoiceId: string, payer: string, raw?: string) { + super( + `Payer ${payer} is not whitelisted for invoice ${invoiceId}`, + "PAYER_NOT_WHITELISTED", + { invoiceId, payer }, + raw + ); + this.name = "PayerNotWhitelistedError"; + this.invoiceId = invoiceId; + this.payer = payer; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPayerNotWhitelistedError(err: unknown): err is PayerNotWhitelistedError { + return err instanceof PayerNotWhitelistedError; +} + +/** Thrown when a payer's contribution exceeds their per-payer cap. */ +export class ContributionCapExceededError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + readonly cap: bigint; + readonly attempted: bigint; + + constructor(invoiceId: string, payer: string, cap: bigint, attempted: bigint, raw?: string) { + super( + `Contribution cap exceeded for payer ${payer} on invoice ${invoiceId}: cap ${cap}, attempted ${attempted}`, + "CONTRIBUTION_CAP_EXCEEDED", + { invoiceId, payer, cap: cap.toString(), attempted: attempted.toString() }, + raw + ); + this.name = "ContributionCapExceededError"; + this.invoiceId = invoiceId; + this.payer = payer; + this.cap = cap; + this.attempted = attempted; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isContributionCapExceededError(err: unknown): err is ContributionCapExceededError { + return err instanceof ContributionCapExceededError; +} + +/** + * Thrown when a payment is attempted during an active cooldown window. + * Includes retryAfter so callers know when they may try again. + */ +export class PaymentCooldownActiveError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + /** The earliest time after which the payer may retry. */ + readonly retryAfter: Date; + + constructor(invoiceId: string, payer: string, retryAfterTimestamp: number, raw?: string) { + const retryAfter = new Date(retryAfterTimestamp * 1000); + super( + `Payment cooldown active for payer ${payer} on invoice ${invoiceId}. Retry after ${retryAfter.toISOString()}`, + "PAYMENT_COOLDOWN_ACTIVE", + { invoiceId, payer, retryAfterTimestamp }, + raw + ); + this.name = "PaymentCooldownActiveError"; + this.invoiceId = invoiceId; + this.payer = payer; + this.retryAfter = retryAfter; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPaymentCooldownActiveError(err: unknown): err is PaymentCooldownActiveError { + return err instanceof PaymentCooldownActiveError; +} + +/** Thrown when the contract is administratively frozen for all operations. */ +export class ContractFrozenError extends StellarSplitError { + constructor(raw?: string) { + super("Contract is frozen and not accepting operations", "CONTRACT_FROZEN", undefined, raw); + this.name = "ContractFrozenError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isContractFrozenError(err: unknown): err is ContractFrozenError { + return err instanceof ContractFrozenError; +} + +/** Thrown when an invoice has been fully funded and cannot receive more payments. */ +export class InvoiceFullyFundedError extends StellarSplitError { + readonly invoiceId: string; + + constructor(invoiceId: string, raw?: string) { + super(`Invoice ${invoiceId} is already fully funded`, "INVOICE_FULLY_FUNDED", { invoiceId }, raw); + this.name = "InvoiceFullyFundedError"; + this.invoiceId = invoiceId; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceFullyFundedError(err: unknown): err is InvoiceFullyFundedError { + return err instanceof InvoiceFullyFundedError; +} + +/** Thrown when attempting to rate an invoice that is not yet released. */ +export class InvoiceNotReleasedForRatingError extends StellarSplitError { + readonly invoiceId: string; + readonly status: string; + + constructor(invoiceId: string, status: string, raw?: string) { + super( + `Invoice ${invoiceId} is not released and cannot be rated (current: ${status})`, + "INVOICE_NOT_RELEASED_FOR_RATING", + { invoiceId, status }, + raw + ); + this.name = "InvoiceNotReleasedForRatingError"; + this.invoiceId = invoiceId; + this.status = status; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isInvoiceNotReleasedForRatingError(err: unknown): err is InvoiceNotReleasedForRatingError { + return err instanceof InvoiceNotReleasedForRatingError; +} + +/** Thrown when a payer has already rated an invoice. */ +export class AlreadyRatedError extends StellarSplitError { + readonly invoiceId: string; + readonly payer: string; + + constructor(invoiceId: string, payer: string, raw?: string) { + super( + `Payer ${payer} has already rated invoice ${invoiceId}`, + "ALREADY_RATED", + { invoiceId, payer }, + raw + ); + this.name = "AlreadyRatedError"; + this.invoiceId = invoiceId; + this.payer = payer; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isAlreadyRatedError(err: unknown): err is AlreadyRatedError { + return err instanceof AlreadyRatedError; +} + +/** Thrown when a non-contributor attempts to vote on a deadline extension. */ +export class NotEligibleToVoteError extends StellarSplitError { + readonly invoiceId: string; + readonly caller: string; + + constructor(invoiceId: string, caller: string, raw?: string) { + super( + `Caller ${caller} is not eligible to vote on invoice ${invoiceId} (must be a contributor)`, + "NOT_ELIGIBLE_TO_VOTE", + { invoiceId, caller }, + raw + ); + this.name = "NotEligibleToVoteError"; + this.invoiceId = invoiceId; + this.caller = caller; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isNotEligibleToVoteError(err: unknown): err is NotEligibleToVoteError { + return err instanceof NotEligibleToVoteError; +} + +// --------------------------------------------------------------------------- +// Price oracle adapter errors +// --------------------------------------------------------------------------- + +/** Thrown when a price oracle request is rate-limited by the upstream provider. */ +export class RateLimitError extends StellarSplitError { + /** Seconds the caller should wait before retrying, if known. */ + readonly retryAfterSeconds?: number; + + constructor(message: string, retryAfterSeconds?: number) { + super(message, "RATE_LIMITED", { retryAfterSeconds }); + this.name = "RateLimitError"; + this.retryAfterSeconds = retryAfterSeconds; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isRateLimitError(err: unknown): err is RateLimitError { + return err instanceof RateLimitError; +} + +/** Thrown when a price oracle request fails for a reason other than rate limiting. */ +export class PriceOracleFetchError extends StellarSplitError { + constructor(message: string) { + super(message, "PRICE_ORACLE_FETCH_FAILED", undefined, message); + this.name = "PriceOracleFetchError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isPriceOracleFetchError(err: unknown): err is PriceOracleFetchError { + return err instanceof PriceOracleFetchError; +} + +/** Thrown when delegate limit is reached. */ +export class DelegateLimitReachedError extends StellarSplitError { + readonly invoiceId: string; + readonly limit: number; + + constructor(invoiceId: string, limit: number = 3) { + super( + `Invoice ${invoiceId} has reached the delegate limit of ${limit}`, + "DELEGATE_LIMIT_REACHED", + { invoiceId, limit }, + ); + this.name = "DelegateLimitReachedError"; + this.invoiceId = invoiceId; + this.limit = limit; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function isDelegateLimitReachedError(err: unknown): err is DelegateLimitReachedError { + return err instanceof DelegateLimitReachedError; +} + +/** Structured classification of a Horizon transaction/operation result code. */ +export interface HorizonErrorClassification { + /** The primary result code string. */ + code: string; + /** Whether the error is safe to retry. */ + isRetryable: boolean; + /** Severity level of the error. */ + severity: "low" | "medium" | "high" | "critical" | "unknown"; + /** Human-readable description of what went wrong. */ + description: string; + /** Recommended action for the caller to take. */ + suggestedAction: string; + /** The specific operation result code, if available. */ + operationCode?: string; +} + + + + diff --git a/src/events/TypedEventEmitter.ts b/src/events/TypedEventEmitter.ts index 1bdd5f3e..b2979e91 100644 --- a/src/events/TypedEventEmitter.ts +++ b/src/events/TypedEventEmitter.ts @@ -27,7 +27,7 @@ export class AbortError extends Error { } } -export type EventMap = Record; +export type EventMap = Record; export type WildcardEventHandler = ( event: keyof T, payload: T[keyof T], diff --git a/src/index.ts b/src/index.ts index bf64e06a..13112ae1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,13 @@ import type { ExportFormat } from "./export.js"; export { StellarSplitClient } from "./client.js"; export { FinalityChecker } from "./finalityChecker.js"; export { buildPaymentMemo, parsePaymentMemo } from "./memoBuilder.js"; +export { + parseAmount, + formatAmount, + isValidStellarAddress, + truncateAddress, + isExpired, +} from "./utils.js"; export type { StellarSplitClientConfig, NetworkConfig, @@ -38,6 +45,24 @@ export type { export { PluginRegistry, LoggingPlugin, MetricsPlugin } from "./plugin.js"; export type { SdkPlugin, SdkMethodName, PluginArgs, PluginResult, StellarSplitClientContext } from "./plugin.js"; +export { + LogLevel, + SdkLogger, + parseLogLevel, + logLevelToString, + sanitizeLogValue, + defaultLogFormatter, + defaultConsoleSink, +} from "./logger.js"; +export type { + Logger, + LoggerOptions, + LogLevelString, + LogEntry, + LogFormatter, + LogSink, +} from "./logger.js"; + export { serializeInvoiceTemplate, deserializeInvoiceTemplate, @@ -222,7 +247,7 @@ export { // New: Horizon Error Classifier ClassifiedHorizonError, isClassifiedHorizonError, - HorizonErrorClassification, + type HorizonErrorClassification, FinalityTimeoutError, isFinalityTimeoutError, ApprovalTimeoutError, @@ -540,7 +565,9 @@ export { checkPayerReadiness, checkInvoiceExpiry, checkSponsorReserve, checkReci export type { PayerReadinessResult, PayerReadinessReason, InvoiceExpiryResult, InvoiceExpiryReason, SponsorReserveCheck, RecipientFlagsCheck, RunPreflightOptions } from "./preflightChecker.js"; export { inspectFlags, hasAnyRestrictiveFlag } from "./accountFlagsInspector.js"; -export type { AccountFlagSet } from "./types.js"; +export { detectLockState, assertAccountUnlocked } from "./accountStateDetector.js"; +export { AccountFrozenError, AccountLockedError } from "./errors.js"; +export type { AccountFlagSet, AccountLockState } from "./types.js"; export { getSuggestion } from "./errorSuggestions.js"; @@ -1679,7 +1706,7 @@ export { TemplateNotFoundError, TemplateAccessDeniedError, } from "./templates.js"; -export type { InvoiceTemplate, CreateFromTemplateParams, SaveTemplateParams } from "./types/templates.js"; +export type { CreateFromTemplateParams, SaveTemplateParams } from "./types/templates.js"; // Recurring subscriptions export { @@ -1692,7 +1719,7 @@ export { SubscriptionNotFoundError, TooEarlyToTriggerError, } from "./subscriptions.js"; -export type { Subscription, SubscriptionStatus, CreateSubscriptionParams } from "./types/subscriptions.js"; +export type { SubscriptionStatus, CreateSubscriptionParams } from "./types/subscriptions.js"; // Invoice recommendation AI (#980) export { InvoiceRecommendationEngine } from "./invoiceRecommendation.js"; diff --git a/src/invoiceStats.ts b/src/invoiceStats.ts index 1f6899f7..c796f769 100644 --- a/src/invoiceStats.ts +++ b/src/invoiceStats.ts @@ -139,10 +139,12 @@ function medianOf(amounts: bigint[]): bigint { const sorted = [...amounts].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); const mid = sorted.length >> 1; + const midVal = sorted[mid] ?? 0n; + const prevVal = sorted[mid - 1] ?? 0n; return sorted.length % 2 === 1 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2n; + ? midVal + : (prevVal + midVal) / 2n; } /** diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 00000000..bfdcbed3 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,470 @@ +/** + * Configurable structured debug logging for the StellarSplit SDK. + * + * Provides granular log levels, contextual metadata, sensitive data redaction, + * pluggable formatters, custom sinks, and dynamic runtime control. + */ + +/** + * Log level severity hierarchy. Lower numerical values correspond to more verbose output. + */ +export enum LogLevel { + /** Detailed tracing and debug diagnostics. */ + DEBUG = 0, + /** General informational events regarding operational state. */ + INFO = 1, + /** Potential issues, recoverable fallbacks, or degraded states. */ + WARN = 2, + /** Critical failures, unrecoverable RPC errors, or contract exceptions. */ + ERROR = 3, + /** Disables all log output. */ + NONE = 4, +} + +/** String representation of supported log levels. */ +export type LogLevelString = "debug" | "info" | "warn" | "error" | "none"; + +/** + * Parses a numeric {@link LogLevel} or lowercase {@link LogLevelString} into a canonical LogLevel enum. + * + * @param level - The log level enum or string representation. + * @returns The resolved {@link LogLevel}. + * + * @example + * ```typescript + * parseLogLevel("debug"); // LogLevel.DEBUG + * parseLogLevel(LogLevel.WARN); // LogLevel.WARN + * ``` + */ +export function parseLogLevel(level: LogLevel | LogLevelString): LogLevel { + if (typeof level === "number") { + if (level >= LogLevel.DEBUG && level <= LogLevel.NONE) { + return level; + } + return LogLevel.NONE; + } + + switch (level?.toLowerCase()?.trim()) { + case "debug": + return LogLevel.DEBUG; + case "info": + return LogLevel.INFO; + case "warn": + case "warning": + return LogLevel.WARN; + case "error": + return LogLevel.ERROR; + case "none": + case "silent": + case "off": + return LogLevel.NONE; + default: + return LogLevel.NONE; + } +} + +/** + * Maps a {@link LogLevel} enum value to its canonical uppercase string label. + * + * @param level - The numeric log level. + * @returns The string label (e.g. "DEBUG", "INFO"). + */ +export function logLevelToString(level: LogLevel): "DEBUG" | "INFO" | "WARN" | "ERROR" | "NONE" { + switch (level) { + case LogLevel.DEBUG: + return "DEBUG"; + case LogLevel.INFO: + return "INFO"; + case LogLevel.WARN: + return "WARN"; + case LogLevel.ERROR: + return "ERROR"; + case LogLevel.NONE: + default: + return "NONE"; + } +} + +/** + * Structured log record emitted for each logged message. + */ +export interface LogEntry { + /** Unix timestamp in milliseconds when the log entry was generated. */ + timestamp: number; + /** ISO 8601 string representation of the timestamp. */ + isoTime: string; + /** Severity level enum of this entry. */ + level: LogLevel; + /** Canonical uppercase name of the log level. */ + levelName: "DEBUG" | "INFO" | "WARN" | "ERROR"; + /** Namespace or component name emitting this entry (e.g. "StellarSplitClient"). */ + namespace: string; + /** Human-readable log message. */ + message: string; + /** Optional contextual metadata and key-value attributes. */ + context?: Record; +} + +/** + * Function signature for converting a {@link LogEntry} into a formatted string. + */ +export type LogFormatter = (entry: LogEntry) => string; + +/** + * Function signature for receiving and persisting or transmitting formatted log entries. + */ +export type LogSink = (entry: LogEntry, formattedMessage: string) => void; + +/** + * Configuration options for initializing an {@link SdkLogger}. + */ +export interface LoggerOptions { + /** + * Minimum active severity level. Entries below this level are ignored. + * Defaults to {@link LogLevel.NONE} unless explicitly enabled or configured via environment. + */ + level?: LogLevel | LogLevelString; + /** + * Component namespace prefix. Defaults to "StellarSplit". + */ + namespace?: string; + /** + * Custom sink for outputting logs. Defaults to console methods. + */ + sink?: LogSink; + /** + * Custom formatter for transforming log entries into strings. + */ + formatter?: LogFormatter; + /** + * Whether to automatically sanitize and redact secret keys, private seeds, and credentials. + * Defaults to true. + */ + redactSensitive?: boolean; + /** + * Initial active enabled flag. If false, behaves as LogLevel.NONE. + */ + enabled?: boolean; +} + +/** Stellar secret key pattern (56 characters starting with 'S'). */ +const STELLAR_SECRET_SEED_RE = /\bS[A-Z0-9]{55}\b/g; + +/** Sensitive keys in context objects that should be redacted. */ +const SENSITIVE_KEY_NAMES = new Set([ + "secret", + "secretkey", + "secret_key", + "secretseed", + "secret_seed", + "privatekey", + "private_key", + "privkey", + "password", + "authorization", + "auth_token", + "token", + "apikey", + "api_key", + "webhooksecret", + "webhook_secret", +]); + +/** + * Redacts Stellar secret seeds and sensitive keys from log context. + * + * @param value - Any value to sanitize. + * @returns The sanitized value safe for logging. + */ +export function sanitizeLogValue(value: unknown): unknown { + if (typeof value === "string") { + return value.replace(STELLAR_SECRET_SEED_RE, (match) => `${match.slice(0, 4)}...[REDACTED]`); + } + + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message.replace(STELLAR_SECRET_SEED_RE, "S...[REDACTED]"), + stack: value.stack?.replace(STELLAR_SECRET_SEED_RE, "S...[REDACTED]"), + }; + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeLogValue(item)); + } + + if (value !== null && typeof value === "object") { + const sanitized: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (SENSITIVE_KEY_NAMES.has(k.toLowerCase())) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeLogValue(v); + } + } + return sanitized; + } + + return value; +} + +/** + * Default human-readable log formatter. + * + * Produces structured output: + * `[2026-10-17T23:50:00.000Z] [DEBUG] [StellarSplitClient] Message {"foo":"bar"}` + * + * @param entry - The log entry to format. + * @returns A formatted string. + */ +export function defaultLogFormatter(entry: LogEntry): string { + let contextStr = ""; + if (entry.context && Object.keys(entry.context).length > 0) { + try { + contextStr = ` ${JSON.stringify(entry.context)}`; + } catch { + contextStr = " [Unserializable Context]"; + } + } + return `[${entry.isoTime}] [${entry.levelName}] [${entry.namespace}] ${entry.message}${contextStr}`; +} + +/** + * Default console sink that routes entries to standard console methods based on severity. + * + * @param entry - The log entry. + * @param formattedMessage - Pre-formatted message string. + */ +export function defaultConsoleSink(entry: LogEntry, formattedMessage: string): void { + switch (entry.level) { + case LogLevel.DEBUG: + if (typeof console.debug === "function") { + console.debug(formattedMessage); + } else { + console.log(formattedMessage); + } + break; + case LogLevel.INFO: + if (typeof console.info === "function") { + console.info(formattedMessage); + } else { + console.log(formattedMessage); + } + break; + case LogLevel.WARN: + console.warn(formattedMessage); + break; + case LogLevel.ERROR: + console.error(formattedMessage); + break; + default: + break; + } +} + +/** + * Public logger contract implemented by {@link SdkLogger}. + */ +export interface Logger { + /** Log a message at DEBUG severity. */ + debug(message: string, context?: Record): void; + /** Log a message at INFO severity. */ + info(message: string, context?: Record): void; + /** Log a message at WARN severity. */ + warn(message: string, context?: Record): void; + /** Log a message at ERROR severity. */ + error(message: string, context?: Record): void; + /** Update current minimum active severity level. */ + setLevel(level: LogLevel | LogLevelString): void; + /** Retrieve the current active severity level. */ + getLevel(): LogLevel; + /** Check if messages at the given level will be emitted. */ + isEnabled(level: LogLevel | LogLevelString): boolean; + /** Create a child logger sharing the same configuration under an appended namespace. */ + child(subNamespace: string): Logger; +} + +/** + * Primary logger implementation for the StellarSplit SDK. + * + * @example + * ```typescript + * const logger = new SdkLogger({ + * level: LogLevel.DEBUG, + * namespace: "MySplitApp", + * }); + * + * logger.debug("Initializing payment", { invoiceId: "123" }); + * ``` + */ +export class SdkLogger implements Logger { + private level: LogLevel; + private namespace: string; + private sink: LogSink; + private formatter: LogFormatter; + private redactSensitive: boolean; + + constructor(options: LoggerOptions = {}) { + let resolvedLevel = LogLevel.NONE; + + if (options.enabled === false) { + resolvedLevel = LogLevel.NONE; + } else if (options.level !== undefined) { + resolvedLevel = parseLogLevel(options.level); + } else if (typeof process !== "undefined" && process?.env) { + const envLevel = process.env.SPLIT_SDK_LOG_LEVEL || (process.env.DEBUG?.includes("stellar-split") ? "debug" : undefined); + if (envLevel) { + resolvedLevel = parseLogLevel(envLevel as LogLevelString); + } + } + + this.level = resolvedLevel; + this.namespace = options.namespace || "StellarSplit"; + this.sink = options.sink || defaultConsoleSink; + this.formatter = options.formatter || defaultLogFormatter; + this.redactSensitive = options.redactSensitive ?? true; + } + + /** + * Checks whether logging is enabled for the specified level. + * + * @param level - The level to test. + * @returns True if the logger will emit for this level. + */ + public isEnabled(level: LogLevel | LogLevelString): boolean { + const numericLevel = parseLogLevel(level); + if (numericLevel === LogLevel.NONE || this.level === LogLevel.NONE) { + return false; + } + return numericLevel >= this.level; + } + + /** + * Retrieves the current logging level. + * + * @returns The active {@link LogLevel}. + */ + public getLevel(): LogLevel { + return this.level; + } + + /** + * Changes the minimum active logging level. + * + * @param level - The new level. + */ + public setLevel(level: LogLevel | LogLevelString): void { + this.level = parseLogLevel(level); + } + + /** + * Updates the output sink. + * + * @param sink - The new log sink function. + */ + public setSink(sink: LogSink): void { + this.sink = sink; + } + + /** + * Updates the log message formatter. + * + * @param formatter - The new formatter function. + */ + public setFormatter(formatter: LogFormatter): void { + this.formatter = formatter; + } + + /** + * Creates a child logger with an extended namespace. + * + * @param subNamespace - Additional namespace suffix to append. + * @returns A new child {@link Logger}. + */ + public child(subNamespace: string): Logger { + return new SdkLogger({ + level: this.level, + namespace: `${this.namespace}:${subNamespace}`, + sink: this.sink, + formatter: this.formatter, + redactSensitive: this.redactSensitive, + }); + } + + /** + * Emits a message at {@link LogLevel.DEBUG}. + * + * @param message - The log message. + * @param context - Optional context properties. + */ + public debug(message: string, context?: Record): void { + this.log(LogLevel.DEBUG, message, context); + } + + /** + * Emits a message at {@link LogLevel.INFO}. + * + * @param message - The log message. + * @param context - Optional context properties. + */ + public info(message: string, context?: Record): void { + this.log(LogLevel.INFO, message, context); + } + + /** + * Emits a message at {@link LogLevel.WARN}. + * + * @param message - The log message. + * @param context - Optional context properties. + */ + public warn(message: string, context?: Record): void { + this.log(LogLevel.WARN, message, context); + } + + /** + * Emits a message at {@link LogLevel.ERROR}. + * + * @param message - The log message. + * @param context - Optional context properties. + */ + public error(message: string, context?: Record): void { + this.log(LogLevel.ERROR, message, context); + } + + /** + * Internal dispatcher for logging an entry. + */ + private log(level: LogLevel, message: string, context?: Record): void { + if (!this.isEnabled(level)) { + return; + } + + const now = Date.now(); + const sanitizedContext = context && this.redactSensitive + ? (sanitizeLogValue(context) as Record) + : context; + + const entry: LogEntry = { + timestamp: now, + isoTime: new Date(now).toISOString(), + level, + levelName: logLevelToString(level) as "DEBUG" | "INFO" | "WARN" | "ERROR", + namespace: this.namespace, + message: this.redactSensitive && typeof message === "string" + ? (sanitizeLogValue(message) as string) + : message, + context: sanitizedContext, + }; + + try { + const formatted = this.formatter(entry); + this.sink(entry, formatted); + } catch { + // Sinks or formatters must never throw into business logic. + } + } +} diff --git a/src/offlineQueue.ts b/src/offlineQueue.ts index c07e1c44..57b31a8e 100644 --- a/src/offlineQueue.ts +++ b/src/offlineQueue.ts @@ -15,7 +15,7 @@ export interface OfflineQueueConfig { export class OfflineQueue { private queue: QueuedOperation[] = []; - private config: OfflineQueueConfig; + public config: OfflineQueueConfig; private client: StellarSplitClient; private readonly storageKey = "stellar_split_offline_queue"; diff --git a/src/paymentValidator.ts b/src/paymentValidator.ts index d782efab..f722bcd4 100644 --- a/src/paymentValidator.ts +++ b/src/paymentValidator.ts @@ -1,4 +1,4 @@ -import type { Invoice, RecipientShare } from "./types.js"; +import type { Invoice } from "./types.js"; import { isExpired } from "./utils.js"; import { deduplicateRecipients } from "./validators/recipientDeduplicator.js"; diff --git a/src/preflightChecker.ts b/src/preflightChecker.ts index 1cf9aa0c..bd85c78f 100644 --- a/src/preflightChecker.ts +++ b/src/preflightChecker.ts @@ -1,8 +1,9 @@ -import { rpc as SorobanRpc, Horizon } from "@stellar/stellar-sdk"; +import { rpc as SorobanRpc, Horizon, Asset } from "@stellar/stellar-sdk"; import { inspectFlags } from "./accountFlagsInspector.js"; -import type { AccountFlagSet } from "./types.js"; +import type { AccountFlagSet, AccountLockState } from "./types.js"; import { withTimeout } from "./timeout.js"; -import { PreflightError } from "./errors.js"; +import { PreflightError, PaymentExpiredError, AccountFrozenError, AccountLockedError } from "./errors.js"; +import { detectLockState } from "./accountStateDetector.js"; export type PayerReadinessReason = | "account_not_found" @@ -33,8 +34,6 @@ export interface InvoiceExpiryResult { secondsRemaining: number; } -import { PaymentExpiredError } from "./errors.js"; - /** * Check whether an invoice has expired and should reject a payment submission. * @@ -127,7 +126,7 @@ export async function checkSponsorReserve( const horizonServer = new Horizon.Server(horizonUrl); const sponsorRecord = await horizonServer.loadAccount(sponsorAddress); - const numSponsored = sponsorRecord.num_sponsored; + const numSponsored = (sponsorRecord as any).num_sponsored ?? 0; const nativeLine = sponsorRecord.balances.find( (b) => b.asset_type === "native", ); diff --git a/src/priceOracle.ts b/src/priceOracle.ts index 6db84e43..1cc71c2b 100644 --- a/src/priceOracle.ts +++ b/src/priceOracle.ts @@ -60,10 +60,21 @@ export function parseOraclePrice( }; } +export interface PriceOracleClientInstance { + onPriceUpdate(listener: PriceUpdateListener): () => void; + getCachedPrice(symbol: string): OraclePrice | undefined; + fetchPrice(symbol: string): Promise; + fetchPrices(symbols: string[]): Promise; +} + +export interface PriceOracleConstructor { + new (fetcher: OracleFetcher): PriceOracleClientInstance; +} + /** * Price oracle client that fetches, parses, and emits price updates. */ -export class PriceOracle { +export class PriceOracleClient implements PriceOracleClientInstance { private readonly fetcher: OracleFetcher; private readonly listeners = new Set(); private readonly cache = new Map(); @@ -108,3 +119,146 @@ export class PriceOracle { } } } + +/** Value constructor for price oracle client helpers. */ +export const PriceOracle: PriceOracleConstructor = PriceOracleClient; + +import { PriceOracleFetchError, RateLimitError } from "./errors.js"; +import { RateCache } from "./rateCache.js"; +import type { PriceOracleAdapter } from "./types.js"; + +/** Default asset-code -> CoinGecko coin-id mapping for common Stellar assets. */ +const DEFAULT_COIN_IDS: Record = { + XLM: "stellar", + USDC: "usd-coin", +}; + +export interface CoinGeckoPriceOracleOptions { + /** Override the CoinGecko API base URL (e.g. for a proxy). Default: the public API. */ + apiBaseUrl?: string; + /** Additional or overriding asset-code -> CoinGecko coin-id mappings. */ + coinIds?: Record; + /** Cache used to avoid redundant requests. Defaults to a private RateCache. */ + cache?: RateCache; +} + +/** + * Fetches fiat/asset conversion rates from CoinGecko's public price API, + * caching results via {@link RateCache}. + */ +export class CoinGeckoPriceOracle implements PriceOracleAdapter { + private readonly apiBaseUrl: string; + private readonly coinIds: Record; + private readonly cache: RateCache; + + constructor(options: CoinGeckoPriceOracleOptions = {}) { + this.apiBaseUrl = options.apiBaseUrl ?? "https://api.coingecko.com/api/v3"; + this.coinIds = { ...DEFAULT_COIN_IDS, ...options.coinIds }; + this.cache = options.cache ?? new RateCache(); + } + + /** + * Resolve the price of one unit of `base` denominated in `quote`. + * `base` may be any key in `coinIds` (defaults include "XLM", "USDC") or a + * raw CoinGecko coin id; `quote` is a CoinGecko vs_currency code (e.g. "USD"). + */ + async getPrice(base: string, quote: string): Promise { + const cached = this.cache.get(base, quote); + if (cached !== undefined) return cached; + + const coinId = this.coinIds[base.toUpperCase()] ?? base.toLowerCase(); + const vsCurrency = quote.toLowerCase(); + const url = `${this.apiBaseUrl}/simple/price?ids=${encodeURIComponent(coinId)}&vs_currencies=${encodeURIComponent(vsCurrency)}`; + + const response = await fetch(url); + + if (response.status === 429) { + const retryAfterHeader = response.headers.get("retry-after"); + throw new RateLimitError( + `CoinGecko rate limit exceeded fetching ${base}/${quote}`, + retryAfterHeader ? Number(retryAfterHeader) : undefined, + ); + } + if (!response.ok) { + throw new PriceOracleFetchError( + `CoinGecko request failed with status ${response.status} fetching ${base}/${quote}`, + ); + } + + const payload = (await response.json()) as Record>; + const rate = payload?.[coinId]?.[vsCurrency]; + if (typeof rate !== "number") { + throw new PriceOracleFetchError(`CoinGecko response missing rate for ${base}/${quote}`); + } + + this.cache.set(base, quote, rate); + return rate; + } +} + +import { + Contract, + rpc as SorobanRpc, + TransactionBuilder, + BASE_FEE, + nativeToScVal, + scValToNative, +} from "@stellar/stellar-sdk"; +import { OraclePriceError, NoReturnValueError } from "./errors.js"; + +/** Resolves a conversion rate between two on-chain assets. */ +export interface PriceOracle { + /** + * Fixed-point rate (1e18 = 1.0) to convert 1 unit of `fromAsset` into + * `toAsset`, or `undefined` when no price is available for that pair. + */ + getRate(fromAsset: string, toAsset: string): Promise; +} + +/** Soroban contract-backed price oracle. */ +export class ContractPriceOracle implements PriceOracle { + constructor( + private readonly server: SorobanRpc.Server, + private readonly oracleAddress: string, + private readonly networkPassphrase: string + ) {} + + async getRate(fromAsset: string, toAsset: string): Promise { + const contract = new Contract(this.oracleAddress); + const operation = contract.call( + "get_price", + nativeToScVal(fromAsset, { type: "symbol" }), + nativeToScVal(toAsset, { type: "symbol" }) + ); + + const sourceAccount = { + accountId: () => this.oracleAddress, + sequenceNumber: () => "0", + incrementSequenceNumber: () => {}, + } as any; + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simResult = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + if (/no price|not found|unsupported/i.test(simResult.error)) { + return undefined; + } + throw new OraclePriceError(`Oracle simulation failed: ${simResult.error}`); + } + + const returnVal = (simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse).result + ?.retval; + if (!returnVal) throw new NoReturnValueError("oracle get_price"); + + return BigInt(scValToNative(returnVal)); + } +} + + diff --git a/src/rateCache.ts b/src/rateCache.ts index 888ac69b..9e765894 100644 --- a/src/rateCache.ts +++ b/src/rateCache.ts @@ -42,16 +42,26 @@ function cacheKey(from: string, to: string): string { */ export class RateCache { private readonly store = new Map>(); - private readonly oracle: RateOracleFn; + private readonly oracle?: RateOracleFn; private readonly ttlMs: number; private readonly maxSize: number; private refreshTimer: ReturnType | null = null; private _running = false; - constructor(oracle: RateOracleFn, config: RateCacheConfig = {}) { - this.oracle = oracle; - this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS; - this.maxSize = config.maxSize ?? DEFAULT_MAX_SIZE; + constructor( + oracleOrConfig?: RateOracleFn | RateCacheConfig, + config?: RateCacheConfig, + ) { + if (typeof oracleOrConfig === "function") { + this.oracle = oracleOrConfig; + this.ttlMs = config?.ttlMs ?? DEFAULT_TTL_MS; + this.maxSize = config?.maxSize ?? DEFAULT_MAX_SIZE; + } else { + this.oracle = undefined; + const cfg = oracleOrConfig ?? config ?? {}; + this.ttlMs = cfg.ttlMs ?? DEFAULT_TTL_MS; + this.maxSize = cfg.maxSize ?? DEFAULT_MAX_SIZE; + } } /** Whether background refresh is currently running. */ @@ -64,21 +74,42 @@ export class RateCache { return this.store.size; } + /** The cached rate for `from`/`to`, or undefined if absent or expired. */ + get(from: string, to: string): TRate | undefined { + const key = cacheKey(from, to); + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() - entry.fetchedAt > this.ttlMs) { + this.store.delete(key); + return undefined; + } + return entry.rate; + } + + /** Cache `rate` for `from`/`to`, expiring after this cache's TTL. */ + set(from: string, to: string, rate: TRate): void { + const key = cacheKey(from, to); + this.store.set(key, { rate, fetchedAt: Date.now() }); + this.evictOverflow(); + } + /** * Get the exchange rate for `from -> to`, serving from cache when the * entry is younger than `ttlMs`. On a cache miss (or expired entry), calls * the configured oracle function and stores the result with a timestamp. */ async getRate(from: string, to: string): Promise { - const key = cacheKey(from, to); - const entry = this.store.get(key); - if (entry && Date.now() - entry.fetchedAt < this.ttlMs) { - return entry.rate; + const cached = this.get(from, to); + if (cached !== undefined) { + return cached; + } + + if (!this.oracle) { + throw new Error(`No oracle function configured in RateCache for ${from}:${to}`); } const rate = await this.oracle(from, to); - this.store.set(key, { rate, fetchedAt: Date.now() }); - this.evictOverflow(); + this.set(from, to, rate); return rate; } @@ -92,6 +123,11 @@ export class RateCache { this.store.clear(); } + /** Remove all cached rates. Alias for invalidateAll. */ + clear(): void { + this.invalidateAll(); + } + /** * Start background refresh. Every `ttlMs * 0.8`, every currently cached * pair is re-fetched from the oracle and its entry updated, so a read @@ -115,6 +151,7 @@ export class RateCache { } private async refreshAll(): Promise { + if (!this.oracle) return; for (const key of Array.from(this.store.keys())) { const [from, to] = key.split(":"); if (from === undefined || to === undefined) continue; diff --git a/src/react/index.tsx b/src/react/index.tsx index 8169fddb..e10081a1 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -33,22 +33,14 @@ import React, { useState, } from "react"; import type { ReactNode } from "react"; -import type { Invoice, InvoiceEvent } from "../types.js"; +import type { Invoice, InvoiceEvent, ProtocolStats } from "../types.js"; import type { StellarSplitClient } from "../client.js"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- -/** ProtocolStats returned by {@link useProtocolStats}. */ -export interface ProtocolStats { - /** Total number of invoices created on-chain. */ - totalInvoices: number; - /** Total amount released in stroops. */ - totalReleased: bigint; - /** Number of currently pending invoices. */ - pendingInvoices: number; -} +export type { ProtocolStats }; /** Filter options accepted by {@link useCreatorInvoices}. */ export interface InvoiceFilter { diff --git a/src/receipt.ts b/src/receipt.ts index 4df5eed7..c91485b9 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -1,9 +1,10 @@ import { createHash } from "crypto"; import { Horizon } from "@stellar/stellar-sdk"; -import type { DecodedTransactionResult, Invoice, Payment } from "./types.js"; +import type { DecodedTransactionResult, Invoice, Payment, FinalityStatus } from "./types.js"; import type { PaymentReceipt as ChainPaymentReceipt } from "./types/receipts.js"; import { PayerAddressRequiredError } from "./errors.js"; import { decodeTransactionResult } from "./txResultDecoder.js"; +import type { FinalityChecker } from "./finalityChecker.js"; /** A verified payment receipt compiled from on-chain invoice data. */ export interface PaymentReceipt { @@ -28,7 +29,9 @@ export interface PaymentReceipt { /** Payment status (optional). */ status?: "pending" | "finalized"; /** Per-account balance deltas aggregated from transaction effects (optional). */ - effectSummary?: Array<{ accountId: string; assetDeltas: Array<{ asset: string; delta: bigint }> }>; + effectSummary?: Array<{ accountId: string; assetDeltas: Array<{ asset: string; delta: bigint }> }> | Record; + /** Finality check results if finalized. */ + finality?: FinalityStatus; } /** Options controlling optional receipt enrichment. */ @@ -57,6 +60,9 @@ export interface PaymentReceiptJSON { generatedAt: number; ledgerTimestamp: number; decodedResult?: DecodedTransactionResult; + status?: "pending" | "finalized"; + effectSummary?: unknown; + finality?: FinalityStatus; } /** Interface for any client capable of fetching an invoice by ID. */ @@ -158,7 +164,7 @@ export async function generatePaymentReceipt( if (options?.includeEffects && options.server && options.txHash) { const effectSummary = await _fetchEffectSummary(options.server as Horizon.Server, options.txHash); - (receipt as Record).effectSummary = effectSummary; + receipt.effectSummary = effectSummary; } return receipt; @@ -272,6 +278,9 @@ function _buildReceiptObject(data: { generatedAt: number; ledgerTimestamp: number; decodedResult?: DecodedTransactionResult; + finality?: FinalityStatus; + status?: "pending" | "finalized"; + effectSummary?: Array<{ accountId: string; assetDeltas: Array<{ asset: string; delta: bigint }> }> | Record; }): PaymentReceipt { return { invoiceId: data.invoiceId, @@ -282,6 +291,9 @@ function _buildReceiptObject(data: { generatedAt: data.generatedAt, ledgerTimestamp: data.ledgerTimestamp, decodedResult: data.decodedResult, + finality: data.finality, + status: data.status, + effectSummary: data.effectSummary, toJSON(): PaymentReceiptJSON { const json: PaymentReceiptJSON = { invoiceId: this.invoiceId, @@ -300,9 +312,22 @@ function _buildReceiptObject(data: { json.status = this.status; } if (this.effectSummary) { - json.effectSummary = Object.fromEntries( - Object.entries(this.effectSummary).map(([k, v]) => [k, v.toString()]) - ); + if (Array.isArray(this.effectSummary)) { + json.effectSummary = this.effectSummary.map((e) => ({ + accountId: e.accountId, + assetDeltas: e.assetDeltas.map((d) => ({ + asset: d.asset, + delta: d.delta.toString(), + })), + })); + } else { + json.effectSummary = Object.fromEntries( + Object.entries(this.effectSummary).map(([k, v]) => [k, v?.toString() ?? ""]) + ); + } + } + if (this.finality) { + json.finality = this.finality; } return json; }, diff --git a/src/requestQueue.ts b/src/requestQueue.ts index 6035be77..24812827 100644 --- a/src/requestQueue.ts +++ b/src/requestQueue.ts @@ -23,12 +23,12 @@ export interface QueueStats { export class RequestQueue { private readonly maxConcurrent: number; private activeCount = 0; - private queue: Map[]> = new Map([ + private queue: Map[]> = new Map([ ["high", []], ["normal", []], ["low", []], ]); - private stats: Record = { + private _stats: Record = { high: { completed: 0, failed: 0 }, normal: { completed: 0, failed: 0 }, low: { completed: 0, failed: 0 }, @@ -70,11 +70,11 @@ export class RequestQueue { request .fn() .then((result) => { - this.stats[request.priority].completed++; + this._stats[request.priority].completed++; request.resolve(result); }) .catch((err) => { - this.stats[request.priority].failed++; + this._stats[request.priority].failed++; request.reject(err); }) .finally(() => { @@ -98,20 +98,20 @@ export class RequestQueue { high: { pending: this.queue.get("high")?.length ?? 0, active: this.activeCount, - completed: this.stats.high.completed, - failed: this.stats.high.failed, + completed: this._stats.high.completed, + failed: this._stats.high.failed, }, normal: { pending: this.queue.get("normal")?.length ?? 0, active: this.activeCount, - completed: this.stats.normal.completed, - failed: this.stats.normal.failed, + completed: this._stats.normal.completed, + failed: this._stats.normal.failed, }, low: { pending: this.queue.get("low")?.length ?? 0, active: this.activeCount, - completed: this.stats.low.completed, - failed: this.stats.low.failed, + completed: this._stats.low.completed, + failed: this._stats.low.failed, }, }; } diff --git a/src/signing/adapters/CloudKmsSigner.ts b/src/signing/adapters/CloudKmsSigner.ts index 4dc28232..b8698433 100644 --- a/src/signing/adapters/CloudKmsSigner.ts +++ b/src/signing/adapters/CloudKmsSigner.ts @@ -239,7 +239,7 @@ export class CloudKmsSigner extends TypedEventEmitter im let lastError: unknown; for (let i = 0; i < regions.length; i++) { - const currentRegion = regions[i]; + const currentRegion = regions[i]!; try { return await this.kmsClient.sign(this.keyId, txHash, { region: currentRegion }); } catch (error) { @@ -247,7 +247,7 @@ export class CloudKmsSigner extends TypedEventEmitter im const hasNextRegion = i + 1 < regions.length; if (hasNextRegion && isRegionError(error)) { - const nextRegion = regions[i + 1]; + const nextRegion = regions[i + 1]!; this.emit("regionFallback", { from: currentRegion, to: nextRegion }); continue; } diff --git a/src/signing/adapters/EncryptedFileSigner.ts b/src/signing/adapters/EncryptedFileSigner.ts index 29296b02..c1d1be4c 100644 --- a/src/signing/adapters/EncryptedFileSigner.ts +++ b/src/signing/adapters/EncryptedFileSigner.ts @@ -65,8 +65,8 @@ export async function writeEncryptedSigningKeyFile( * {@link clearCache} was called), the next `sign` call re-reads the file. */ export class EncryptedFileSigner implements Signer { - readonly filePath: string; - private readonly aesKey: Buffer; + filePath: string; + private aesKey: Buffer; /** Weak reference to the decrypted keypair — cleared by GC or clearCache(). */ private cachedKeypairRef: WeakRef | null = null; /** Promise-based lock serialising key rotation and sign operations. */ diff --git a/src/snapshot.ts b/src/snapshot.ts index 80a609e9..83c69fbc 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -3,6 +3,7 @@ import type { Invoice, Payment, ReminderSchedule, + SplitLeg, SplitRollbackCheckpoint, } from "./types.js"; @@ -139,7 +140,7 @@ export function snapshotSplitRollback( capturedAt, checkpoint: Object.freeze({ ...checkpoint, - legs: Object.freeze([...checkpoint.legs]), + legs: Object.freeze([...checkpoint.legs]) as unknown as SplitLeg[], }), }); } diff --git a/src/soroban/footprint.ts b/src/soroban/footprint.ts index 47ccd660..e39ecdfc 100644 --- a/src/soroban/footprint.ts +++ b/src/soroban/footprint.ts @@ -110,3 +110,25 @@ function readSorobanData(tx: Transaction): xdr.SorobanTransactionData | undefine } return undefined; } + +/** + * Simulates a transaction to obtain its footprint. + */ +export async function simulateFootprint( + server: SorobanRpc.Server, + tx: Transaction, +): Promise { + const sim = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw new Error(`Simulation failed: ${sim.error}`); + } + return sim as SorobanRpc.Api.SimulateTransactionSuccessResponse; +} + +/** + * Clears any internal simulation cache for footprints. + */ +export function clearFootprintSimulationCache(): void { + // Clear footprint simulation cache hook +} + diff --git a/src/streaming/SubscriptionManager.ts b/src/streaming/SubscriptionManager.ts index 81c4f683..de58a73b 100644 --- a/src/streaming/SubscriptionManager.ts +++ b/src/streaming/SubscriptionManager.ts @@ -175,7 +175,7 @@ export class SubscriptionManager { private _saveCursor(storage: StorageAdapter, cursor: EventCursor): void { try { storage.setItem(this._cursorKey(cursor.invoiceId), JSON.stringify(cursor)); - this.onLifecycleEvent?.({ type: "cursor_persisted", invoiceId: cursor.invoiceId, cursor }); + this.onLifecycleEvent?.({ kind: "CursorPersisted", type: "cursor_persisted", invoiceId: cursor.invoiceId, cursor }); } catch { // Best-effort persistence — an in-memory replay window still applies. } @@ -236,7 +236,7 @@ export class SubscriptionManager { state.retryAttempt = 0; state.backoffMs = this.options.initialBackoffMs; - this.onLifecycleEvent?.({ type: "connected", invoiceId: state.invoiceId }); + this.onLifecycleEvent?.({ kind: "Connected", type: "connected", invoiceId: state.invoiceId }); if (!state.stopped) { state.pollTimer = setTimeout(() => void this._poll(state, storage), this.options.pollIntervalMs); @@ -245,7 +245,7 @@ export class SubscriptionManager { if (state.stopped) return; const err = error instanceof Error ? error : new Error(String(error)); state.retryAttempt += 1; - this.onLifecycleEvent?.({ type: "disconnected", invoiceId: state.invoiceId, error: err }); + this.onLifecycleEvent?.({ kind: "Disconnected", type: "disconnected", invoiceId: state.invoiceId, error: err }); if (state.retryAttempt > this.options.maxRetries) { this._stop(state); @@ -254,6 +254,7 @@ export class SubscriptionManager { const delay = Math.min(state.backoffMs, this.options.maxBackoffMs); this.onLifecycleEvent?.({ + kind: "Reconnecting", type: "reconnecting", invoiceId: state.invoiceId, attempt: state.retryAttempt, diff --git a/src/templates.ts b/src/templates.ts index ffa03d80..cab17513 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -7,7 +7,7 @@ import type { CreateFromTemplateParams, SaveTemplateParams, } from "./types/templates.js"; -import type { CreateInvoiceParams } from "./types/index.js"; +import type { CreateInvoiceParams } from "./types.js"; import { StellarSplitError } from "./errors.js"; export class TemplateNotFoundError extends StellarSplitError { diff --git a/src/txBuilder.ts b/src/txBuilder.ts index 2fd283f1..8228e428 100644 --- a/src/txBuilder.ts +++ b/src/txBuilder.ts @@ -15,6 +15,7 @@ import { signTransaction } from "./wallet.js"; import { SimulationFailedError, TransactionFailedError, TransactionNotConfirmedError } from "./errors.js"; import { checkInvoiceExpiry } from "./preflightChecker.js"; import type { LedgerCloseEstimator } from "./ledgerCloseEstimator.js"; +import { detectFeeSurge, type FeeSurgeConfig } from "./feeSurgeDetector.js"; /** Builder for composing multi-operation StellarSplit transactions. */ export class StellarSplitTxBuilder { diff --git a/src/types.ts b/src/types.ts index 3fbf548e..832c63bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -432,6 +432,8 @@ export interface InvoiceStats { totalPayers: number; /** Mean payment size in stroops (0 when there are no payments). */ avgPayment: bigint; + /** Median payment size in stroops (0 when there are no payments). */ + medianAmount?: bigint; /** * Stroops funded per day across the payment window, i.e. the sum of payment * amounts divided by the span between the first and last payment. @@ -495,6 +497,8 @@ export interface CreateInvoiceParams { deadline: number; /** Optional memo / description. */ memo?: string; + /** Optional JSON metadata object attached to invoice. */ + metadata?: Record; /** * When `true`, simulate the transaction against Soroban RPC instead of * submitting it, and resolve with a {@link SimulationResult} (issue #844). @@ -1588,6 +1592,15 @@ export interface AccountFlagSet { isCompatibleWith(operation: string): boolean; } +/** Result of checking an account's freeze / lock state for an asset. */ +export interface AccountLockState { + isFrozen: boolean; + isLocked: boolean; + trustlineAuthorized: boolean; + revocableByIssuer: boolean; + reason?: "no_trustline" | "frozen" | "immutable" | string; +} + /** Declarative description of a claimable-balance claim predicate, buildable via `PredicateBuilder.build()`. */ export type PredicateConfig = | { type: "unconditional" } @@ -2276,3 +2289,381 @@ export interface Attestation { /** Whether the attestation has been revoked. */ revoked: boolean; } + +// --------------------------------------------------------------------------- +// Price Oracle Adapter Types +// --------------------------------------------------------------------------- + +/** + * Pluggable adapter contract for resolving fiat-to-asset conversion rates. + * Implementations may call an external REST API, an on-chain oracle + * contract, or a static table; the SDK ships {@link CoinGeckoPriceOracle} + * (see priceOracle.ts) as the default REST-backed implementation. + */ +export interface PriceOracleAdapter { + /** + * Resolve the price of one unit of `base` denominated in `quote` + * (e.g. `getPrice("XLM", "USD")` resolves to how many USD one XLM is worth). + */ + getPrice(base: string, quote: string): Promise; +} + +// --------------------------------------------------------------------------- +// Path Query Builder Types +// --------------------------------------------------------------------------- + +/** A validated strict-send Horizon path query, built by PathQueryBuilder. */ +export interface StrictSendPathQuery { + kind: "strictSend"; + sourceAsset: import("@stellar/stellar-sdk").Asset; + sourceAmount: bigint; + /** A specific destination account, or a fixed list of acceptable destination assets. */ + destination: string | import("@stellar/stellar-sdk").Asset[]; +} + +/** A validated strict-receive Horizon path query, built by PathQueryBuilder. */ +export interface StrictReceivePathQuery { + kind: "strictReceive"; + /** A specific source account, or a fixed list of acceptable source assets. */ + source: string | import("@stellar/stellar-sdk").Asset[]; + destinationAsset: import("@stellar/stellar-sdk").Asset; + destinationAmount: bigint; +} + +/** A path query built by PathQueryBuilder, ready to execute. */ +export type PathQuery = StrictSendPathQuery | StrictReceivePathQuery; + +/** A single resolved conversion path returned by executing a PathQuery. */ +export interface PathQueryResult { + /** Ordered list of intermediate assets forming the conversion route. */ + path: Array<{ asset_code: string; asset_issuer: string; asset_type: string }>; + /** Amount sent from the source, in the source asset's base unit. */ + sourceAmount: bigint; + /** Amount the destination receives, in the destination asset's base unit. */ + destinationAmount: bigint; +} + +// --------------------------------------------------------------------------- +// Payment Workflow Utilities +// --------------------------------------------------------------------------- + +export interface FinalityStatus { + finalized: boolean; + confirmations: number; + ledgerSequence: number; +} + +export interface FinalityCheckConfig { + minConfirmations?: number; + pollIntervalMs?: number; + maxWaitMs?: number; +} + +export interface MultiSigPolicy { + threshold: number; + timeoutMs: number; + signers: Array<{ + publicKey: string; + weight: number; + }>; +} + +export interface ApprovalSessionResult { + complete: boolean; + weight: number; +} + +export interface BatchPaymentResult { + succeeded: Array<{ + chunkIndex: number; + txHash: string; + }>; + failed: { + chunkIndex: number; + error: Error; + } | null; +} + +export type ChunkSubmitter = ( + operations: T[], + chunkIndex: number, +) => Promise<{ txHash: string }>; + +// --------------------------------------------------------------------------- +// Effect Aggregator Types +// --------------------------------------------------------------------------- + +/** Net balance change for a single asset within an account's effect summary. */ +export interface AssetDelta { + /** Asset identifier: "native" or "CODE:ISSUER". */ + asset: string; + /** Net change in stroops; positive = credited, negative = debited. */ + delta: bigint; +} + +/** Net effect summary for one account, aggregated across a transaction's effects. */ +export interface AccountEffectSummary { + /** Stellar account ID affected. */ + accountId: string; + /** Net asset balance changes for this account. */ + assetDeltas: AssetDelta[]; +} + +// --------------------------------------------------------------------------- +// Pause/Resume Types (Issue #866) +// --------------------------------------------------------------------------- + +export interface PauseStatus { + paused: boolean; + autoResumeAt?: Date; +} + +// --------------------------------------------------------------------------- +// Pledge Match Types (Issue #867) +// --------------------------------------------------------------------------- + +export interface MatchPledge { + matcher: string; + pledgedAmount: bigint; + matchedAmount: bigint; + unmatched: bigint; +} + +// --------------------------------------------------------------------------- +// Streaming Types (Issue #868) +// --------------------------------------------------------------------------- + +export interface Stream { + id: string; + invoiceId: string; + payer: string; + amountPerLedger: bigint; + startLedger: number; + status: "active" | "settled" | "cancelled"; +} + +// --------------------------------------------------------------------------- +// Payment Progress Tracker Types +// --------------------------------------------------------------------------- + +export interface RecipientPaymentState { + accountId: string; + status: string; + settledAmount: bigint; + lastUpdatedAt: number; +} + +export interface InvoicePaymentProgress { + invoiceId: string; + percentComplete: number; + recipientStates: RecipientPaymentState[]; + trancheProgress?: import("./trancheProgress.js").TrancheProgressReport; +} + +// --------------------------------------------------------------------------- +// Issues #878 — Round types +// --------------------------------------------------------------------------- + +export interface PayerRefund { + /** Stellar address of the payer. */ + payer: string; + /** Amount (in stroops) that was kept / credited toward the invoice. */ + amountKept: bigint; + /** Amount (in stroops) refunded to this payer due to over-subscription. */ + amountRefunded: bigint; +} + +/** Result returned by closeRound. */ +export interface RoundCloseResult { + /** Total amount raised (in stroops) before overflow trimming. */ + totalRaised: bigint; + /** Maximum amount the round was capped at (in stroops). */ + hardCap: bigint; + /** Overflow amount distributed back as refunds (in stroops). */ + overflow: bigint; + /** Per-payer refund breakdown. */ + refunds: PayerRefund[]; + /** Transaction hash of the close_round call. */ + txHash: string; +} + +/** Current state of a fundraising round. */ +export interface RoundInfo { + /** Invoice identifier. */ + invoiceId: string; + /** Total raised so far (in stroops). */ + totalRaised: bigint; + /** Round hard cap (in stroops). */ + hardCap: bigint; + /** Unix timestamp when the round ends. */ + roundEnd: number; + /** Whether the round has ended and been closed. */ + closed: boolean; +} + +// --------------------------------------------------------------------------- +// Issues #879 — Milestone types +// --------------------------------------------------------------------------- + +/** Status of a milestone. */ +export type MilestoneStatus = 'locked' | 'active' | 'completed'; + +/** A single milestone in an invoice's milestone schedule. */ +export interface Milestone { + /** Zero-based milestone index. */ + index: number; + /** Human-readable description of what this milestone represents. */ + description: string; + /** Target amount required to complete this milestone (in stroops). */ + targetAmount: bigint; + /** Amount currently funded toward this milestone (in stroops). */ + fundedAmount: bigint; + /** Current lifecycle status of this milestone. */ + status: MilestoneStatus; +} + +/** Result returned by completeMilestone. */ +export interface MilestoneResult { + /** Index of the milestone that was completed. */ + index: number; + /** Amount released to recipients upon completion (in stroops). */ + amountReleased: bigint; + /** Index of the next milestone, if any. */ + nextMilestoneIndex?: number; + /** Transaction hash. */ + txHash: string; +} + +// --------------------------------------------------------------------------- +// Issues #880 — Referral types +// --------------------------------------------------------------------------- + +/** Result returned by claimReferralRewards. */ +export interface ReferralClaimResult { + /** Amount of referral rewards claimed (in stroops). */ + amountClaimed: bigint; + /** Transaction hash. */ + txHash: string; +} + +/** Result returned by payWithReferral. */ +export interface ReferralPayResult { + /** Transaction hash. */ + txHash: string; + /** Referrer address credited for this payment. */ + referrer: string; +} + +// --------------------------------------------------------------------------- +// Issue #876 — Protocol stats +// --------------------------------------------------------------------------- + +/** Protocol statistics and aggregate metrics across invoices. */ +export interface ProtocolStats { + /** Total number of invoices created on-chain. */ + totalInvoices: number; + /** Total amount released in stroops. */ + totalReleased?: bigint; + /** Number of currently pending invoices. */ + pendingInvoices?: number; + /** Total amount paid in stroops. */ + totalPaidAmount?: bigint; + /** Total amount released in stroops. */ + totalReleasedAmount?: bigint; + /** Total amount refunded in stroops. */ + totalRefundedAmount?: bigint; + /** Unique creator count. */ + uniqueCreators?: number; + /** Unique payer count. */ + uniquePayers?: number; +} + +// --------------------------------------------------------------------------- +// Issue #875 — Notes +// --------------------------------------------------------------------------- + +/** Note attached to an invoice. */ +export interface Note { + index: number; + content: string; + timestamp: Date; +} + +export type { SdkLogger } from "./logger.js"; + +// --------------------------------------------------------------------------- +// Split Rounding Auditor Types (Issue #590) +// --------------------------------------------------------------------------- + +/** + * One input line in a split: a recipient and their ratio of the total. + */ +export interface SplitLine { + /** Unique identifier for this recipient (e.g. Stellar address). */ + recipientId: string; + /** + * Fraction of the invoice total owed to this recipient (0 < ratio ≤ 1). + * The sum of all ratios in a split should be 1.0. + */ + ratio: number; +} + +/** + * A single stroop-level adjustment applied by the rounding auditor. + */ +export interface RoundingAdjustment { + /** Recipient whose computed amount was adjusted. */ + recipientId: string; + /** + * Signed stroop delta applied to the raw floor amount. + * Positive means one stroop was added; negative means one was removed. + */ + delta: bigint; +} + +/** + * Result returned by `auditSplitRounding`. + * + * Guarantees that `amounts` values sum exactly to the original `total`. + */ +export interface AuditedSplitResult { + /** + * Final per-recipient amounts in stroops, keyed by recipientId. + * These are guaranteed to sum exactly to `total`. + */ + amounts: Record; + /** + * Adjustments applied by the largest-remainder correction. + * Empty when the raw floor amounts already sum to `total`. + */ + adjustments: RoundingAdjustment[]; + /** The original invoice total, in stroops. */ + total: bigint; +} + +// --------------------------------------------------------------------------- +// Offer tracking types +// --------------------------------------------------------------------------- + +/** The lifecycle status of a tracked DEX offer. */ +export type OfferStatus = "open" | "partially_filled" | "filled" | "cancelled" | "stale"; + +/** A DEX offer tracked by the OfferTracker. */ +export interface OfferRecord { + /** Stellar offer ID (u64 from Horizon). */ + offerId: string; + /** Stellar address of the account that owns the offer. */ + account: string; + /** Asset being sold (e.g. "native" or "USDC:G..."). */ + selling: string; + /** Asset being bought (e.g. "native" or "USDC:G..."). */ + buying: string; + /** Amount of the selling asset still available on the order book. */ + amount: string; + /** Offer price as a ratio string (e.g. "2.5"). */ + price: string; + /** Current lifecycle status. */ + status: OfferStatus; + /** Unix timestamp in milliseconds when the offer was created. */ + createdAt: number; +} diff --git a/src/types/subscriptions.ts b/src/types/subscriptions.ts index 2009a093..95d31553 100644 --- a/src/types/subscriptions.ts +++ b/src/types/subscriptions.ts @@ -2,7 +2,7 @@ * Recurring subscription types for invoice subscriptions. */ -import type { CreateInvoiceParams } from "./index.js"; +import type { CreateInvoiceParams } from "../types.js"; export type SubscriptionStatus = "active" | "paused" | "cancelled"; diff --git a/src/types/templates.ts b/src/types/templates.ts index e278aa1e..aac775dc 100644 --- a/src/types/templates.ts +++ b/src/types/templates.ts @@ -2,7 +2,7 @@ * Invoice template types for template-based invoice creation. */ -import type { CreateInvoiceParams, Recipient } from "./index.js"; +import type { CreateInvoiceParams, Recipient } from "../types.js"; export interface InvoiceTemplate { id: bigint; diff --git a/src/utils.ts b/src/utils.ts index 41b1c833..894d71bc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -53,7 +53,7 @@ export function parseAmount(value: string): bigint { return BigInt(whole) * STROOPS_PER_UNIT + BigInt(fracPadded); } -export function groupBy>( +export function groupBy>( array: T[], key: keyof T, ): Record { diff --git a/src/wallet.ts b/src/wallet.ts index 5153e611..007cd0c6 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -4,13 +4,16 @@ * Wraps @stellar/freighter-api with typed helpers. */ -import { +import freighterPkg from "@stellar/freighter-api"; +import { WalletNotConnectedError } from "./errors.js"; + +const freighter: any = (freighterPkg as any)?.default ?? freighterPkg; +const { isConnected, getAddress, - signTransaction as freighterSignTransaction, + signTransaction: freighterSignTransaction, requestAccess, -} from "@stellar/freighter-api"; -import { WalletNotConnectedError } from "./errors.js"; +} = freighter; /** Connect to the Freighter wallet extension and request access. */ export async function connectWallet(): Promise { diff --git a/src/webhookMiddleware.ts b/src/webhookMiddleware.ts index 7c897ea8..babd2ccb 100644 --- a/src/webhookMiddleware.ts +++ b/src/webhookMiddleware.ts @@ -403,209 +403,193 @@ function hexToBytes(hex: string): Uint8Array { */ function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) + .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } /** - * Constant-time comparison of two byte arrays to prevent timing attacks. - * Returns true if arrays are equal, false otherwise. + * Constant-time comparison of two byte arrays. + * Prevents timing side-channel attacks during signature verification. + * + * This implementation uses bitwise XOR to accumulate differences, + * ensuring the comparison time is independent of where differences occur. */ -function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { +function constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean { + // Length must match - but don't return early to maintain constant time if (a.length !== b.length) { return false; } - let result = 0; + let diff = 0; for (let i = 0; i < a.length; i++) { - result |= a[i] ^ b[i]; + // Use ?? 0 to satisfy TypeScript's noUncheckedIndexedAccess + const byteA = a[i] ?? 0; + const byteB = b[i] ?? 0; + diff |= byteA ^ byteB; } - return result === 0; + return diff === 0; } -// ============================================================================ -// Signature Validation -// ============================================================================ - /** - * Compute the HMAC-SHA256 signature for a webhook payload. - * The signed message is `${timestamp}.${rawBody}` to bind the timestamp - * to the payload and prevent timestamp tampering. - * - * @param secret - The shared webhook secret - * @param timestamp - Unix timestamp in seconds - * @param rawBody - The raw request body as a string - * @returns Hex-encoded signature string - */ -export async function computeSignature( - secret: string, - timestamp: number | string, - rawBody: string, -): Promise { - const message = `${timestamp}.${rawBody}`; - const digest = await computeHmacSha256(secret, message); - return bytesToHex(digest); -} - -/** - * Verify a webhook signature against the expected HMAC-SHA256 digest. - * Uses constant-time comparison to prevent timing attacks. - * - * @param secret - The shared webhook secret - * @param timestamp - Unix timestamp in seconds - * @param rawBody - The raw request body as a string - * @param signature - The hex-encoded signature to verify - * @returns True if the signature is valid, false otherwise + * Verify HMAC-SHA256 signature in constant time. + * + * @param payload - The original payload string + * @param signature - Hex-encoded HMAC signature + * @param secret - Shared secret key + * @returns True if signature is valid */ -export async function verifySignature( - secret: string, - timestamp: number | string, - rawBody: string, +async function verifySignature( + payload: string, signature: string, + secret: string, ): Promise { - if (!secret || !signature) { - return false; - } - - let providedBytes: Uint8Array; try { - providedBytes = hexToBytes(signature); - } catch { + const expectedBytes = await computeHmacSha256(secret, payload); + const providedBytes = hexToBytes(signature); + return constantTimeCompare(expectedBytes, providedBytes); + } catch (error) { + // Log error but return false (invalid signature) return false; } - - const expectedHex = await computeSignature(secret, timestamp, rawBody); - const expectedBytes = hexToBytes(expectedHex); - - return constantTimeEqual(expectedBytes, providedBytes); } // ============================================================================ -// Webhook Event Handling +// Webhook Middleware Error Classes // ============================================================================ /** - * Handler function invoked for a validated webhook event. + * Base error class for webhook validation failures. */ -export type WebhookEventHandler = ( - payload: WebhookPayload, -) => void | Promise; +export class WebhookValidationError extends ValidationError { + constructor(message: string, context?: Record) { + super(message, context); + this.name = "WebhookValidationError"; + Object.setPrototypeOf(this, new.target.prototype); + } +} /** - * Simple typed event emitter for dispatching validated webhook events. - * Supports multiple listeners per event and a wildcard "*" listener. + * Error thrown when webhook signature verification fails. */ -export class WebhookEventEmitter { - private readonly listeners: Map> = new Map(); - - /** - * Register a listener for a specific event type, or "*" for all events. - * @returns An unsubscribe function. - */ - on( - event: InvoiceEventType | "*", - handler: WebhookEventHandler, - ): () => void { - let set = this.listeners.get(event); - if (!set) { - set = new Set(); - this.listeners.set(event, set); - } - set.add(handler as WebhookEventHandler); - - return () => { - set?.delete(handler as WebhookEventHandler); - }; +export class InvalidSignatureError extends WebhookValidationError { + constructor(message = "Invalid webhook signature") { + super(message); + this.name = "InvalidSignatureError"; + Object.setPrototypeOf(this, new.target.prototype); } +} - /** - * Register a one-time listener for a specific event type. - */ - once( - event: InvoiceEventType | "*", - handler: WebhookEventHandler, - ): () => void { - const unsubscribe = this.on(event, async (payload) => { - unsubscribe(); - await handler(payload); +/** + * Error thrown when webhook timestamp is outside tolerance window. + */ +export class TimestampOutOfBoundsError extends WebhookValidationError { + constructor( + public readonly timestamp: number, + public readonly tolerance: number, + ) { + super("Webhook timestamp outside tolerance window", { + timestamp, + tolerance, + now: Math.floor(Date.now() / 1000), }); - return unsubscribe; + this.name = "TimestampOutOfBoundsError"; + Object.setPrototypeOf(this, new.target.prototype); } +} - /** - * Remove a previously registered listener. - */ - off( - event: InvoiceEventType | "*", - handler: WebhookEventHandler, - ): void { - this.listeners.get(event)?.delete(handler as WebhookEventHandler); +/** + * Error thrown when a webhook nonce has been seen before (replay attack). + */ +export class ReplayAttackError extends WebhookValidationError { + constructor(public readonly nonce: string) { + super("Webhook nonce has already been used (replay attack detected)", { + nonce, + }); + this.name = "ReplayAttackError"; + Object.setPrototypeOf(this, new.target.prototype); } +} - /** - * Dispatch a validated payload to all matching listeners. - * Errors thrown by listeners are isolated so one failure does not - * prevent other listeners from running. - */ - async emit(payload: WebhookPayload): Promise { - const specific = this.listeners.get(payload.event); - const wildcard = this.listeners.get("*"); - - const handlers: WebhookEventHandler[] = []; - if (specific) { - handlers.push(...specific); - } - if (wildcard) { - handlers.push(...wildcard); - } - - for (const handler of handlers) { - try { - await handler(payload); - } catch { - // Isolate listener errors; continue dispatching to remaining handlers. - } - } +/** + * Error thrown when required webhook headers are missing. + */ +export class MissingHeaderError extends WebhookValidationError { + constructor(public readonly headerName: string) { + super(`Missing required webhook header: ${headerName}`, { headerName }); + this.name = "MissingHeaderError"; + Object.setPrototypeOf(this, new.target.prototype); } +} - /** - * Remove all listeners, optionally for a single event type. - */ - removeAllListeners(event?: InvoiceEventType | "*"): void { - if (event) { - this.listeners.delete(event); - } else { - this.listeners.clear(); - } +/** + * Error thrown when webhook payload is invalid or malformed. + */ +export class InvalidPayloadError extends WebhookValidationError { + constructor(message: string, context?: Record) { + super(`Invalid webhook payload: ${message}`, context); + this.name = "InvalidPayloadError"; + Object.setPrototypeOf(this, new.target.prototype); } } // ============================================================================ -// Middleware Factory +// Webhook Middleware Factory // ============================================================================ -const DEFAULT_TOLERANCE_SECONDS = 300; -const DEFAULT_NONCE_WINDOW_SIZE = 1000; -const DEFAULT_SIGNATURE_HEADER = "x-stellarsplit-signature"; -const DEFAULT_TIMESTAMP_HEADER = "x-stellarsplit-timestamp"; -const DEFAULT_NONCE_HEADER = "x-stellarsplit-nonce"; +const DEFAULT_OPTIONS: Required = { + toleranceSeconds: 300, // 5 minutes + nonceWindowSize: 1000, + signatureHeader: "x-stellarsplit-signature", + timestampHeader: "x-stellarsplit-timestamp", + nonceHeader: "x-stellarsplit-nonce", +}; /** - * Create an Express middleware that validates incoming webhook requests. - * - * The middleware: - * 1. Extracts the raw body, signature, timestamp, and nonce from the request. - * 2. Verifies the HMAC-SHA256 signature using constant-time comparison. - * 3. Rejects requests with timestamps outside the tolerance window. - * 4. Rejects replayed nonces using an LRU cache. - * 5. Attaches the parsed payload to `req.webhookPayload` and dispatches it - * to any registered event handlers. - * - * @param secret - The shared webhook secret - * @param options - Optional configuration - * @param emitter - Optional event emitter for dispatching validated events - * @returns Express request handler + * Create a secure webhook middleware for Express/Next.js. + * + * This middleware verifies incoming StellarSplit webhooks using: + * 1. HMAC-SHA256 signature verification with constant-time comparison + * 2. Timestamp validation to prevent old requests + * 3. Nonce tracking with LRU cache to prevent replay attacks + * + * @param secret - Shared secret key for HMAC verification + * @param options - Configuration options + * @returns Express-compatible middleware function + * + * @example + * ```typescript + * import express from 'express'; + * import { createWebhookMiddleware } from '@stellar-split/sdk'; + * + * const app = express(); + * + * // Raw body parser for signature verification + * app.use('/webhooks/stellarsplit', express.raw({ type: 'application/json' })); + * + * // Webhook middleware with verification + * app.post( + * '/webhooks/stellarsplit', + * createWebhookMiddleware(process.env.WEBHOOK_SECRET!, { + * toleranceSeconds: 300, + * nonceWindowSize: 1000, + * }), + * (req, res) => { + * const { event, data } = req.webhookPayload; + * + * switch (event) { + * case 'invoice.paid': + * console.log('Invoice paid:', data); + * break; + * case 'invoice.released': + * console.log('Invoice released:', data); + * break; + * } + * + * res.status(200).json({ received: true }); + * } + * ); + * ``` */ export function createWebhookMiddleware( secret: string, @@ -639,54 +623,113 @@ export function createWebhookMiddleware( next: NextFunction, ): Promise => { try { - const rawBody = extractRawBody(req); - const signature = getHeader(req, signatureHeader); - const timestampRaw = getHeader(req, timestampHeader); - const nonce = getHeader(req, nonceHeader); + // ==================================================================== + // Step 1: Extract and validate headers + // ==================================================================== + const signature = req.headers[config.signatureHeader.toLowerCase()]; + const timestampHeader = req.headers[config.timestampHeader.toLowerCase()]; + const nonce = req.headers[config.nonceHeader.toLowerCase()]; - if (!signature || !timestampRaw || !nonce) { - res.status(401).json({ error: "Missing webhook authentication headers" }); - return; + if (!signature || typeof signature !== "string") { + throw new MissingHeaderError(config.signatureHeader); } - const timestamp = Number.parseInt(timestampRaw, 10); + if (!timestampHeader || typeof timestampHeader !== "string") { + throw new MissingHeaderError(config.timestampHeader); + } + + if (!nonce || typeof nonce !== "string") { + throw new MissingHeaderError(config.nonceHeader); + } + + // ==================================================================== + // Step 2: Validate timestamp (prevent old requests) + // ==================================================================== + const timestamp = Number.parseInt(timestampHeader, 10); if (Number.isNaN(timestamp)) { - res.status(401).json({ error: "Invalid webhook timestamp" }); - return; + throw new InvalidPayloadError("Timestamp must be a valid integer"); } - const nowSeconds = Math.floor(Date.now() / 1000); - if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) { - res.status(401).json({ error: "Webhook timestamp outside tolerance" }); - return; + const now = Math.floor(Date.now() / 1000); + const timeDiff = Math.abs(now - timestamp); + + if (timeDiff > config.toleranceSeconds) { + throw new TimestampOutOfBoundsError(timestamp, config.toleranceSeconds); } - const valid = await verifySignature(secret, timestamp, rawBody, signature); - if (!valid) { - res.status(401).json({ error: "Invalid webhook signature" }); - return; + // ==================================================================== + // Step 3: Check nonce for replay attacks + // ==================================================================== + if (nonceCache.has(nonce)) { + throw new ReplayAttackError(nonce); } - if (seenNonces.has(nonce)) { - res.status(409).json({ error: "Webhook replay detected" }); - return; + // ==================================================================== + // Step 4: Extract raw body for signature verification + // ==================================================================== + let rawBody: string; + + if (Buffer.isBuffer(req.body)) { + // Body is a Buffer (from express.raw()) + rawBody = req.body.toString("utf8"); + } else if (typeof req.body === "string") { + // Body is already a string + rawBody = req.body; + } else if (typeof req.body === "object" && req.body !== null) { + // Body has been parsed to object - need to re-stringify + // This is not ideal but can happen if middleware order is wrong + rawBody = JSON.stringify(req.body); + } else { + throw new InvalidPayloadError("Request body is missing or invalid"); } - seenNonces.set(nonce, true); + // ==================================================================== + // Step 5: Verify HMAC-SHA256 signature (constant-time) + // ==================================================================== + const isValid = await verifySignature(rawBody, signature, secret); + + if (!isValid) { + throw new InvalidSignatureError(); + } + + // ==================================================================== + // Step 6: Parse and validate payload structure + // ==================================================================== let payload: WebhookPayload; + try { payload = JSON.parse(rawBody) as WebhookPayload; - } catch { - res.status(400).json({ error: "Invalid webhook payload" }); - return; + } catch (parseError) { + throw new InvalidPayloadError("Payload is not valid JSON", { + error: parseError instanceof Error ? parseError.message : String(parseError), + }); } - const webhookReq = req as WebhookRequest; - webhookReq.webhookPayload = payload; - webhookReq.rawWebhookBody = rawBody; + // Validate required fields + if (!payload.event || typeof payload.event !== "string") { + throw new InvalidPayloadError("Missing or invalid 'event' field"); + } + + if (typeof payload.timestamp !== "number") { + throw new InvalidPayloadError("Missing or invalid 'timestamp' field"); + } + + if (!payload.nonce || typeof payload.nonce !== "string") { + throw new InvalidPayloadError("Missing or invalid 'nonce' field"); + } - if (emitter) { - await emitter.emit(payload); + if (!payload.data) { + throw new InvalidPayloadError("Missing 'data' field"); + } + + // Verify nonce matches header + if (payload.nonce !== nonce) { + throw new InvalidPayloadError("Nonce in payload does not match header"); + } + + // Verify timestamp matches header + if (payload.timestamp !== timestamp) { + throw new InvalidPayloadError("Timestamp in payload does not match header"); } // ==================================================================== @@ -714,7 +757,22 @@ export function createWebhookMiddleware( // All checks passed - proceed to next middleware/handler next(); } catch (error) { - next(error); + // Handle validation errors + if (error instanceof WebhookValidationError) { + res.status(400).json({ + error: error.name, + message: error.message, + code: error.code, + }); + return; + } + + // Handle unexpected errors + console.error("Webhook middleware error:", error); + res.status(500).json({ + error: "InternalServerError", + message: "An unexpected error occurred while processing the webhook", + }); } }; @@ -724,35 +782,133 @@ export function createWebhookMiddleware( return handler; } +// ============================================================================ +// Utility Functions +// ============================================================================ + /** - * Extract the raw request body as a string. - * Supports bodies captured by `express.raw()` (Buffer) or pre-parsed strings. + * Generate HMAC-SHA256 signature for a webhook payload. + * Used by webhook senders to sign outgoing webhooks. + * + * @param payload - The webhook payload object + * @param secret - Shared secret key + * @returns Hex-encoded HMAC signature + * + * @example + * ```typescript + * const payload = { + * event: 'invoice.paid', + * timestamp: Math.floor(Date.now() / 1000), + * nonce: crypto.randomUUID(), + * data: { invoiceId: '123', amount: '1000' } + * }; + * + * const signature = await generateWebhookSignature(payload, secret); + * ``` */ -function extractRawBody(req: Request): string { - const body = (req as Request & { rawBody?: unknown }).rawBody ?? req.body; +export async function generateWebhookSignature( + payload: WebhookPayload, + secret: string, +): Promise { + const payloadString = JSON.stringify(payload); + const signatureBytes = await computeHmacSha256(secret, payloadString); + return bytesToHex(signatureBytes); +} - if (typeof body === "string") { - return body; +/** + * Manually verify a webhook signature without middleware. + * Useful for testing or custom webhook handling. + * + * @param payload - The webhook payload string or object + * @param signature - Hex-encoded HMAC signature + * @param secret - Shared secret key + * @returns True if signature is valid + * + * @example + * ```typescript + * const isValid = await verifyWebhookSignature( + * rawBody, + * req.headers['x-stellarsplit-signature'], + * process.env.WEBHOOK_SECRET + * ); + * ``` + */ +export async function verifyWebhookSignature( + payload: string | WebhookPayload, + signature: string, + secret: string, +): Promise { + const payloadString = + typeof payload === "string" ? payload : JSON.stringify(payload); + return verifySignature(payloadString, signature, secret); +} + +/** + * Type guard to check if an event type is valid. + */ +export function isValidEventType(event: string): event is InvoiceEventType { + const validEvents: InvoiceEventType[] = [ + "invoice.created", + "invoice.paid", + "invoice.failed", + "invoice.released", + "invoice.refunded", + "invoice.cancelled", + "invoice.expired", + ]; + return validEvents.includes(event as InvoiceEventType); +} + +/** + * Parse and validate a webhook payload with type checking. + * + * @param rawPayload - Raw webhook payload string + * @returns Parsed and validated webhook payload + * @throws {InvalidPayloadError} If payload is invalid + */ +export function parseWebhookPayload( + rawPayload: string, +): WebhookPayload { + let parsed: unknown; + + try { + parsed = JSON.parse(rawPayload); + } catch (error) { + throw new InvalidPayloadError("Payload is not valid JSON", { + error: error instanceof Error ? error.message : String(error), + }); } - if (body instanceof Uint8Array) { - return new TextDecoder().decode(body); + if (typeof parsed !== "object" || parsed === null) { + throw new InvalidPayloadError("Payload must be an object"); } - if (body && typeof body === "object") { - return JSON.stringify(body); + const payload = parsed as Record; + + if (!payload.event || typeof payload.event !== "string") { + throw new InvalidPayloadError("Missing or invalid 'event' field"); } - return ""; -} + if (!isValidEventType(payload.event)) { + throw new InvalidPayloadError(`Unknown event type: ${payload.event}`); + } -/** - * Read a header value from the request, normalizing to a single string. - */ -function getHeader(req: Request, name: string): string | undefined { - const value = req.headers[name.toLowerCase()]; - if (Array.isArray(value)) { - return value[0]; + if (typeof payload.timestamp !== "number") { + throw new InvalidPayloadError("Missing or invalid 'timestamp' field"); + } + + if (!payload.nonce || typeof payload.nonce !== "string") { + throw new InvalidPayloadError("Missing or invalid 'nonce' field"); } - return value; + + if (!payload.data) { + throw new InvalidPayloadError("Missing 'data' field"); + } + + return { + event: payload.event as InvoiceEventType, + timestamp: payload.timestamp as number, + nonce: payload.nonce as string, + data: payload.data as T, + }; } diff --git a/src/xdrDecoder.ts b/src/xdrDecoder.ts index cffcac22..d0415fa9 100644 --- a/src/xdrDecoder.ts +++ b/src/xdrDecoder.ts @@ -115,7 +115,7 @@ function decodeTransactionEnvelope( ), timeBounds: (() => { try { - const tb = v1.tx().timeBounds(); + const tb = (v1.tx() as any).timeBounds?.(); if (tb) { return { minTime: tb.minTime().toString(), @@ -165,7 +165,7 @@ function decodeTransactionResult( try { resultCode = result.result().switch().name; - const inner = result.result().innerResult(); + const inner = (result.result() as any).innerResult?.(); if (inner) { innerResult = { switch: inner.value().switch().name, @@ -198,7 +198,7 @@ function decodeTransactionMeta( for (const opMeta of opsMeta) { const changes = (opMeta.changes() ?? []).map((change) => ({ type: change.switch().name, - key: change.key()?.toXDR("base64") ?? "unknown", + key: (change as any).key?.()?.toXDR("base64") ?? "unknown", })); operations.push({ changes }); } @@ -227,10 +227,11 @@ function decodeLedgerEntry( key: s.key().toXDR("base64"), weight: s.weight(), })); + const thresholds = account.thresholds(); data.thresholds = { - low: account.thresholds()[0], - med: account.thresholds()[1], - high: account.thresholds()[2], + low: thresholds[0] ?? 0, + med: thresholds[1] ?? 0, + high: thresholds[2] ?? 0, }; } } catch { diff --git a/test/logger.test.ts b/test/logger.test.ts new file mode 100644 index 00000000..4939aa9d --- /dev/null +++ b/test/logger.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + LogLevel, + parseLogLevel, + logLevelToString, + sanitizeLogValue, + defaultLogFormatter, + SdkLogger, + type LogEntry, +} from "../src/logger.js"; + +describe("SdkLogger", () => { + let entries: LogEntry[]; + let formattedMessages: string[]; + + const testSink = (entry: LogEntry, formatted: string) => { + entries.push(entry); + formattedMessages.push(formatted); + }; + + beforeEach(() => { + entries = []; + formattedMessages = []; + }); + + describe("parseLogLevel", () => { + it("parses numeric values correctly", () => { + expect(parseLogLevel(LogLevel.DEBUG)).toBe(LogLevel.DEBUG); + expect(parseLogLevel(LogLevel.INFO)).toBe(LogLevel.INFO); + expect(parseLogLevel(LogLevel.WARN)).toBe(LogLevel.WARN); + expect(parseLogLevel(LogLevel.ERROR)).toBe(LogLevel.ERROR); + expect(parseLogLevel(LogLevel.NONE)).toBe(LogLevel.NONE); + }); + + it("parses string representations case-insensitively", () => { + expect(parseLogLevel("debug")).toBe(LogLevel.DEBUG); + expect(parseLogLevel("DEBUG" as any)).toBe(LogLevel.DEBUG); + expect(parseLogLevel("info")).toBe(LogLevel.INFO); + expect(parseLogLevel("warn")).toBe(LogLevel.WARN); + expect(parseLogLevel("warning" as any)).toBe(LogLevel.WARN); + expect(parseLogLevel("error")).toBe(LogLevel.ERROR); + expect(parseLogLevel("none")).toBe(LogLevel.NONE); + expect(parseLogLevel("silent" as any)).toBe(LogLevel.NONE); + }); + + it("defaults to NONE for invalid strings or numbers", () => { + expect(parseLogLevel("invalid" as any)).toBe(LogLevel.NONE); + expect(parseLogLevel(99 as any)).toBe(LogLevel.NONE); + }); + }); + + describe("logLevelToString", () => { + it("maps enum values to strings", () => { + expect(logLevelToString(LogLevel.DEBUG)).toBe("DEBUG"); + expect(logLevelToString(LogLevel.INFO)).toBe("INFO"); + expect(logLevelToString(LogLevel.WARN)).toBe("WARN"); + expect(logLevelToString(LogLevel.ERROR)).toBe("ERROR"); + expect(logLevelToString(LogLevel.NONE)).toBe("NONE"); + }); + }); + + describe("Level filtering", () => { + it("emits all levels when set to DEBUG", () => { + const logger = new SdkLogger({ + level: LogLevel.DEBUG, + sink: testSink, + }); + + logger.debug("Debug msg"); + logger.info("Info msg"); + logger.warn("Warn msg"); + logger.error("Error msg"); + + expect(entries).toHaveLength(4); + expect(entries.map((e) => e.level)).toEqual([ + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.WARN, + LogLevel.ERROR, + ]); + }); + + it("filters out DEBUG when set to INFO", () => { + const logger = new SdkLogger({ + level: LogLevel.INFO, + sink: testSink, + }); + + logger.debug("Debug msg"); + logger.info("Info msg"); + logger.warn("Warn msg"); + logger.error("Error msg"); + + expect(entries).toHaveLength(3); + expect(entries.map((e) => e.levelName)).toEqual(["INFO", "WARN", "ERROR"]); + }); + + it("filters out DEBUG and INFO when set to WARN", () => { + const logger = new SdkLogger({ + level: LogLevel.WARN, + sink: testSink, + }); + + logger.debug("Debug msg"); + logger.info("Info msg"); + logger.warn("Warn msg"); + logger.error("Error msg"); + + expect(entries).toHaveLength(2); + expect(entries.map((e) => e.levelName)).toEqual(["WARN", "ERROR"]); + }); + + it("emits only ERROR when set to ERROR", () => { + const logger = new SdkLogger({ + level: LogLevel.ERROR, + sink: testSink, + }); + + logger.debug("Debug msg"); + logger.info("Info msg"); + logger.warn("Warn msg"); + logger.error("Error msg"); + + expect(entries).toHaveLength(1); + expect(entries[0]?.levelName).toBe("ERROR"); + }); + + it("emits nothing when set to NONE", () => { + const logger = new SdkLogger({ + level: LogLevel.NONE, + sink: testSink, + }); + + logger.debug("Debug msg"); + logger.info("Info msg"); + logger.warn("Warn msg"); + logger.error("Error msg"); + + expect(entries).toHaveLength(0); + }); + + it("supports dynamic level updates via setLevel", () => { + const logger = new SdkLogger({ + level: LogLevel.NONE, + sink: testSink, + }); + + logger.info("Should not appear"); + expect(entries).toHaveLength(0); + + logger.setLevel("info"); + expect(logger.getLevel()).toBe(LogLevel.INFO); + + logger.info("Should appear"); + expect(entries).toHaveLength(1); + expect(entries[0]?.message).toBe("Should appear"); + }); + }); + + describe("Sensitive data redaction", () => { + it("redacts Stellar secret seeds from strings", () => { + const secret = "SBTEST00000000000000000000000000000000000000000000000000"; + const sanitized = sanitizeLogValue(`Connecting with key ${secret}`); + expect(sanitized).toBe("Connecting with key SBTE...[REDACTED]"); + }); + + it("redacts sensitive context dictionary keys", () => { + const sanitized = sanitizeLogValue({ + publicKey: "GBTEST...", + secretKey: "SBTEST00000000000000000000000000000000000000000000000000", + password: "SuperSecretPassword123", + token: "jwt-token-val", + nested: { + apiKey: "my-key", + amount: 100n, + }, + }) as Record; + + expect(sanitized.publicKey).toBe("GBTEST..."); + expect(sanitized.secretKey).toBe("[REDACTED]"); + expect(sanitized.password).toBe("[REDACTED]"); + expect(sanitized.token).toBe("[REDACTED]"); + expect(sanitized.nested.apiKey).toBe("[REDACTED]"); + expect(sanitized.nested.amount).toBe("100"); + }); + + it("applies redaction during logger.log execution", () => { + const logger = new SdkLogger({ + level: LogLevel.DEBUG, + sink: testSink, + redactSensitive: true, + }); + + logger.debug("User logged in", { + account: "GABC...", + secretSeed: "SBTEST00000000000000000000000000000000000000000000000000", + }); + + expect(entries[0]?.context?.secretSeed).toBe("[REDACTED]"); + }); + }); + + describe("Formatting and Child Loggers", () => { + it("formats messages with timestamp, level, namespace, and context", () => { + const logger = new SdkLogger({ + level: LogLevel.INFO, + namespace: "TestClient", + sink: testSink, + }); + + logger.info("Invoice processed", { id: 42 }); + + expect(formattedMessages[0]).toMatch(/\[\d{4}-\d{2}-\d{2}T.*\] \[INFO\] \[TestClient\] Invoice processed {"id":42}/); + }); + + it("allows custom log formatters", () => { + const logger = new SdkLogger({ + level: LogLevel.DEBUG, + formatter: (entry) => `CUSTOM: ${entry.levelName} - ${entry.message}`, + sink: testSink, + }); + + logger.debug("Hello world"); + expect(formattedMessages[0]).toBe("CUSTOM: DEBUG - Hello world"); + }); + + it("creates child loggers with hierarchical namespaces", () => { + const parent = new SdkLogger({ + level: LogLevel.DEBUG, + namespace: "Parent", + sink: testSink, + }); + + const child = parent.child("Child"); + child.info("From child"); + + expect(entries[0]?.namespace).toBe("Parent:Child"); + }); + }); + + describe("StellarSplitClient integration", () => { + it("initializes with default LogLevel.NONE", async () => { + const { StellarSplitClient } = await import("../src/client.js"); + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", + validatePassphrase: false, + }); + + expect(client.logger.getLevel()).toBe(LogLevel.NONE); + }); + + it("respects logger config and captures startup log", async () => { + const { StellarSplitClient } = await import("../src/client.js"); + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", + validatePassphrase: false, + logger: { + level: LogLevel.DEBUG, + sink: testSink, + }, + }); + + expect(client.logger.getLevel()).toBe(LogLevel.DEBUG); + expect(entries.length).toBeGreaterThan(0); + expect(entries[0]?.message).toBe("StellarSplitClient initialized"); + }); + + it("allows dynamic log level switching on client", async () => { + const { StellarSplitClient } = await import("../src/client.js"); + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526", + validatePassphrase: false, + logger: { + level: LogLevel.NONE, + sink: testSink, + }, + }); + + expect(client.logger.getLevel()).toBe(LogLevel.NONE); + client.setLogLevel("info"); + expect(client.logger.getLevel()).toBe(LogLevel.INFO); + + client.enableDebugLogging(true); + expect(client.logger.getLevel()).toBe(LogLevel.DEBUG); + + client.enableDebugLogging(false); + expect(client.logger.getLevel()).toBe(LogLevel.NONE); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 89983786..376892c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2020", "ES2021.WeakRef", "DOM"], + "lib": ["ES2020", "ES2021", "DOM"], "types": ["node"], "jsx": "react", "strict": true,