diff --git a/src/cache/OptimisticCache.ts b/src/cache/OptimisticCache.ts index d310c92..f249b59 100644 --- a/src/cache/OptimisticCache.ts +++ b/src/cache/OptimisticCache.ts @@ -162,6 +162,8 @@ export class OptimisticCache { /** Per-invoice FIFO queue of pending (uncommitted, unrolled-back) predictions. */ private readonly pending = new Map[]>(); private readonly rollbackHandlers = new Set<(event: RollbackEvent) => void>(); + private readonly eventHandlers = new Set<(event: CacheEvent) => void>(); + private readonly expiries = new Map(); private readonly versionCounters = new Map(); /** Freshness bookkeeping for values written through read()/set(). */ private readonly freshness = new Map>(); diff --git a/src/client.ts b/src/client.ts index c8384fc..0cc01ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -52,6 +52,9 @@ export type SplitClientEventMap = { dispute: import("./contractEvents.js").DisputeEvent; /** Emitted when a tier is unlocked on an invoice. */ tier_unlocked: import("./contractEvents.js").TierUnlockedEvent; + "queue:drained": undefined; + "queue:operation:failed": { operation: import("./offlineQueue.js").QueuedOperation; error: unknown }; + "queue:conflict:resolved": { operation: import("./offlineQueue.js").QueuedOperation; strategy: string; remoteState?: unknown }; }; import { signTransaction } from "./wallet.js"; import { telemetry } from "./telemetry.js"; @@ -924,6 +927,9 @@ export class StellarSplitClient extends TypedEventEmitter { this._rpcClient = config.container?.getRPCClient() ?? null; this._adapter = config.container?.getWalletAdapter() ?? config.adapter ?? null; + if (config.offlineQueue) { + this._offlineQueue = new OfflineQueue(this, config.offlineQueue); + } // Per-method timeout manager (Issue #1) /** @@ -1307,6 +1313,11 @@ export class StellarSplitClient extends TypedEventEmitter { if (this._offlineQueue) this._offlineQueue.clear(); } + async syncOfflineQueue(conflictStrategy?: import("./types.js").ConflictStrategy) { + if (!this._offlineQueue) return null; + return this._offlineQueue.sync(conflictStrategy); + } + async switchTo(network: "mainnet" | "testnet" | "futurenet"): Promise { const { NetworkSwitcher } = await import("./network/NetworkSwitcher.js"); return NetworkSwitcher.switchTo(network, this); @@ -3379,6 +3390,18 @@ export class StellarSplitClient extends TypedEventEmitter { params as unknown as Record, ); } + if (this._offlineQueue?.config?.enabled) { + try { + const health = await this.checkRPCHealth(); + if (health.status === "down") { + this._offlineQueue.enqueue("pay", [params]); + return { txHash: "queued" }; + } + } catch (e) { + this._offlineQueue.enqueue("pay", [params]); + return { txHash: "queued" }; + } + } const startTime = Date.now(); params = this._pluginRegistry.runBeforeCall("pay", params); @@ -10709,15 +10732,23 @@ export class StellarSplitClient extends TypedEventEmitter { * @throws {Error} If the method fails. */ async connectWallet(adapter: WalletAdapter): Promise { - const address = await adapter.connect(); + const address = + typeof (adapter as any).connect === "function" + ? await (adapter as any).connect() + : await adapter.getAddress(); this._adapter = adapter; // Listen for account changes and update internal reference - adapter.onAccountChange((newAddress: string) => { - this.emit("wallet:accountChanged", newAddress); - }); + if (typeof (adapter as any).onAccountChange === "function") { + (adapter as any).onAccountChange((newAddress: string) => { + this.emit("wallet:accountChanged", newAddress); + }); + } - this.emit("wallet:connected", { walletName: adapter.name, address }); + this.emit("wallet:connected", { + walletName: (adapter as any).name ?? "Wallet", + address, + }); return address; } diff --git a/src/configValidator.ts b/src/configValidator.ts index 111cdd9..46ddb31 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -48,6 +48,7 @@ const KNOWN_CONFIG_KEYS = new Set([ "dexContractId", "circuitBreaker", "optimisticCache", + "offlineQueue", ]); export class UnknownConfigKeyError extends StellarSplitError { diff --git a/src/offlineQueue.ts b/src/offlineQueue.ts index c07e1c4..87b410c 100644 --- a/src/offlineQueue.ts +++ b/src/offlineQueue.ts @@ -1,4 +1,5 @@ import type { StellarSplitClient } from "./client.js"; +import type { ConflictStrategy } from "./types.js"; export interface QueuedOperation { id: string; @@ -11,13 +12,37 @@ export interface OfflineQueueConfig { enabled: boolean; maxQueueSize: number; persistToStorage: boolean; + conflictStrategy?: ConflictStrategy; +} + +export interface SyncResult { + synced: number; + failed: number; + conflicts: number; + operations: QueuedOperation[]; +} + +function serializeQueue(queue: QueuedOperation[]): string { + return JSON.stringify(queue, (_key, value) => + typeof value === "bigint" ? { __type: "bigint", value: value.toString() } : value + ); +} + +function deserializeQueue(raw: string): QueuedOperation[] { + return JSON.parse(raw, (_key, value) => { + if (value && typeof value === "object" && value.__type === "bigint") { + return BigInt(value.value); + } + return value; + }); } export class OfflineQueue { private queue: QueuedOperation[] = []; - private config: OfflineQueueConfig; + public readonly config: OfflineQueueConfig; private client: StellarSplitClient; private readonly storageKey = "stellar_split_offline_queue"; + private isDraining = false; constructor(client: StellarSplitClient, config: OfflineQueueConfig) { this.client = client; @@ -35,7 +60,10 @@ export class OfflineQueue { } const operation: QueuedOperation = { - id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2), + id: + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).substring(2), method, args, timestamp: Date.now(), @@ -59,30 +87,76 @@ export class OfflineQueue { } } + public recoverState(): QueuedOperation[] { + this.loadFromStorage(); + return this.getQueue(); + } + public async drain(): Promise { - if (this.queue.length === 0) return; + await this.sync(); + } + + public async sync(conflictStrategy?: ConflictStrategy): Promise { + if (this.isDraining || this.queue.length === 0) { + return { synced: 0, failed: 0, conflicts: 0, operations: [] }; + } + this.isDraining = true; + const strategy = conflictStrategy ?? this.config.conflictStrategy ?? "remote-wins"; const operationsToDrain = [...this.queue]; this.clear(); + const result: SyncResult = { + synced: 0, + failed: 0, + conflicts: 0, + operations: operationsToDrain, + }; + for (const op of operationsToDrain) { try { + if (op.method === "pay" && op.args.length > 0 && typeof this.client.getInvoice === "function") { + const invoiceId = op.args[0]?.invoiceId; + if (invoiceId) { + try { + const remoteInvoice = await this.client.getInvoice(invoiceId); + if (remoteInvoice && remoteInvoice.status !== "pending") { + result.conflicts++; + if (strategy === "remote-wins") { + this.client.emit("queue:conflict:resolved", { + operation: op, + strategy, + remoteState: remoteInvoice, + }); + continue; + } + } + } catch { + // Proceed if remote fetch fails + } + } + } + // @ts-ignore await this.client[op.method](...op.args); + result.synced++; } catch (error) { + result.failed++; this.client.emit("queue:operation:failed", { operation: op, error }); } } this.client.emit("queue:drained", undefined); + this.isDraining = false; + return result; } - private loadFromStorage(): void { + public loadFromStorage(): void { if (typeof localStorage !== "undefined") { try { const stored = localStorage.getItem(this.storageKey); if (stored) { - this.queue = JSON.parse(stored); + this.queue = deserializeQueue(stored); } } catch (e) { console.warn("Failed to load offline queue from storage", e); @@ -90,13 +164,14 @@ export class OfflineQueue { } } - private saveToStorage(): void { + public saveToStorage(): void { if (typeof localStorage !== "undefined") { try { - localStorage.setItem(this.storageKey, JSON.stringify(this.queue)); + localStorage.setItem(this.storageKey, serializeQueue(this.queue)); } catch (e) { console.warn("Failed to save offline queue to storage", e); } } } } + diff --git a/src/retryPolicy.ts b/src/retryPolicy.ts index 9aebd64..ac5b7af 100644 --- a/src/retryPolicy.ts +++ b/src/retryPolicy.ts @@ -21,7 +21,7 @@ export function isRetryable(error: unknown): boolean { if (error instanceof WalletNotConnectedError) return false; if (error instanceof RpcError) { - return error.statusCode === 429 || error.statusCode === 503; + return error.statusCode === 429 || error.statusCode === 502 || error.statusCode === 503 || error.statusCode === 504; } if (!(error instanceof Error)) return false; @@ -29,7 +29,7 @@ export function isRetryable(error: unknown): boolean { const msg = error.message.toLowerCase(); return ( error.name === "AbortError" || - /timeout|timed out|network|failed to fetch|connection|connect|econnreset|econnrefused|eai_again|enotfound|429|503/.test( + /timeout|timed out|network|failed to fetch|connection|connect|econnreset|econnrefused|eai_again|enotfound|429|502|503|504/.test( msg ) ); diff --git a/test/concurrentPaymentHandling.test.ts b/test/concurrentPaymentHandling.test.ts new file mode 100644 index 0000000..87b4c2e --- /dev/null +++ b/test/concurrentPaymentHandling.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { NonceDeduplicator } from "../src/cache.js"; +import { IdempotencyManager } from "../src/idempotency.js"; +import { OptimisticCache } from "../src/cache/OptimisticCache.js"; +import { InvoiceBatchProcessor } from "../src/invoiceBatchProcessor.js"; +import type { Invoice } from "../src/types.js"; + +const validContractId = StrKey.encodeContract(Keypair.random().rawPublicKey()); +const validNetworkPassphrase = "Test SDF Network ; September 2015"; +const randomAddress = () => Keypair.random().publicKey(); + +function createMockClient(): StellarSplitClient { + return new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: validNetworkPassphrase, + contractId: validContractId, + }); +} + +describe("Concurrent Payment Handling (#1040)", () => { + let client: StellarSplitClient; + + beforeEach(() => { + client = createMockClient(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================= + // 1. Multiple Pays Tests + // ========================================================================= + describe("Multiple Pays", () => { + it("handles multiple concurrent pay operations across distinct invoices", async () => { + vi.spyOn(client as any, "_submitTx").mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 10)); + return { + txHash: `tx-hash-${Math.random().toString(36).slice(2)}`, + returnValue: { value: () => 100n }, + }; + }); + + // Submit 5 concurrent payments with valid Stellar addresses + const payments = [ + client.pay({ invoiceId: "1", amount: 100n, payer: randomAddress() }), + client.pay({ invoiceId: "2", amount: 200n, payer: randomAddress() }), + client.pay({ invoiceId: "3", amount: 300n, payer: randomAddress() }), + client.pay({ invoiceId: "4", amount: 400n, payer: randomAddress() }), + client.pay({ invoiceId: "5", amount: 500n, payer: randomAddress() }), + ]; + + const results = await Promise.all(payments); + + expect(results).toHaveLength(5); + results.forEach((res) => { + expect(res.txHash).toMatch(/^tx-hash-/); + }); + }); + + it("executes concurrent batch invoice payments respecting maxConcurrent limits", async () => { + let activeSubmissions = 0; + let maxActiveSubmissions = 0; + const completedInvoices: string[] = []; + + const mockSubmitter = { + submitPayment: vi.fn().mockImplementation(async ({ invoiceId }: { invoiceId: string }) => { + activeSubmissions++; + maxActiveSubmissions = Math.max(maxActiveSubmissions, activeSubmissions); + await new Promise((resolve) => setTimeout(resolve, 20)); + completedInvoices.push(invoiceId); + activeSubmissions--; + return { txHash: `tx-${invoiceId}` }; + }), + }; + + const processor = new InvoiceBatchProcessor(mockSubmitter); + const invoiceIds = ["inv-1", "inv-2", "inv-3", "inv-4", "inv-5", "inv-6"]; + const amounts = invoiceIds.reduce>((acc, id) => { + acc[id] = 100n; + return acc; + }, {}); + + const results: any[] = []; + for await (const res of processor.process(invoiceIds, { + payer: randomAddress(), + amounts, + maxConcurrent: 2, + })) { + results.push(res); + } + + expect(results).toHaveLength(6); + expect(results.every((r) => r.status === "success")).toBe(true); + expect(maxActiveSubmissions).toBeLessThanOrEqual(2); + expect(completedInvoices).toHaveLength(6); + }); + + it("allows independent callers to pay the same invoice concurrently (partial split)", async () => { + vi.spyOn(client as any, "_submitTx").mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 15)); + return { + txHash: `tx-split-${Math.random().toString(36).slice(2)}`, + returnValue: { value: () => 100n }, + }; + }); + + // 3 distinct payers contributing to split invoice "100" + const p1 = client.pay({ invoiceId: "100", amount: 50n, payer: randomAddress() }); + const p2 = client.pay({ invoiceId: "100", amount: 30n, payer: randomAddress() }); + const p3 = client.pay({ invoiceId: "100", amount: 20n, payer: randomAddress() }); + + const results = await Promise.all([p1, p2, p3]); + expect(results).toHaveLength(3); + results.forEach((r) => expect(r.txHash).toBeDefined()); + }); + }); + + // ========================================================================= + // 2. Nonce Handling Tests + // ========================================================================= + describe("Nonce Handling", () => { + it("accepts unique nonces for concurrent payment requests", () => { + const dedup = new NonceDeduplicator({ enabled: true, ttlMs: 60_000, maxEntries: 100 }); + const nonces = Array.from({ length: 10 }, (_, i) => `nonce-${Date.now()}-${i}`); + + const outcomes = nonces.map((nonce) => dedup.check(nonce)); + expect(outcomes.every((outcome) => outcome === "accepted")).toBe(true); + expect(dedup.size).toBe(10); + }); + + it("rejects duplicate nonces during concurrent payment replay", () => { + const dedup = new NonceDeduplicator({ enabled: true, ttlMs: 60_000, maxEntries: 100 }); + const nonce = "replay-nonce-xyz"; + + const first = dedup.check(nonce); + expect(first).toBe("accepted"); + + // Repeated submissions with the same nonce must be detected as duplicate + const duplicate1 = dedup.check(nonce); + const duplicate2 = dedup.check(nonce); + + expect(duplicate1).toBe("duplicate"); + expect(duplicate2).toBe("duplicate"); + expect(dedup.isDuplicate(nonce)).toBe(true); + }); + + it("enforces idempotency key claims so identical concurrent calls share the same txHash", () => { + const idemp = new IdempotencyManager({ ttlMs: 60_000 }); + const key = idemp.generateKey(randomAddress(), "operation-xdr-payload"); + + const claim1 = idemp.tryClaim(key, { txHash: "tx-settled-first" }); + expect(claim1.duplicate).toBe(false); + + const claim2 = idemp.tryClaim(key, { txHash: "tx-settled-second" }); + expect(claim2.duplicate).toBe(true); + expect(claim2.existing?.txHash).toBe("tx-settled-first"); + + // Re-fetching result returns original transaction + const cached = idemp.getResult(key); + expect(cached?.txHash).toBe("tx-settled-first"); + }); + }); + + // ========================================================================= + // 3. State Consistency Tests + // ========================================================================= + describe("State Consistency", () => { + it("maintains consistent optimistic cache state across concurrent mutations", () => { + const initialInvoice: Invoice = { + id: "1", + status: "pending", + totalAmount: 1000n, + paidAmount: 0n, + token: "USDC", + creator: randomAddress(), + recipient: randomAddress(), + recipients: [], + createdAt: Date.now(), + }; + + const cache = new OptimisticCache(); + + // Apply first optimistic payment of 200n + const mutation1 = cache.applyOptimistic( + "1", + { ...initialInvoice, paidAmount: 200n }, + initialInvoice + ); + expect(cache.get("1")?.paidAmount).toBe(200n); + + // Apply second concurrent optimistic payment of another 300n (total 500n) + const invoiceAt200 = { ...initialInvoice, paidAmount: 200n }; + const mutation2 = cache.applyOptimistic( + "1", + { ...initialInvoice, paidAmount: 500n }, + invoiceAt200 + ); + expect(cache.get("1")?.paidAmount).toBe(500n); + + // Roll back mutation 2, reverts to baseline before mutation 2 (200n) + mutation2.rollback(); + expect(cache.get("1")?.paidAmount).toBe(200n); + + // Commit mutation 1, permanently commits 200n + mutation1.commit(); + expect(cache.get("1")?.paidAmount).toBe(200n); + }); + + it("guarantees consistent state when concurrent payments succeed and fail mixed together", async () => { + let callCount = 0; + vi.spyOn(client as any, "_submitTx").mockImplementation(async () => { + callCount++; + if (callCount % 2 === 0) { + throw new Error("Simulated failure: insufficient allowance"); + } + return { + txHash: `tx-consistent-${callCount}`, + returnValue: { value: () => 100n }, + }; + }); + + const promises = [ + client.pay({ invoiceId: "1", amount: 100n, payer: randomAddress() }), + client.pay({ invoiceId: "2", amount: 100n, payer: randomAddress() }), + client.pay({ invoiceId: "3", amount: 100n, payer: randomAddress() }), + client.pay({ invoiceId: "4", amount: 100n, payer: randomAddress() }), + ]; + + const settled = await Promise.allSettled(promises); + + const fulfilled = settled.filter((s) => s.status === "fulfilled"); + const rejected = settled.filter((s) => s.status === "rejected"); + + expect(fulfilled).toHaveLength(2); + expect(rejected).toHaveLength(2); + expect(callCount).toBe(4); + }); + }); + + // ========================================================================= + // 4. Race Conditions Tests + // ========================================================================= + describe("Race Conditions", () => { + it("serializes concurrent transactions via the priority queue without race corruption", async () => { + const executionOrder: number[] = []; + + vi.spyOn(client as any, "_doSubmitTx").mockImplementation(async () => { + const id = executionOrder.length + 1; + executionOrder.push(id); + await new Promise((r) => setTimeout(r, 10)); + return { + txHash: `tx-queued-${id}`, + returnValue: { value: () => id }, + }; + }); + + // Launch 5 concurrent payments simultaneously + const submissions = [ + client.pay({ invoiceId: "1", amount: 10n, payer: randomAddress() }), + client.pay({ invoiceId: "2", amount: 20n, payer: randomAddress() }), + client.pay({ invoiceId: "3", amount: 30n, payer: randomAddress() }), + client.pay({ invoiceId: "4", amount: 40n, payer: randomAddress() }), + client.pay({ invoiceId: "5", amount: 50n, payer: randomAddress() }), + ]; + + const results = await Promise.all(submissions); + expect(results).toHaveLength(5); + // All 5 executed in sequential order through queue + expect(executionOrder).toEqual([1, 2, 3, 4, 5]); + }); + + it("survives a high-concurrency burst of 20 concurrent payment attempts without race errors", async () => { + let completed = 0; + vi.spyOn(client as any, "_submitTx").mockImplementation(async () => { + await new Promise((r) => setTimeout(r, 5)); + completed++; + return { + txHash: `burst-tx-${completed}`, + returnValue: { value: () => completed }, + }; + }); + + const burst = Array.from({ length: 20 }, (_, i) => + client.pay({ invoiceId: `${i + 1}`, amount: BigInt(i + 1), payer: randomAddress() }) + ); + + const results = await Promise.all(burst); + expect(results).toHaveLength(20); + expect(completed).toBe(20); + const uniqueHashes = new Set(results.map((r) => r.txHash)); + expect(uniqueHashes.size).toBe(20); + }); + }); +}); diff --git a/test/offlineQueue.test.ts b/test/offlineQueue.test.ts index 38b31d6..fd1610d 100644 --- a/test/offlineQueue.test.ts +++ b/test/offlineQueue.test.ts @@ -1,84 +1,320 @@ -import { expect } from "chai"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { StellarSplitClient } from "../src/client.js"; +import { OfflineQueue } from "../src/offlineQueue.js"; import * as health from "../src/health.js"; -import sinon from "sinon"; +import { resolveConflict } from "../src/conflictResolver.js"; +import type { Invoice } from "../src/types.js"; -describe("OfflineQueue", () => { +import { Keypair, StrKey } from "@stellar/stellar-sdk"; + +const validContractId = StrKey.encodeContract(Keypair.random().rawPublicKey()); +const validNetworkPassphrase = "Test SDF Network ; September 2015"; + +describe("OfflineQueue and Synchronization (#1041)", () => { let client: StellarSplitClient; - let healthStub: sinon.SinonStub; + let healthSpy: any; beforeEach(() => { - if (typeof localStorage === "undefined") { - (global as any).localStorage = { - store: {} as Record, - getItem(key: string) { return this.store[key] || null; }, - setItem(key: string, value: string) { this.store[key] = value; }, - clear() { this.store = {}; } - }; - } else { - localStorage.clear(); - } + // Setup in-memory mock localStorage + const store: Record = {}; + (globalThis as any).localStorage = { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + }; client = new StellarSplitClient({ rpcUrl: "http://localhost:8000", - networkPassphrase: "Test", - contractId: "C123", + networkPassphrase: validNetworkPassphrase, + contractId: validContractId, offlineQueue: { enabled: true, - maxQueueSize: 2, + maxQueueSize: 3, persistToStorage: true, - } + }, }); - healthStub = sinon.stub(health, "checkRPCHealth").resolves({ status: "down", latencyMs: 0, blockHeight: 0, timestamp: 0 }); + healthSpy = vi.spyOn(health, "checkRPCHealth").mockResolvedValue({ + status: "down", + latencyMs: 0, + blockHeight: 0, + timestamp: 0, + }); }); afterEach(() => { - healthStub.restore(); - sinon.restore(); + vi.restoreAllMocks(); }); - it("fills on RPC failure and enforces max size", async () => { - await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); - await client.pay({ invoiceId: "2", amount: 200n, payer: "B" }); - await client.pay({ invoiceId: "3", amount: 300n, payer: "C" }); + // ========================================================================= + // 1. Queue Storage Tests + // ========================================================================= + describe("Queue Storage", () => { + it("fills on RPC failure and returns queued result", async () => { + const result = await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); + expect(result).toEqual({ txHash: "queued" }); - const q = client.getOfflineQueue(); - expect(q.length).to.equal(2); - expect(q[0].args[0].invoiceId).to.equal("2"); - expect(q[1].args[0].invoiceId).to.equal("3"); - }); + const q = client.getOfflineQueue(); + expect(q).toHaveLength(1); + expect(q[0].method).toBe("pay"); + expect(q[0].args[0].invoiceId).toBe("1"); + expect(q[0].args[0].amount).toBe(100n); + }); - it("persists to localStorage", async () => { - await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); - - const client2 = new StellarSplitClient({ - rpcUrl: "http://localhost:8000", - networkPassphrase: "Test", - contractId: "C123", - offlineQueue: { + it("enforces maxQueueSize with FIFO eviction when full", async () => { + await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); + await client.pay({ invoiceId: "2", amount: 200n, payer: "B" }); + await client.pay({ invoiceId: "3", amount: 300n, payer: "C" }); + await client.pay({ invoiceId: "4", amount: 400n, payer: "D" }); + + const q = client.getOfflineQueue(); + expect(q).toHaveLength(3); + expect(q.map((op) => op.args[0].invoiceId)).toEqual(["2", "3", "4"]); + }); + + it("persists queue to localStorage including BigInt fields", async () => { + await client.pay({ invoiceId: "101", amount: 5000000000n, payer: "A" }); + + // Create new client instance loading from same storage + const client2 = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: validNetworkPassphrase, + contractId: validContractId, + offlineQueue: { + enabled: true, + maxQueueSize: 5, + persistToStorage: true, + }, + }); + + const q = client2.getOfflineQueue(); + expect(q).toHaveLength(1); + expect(q[0].args[0].invoiceId).toBe("101"); + expect(q[0].args[0].amount).toBe(5000000000n); + }); + + it("clearing the queue wipes in-memory and persistent storage", async () => { + await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); + expect(client.getOfflineQueue()).toHaveLength(1); + + client.clearOfflineQueue(); + expect(client.getOfflineQueue()).toHaveLength(0); + + // Re-hydrating another client sees empty queue + const client2 = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: validNetworkPassphrase, + contractId: validContractId, + offlineQueue: { + enabled: true, + maxQueueSize: 5, + persistToStorage: true, + }, + }); + expect(client2.getOfflineQueue()).toHaveLength(0); + }); + + it("gracefully tolerates corrupted storage data without throwing", () => { + (globalThis as any).localStorage.setItem("stellar_split_offline_queue", "invalid-json-content{"); + const offlineQueue = new OfflineQueue(client, { enabled: true, maxQueueSize: 5, persistToStorage: true, - } + }); + + expect(offlineQueue.getQueue()).toEqual([]); }); + }); + + // ========================================================================= + // 2. Sync Process Tests + // ========================================================================= + describe("Sync Process", () => { + it("drains and executes queued operations when network recovers", async () => { + await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); + await client.pay({ invoiceId: "2", amount: 200n, payer: "B" }); + + // Health recovers + healthSpy.mockResolvedValue({ status: "ok", latencyMs: 10, blockHeight: 1, timestamp: 1 }); + const paySpy = vi.spyOn(client, "pay").mockResolvedValue({ txHash: "tx-success" }); + + const drainedPromise = client.once("queue:drained"); + await client.checkRPCHealth(); + await drainedPromise; + + expect(client.getOfflineQueue()).toHaveLength(0); + expect(paySpy).toHaveBeenCalledTimes(2); + expect(paySpy).toHaveBeenNthCalledWith(1, { invoiceId: "1", amount: 100n, payer: "A" }); + expect(paySpy).toHaveBeenNthCalledWith(2, { invoiceId: "2", amount: 200n, payer: "B" }); + }); + + it("emits queue:operation:failed on error and continues remaining sync operations", async () => { + await client.pay({ invoiceId: "fail-1", amount: 100n, payer: "A" }); + await client.pay({ invoiceId: "pass-2", amount: 200n, payer: "B" }); + + healthSpy.mockResolvedValue({ status: "ok", latencyMs: 10, blockHeight: 1, timestamp: 1 }); + const failedOps: any[] = []; + client.on("queue:operation:failed", (evt) => failedOps.push(evt)); - const q = client2.getOfflineQueue(); - expect(q.length).to.equal(1); - expect(q[0].args[0].invoiceId).to.equal("1"); + vi.spyOn(client, "pay").mockImplementation(async (params: any) => { + if (params.invoiceId === "fail-1") { + throw new Error("Simulation failure: balance too low"); + } + return { txHash: "tx-pass" }; + }); + + const syncResult = await client.syncOfflineQueue(); + expect(syncResult).not.toBeNull(); + expect(syncResult?.synced).toBe(1); + expect(syncResult?.failed).toBe(1); + expect(failedOps).toHaveLength(1); + expect(failedOps[0].operation.args[0].invoiceId).toBe("fail-1"); + expect(failedOps[0].error.message).toContain("Simulation failure"); + }); + + it("prevents re-entrant sync calls when a sync is already in flight", async () => { + await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); + + let syncResolve: any; + const syncBlocker = new Promise((resolve) => { + syncResolve = resolve; + }); + + vi.spyOn(client, "pay").mockImplementation(async () => { + await syncBlocker; + return { txHash: "tx-done" }; + }); + + const queueInstance = (client as any)._offlineQueue; + const firstSync = queueInstance.sync(); + const concurrentSync = queueInstance.sync(); + + // Concurrent call returns early without duplicating operations + const concurrentResult = await concurrentSync; + expect(concurrentResult.operations).toHaveLength(0); + + syncResolve(); + const firstResult = await firstSync; + expect(firstResult.synced).toBe(1); + }); + }); + + // ========================================================================= + // 3. Conflict Resolution Tests + // ========================================================================= + describe("Conflict Resolution", () => { + it("resolves conflicting invoice states using resolveConflict helper", () => { + const localInvoice = { + id: "inv-1", + status: "pending", + totalAmount: 1000n, + paidAmount: 500n, + lastModifiedLedger: 100, + } as unknown as Invoice; + + const remoteInvoice = { + id: "inv-1", + status: "paid", + totalAmount: 1000n, + paidAmount: 1000n, + lastModifiedLedger: 110, + } as unknown as Invoice; + + // remote-wins + const resolvedRemote = resolveConflict(localInvoice, remoteInvoice, "remote-wins"); + expect(resolvedRemote.status).toBe("paid"); + expect(resolvedRemote.paidAmount).toBe(1000n); + + // local-wins + const resolvedLocal = resolveConflict(localInvoice, remoteInvoice, "local-wins"); + expect(resolvedLocal.status).toBe("pending"); + expect(resolvedLocal.paidAmount).toBe(500n); + + // latest-ledger: remote has higher ledger + const resolvedLedger = resolveConflict(localInvoice, remoteInvoice, "latest-ledger"); + expect(resolvedLedger.lastModifiedLedger).toBe(110); + expect(resolvedLedger.status).toBe("paid"); + }); + + it("detects conflict on sync when remote invoice is already paid and skips obsolete mutation with remote-wins", async () => { + await client.pay({ invoiceId: "inv-conflict", amount: 500n, payer: "A" }); + + // Stub getInvoice to return an invoice that settled remotely while offline + vi.spyOn(client, "getInvoice").mockResolvedValue({ + id: "inv-conflict", + status: "paid", + totalAmount: 500n, + paidAmount: 500n, + token: "USDC", + creator: "GC", + recipient: "GR", + recipients: [], + createdAt: 0, + } as Invoice); + + const paySpy = vi.spyOn(client, "pay").mockResolvedValue({ txHash: "should-not-be-called" }); + const conflictEvents: any[] = []; + client.on("queue:conflict:resolved", (evt) => conflictEvents.push(evt)); + + const result = await client.syncOfflineQueue("remote-wins"); + expect(result?.conflicts).toBe(1); + expect(result?.synced).toBe(0); + expect(paySpy).not.toHaveBeenCalled(); + expect(conflictEvents).toHaveLength(1); + expect(conflictEvents[0].operation.args[0].invoiceId).toBe("inv-conflict"); + expect(conflictEvents[0].remoteState.status).toBe("paid"); + }); }); - it("drains on recovery", async () => { - await client.pay({ invoiceId: "1", amount: 100n, payer: "A" }); + // ========================================================================= + // 4. State Recovery Tests + // ========================================================================= + describe("State Recovery", () => { + it("recovers state from storage using recoverState() after simulated crash", async () => { + await client.pay({ invoiceId: "crash-test", amount: 999n, payer: "GCRASH" }); + + const offlineQueue = (client as any)._offlineQueue; + // Clear in-memory queue to simulate memory loss + (offlineQueue as any).queue = []; + expect(offlineQueue.getQueue()).toHaveLength(0); + + // Recover state from persistent storage + const recovered = offlineQueue.recoverState(); + expect(recovered).toHaveLength(1); + expect(recovered[0].args[0].invoiceId).toBe("crash-test"); + expect(recovered[0].args[0].amount).toBe(999n); + }); + + it("handles multiple offline/online cycles recovering and syncing correctly", async () => { + // Cycle 1: Offline + await client.pay({ invoiceId: "cycle-1", amount: 10n, payer: "A" }); + expect(client.getOfflineQueue()).toHaveLength(1); - healthStub.resolves({ status: "ok", latencyMs: 10, blockHeight: 1, timestamp: 1 }); - const payStub = sinon.stub(client, "pay").resolves({ txHash: "success" }); + // Cycle 1: Come online and sync + healthSpy.mockResolvedValue({ status: "ok", latencyMs: 5, blockHeight: 1, timestamp: 1 }); + const paySpy1 = vi.spyOn(client, "pay").mockResolvedValue({ txHash: "tx-cycle-1" }); + await client.syncOfflineQueue(); + expect(client.getOfflineQueue()).toHaveLength(0); + paySpy1.mockRestore(); - await client.checkRPCHealth(); + // Cycle 2: Goes offline again + healthSpy.mockResolvedValue({ status: "down", latencyMs: 0, blockHeight: 0, timestamp: 2 }); + await client.pay({ invoiceId: "cycle-2", amount: 20n, payer: "B" }); + expect(client.getOfflineQueue()).toHaveLength(1); - const q = client.getOfflineQueue(); - expect(q.length).to.equal(0); - expect(payStub.calledOnce).to.be.true; - expect(payStub.firstCall.args[0].invoiceId).to.equal("1"); + // Cycle 2: Recovery and sync + healthSpy.mockResolvedValue({ status: "ok", latencyMs: 5, blockHeight: 2, timestamp: 3 }); + const paySpy2 = vi.spyOn(client, "pay").mockResolvedValue({ txHash: "tx-cycle-2" }); + await client.syncOfflineQueue(); + expect(client.getOfflineQueue()).toHaveLength(0); + paySpy2.mockRestore(); + }); }); }); diff --git a/test/transactionRetry.test.ts b/test/transactionRetry.test.ts new file mode 100644 index 0000000..d20ee85 --- /dev/null +++ b/test/transactionRetry.test.ts @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { withRetry } from "../src/retry.js"; +import { executeWithRetry, isRetryable } from "../src/retryPolicy.js"; +import type { RetryOptions } from "../src/retryPolicy.js"; +import { StellarSplitClient } from "../src/client.js"; +import { + InvoiceNotFoundError, + ValidationError, + WalletNotConnectedError, + RpcError, +} from "../src/errors.js"; + +const validContractId = StrKey.encodeContract(Keypair.random().rawPublicKey()); +const validNetworkPassphrase = "Test SDF Network ; September 2015"; +const randomAddress = () => Keypair.random().publicKey(); + +describe("Transaction Retry Logic (#1039)", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + // ========================================================================= + // 1. Timeout Failure Scenarios + // ========================================================================= + describe("Timeout Handling", () => { + it("retries on operation timeout and succeeds once recovered", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("timed out waiting for response")) + .mockResolvedValueOnce({ txHash: "tx-recovered" }); + + const promise = withRetry(fn, 3, 50); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual({ txHash: "tx-recovered" }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("retries on AbortError representing request timeout", async () => { + const abortErr = new Error("The operation was aborted"); + abortErr.name = "AbortError"; + + const fn = vi + .fn() + .mockRejectedValueOnce(abortErr) + .mockResolvedValueOnce({ txHash: "tx-after-abort" }); + + const promise = executeWithRetry(fn, { + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 1000, + }); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual({ txHash: "tx-after-abort" }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("enforces timeout and marks error as retryExhausted when all timeout retries fail", async () => { + const timeoutErr = new Error("network timeout"); + const fn = vi.fn().mockRejectedValue(timeoutErr); + + let caught: any; + const promise = executeWithRetry(fn, { + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 1000, + }).catch((e) => { + caught = e; + }); + + await vi.runAllTimersAsync(); + await promise; + + expect(fn).toHaveBeenCalledTimes(3); + expect(caught).toBeDefined(); + expect(caught.retryExhausted).toBe(true); + expect(caught.message).toContain("network timeout"); + }); + }); + + // ========================================================================= + // 2. Network Error Scenarios + // ========================================================================= + describe("Network Errors", () => { + it.each([ + ["ECONNRESET", new Error("read ECONNRESET")], + ["ECONNREFUSED", new Error("connect ECONNREFUSED 127.0.0.1:8000")], + ["failed to fetch", new Error("TypeError: failed to fetch")], + ["503 service unavailable", new RpcError("Service Unavailable", 503, "https://rpc.stellar.org")], + ["502 bad gateway", new Error("HTTP 502 Bad Gateway")], + ["504 gateway timeout", new Error("HTTP 504 Gateway Timeout")], + ])("retries transient network error: %s", async (_label, error) => { + const fn = vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ txHash: "tx-success" }); + + const promise = executeWithRetry(fn, { + maxAttempts: 3, + baseDelayMs: 50, + maxDelayMs: 500, + }); + + await vi.runAllTimersAsync(); + const res = await promise; + + expect(res).toEqual({ txHash: "tx-success" }); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["ValidationError", new ValidationError("Invalid amount parameter")], + ["InvoiceNotFoundError", new InvoiceNotFoundError("999")], + ["WalletNotConnectedError", new WalletNotConnectedError()], + ["HTTP 404", new RpcError("Contract not found", 404, "https://rpc.stellar.org")], + ])("does NOT retry non-transient error: %s", async (_label, error) => { + const fn = vi.fn().mockRejectedValue(error); + + await expect( + executeWithRetry(fn, { maxAttempts: 4, baseDelayMs: 50, maxDelayMs: 500 }) + ).rejects.toThrow(); + + // Immediately fails on first attempt without retrying + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================================================= + // 3. Backoff Scenarios + // ========================================================================= + describe("Exponential Backoff", () => { + it("computes exponential backoff correctly: delay = baseDelayMs * 2^(attempt - 1)", async () => { + const delays: number[] = []; + const origSetTimeout = globalThis.setTimeout; + + vi.spyOn(globalThis, "setTimeout").mockImplementation( + (cb: any, ms: number, ...args: any[]) => { + if (ms > 0) delays.push(ms); + return origSetTimeout(cb, 0, ...args); + } + ); + + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("503")) + .mockRejectedValueOnce(new Error("503")) + .mockRejectedValueOnce(new Error("503")) + .mockResolvedValueOnce({ txHash: "done" }); + + const promise = withRetry(fn, 4, 100); + await vi.runAllTimersAsync(); + await promise; + + expect(delays).toHaveLength(3); + // Attempt 1 -> 2: 100 * 2^0 = 100ms + expect(delays[0]).toBe(100); + // Attempt 2 -> 3: 100 * 2^1 = 200ms + expect(delays[1]).toBe(200); + // Attempt 3 -> 4: 100 * 2^2 = 400ms + expect(delays[2]).toBe(400); + }); + + it("respects maxDelayMs cap so backoff never exceeds ceiling", async () => { + const delays: number[] = []; + const origSetTimeout = globalThis.setTimeout; + + vi.spyOn(globalThis, "setTimeout").mockImplementation( + (cb: any, ms: number, ...args: any[]) => { + if (ms > 0) delays.push(ms); + return origSetTimeout(cb, 0, ...args); + } + ); + + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("network timeout")) + .mockRejectedValueOnce(new Error("network timeout")) + .mockResolvedValueOnce({ txHash: "done" }); + + const promise = executeWithRetry(fn, { + maxAttempts: 3, + baseDelayMs: 2000, + maxDelayMs: 1500, // cap is lower than baseDelay * 2^0 + }); + + await vi.runAllTimersAsync(); + await promise; + + expect(delays.length).toBe(2); + expect(delays[0]).toBeLessThanOrEqual(1500); + expect(delays[1]).toBeLessThanOrEqual(1500); + }); + }); + + // ========================================================================= + // 4. Max Retries Scenarios + // ========================================================================= + describe("Max Retries Limits", () => { + it("respects maxAttempts limit and does not exceed configured ceiling", async () => { + const fn = vi.fn().mockRejectedValue(new Error("connection reset")); + + let caught: any; + const promise = withRetry(fn, 5, 20).catch((e) => { + caught = e; + }); + + await vi.runAllTimersAsync(); + await promise; + + expect(fn).toHaveBeenCalledTimes(5); + expect(caught).toBeDefined(); + expect(caught.message).toContain("connection reset"); + }); + + it("invokes onRetry hook with attempt index, error, and delay for each retry", async () => { + const hookCalls: Array<{ attempt: number; error: Error; delayMs: number }> = []; + + const err = new Error("network timeout"); + const fn = vi + .fn() + .mockRejectedValueOnce(err) + .mockRejectedValueOnce(err) + .mockResolvedValueOnce({ txHash: "ok" }); + + const promise = executeWithRetry(fn, { + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 1000, + onRetry: (attempt, error, delayMs) => { + hookCalls.push({ attempt, error: error as Error, delayMs }); + }, + }); + + await vi.runAllTimersAsync(); + await promise; + + expect(hookCalls).toHaveLength(2); + expect(hookCalls[0].attempt).toBe(1); + expect(hookCalls[0].error).toBe(err); + expect(hookCalls[0].delayMs).toBeGreaterThanOrEqual(100); + + expect(hookCalls[1].attempt).toBe(2); + expect(hookCalls[1].error).toBe(err); + expect(hookCalls[1].delayMs).toBeGreaterThanOrEqual(200); + }); + + it("integrates with StellarSplitClient.pay retry configuration", async () => { + const retryAttempts: number[] = []; + + const client = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: validNetworkPassphrase, + contractId: validContractId, + retry: { + maxAttempts: 3, + baseDelayMs: 50, + maxDelayMs: 200, + onRetry: (attempt) => retryAttempts.push(attempt), + }, + }); + + let callCount = 0; + vi.spyOn(client as any, "_submitTx").mockImplementation(async () => { + callCount++; + if (callCount < 3) { + throw new RpcError("503 Service Unavailable", 503, "http://localhost:8000"); + } + return { + txHash: "client-retry-tx", + returnValue: { value: () => 100n }, + }; + }); + + const promise = client.pay({ + invoiceId: "1", + amount: 100n, + payer: randomAddress(), + }); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.txHash).toBe("client-retry-tx"); + expect(callCount).toBe(3); + expect(retryAttempts).toEqual([1, 2]); + }); + }); +}); diff --git a/test/walletIntegration.test.ts b/test/walletIntegration.test.ts new file mode 100644 index 0000000..d6445bd --- /dev/null +++ b/test/walletIntegration.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + Keypair, + StrKey, + TransactionBuilder, + Account, + Operation, + Asset, + Networks, +} from "@stellar/stellar-sdk"; +import { StellarSplitClient } from "../src/client.js"; +import { WalletSessionManager } from "../src/wallets/WalletSessionManager.js"; +import { FreighterAdapter, FreighterNotInstalledError } from "../src/wallets/adapters/FreighterAdapter.js"; +import { LobstrAdapter } from "../src/wallets/adapters/LobstrAdapter.js"; +import { XBullAdapter } from "../src/wallets/adapters/XBullAdapter.js"; +import { LedgerAdapter } from "../src/adapters/ledger.js"; +import { WalletConnectAdapter } from "../src/adapters/walletconnect.js"; +import type { WalletAdapter } from "../src/adapters/types.js"; + +const TEST_NETWORK = Networks.TESTNET; +const validContractId = StrKey.encodeContract(Keypair.random().rawPublicKey()); + +// Generate real Stellar keypairs for tests +const freighterKeypair = Keypair.random(); +const lobstrKeypair = Keypair.random(); +const xbullKeypair = Keypair.random(); +const ledgerKeypair = Keypair.random(); +const wcKeypair = Keypair.random(); + +function makeSampleTxXdr(sourceAddress: string): string { + const tx = new TransactionBuilder(new Account(sourceAddress, "100"), { + fee: "100", + networkPassphrase: TEST_NETWORK, + }) + .addOperation( + Operation.payment({ + destination: Keypair.random().publicKey(), + asset: Asset.native(), + amount: "50", + }) + ) + .setTimeout(30) + .build(); + + return tx.toXDR(); +} + +describe("Integration tests for all wallet types (#1038)", () => { + let sessionManager: WalletSessionManager; + let client: StellarSplitClient; + + beforeEach(() => { + // In-memory mock localStorage + const store: Record = {}; + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const k in store) delete store[k]; + }), + }); + + // Clean mock window + vi.stubGlobal("window", {}); + + sessionManager = new WalletSessionManager(); + + client = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: TEST_NETWORK, + contractId: validContractId, + }); + }); + + afterEach(() => { + sessionManager.disconnect(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + // ========================================================================= + // 1. Test Each Wallet & Connections + // ========================================================================= + describe("Connections & Wallet Detection", () => { + it("connects to FreighterAdapter and integrates with WalletSessionManager and client", async () => { + (window as any).freighter = { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue(freighterKeypair.publicKey()), + signTransaction: vi.fn().mockResolvedValue("signed-freighter-xdr"), + }; + + const adapter = new FreighterAdapter(); + const detected = sessionManager.detect(); + expect(detected.some((a) => (a as any).name === "Freighter")).toBe(true); + + const connectedEvent = new Promise((resolve) => + sessionManager.once("connected", resolve) + ); + + const address = await sessionManager.connect(adapter as any); + expect(address).toBe(freighterKeypair.publicKey()); + expect(sessionManager.getConnectedAccount()).toBe(freighterKeypair.publicKey()); + expect(sessionManager.getWalletName()).toBe("Freighter"); + + const evt = await connectedEvent; + expect(evt.walletName).toBe("Freighter"); + expect(evt.address).toBe(freighterKeypair.publicKey()); + + // Connect to StellarSplitClient + const clientAddress = await client.connectWallet(adapter as any); + expect(clientAddress).toBe(freighterKeypair.publicKey()); + }); + + it("connects to LobstrAdapter and handles account changes", async () => { + let accountChangeCb: ((data: any) => void) | null = null; + + (window as any).lobstr = { + connect: vi.fn().mockResolvedValue({ publicKey: lobstrKeypair.publicKey() }), + signTransaction: vi.fn().mockResolvedValue({ signedXDR: "signed-lobstr-xdr" }), + on: vi.fn((event, handler) => { + if (event === "accountChanged") accountChangeCb = handler; + }), + off: vi.fn(), + }; + + const adapter = new LobstrAdapter(); + const detected = sessionManager.detect(); + expect(detected.some((a) => (a as any).name === "LOBSTR")).toBe(true); + + const address = await sessionManager.connect(adapter as any); + expect(address).toBe(lobstrKeypair.publicKey()); + + // Test account change event propagation + const newKey = Keypair.random().publicKey(); + const accountChangedPromise = new Promise((resolve) => + sessionManager.once("accountChanged", resolve) + ); + + accountChangeCb?.({ publicKey: newKey }); + const changedAddr = await accountChangedPromise; + expect(changedAddr).toBe(newKey); + expect(sessionManager.getConnectedAccount()).toBe(newKey); + }); + + it("connects to XBullAdapter and verifies session state", async () => { + (window as any).xbull = { + getPublicKey: vi.fn().mockResolvedValue(xbullKeypair.publicKey()), + connect: vi.fn().mockResolvedValue({ public_key: xbullKeypair.publicKey() }), + sign: vi.fn().mockResolvedValue("signed-xbull-xdr"), + onAccountChange: vi.fn(() => vi.fn()), + }; + + const adapter = new XBullAdapter(); + const detected = sessionManager.detect(); + expect(detected.some((a) => (a as any).name === "xBull")).toBe(true); + + const address = await sessionManager.connect(adapter as any); + expect(address).toBe(xbullKeypair.publicKey()); + expect(sessionManager.getWalletName()).toBe("xBull"); + }); + + it("connects to LedgerAdapter and retrieves device address", async () => { + const adapter = new LedgerAdapter({ + skipFirmwareCheck: true, + }); + + vi.spyOn(adapter, "getAddress").mockResolvedValue(ledgerKeypair.publicKey()); + const address = await adapter.getAddress(); + expect(address).toBe(ledgerKeypair.publicKey()); + + const clientAddress = await client.connectWallet(adapter); + expect(clientAddress).toBe(ledgerKeypair.publicKey()); + }); + + it("connects to WalletConnectAdapter and verifies session setup", async () => { + const mockWcClient = { request: vi.fn() }; + const adapter = new WalletConnectAdapter({ + client: mockWcClient, + topic: "test-wc-topic", + chainId: "stellar:testnet", + address: wcKeypair.publicKey(), + }); + + const address = await adapter.getAddress(); + expect(address).toBe(wcKeypair.publicKey()); + + const clientAddress = await client.connectWallet(adapter); + expect(clientAddress).toBe(wcKeypair.publicKey()); + }); + + it("handles wallet disconnection cleanly and emits disconnected event", async () => { + (window as any).freighter = { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue(freighterKeypair.publicKey()), + }; + + const adapter = new FreighterAdapter(); + await sessionManager.connect(adapter as any); + expect(sessionManager.getConnectedAccount()).toBe(freighterKeypair.publicKey()); + + const disconnectedEvent = new Promise((resolve) => + sessionManager.once("disconnected", resolve) + ); + + sessionManager.disconnect(); + expect(sessionManager.getConnectedAccount()).toBeNull(); + expect(sessionManager.getWalletName()).toBeNull(); + + const evt = await disconnectedEvent; + expect(evt.walletName).toBe("Freighter"); + }); + }); + + // ========================================================================= + // 2. Test Transactions for All Wallet Types + // ========================================================================= + describe("Transactions Signing & Submission", () => { + it("signs transaction with FreighterAdapter", async () => { + const sampleXdr = makeSampleTxXdr(freighterKeypair.publicKey()); + (window as any).freighter = { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue(freighterKeypair.publicKey()), + signTransaction: vi.fn().mockResolvedValue("signed-freighter-sample-xdr"), + }; + + const adapter = new FreighterAdapter(); + const signed = await adapter.signTransaction(sampleXdr, TEST_NETWORK); + + expect(signed).toBe("signed-freighter-sample-xdr"); + expect((window as any).freighter.signTransaction).toHaveBeenCalledWith( + sampleXdr, + TEST_NETWORK + ); + }); + + it("signs transaction with LobstrAdapter", async () => { + const sampleXdr = makeSampleTxXdr(lobstrKeypair.publicKey()); + (window as any).lobstr = { + connect: vi.fn().mockResolvedValue({ publicKey: lobstrKeypair.publicKey() }), + signTransaction: vi.fn().mockResolvedValue({ signedXDR: "signed-lobstr-sample-xdr" }), + }; + + const adapter = new LobstrAdapter(); + const signed = await adapter.signTransaction(sampleXdr, TEST_NETWORK); + + expect(signed).toBe("signed-lobstr-sample-xdr"); + expect((window as any).lobstr.signTransaction).toHaveBeenCalledWith(sampleXdr); + }); + + it("signs transaction with XBullAdapter", async () => { + const sampleXdr = makeSampleTxXdr(xbullKeypair.publicKey()); + (window as any).xbull = { + getPublicKey: vi.fn().mockResolvedValue(xbullKeypair.publicKey()), + connect: vi.fn().mockResolvedValue({ public_key: xbullKeypair.publicKey() }), + sign: vi.fn().mockResolvedValue({ xdr: "signed-xbull-sample-xdr" }), + onAccountChange: vi.fn(() => vi.fn()), + }; + + const adapter = new XBullAdapter(); + await adapter.connect(); + const signed = await adapter.signTransaction(sampleXdr, TEST_NETWORK); + + expect(signed).toBe("signed-xbull-sample-xdr"); + expect((window as any).xbull.sign).toHaveBeenCalledWith({ + xdr: sampleXdr, + publicKey: xbullKeypair.publicKey(), + }); + }); + + it("signs transaction with LedgerAdapter", async () => { + const sampleXdr = makeSampleTxXdr(ledgerKeypair.publicKey()); + const adapter = new LedgerAdapter({ skipFirmwareCheck: true }); + + vi.spyOn(adapter, "signTransaction").mockResolvedValue("signed-ledger-sample-xdr"); + const signed = await adapter.signTransaction(sampleXdr, TEST_NETWORK); + + expect(signed).toBe("signed-ledger-sample-xdr"); + }); + + it("signs transaction with WalletConnectAdapter", async () => { + const sampleXdr = makeSampleTxXdr(wcKeypair.publicKey()); + const mockWcClient = { + request: vi.fn().mockResolvedValue("signed-wc-sample-xdr"), + }; + + const adapter = new WalletConnectAdapter({ + client: mockWcClient, + topic: "topic-wc", + chainId: "stellar:testnet", + address: wcKeypair.publicKey(), + }); + + const signed = await adapter.signTransaction(sampleXdr, TEST_NETWORK); + expect(signed).toBe("signed-wc-sample-xdr"); + expect(mockWcClient.request).toHaveBeenCalledWith({ + topic: "topic-wc", + chainId: "stellar:testnet", + request: { + method: "stellar_signXDR", + params: { xdr: sampleXdr, network: TEST_NETWORK }, + }, + }); + }); + + it("submits payment through client configured with wallet adapter", async () => { + const mockAdapter: WalletAdapter = { + getAddress: vi.fn().mockResolvedValue(freighterKeypair.publicKey()), + signTransaction: vi.fn().mockResolvedValue("signed-envelope-xdr"), + }; + + const customClient = new StellarSplitClient({ + rpcUrl: "http://localhost:8000", + networkPassphrase: TEST_NETWORK, + contractId: validContractId, + adapter: mockAdapter, + }); + + vi.spyOn(customClient as any, "_submitTx").mockResolvedValue({ + txHash: "tx-wallet-payment-confirmed", + returnValue: { value: () => 100n }, + }); + + const res = await customClient.pay({ + invoiceId: "1", + amount: 50n, + payer: freighterKeypair.publicKey(), + }); + + expect(res.txHash).toBe("tx-wallet-payment-confirmed"); + }); + }); + + // ========================================================================= + // 3. Test Error Cases for All Wallet Types + // ========================================================================= + describe("Error Cases", () => { + it("throws specific error when Freighter is not installed", async () => { + (window as any).freighter = undefined; + const adapter = new FreighterAdapter(); + + await expect(adapter.connect()).rejects.toThrow(FreighterNotInstalledError); + await expect(adapter.sign("xdr", TEST_NETWORK)).rejects.toThrow( + FreighterNotInstalledError + ); + }); + + it("throws specific error when LOBSTR is not installed", async () => { + (window as any).lobstr = undefined; + const adapter = new LobstrAdapter(); + + await expect(adapter.connect()).rejects.toThrow("LOBSTR wallet not installed"); + await expect(adapter.sign("xdr")).rejects.toThrow("LOBSTR wallet not installed"); + }); + + it("throws specific error when xBull is not installed", async () => { + (window as any).xbull = undefined; + const adapter = new XBullAdapter(); + + await expect(adapter.connect()).rejects.toThrow("xBull wallet not installed"); + await expect(adapter.sign("xdr")).rejects.toThrow( + "xBull wallet not connected" + ); + }); + + it("handles user rejection when signing in Freighter", async () => { + (window as any).freighter = { + isConnected: vi.fn().mockResolvedValue(true), + getPublicKey: vi.fn().mockResolvedValue(freighterKeypair.publicKey()), + signTransaction: vi.fn().mockRejectedValue(new Error("User declined transaction")), + }; + + const adapter = new FreighterAdapter(); + await expect(adapter.signTransaction("sample-xdr", TEST_NETWORK)).rejects.toThrow( + "User declined transaction" + ); + }); + + it("handles user rejection when signing in LOBSTR", async () => { + (window as any).lobstr = { + connect: vi.fn().mockResolvedValue({ publicKey: lobstrKeypair.publicKey() }), + signTransaction: vi.fn().mockRejectedValue(new Error("Transaction rejected by user")), + }; + + const adapter = new LobstrAdapter(); + await expect(adapter.signTransaction("sample-xdr", TEST_NETWORK)).rejects.toThrow( + "Transaction rejected by user" + ); + }); + + it("handles user rejection when signing in WalletConnect", async () => { + const mockWcClient = { + request: vi.fn().mockRejectedValue(new Error("User rejected signing request")), + }; + + const adapter = new WalletConnectAdapter({ + client: mockWcClient, + topic: "topic-wc", + chainId: "stellar:testnet", + address: wcKeypair.publicKey(), + }); + + await expect(adapter.signTransaction("sample-xdr", TEST_NETWORK)).rejects.toThrow( + "User rejected signing request" + ); + }); + + it("handles Ledger hardware disconnection error gracefully", async () => { + const adapter = new LedgerAdapter({ skipFirmwareCheck: true }); + vi.spyOn(adapter, "signTransaction").mockRejectedValue( + new Error("Ledger device disconnected (0x6804)") + ); + + await expect(adapter.signTransaction("sample-xdr", TEST_NETWORK)).rejects.toThrow( + "Ledger device disconnected" + ); + }); + }); +});