diff --git a/packages/sdk/examples/production-integration-guide.ts b/packages/sdk/examples/production-integration-guide.ts new file mode 100644 index 00000000..1a2afded --- /dev/null +++ b/packages/sdk/examples/production-integration-guide.ts @@ -0,0 +1,185 @@ +/** + * Production Integration Guide — Issue #381 + * + * Demonstrates real-world usage of the chenpilot SDK for production integrations. + * Covers: + * 1. Capability discovery and version negotiation + * 2. Planning and simulating a cross-chain swap + * 3. Execution with retry and progress tracking + * 4. Contract interaction (Soroban) + * 5. Realtime event subscription + * + * Run: + * npx ts-node packages/sdk/examples/production-integration-guide.ts + */ + +// ─── 1. Capability discovery ───────────────────────────────────────────────── + +import { createCapabilityDiscovery } from "../src/capabilityDiscovery"; + +async function demonstrateCapabilityDiscovery() { + console.log(" +=== 1. Capability Discovery ==="); + + const discovery = createCapabilityDiscovery("testnet"); + const caps = await discovery.getCapabilities(); + + console.log("Network:", caps.network); + console.log("Protocol version:", caps.versions.protocol); + console.log("API version:", caps.versions.api); + console.log("Soroban enabled:", caps.features.sorobanEnabled); + console.log("Multi-hop enabled:", caps.features.multiHopEnabled); + + // Negotiate before using Soroban features + const negotiation = await discovery.negotiate({ + minProtocol: 21, + requiredFeatures: ["sorobanEnabled", "feeBumpingEnabled"], + }); + + if (!negotiation.compatible) { + console.error("Backend incompatible:", negotiation.reason); + return; + } + + console.log("Backend is compatible — proceeding."); +} + +// ─── 2. Planning and simulation ─────────────────────────────────────────────── + +import type { CrossChainSwapRequest } from "../src/types"; +import { checkNetworkHealth } from "../src/networkStatus"; + +async function demonstrateSimulation() { + console.log(" +=== 2. Simulation ==="); + + // Always check network health before submitting transactions + const health = await checkNetworkHealth({ network: "testnet" }); + if (!health.isHealthy) { + console.error("Network unhealthy:", health.error); + return; + } + console.log("Network healthy. Latest ledger:", health.latestLedger); + + const swapPlan: CrossChainSwapRequest = { + fromChain: "stellar" as any, + toChain: "starknet" as any, + fromToken: "XLM", + toToken: "ETH", + amount: "100", + destinationAddress: "0xYourStarkNetAddress", + }; + + // Simulate before executing — catches validation errors cheaply + console.log("Simulating swap:", swapPlan); + // In production: const simulation = await swapSimulator.simulate(swapPlan); + console.log("Simulation complete — no issues detected."); +} + +// ─── 3. Execution with retry and progress tracking ──────────────────────────── + +async function demonstrateExecutionTracking() { + console.log(" +=== 3. Execution Tracking ==="); + + // Conceptual execution tracker — swap IDs come from your swap service + const swapId = ; + console.log("Swap ID:", swapId); + + // Polling-based progress tracking + const maxPolls = 10; + const pollIntervalMs = 3_000; + + for (let attempt = 0; attempt < maxPolls; attempt++) { + // In production: const status = await swapService.getStatus(swapId); + const mockStatus = attempt < 3 ? "pending" : attempt < 6 ? "bridging" : "complete"; + + console.log(`Poll ${attempt + 1}/${maxPolls}: status = ${mockStatus}`); + + if (mockStatus === "complete") { + console.log("Swap completed successfully."); + break; + } + if (mockStatus === "failed") { + console.error("Swap failed — initiating recovery."); + break; + } + + if (attempt < maxPolls - 1) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + } +} + +// ─── 4. Soroban contract interaction ───────────────────────────────────────── + +import { buildSorobanTransaction, simulateSorobanTx } from "../src/soroban"; + +async function demonstrateSorobanInteraction() { + console.log(" +=== 4. Soroban Contract Interaction ==="); + + const TESTNET_RPC = "https://soroban-testnet.stellar.org"; + + // Always simulate before submitting to avoid wasting fees on failures + console.log("Simulating Soroban contract call..."); + try { + const tx = await buildSorobanTransaction({ + rpcUrl: TESTNET_RPC, + contractId: "CDEMO_CONTRACT_ID", + method: "get_counter", + args: [], + sourceAccount: "GABC...", + }); + const simulation = await simulateSorobanTx({ rpcUrl: TESTNET_RPC, transaction: tx }); + console.log("Simulation succeeded. Estimated fee:", simulation.minResourceFee); + // In production: sign tx, then submit with retry on sequence number errors + } catch (err) { + console.warn("Soroban simulation skipped in demo context:", (err as Error).message); + } +} + +// ─── 5. Realtime event subscription ───────────────────────────────────────── + +import { subscribeToEvents } from "../src/events"; + +async function demonstrateRealtimeEvents() { + console.log(" +=== 5. Realtime Event Subscription ==="); + + let receivedCount = 0; + const subscription = subscribeToEvents({ + network: "testnet", + contractId: "CDEMO_CONTRACT_ID", + onEvent: (event) => { + receivedCount++; + console.log(`Event received [#${receivedCount}]:`, event); + }, + onError: (err) => { + console.error("Event subscription error:", err); + // In production: implement reconnect with exponential back-off + }, + }); + + // Unsubscribe after 5 seconds in this demo + await new Promise((r) => setTimeout(r, 5_000)); + subscription.close(); + console.log(`Subscription closed after receiving ${receivedCount} events.`); +} + +// ─── Entrypoint ─────────────────────────────────────────────────────────────── + +(async () => { + try { + await demonstrateCapabilityDiscovery(); + await demonstrateSimulation(); + await demonstrateExecutionTracking(); + await demonstrateSorobanInteraction(); + await demonstrateRealtimeEvents(); + console.log(" +=== Production integration guide complete ==="); + } catch (err) { + console.error("Guide error:", err); + process.exit(1); + } +})(); diff --git a/packages/sdk/src/__tests__/capabilityDiscovery.test.ts b/packages/sdk/src/__tests__/capabilityDiscovery.test.ts new file mode 100644 index 00000000..477b283e --- /dev/null +++ b/packages/sdk/src/__tests__/capabilityDiscovery.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for CapabilityDiscovery — Issue #378 + */ + +import { CapabilityDiscovery, createCapabilityDiscovery } from "../capabilityDiscovery"; + +const mockHorizonRoot = { + core_supported_protocol_version: 21, + horizon_version: "2.28.0", +}; + +function mockFetch( + horizonOk = true, + rpcOk = true +): jest.SpyInstance { + return jest.spyOn(global, "fetch").mockImplementation(async (url: RequestInfo | URL) => { + const urlStr = String(url); + if (urlStr.includes("horizon")) { + if (!horizonOk) throw new Error("horizon unreachable"); + return { + ok: true, + json: async () => mockHorizonRoot, + } as Response; + } + if (!rpcOk) throw new Error("rpc unreachable"); + return { + ok: true, + json: async () => ({ result: { sequence: 1234 } }), + } as Response; + }); +} + +afterEach(() => jest.restoreAllMocks()); + +describe("CapabilityDiscovery.getCapabilities()", () => { + it("returns sorobanEnabled=true when RPC is reachable", async () => { + mockFetch(true, true); + const d = new CapabilityDiscovery({ network: "testnet" }); + const caps = await d.getCapabilities(); + expect(caps.features.sorobanEnabled).toBe(true); + expect(caps.versions.protocol).toBe(21); + expect(caps.network).toBe("testnet"); + }); + + it("returns sorobanEnabled=false when RPC is unreachable", async () => { + mockFetch(true, false); + const d = new CapabilityDiscovery({ network: "testnet" }); + const caps = await d.getCapabilities(); + expect(caps.features.sorobanEnabled).toBe(false); + expect(caps.features.metadataEnabled).toBe(false); + }); + + it("caches result and avoids second fetch within TTL", async () => { + const spy = mockFetch(); + const d = new CapabilityDiscovery({ network: "testnet", cacheTtlMs: 60_000 }); + await d.getCapabilities(); + await d.getCapabilities(); // should be cache hit + expect(spy).toHaveBeenCalledTimes(2); // two underlying calls (horizon + rpc) from first getCapabilities + }); + + it("re-fetches after invalidateCache()", async () => { + const spy = mockFetch(); + const d = new CapabilityDiscovery({ network: "testnet" }); + await d.getCapabilities(); + d.invalidateCache(); + await d.getCapabilities(); + expect(spy).toHaveBeenCalledTimes(4); // 2 per fetch * 2 fetches + }); +}); + +describe("CapabilityDiscovery.negotiate()", () => { + it("returns compatible=true when all requirements are satisfied", async () => { + mockFetch(true, true); + const d = new CapabilityDiscovery({ network: "testnet" }); + const result = await d.negotiate({ + minProtocol: 20, + requiredFeatures: ["sorobanEnabled", "feeBumpingEnabled"], + }); + expect(result.compatible).toBe(true); + expect(result.reason).toBeUndefined(); + }); + + it("returns compatible=false when protocol is too old", async () => { + mockFetch(true, true); + const d = new CapabilityDiscovery({ network: "testnet" }); + const result = await d.negotiate({ minProtocol: 99 }); + expect(result.compatible).toBe(false); + expect(result.reason).toContain("Protocol"); + }); + + it("returns compatible=false when required feature is missing", async () => { + mockFetch(true, false); // rpc down → sorobanEnabled=false + const d = new CapabilityDiscovery({ network: "testnet" }); + const result = await d.negotiate({ + requiredFeatures: ["sorobanEnabled"], + }); + expect(result.compatible).toBe(false); + expect(result.reason).toContain("sorobanEnabled"); + }); + + it("includes capabilities in every negotiate result", async () => { + mockFetch(); + const d = new CapabilityDiscovery({ network: "testnet" }); + const result = await d.negotiate({}); + expect(result.capabilities).toBeDefined(); + expect(result.capabilities.network).toBe("testnet"); + }); +}); + +describe("createCapabilityDiscovery factory", () => { + it("creates a CapabilityDiscovery instance", () => { + const d = createCapabilityDiscovery("mainnet"); + expect(d).toBeInstanceOf(CapabilityDiscovery); + }); +}); diff --git a/packages/sdk/src/capabilityDiscovery.ts b/packages/sdk/src/capabilityDiscovery.ts new file mode 100644 index 00000000..b5fcd778 --- /dev/null +++ b/packages/sdk/src/capabilityDiscovery.ts @@ -0,0 +1,317 @@ +/** + * Capability Discovery Layer — Issue #378 + * + * Lets clients discover backend capabilities, contract versions, and feature + * availability dynamically so integrations can adapt safely to platform evolution. + * + * Usage: + * ```typescript + * import { CapabilityDiscovery } from "@chenpilot/sdk"; + * + * const discovery = new CapabilityDiscovery({ network: "testnet" }); + * const caps = await discovery.getCapabilities(); + * + * if (caps.features.sorobanEnabled) { + * // safe to use Soroban APIs + * } + * + * const negotiated = await discovery.negotiate({ minProtocol: 21 }); + * if (!negotiated.compatible) throw new Error(negotiated.reason); + * ``` + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Versions of backend contracts/components. */ +export interface ContractVersions { + /** Stellar protocol version (e.g. 21). */ + protocol: number; + /** Core SDK / server API version (semver). */ + api: string; + /** Soroban contract version, if deployed. */ + soroban?: string; + /** Multi-hop contract version, if deployed. */ + multiHop?: string; + /** Agent contract version, if deployed. */ + agent?: string; +} + +/** Runtime feature flags. */ +export interface FeatureFlags { + /** Soroban smart-contract execution is enabled. */ + sorobanEnabled: boolean; + /** Multi-hop cross-chain bridging is available. */ + multiHopEnabled: boolean; + /** Streaming real-time events via SSE/WebSocket is supported. */ + realtimeEnabled: boolean; + /** Claimable balance operations are supported. */ + claimableBalancesEnabled: boolean; + /** Fee-bump transaction wrapping is available. */ + feeBumpingEnabled: boolean; + /** Sponsored reserve operations are available. */ + sponsorshipEnabled: boolean; + /** On-chain metadata storage is available. */ + metadataEnabled: boolean; +} + +/** Operational limits reported by the backend. */ +export interface BackendLimits { + /** Maximum simultaneous connections per API key. */ + maxConnections: number; + /** Requests allowed per minute per API key. */ + rateLimitPerMinute: number; + /** Maximum XDR payload size in bytes. */ + maxXdrBytes: number; + /** Maximum age of a cached ledger response (ms). */ + maxCacheAgeMs: number; +} + +/** Full capability snapshot returned by the discovery layer. */ +export interface BackendCapabilities { + /** Stellar network identifier ("testnet" or "mainnet"). */ + network: string; + /** Resolved contract and API versions. */ + versions: ContractVersions; + /** Enabled feature flags. */ + features: FeatureFlags; + /** Operational limits. */ + limits: BackendLimits; + /** ISO-8601 timestamp at which this snapshot was fetched. */ + fetchedAt: string; + /** Horizon endpoint used for this snapshot. */ + horizonUrl: string; + /** Soroban RPC endpoint used for this snapshot. */ + rpcUrl: string; +} + +/** Input for client-side version negotiation. */ +export interface NegotiationRequest { + /** Minimum Stellar protocol version the client requires. */ + minProtocol?: number; + /** Minimum API version the client requires (semver). */ + minApi?: string; + /** Features the client requires (all must be enabled). */ + requiredFeatures?: Array; +} + +/** Result of version / capability negotiation. */ +export interface NegotiationResult { + /** True when all client requirements are satisfied. */ + compatible: boolean; + /** Human-readable explanation when not compatible. */ + reason?: string; + /** The resolved capability snapshot. */ + capabilities: BackendCapabilities; +} + +/** Configuration for CapabilityDiscovery. */ +export interface CapabilityDiscoveryConfig { + /** Network identifier ("testnet" | "mainnet"). */ + network: string; + /** Override Horizon URL. */ + horizonUrl?: string; + /** Override Soroban RPC URL. */ + rpcUrl?: string; + /** How long (ms) to cache a capability snapshot before re-fetching. + * Default: 60 000 ms (1 minute). */ + cacheTtlMs?: number; + /** Request timeout in ms. Default: 10 000 ms. */ + timeout?: number; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const HORIZON_URLS: Record = { + testnet: "https://horizon-testnet.stellar.org", + mainnet: "https://horizon.stellar.org", +}; + +const RPC_URLS: Record = { + testnet: "https://soroban-testnet.stellar.org", + mainnet: "https://soroban-mainnet.stellar.org", +}; + +const CURRENT_API_VERSION = "1.0.0"; +const DEFAULT_CACHE_TTL_MS = 60_000; +const DEFAULT_TIMEOUT_MS = 10_000; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function semverMajor(version: string): number { + return parseInt(version.split(".")[0] ?? "0", 10); +} + +function semverSatisfies(required: string, available: string): boolean { + const req = required.split(".").map(Number); + const avail = available.split(".").map(Number); + for (let i = 0; i < req.length; i++) { + if ((avail[i] ?? 0) > (req[i] ?? 0)) return true; + if ((avail[i] ?? 0) < (req[i] ?? 0)) return false; + } + return true; +} + +// ─── CapabilityDiscovery ───────────────────────────────────────────────────── + +/** + * Discovers backend capabilities, contract versions, and feature flags + * dynamically by querying the Stellar network. Results are cached for + * `cacheTtlMs` milliseconds to avoid hammering the RPC on every call. + */ +export class CapabilityDiscovery { + private readonly config: Required; + private cache: BackendCapabilities | null = null; + private cacheTime = 0; + + constructor(config: CapabilityDiscoveryConfig) { + this.config = { + network: config.network, + horizonUrl: config.horizonUrl ?? HORIZON_URLS[config.network] ?? HORIZON_URLS["testnet"], + rpcUrl: config.rpcUrl ?? RPC_URLS[config.network] ?? RPC_URLS["testnet"], + cacheTtlMs: config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS, + timeout: config.timeout ?? DEFAULT_TIMEOUT_MS, + }; + } + + /** + * Fetch (or return cached) backend capabilities. + * + * Performs two lightweight network requests: + * 1. Horizon / — resolves protocol version, horizon version. + * 2. Soroban RPC getLatestLedger — confirms Soroban reachability. + * + * Falls back gracefully: if Soroban is unreachable, sorobanEnabled = false. + */ + async getCapabilities(): Promise { + if (this.cache && Date.now() - this.cacheTime < this.config.cacheTtlMs) { + return this.cache; + } + + const [horizonInfo, rpcInfo] = await Promise.allSettled([ + this._fetchHorizonRoot(), + this._fetchRpcLedger(), + ]); + + const protocol = horizonInfo.status === "fulfilled" + ? (horizonInfo.value.core_supported_protocol_version ?? 0) + : 0; + + const sorobanEnabled = rpcInfo.status === "fulfilled"; + + const caps: BackendCapabilities = { + network: this.config.network, + horizonUrl: this.config.horizonUrl, + rpcUrl: this.config.rpcUrl, + fetchedAt: new Date().toISOString(), + versions: { + protocol, + api: CURRENT_API_VERSION, + soroban: sorobanEnabled ? "21.0" : undefined, + }, + features: { + sorobanEnabled, + multiHopEnabled: sorobanEnabled, + realtimeEnabled: true, + claimableBalancesEnabled: protocol >= 15, + feeBumpingEnabled: protocol >= 13, + sponsorshipEnabled: protocol >= 14, + metadataEnabled: sorobanEnabled, + }, + limits: { + maxConnections: 100, + rateLimitPerMinute: 600, + maxXdrBytes: 1_048_576, + maxCacheAgeMs: this.config.cacheTtlMs, + }, + }; + + this.cache = caps; + this.cacheTime = Date.now(); + return caps; + } + + /** + * Negotiate compatibility between the client requirements and the backend. + * + * @example + * ```typescript + * const result = await discovery.negotiate({ + * minProtocol: 21, + * requiredFeatures: ["sorobanEnabled", "metadataEnabled"], + * }); + * if (!result.compatible) throw new Error(result.reason); + * ``` + */ + async negotiate(req: NegotiationRequest): Promise { + const caps = await this.getCapabilities(); + const reasons: string[] = []; + + if (req.minProtocol !== undefined && caps.versions.protocol < req.minProtocol) { + reasons.push( + `Protocol ${caps.versions.protocol} < required minimum ${req.minProtocol}` + ); + } + + if (req.minApi !== undefined && !semverSatisfies(req.minApi, caps.versions.api)) { + reasons.push( + `API version ${caps.versions.api} does not satisfy minimum ${req.minApi}` + ); + } + + for (const feature of req.requiredFeatures ?? []) { + if (!caps.features[feature]) { + reasons.push(`Required feature "${feature}" is not available on ${caps.network}`); + } + } + + return { + compatible: reasons.length === 0, + reason: reasons.length > 0 ? reasons.join("; ") : undefined, + capabilities: caps, + }; + } + + /** Invalidate the capability cache, forcing a fresh fetch on next call. */ + invalidateCache(): void { + this.cache = null; + this.cacheTime = 0; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + private async _fetchHorizonRoot(): Promise> { + const signal = AbortSignal.timeout(this.config.timeout); + const res = await fetch(this.config.horizonUrl, { signal }); + if (!res.ok) throw new Error(`Horizon root ${res.status}`); + return res.json() as Promise>; + } + + private async _fetchRpcLedger(): Promise { + const signal = AbortSignal.timeout(this.config.timeout); + const res = await fetch(this.config.rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger", params: [] }), + signal, + }); + if (!res.ok) throw new Error(`RPC ${res.status}`); + return res.json(); + } +} + +// ─── Convenience factory ───────────────────────────────────────────────────── + +/** + * Create a CapabilityDiscovery instance for the given network. + * + * ```typescript + * const discovery = createCapabilityDiscovery("testnet"); + * const caps = await discovery.getCapabilities(); + * ``` + */ +export function createCapabilityDiscovery( + network: string, + options?: Partial +): CapabilityDiscovery { + return new CapabilityDiscovery({ network, ...options }); +} diff --git a/src/lifecycle.ts b/src/lifecycle.ts new file mode 100644 index 00000000..e69de29b