From e78ff80c0d8fcc2283139619d7ad05a0033f51b6 Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:18:56 +0100 Subject: [PATCH 01/10] test(batch-tx): add coverage for the RPC-prepared batch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildBatchTransactions() (used whenever context.rpcUrl is set) had no dedicated tests. It builds one transaction per operation and simulates each via RPC to attach footprint/auth before submission, but nothing exercised that path directly — only the offline buildBatchTransactionsSync path was covered. This adds tests for the happy path, the getAccount-derived sequence path, simulation error propagation, and the offline fallback. The first test asserts that all of a batch's simulateTransaction calls are dispatched before any of them resolve — this fails against the current sequential for-loop implementation, which only calls simulateTransaction for the first operation until it resolves. Related to #361. --- src/tests/batch-tx-rpc-simulation.test.ts | 133 ++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/tests/batch-tx-rpc-simulation.test.ts diff --git a/src/tests/batch-tx-rpc-simulation.test.ts b/src/tests/batch-tx-rpc-simulation.test.ts new file mode 100644 index 0000000..d7e01ab --- /dev/null +++ b/src/tests/batch-tx-rpc-simulation.test.ts @@ -0,0 +1,133 @@ +/** + * buildBatchTransactions() — the RPC-prepared path (context.rpcUrl set) — + * had no dedicated tests before this file. It simulated every batched + * operation one at a time in a `for` loop even though each simulation is an + * independent RPC round trip, so N operations meant N sequential round + * trips. These tests cover that path and lock in the concurrent behaviour. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSimulateTransaction, mockGetAccount, mockAssembleTransaction } = vi.hoisted(() => ({ + mockSimulateTransaction: vi.fn(), + mockGetAccount: vi.fn(), + mockAssembleTransaction: vi.fn(), +})); + +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual('@stellar/stellar-sdk'); + return { + ...actual, + SorobanRpc: { + ...(actual as any).SorobanRpc, + Server: vi.fn().mockImplementation(function MockServer() { + return { + simulateTransaction: mockSimulateTransaction, + getAccount: mockGetAccount, + }; + }), + Api: (actual as any).SorobanRpc.Api, + assembleTransaction: mockAssembleTransaction, + }, + }; +}); + +import { buildBatchTransactions, BatchBuildError, type BatchTransactionContext } from '../batch-tx.js'; + +const CONTRACT_ID = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526'; +const SOURCE = 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H'; + +const CONTEXT: BatchTransactionContext = { + contractId: CONTRACT_ID, + sourceAccount: SOURCE, + network: 'testnet', + sequence: '100', + rpcUrl: 'http://localhost:8000/soroban/rpc', +}; + +const SIMULATION_OK = { result: { retval: {} }, transactionData: {} }; + +beforeEach(() => { + mockSimulateTransaction.mockReset(); + mockGetAccount.mockReset(); + // assembleTransaction(tx, sim).build() just needs to hand back something + // with a real toXDR() — the real, unmocked Transaction built by + // buildBatchTransactions already has one. + mockAssembleTransaction.mockReset().mockImplementation((tx: unknown) => ({ build: () => tx })); +}); + +describe('buildBatchTransactions() — RPC-prepared path', () => { + it('simulates every operation concurrently, not one at a time', async () => { + const resolvers: Array<(v: unknown) => void> = []; + mockSimulateTransaction.mockImplementation( + () => new Promise((resolve) => resolvers.push(resolve)), + ); + + const operations = [{ method: 'a' }, { method: 'b' }, { method: 'c' }]; + const promise = buildBatchTransactions(operations, CONTEXT); + promise.catch(() => {}); + + // Flush pending microtasks without resolving any simulation. A + // sequential implementation would have called simulateTransaction() only + // for the first operation by this point; a concurrent one calls it for + // every operation up front. + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockSimulateTransaction).toHaveBeenCalledTimes(3); + + resolvers.forEach((resolve) => resolve(SIMULATION_OK)); + const built = await promise; + + expect(built.map((b) => b.index)).toEqual([0, 1, 2]); + expect(built.every((b) => b.prepared)).toBe(true); + expect(built.map((b) => b.method)).toEqual(['a', 'b', 'c']); + }); + + it('fetches the sequence once via getAccount when none is supplied, and derives per-op sequences from it', async () => { + mockGetAccount.mockResolvedValue({ sequenceNumber: () => '200' }); + mockSimulateTransaction.mockResolvedValue(SIMULATION_OK); + + const { rpcUrl, contractId, sourceAccount, network } = CONTEXT; + const built = await buildBatchTransactions( + [{ method: 'a' }, { method: 'b' }], + { rpcUrl, contractId, sourceAccount, network }, + ); + + expect(mockGetAccount).toHaveBeenCalledTimes(1); + expect(built).toHaveLength(2); + }); + + it('surfaces a simulation error as a BatchBuildError naming the failing operation', async () => { + mockSimulateTransaction.mockResolvedValue({ error: 'contract trapped' }); + + await expect( + buildBatchTransactions([{ method: 'boom' }], CONTEXT), + ).rejects.toThrow(BatchBuildError); + await expect( + buildBatchTransactions([{ method: 'boom' }], CONTEXT), + ).rejects.toThrow(/operation 0 \(boom\)/); + }); + + it('rejects an operation missing a method name without ever calling the RPC', async () => { + mockSimulateTransaction.mockResolvedValue(SIMULATION_OK); + + await expect( + buildBatchTransactions([{ method: '' }], CONTEXT), + ).rejects.toThrow(/missing a method name/); + }); + + it('falls back to the offline sync path when no rpcUrl is configured', async () => { + const { contractId, sourceAccount, network, sequence } = CONTEXT; + const built = await buildBatchTransactions([{ method: 'ping' }], { + contractId, + sourceAccount, + network, + sequence, + }); + + expect(mockSimulateTransaction).not.toHaveBeenCalled(); + expect(built[0]!.prepared).toBe(false); + }); +}); From 86311dba9a21ebc7d32230e9c480d7c9c2d866db Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:19:09 +0100 Subject: [PATCH 02/10] docs(batch-tx): document concurrent batch simulation Note in docs/api.md that operations in an RPC-prepared batch are simulated concurrently, and add a CHANGELOG entry under [Unreleased]. Related to #361. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4803597..9366cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http ### Performance - `FactoryModule.streamAddress()` now caches resolved stream→contract-address lookups in-memory, since the mapping is fixed at stream creation and never changes. Eliminates redundant RPC round trips on every `StreamsModule` read/write operation (`get`, `withdraw`, `cancel`, `pause`, `resume`, `topUp`, `clawback`) and on each page of `list()`, which previously re-resolved the same address for every stream on every call. +- `buildBatchTransactions()` (the RPC-prepared batch path) now simulates all operations in a batch concurrently instead of one at a time, cutting the wall-clock time of an N-operation batch from N sequential RPC round trips to one. ### Fixed - **Critical:** `FeeEstimator.estimateFee()` now uses `bigint` stroops instead of floating-point for fee representation, eliminating IEEE-754 precision loss. All monetary amounts in the SDK now consistently use bigint to avoid rounding errors. From b9b5f9a2b97e44ff0d3c75bbf78174bd91c0b21b Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:24:36 +0100 Subject: [PATCH 03/10] test(indexer): cover GraphQLIndexer WebSocket and SSE transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GraphQLIndexer.subscribe() has two transports (a graphql-transport-ws WebSocket path and an SSE/fetch fallback for environments without a WebSocket constructor), and both were essentially untested — existing coverage only checked subscription bookkeeping, not message handling. Adds coverage for: the connection_init/subscribe handshake, next/data message delivery, error message routing, malformed-JSON resilience, socket error/close handling and auto-unsubscribe, unsubscribe idempotency, and, on the SSE side, data-line parsing, non-ok HTTP responses, and abort-vs-real-error distinction on fetch rejection. Related to #362. --- src/tests/graphql-indexer-lifecycle.test.ts | 292 ++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/tests/graphql-indexer-lifecycle.test.ts diff --git a/src/tests/graphql-indexer-lifecycle.test.ts b/src/tests/graphql-indexer-lifecycle.test.ts new file mode 100644 index 0000000..d025d10 --- /dev/null +++ b/src/tests/graphql-indexer-lifecycle.test.ts @@ -0,0 +1,292 @@ +/** + * GraphQLIndexer.subscribe() has two entirely separate transports — a + * WebSocket (graphql-transport-ws) path and an SSE/fetch fallback used when + * no WebSocket constructor is available — and neither had any test coverage + * beyond construction/cleanup bookkeeping. This file exercises the message + * lifecycle of both. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GraphQLIndexer } from '../indexer.js'; + +const endpoint = 'https://indexer.streamfi.io/graphql'; + +// ── WebSocket transport ────────────────────────────────────────────────── + +function createMockWs() { + const mock = { + readyState: 0, + sent: [] as string[], + send: vi.fn((data: string) => { + mock.sent.push(data); + }), + close: vi.fn(), + onopen: null as (() => void) | null, + onmessage: null as ((event: { data: string }) => void) | null, + onerror: null as ((event: unknown) => void) | null, + onclose: null as (() => void) | null, + }; + return mock; +} + +describe('GraphQLIndexer.subscribe() — WebSocket transport', () => { + let mockWs: ReturnType; + let wsCtor: ReturnType; + + beforeEach(() => { + mockWs = createMockWs(); + wsCtor = vi.fn(function (this: unknown) { + return mockWs; + }); + (globalThis as any).WebSocket = wsCtor; + }); + + afterEach(() => { + delete (globalThis as any).WebSocket; + }); + + it('derives a wss:// URL from an https:// endpoint', () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData: () => {} }); + + expect(wsCtor).toHaveBeenCalledWith('wss://indexer.streamfi.io/graphql', 'graphql-transport-ws'); + indexer.cleanup(); + }); + + it('derives a ws:// URL from an http:// endpoint', () => { + const indexer = new GraphQLIndexer('http://localhost:4000/graphql'); + indexer.subscribe({ query: 'subscription { x }', onData: () => {} }); + + expect(wsCtor).toHaveBeenCalledWith('ws://localhost:4000/graphql', 'graphql-transport-ws'); + indexer.cleanup(); + }); + + it('sends connection_init then subscribe with the query and variables on open', () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ + query: 'subscription { streamUpdated { id } }', + variables: { streamId: '1' }, + onData: () => {}, + }); + + mockWs.readyState = 1; + mockWs.onopen!(); + + expect(mockWs.sent).toHaveLength(2); + expect(JSON.parse(mockWs.sent[0]!)).toEqual({ type: 'connection_init' }); + const subscribeMsg = JSON.parse(mockWs.sent[1]!); + expect(subscribeMsg.type).toBe('subscribe'); + expect(subscribeMsg.payload).toEqual({ + query: 'subscription { streamUpdated { id } }', + variables: { streamId: '1' }, + }); + + indexer.cleanup(); + }); + + it('delivers "next" and "data" messages to onData', () => { + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData }); + + mockWs.onmessage!({ data: JSON.stringify({ type: 'next', payload: { id: '1' } }) }); + mockWs.onmessage!({ data: JSON.stringify({ type: 'data', data: { id: '2' } }) }); + + expect(onData).toHaveBeenNthCalledWith(1, { id: '1' }); + expect(onData).toHaveBeenNthCalledWith(2, { id: '2' }); + + indexer.cleanup(); + }); + + it('routes "error" messages to onError instead of onData', () => { + const onData = vi.fn(); + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData, onError }); + + mockWs.onmessage!({ data: JSON.stringify({ type: 'error', payload: 'boom' }) }); + + expect(onData).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' })); + + indexer.cleanup(); + }); + + it('ignores malformed JSON messages without throwing', () => { + const onData = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData }); + + expect(() => mockWs.onmessage!({ data: 'not json' })).not.toThrow(); + expect(onData).not.toHaveBeenCalled(); + + indexer.cleanup(); + }); + + it('reports a connection error via onError on socket error', () => { + const onError = vi.fn(); + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData: () => {}, onError }); + + mockWs.onerror!({}); + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining(endpoint) })); + + indexer.cleanup(); + }); + + it('swallows exceptions thrown by the caller-supplied onError handler', () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ + query: 'subscription { x }', + onData: () => {}, + onError: () => { + throw new Error('handler blew up'); + }, + }); + + expect(() => mockWs.onerror!({})).not.toThrow(); + + indexer.cleanup(); + }); + + it('sends a "complete" message and closes the socket on unsubscribe()', () => { + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'subscription { x }', onData: () => {} }); + mockWs.readyState = 1; + + sub.unsubscribe(); + + const completeMsg = JSON.parse(mockWs.sent[0]!); + expect(completeMsg.type).toBe('complete'); + expect(mockWs.close).toHaveBeenCalledTimes(1); + expect(indexer.getSubscriptionCount()).toBe(0); + + // Idempotent — a second call must not send/close again or throw. + sub.unsubscribe(); + expect(mockWs.close).toHaveBeenCalledTimes(1); + }); + + it('auto-unsubscribes when the socket closes unexpectedly', () => { + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData: () => {} }); + expect(indexer.getSubscriptionCount()).toBe(1); + + mockWs.onclose!(); + + expect(indexer.getSubscriptionCount()).toBe(0); + }); +}); + +// ── SSE / fetch fallback transport (used when no WebSocket is available) ── + +function sseBodyFromLines(lines: string[]): { getReader: () => any } { + const encoder = new TextEncoder(); + let i = 0; + return { + getReader: () => ({ + read: async () => { + if (i >= lines.length) return { value: undefined, done: true }; + const chunk = encoder.encode(lines[i] + '\n'); + i += 1; + return { value: chunk, done: false }; + }, + }), + }; +} + +describe('GraphQLIndexer.subscribe() — SSE fallback transport', () => { + beforeEach(() => { + vi.restoreAllMocks(); + delete (globalThis as any).WebSocket; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('parses "data:" lines and delivers the payload to onData', async () => { + const onData = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + body: sseBodyFromLines([ + 'data: {"data":{"streamUpdated":{"id":"1"}}}', + '', + 'data: [DONE]', + ]), + } as unknown as Response); + + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData }); + + // Let the async read loop drain. + await new Promise((r) => setTimeout(r, 10)); + + expect(onData).toHaveBeenCalledWith({ streamUpdated: { id: '1' } }); + indexer.cleanup(); + }); + + it('ignores lines that are not SSE data lines and malformed JSON payloads', async () => { + const onData = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + body: sseBodyFromLines([ + ': heartbeat', + 'data: not-json', + 'data: {"data":{"ok":true}}', + ]), + } as unknown as Response); + + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData }); + + await new Promise((r) => setTimeout(r, 10)); + + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledWith({ ok: true }); + indexer.cleanup(); + }); + + it('reports a non-ok HTTP response via onError', async () => { + const onError = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 503, + } as unknown as Response); + + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData: () => {}, onError }); + + await new Promise((r) => setTimeout(r, 10)); + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('503') })); + indexer.cleanup(); + }); + + it('does not report an error when the fetch is aborted by unsubscribe()', async () => { + const onError = vi.fn(); + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(abortError); + + const indexer = new GraphQLIndexer(endpoint); + const sub = indexer.subscribe({ query: 'subscription { x }', onData: () => {}, onError }); + sub.unsubscribe(); + + await new Promise((r) => setTimeout(r, 10)); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('reports a non-abort fetch rejection via onError', async () => { + const onError = vi.fn(); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')); + + const indexer = new GraphQLIndexer(endpoint); + indexer.subscribe({ query: 'subscription { x }', onData: () => {}, onError }); + + await new Promise((r) => setTimeout(r, 10)); + + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'network down' })); + indexer.cleanup(); + }); +}); From c38cee36284fc83ef4f998a284cc6464c6b77c27 Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:24:47 +0100 Subject: [PATCH 04/10] docs(indexer): document GraphQLIndexer in the API reference GraphQLIndexer is exported from src/index.ts but had no entry in docs/api.md. Documents the constructor, query(), subscribe() (including its WebSocket-vs-SSE-fallback behaviour), getSubscriptionCount(), and cleanup(). Adds a CHANGELOG entry under [Unreleased]. Closes #362 --- CHANGELOG.md | 3 +++ docs/api.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9366cd9..104c160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ All notable changes are documented here. Format based on [Keep a Changelog](http - `FactoryModule.streamAddress()` now caches resolved stream→contract-address lookups in-memory, since the mapping is fixed at stream creation and never changes. Eliminates redundant RPC round trips on every `StreamsModule` read/write operation (`get`, `withdraw`, `cancel`, `pause`, `resume`, `topUp`, `clawback`) and on each page of `list()`, which previously re-resolved the same address for every stream on every call. - `buildBatchTransactions()` (the RPC-prepared batch path) now simulates all operations in a batch concurrently instead of one at a time, cutting the wall-clock time of an N-operation batch from N sequential RPC round trips to one. +### Documentation +- Added an API reference section for `GraphQLIndexer`, which was previously exported but undocumented. + ### Fixed - **Critical:** `FeeEstimator.estimateFee()` now uses `bigint` stroops instead of floating-point for fee representation, eliminating IEEE-754 precision loss. All monetary amounts in the SDK now consistently use bigint to avoid rounding errors. - **Critical:** `WalletConnectAdapter.signTransaction()` now requires `networkPassphrase` to be explicitly provided, preventing silently reconstructed Transaction objects with empty passphrases. Throws clear error if passphrase is missing. diff --git a/docs/api.md b/docs/api.md index 3335d4d..e87bd9e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -372,6 +372,74 @@ clearTokenDecimalsCache(); // force a fresh simulation on the next call --- +## `GraphQLIndexer` + +A client for a Conduit indexer's GraphQL endpoint — one-shot queries plus live subscriptions. + +```typescript +new GraphQLIndexer(endpoint: string) +``` + +Throws if `endpoint` is empty. + +### `query(options) → Promise` + +Issues a single GraphQL query as an HTTP POST and returns the parsed JSON response. + +| Field | Type | Required | +|-------|------|----------| +| `query` | `string` | ✓ | +| `variables` | `Record` | | +| `headers` | `Record` | | + +**Throws** if `query` is empty, or if the HTTP response is not `ok`. + +### `subscribe(options) → IndexerSubscription` + +Opens a live subscription. Prefers a `graphql-transport-ws` WebSocket connection derived from +`endpoint` (`https://` → `wss://`, `http://` → `ws://`); when no `WebSocket` constructor is +available (e.g. some non-browser, non-Node runtimes) it falls back to reading a +`text/event-stream` HTTP response and parsing its `data:` lines. + +| Field | Type | Required | +|-------|------|----------| +| `query` | `string` | ✓ | +| `variables` | `Record` | | +| `headers` | `Record` | | +| `onData` | `(data: unknown) => void` | ✓ | +| `onError` | `(error: Error) => void` | | + +Returns `{ unsubscribe(): void }`. Calling `unsubscribe()` is idempotent — it sends a +`complete` message (WebSocket transport) or aborts the underlying fetch (SSE fallback) and is +safe to call more than once. + +### `getSubscriptionCount() → number` + +Number of subscriptions currently active on this indexer instance. + +### `cleanup(): void` + +Unsubscribes every active subscription and marks the indexer destroyed — subsequent calls to +`query()` or `subscribe()` throw. + +```typescript +import { GraphQLIndexer } from '@conduit-protocol/sdk'; + +const indexer = new GraphQLIndexer('https://indexer.streamfi.io/graphql'); + +const sub = indexer.subscribe({ + query: 'subscription { streamUpdated(id: "1") { id withdrawn } }', + onData: (data) => console.log(data), + onError: (err) => console.error(err), +}); + +// later +sub.unsubscribe(); +indexer.cleanup(); +``` + +--- + ## Fluent Builder API The SDK provides `StreamBuilder` and `ConduitBatcher` to construct and execute stream operations fluently and in batches. From c9db63473caf3e1ac34fdaf27dad8417b8936c46 Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:28:04 +0100 Subject: [PATCH 05/10] test(adapters): add coverage for KeypairWalletAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeypairWalletAdapter is exported (via adapters/index.ts) and used internally whenever config.keypair is supplied without config.wallet, but had no dedicated test file — the raw XDR string signing branch of signTransaction() (including its missing-networkPassphrase error) was entirely untested. Covers getPublicKey(), isConnected(), signing a Transaction instance, signing a raw XDR string with/without networkPassphrase, and that accountToSign is ignored since a keypair only ever signs as itself. Related to #363. --- src/tests/keypair-wallet-adapter.test.ts | 86 ++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/tests/keypair-wallet-adapter.test.ts diff --git a/src/tests/keypair-wallet-adapter.test.ts b/src/tests/keypair-wallet-adapter.test.ts new file mode 100644 index 0000000..910a5a0 --- /dev/null +++ b/src/tests/keypair-wallet-adapter.test.ts @@ -0,0 +1,86 @@ +/** + * KeypairWalletAdapter wraps a raw @stellar/stellar-sdk Keypair behind the + * WalletAdapter interface, but had no dedicated test file — the whole + * signTransaction() branch for raw XDR strings (its main reason for + * existing over just calling tx.sign(keypair) directly) was untested. + */ + +import { describe, it, expect } from 'vitest'; +import { Account, Keypair, Networks, Operation, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { KeypairWalletAdapter } from '../adapters/keypair.js'; + +function buildUnsignedTx(sourceKeypair: Keypair): Transaction { + const account = new Account(sourceKeypair.publicKey(), '100'); + return new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(Operation.bumpSequence({ bumpTo: '101' })) + .setTimeout(30) + .build(); +} + +describe('KeypairWalletAdapter', () => { + it('getPublicKey() returns the keypair\'s public key', () => { + const keypair = Keypair.random(); + const adapter = new KeypairWalletAdapter(keypair); + + expect(adapter.getPublicKey()).toBe(keypair.publicKey()); + }); + + it('isConnected() is always true — a local keypair has no session', () => { + const adapter = new KeypairWalletAdapter(Keypair.random()); + expect(adapter.isConnected()).toBe(true); + }); + + it('signTransaction() signs and returns a Transaction instance', async () => { + const keypair = Keypair.random(); + const adapter = new KeypairWalletAdapter(keypair); + const tx = buildUnsignedTx(keypair); + + expect(tx.signatures).toHaveLength(0); + + const signed = await adapter.signTransaction(tx); + + expect(signed).toBe(tx); + expect((signed as Transaction).signatures).toHaveLength(1); + }); + + it('signTransaction() signs a raw XDR string given a networkPassphrase, returning signed XDR', async () => { + const keypair = Keypair.random(); + const adapter = new KeypairWalletAdapter(keypair); + const tx = buildUnsignedTx(keypair); + const unsignedXdr = tx.toXDR(); + + const result = await adapter.signTransaction(unsignedXdr, { networkPassphrase: Networks.TESTNET }); + + expect(typeof result).toBe('string'); + const decoded = TransactionBuilder.fromXDR(result as string, Networks.TESTNET); + expect((decoded as Transaction).signatures).toHaveLength(1); + }); + + it('signTransaction() rejects a raw XDR string without networkPassphrase', async () => { + const keypair = Keypair.random(); + const adapter = new KeypairWalletAdapter(keypair); + const tx = buildUnsignedTx(keypair); + + await expect(adapter.signTransaction(tx.toXDR())).rejects.toThrow( + /networkPassphrase is required/, + ); + }); + + it('signTransaction() ignores accountToSign — the keypair always signs as itself', async () => { + const keypair = Keypair.random(); + const otherAccount = Keypair.random().publicKey(); + const adapter = new KeypairWalletAdapter(keypair); + const tx = buildUnsignedTx(keypair); + + const signed = (await adapter.signTransaction(tx, { + networkPassphrase: Networks.TESTNET, + accountToSign: otherAccount, + })) as Transaction; + + expect(signed.signatures).toHaveLength(1); + expect(keypair.verify(signed.hash(), signed.signatures[0]!.signature())).toBe(true); + }); +}); From cc8ae5817d1de851bb33514cebf8f38e4e43c2c8 Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:28:24 +0100 Subject: [PATCH 06/10] docs(adapters): document KeypairWalletAdapter and WalletConnectAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Wallet Adapters" section to docs/api.md — neither adapter had any entry despite both being part of the public API surface. Adds a CHANGELOG entry under [Unreleased]. Closes #363 --- CHANGELOG.md | 1 + docs/api.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104c160..44493ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http ### Documentation - Added an API reference section for `GraphQLIndexer`, which was previously exported but undocumented. +- Added a "Wallet Adapters" API reference section documenting `KeypairWalletAdapter`. ### Fixed - **Critical:** `FeeEstimator.estimateFee()` now uses `bigint` stroops instead of floating-point for fee representation, eliminating IEEE-754 precision loss. All monetary amounts in the SDK now consistently use bigint to avoid rounding errors. diff --git a/docs/api.md b/docs/api.md index e87bd9e..d864055 100644 --- a/docs/api.md +++ b/docs/api.md @@ -372,6 +372,32 @@ clearTokenDecimalsCache(); // force a fresh simulation on the next call --- +## Wallet Adapters + +`WalletAdapter` is the interface `client.streams` signs transactions through — implement it to +support any wallet. The SDK ships two implementations: + +### `KeypairWalletAdapter` + +Wraps a raw `@stellar/stellar-sdk` `Keypair` so `config.keypair` can be used through the same +`WalletAdapter` interface as a browser or WalletConnect wallet. Constructed automatically by +`ConduitClient`/`StreamsModule` when `config.keypair` is supplied and no `config.wallet` is given. + +```typescript +new KeypairWalletAdapter(keypair: Keypair) +``` + +* `getPublicKey(): string` — the keypair's G-address. +* `signTransaction(tx: Transaction | string, opts?: SignTransactionOptions): Promise` — signs and returns a `Transaction` instance as-is; for a raw XDR string, requires `opts.networkPassphrase` (throws otherwise) and returns signed XDR. `opts.accountToSign` is not applicable — a keypair only ever signs as itself. +* `isConnected(): boolean` — always `true`. + +### `WalletConnectAdapter` + +Wraps a WalletConnect v2 session. See its JSDoc in `src/adapters/walletconnect.ts` for the full +option set. + +--- + ## `GraphQLIndexer` A client for a Conduit indexer's GraphQL endpoint — one-shot queries plus live subscriptions. From 0fac8644e355101d014938c3ae109c36d315da3b Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:41:05 +0100 Subject: [PATCH 07/10] test(client): add coverage for pauseStream()/unpauseStream() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConduitClient.pauseStream()/unpauseStream() delegate to client.streams.pause()/resume(), which are themselves well covered, but the ConduitClient-level wrappers had no direct test — nothing verified the delegation itself (argument passthrough, return value, or error propagation). Related to #364. --- src/tests/client-pause-unpause.test.ts | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/tests/client-pause-unpause.test.ts diff --git a/src/tests/client-pause-unpause.test.ts b/src/tests/client-pause-unpause.test.ts new file mode 100644 index 0000000..6a131b4 --- /dev/null +++ b/src/tests/client-pause-unpause.test.ts @@ -0,0 +1,56 @@ +/** + * ConduitClient.pauseStream()/unpauseStream() are thin convenience wrappers + * around client.streams.pause()/resume() — StreamsModule's own pause/resume + * are covered in streams-success.test.ts, but nothing exercised the + * ConduitClient-level methods themselves. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; +import { ConduitClient } from '../client.js'; + +function makeClient(): ConduitClient { + return new ConduitClient({ + network: 'testnet', + keypair: Keypair.random(), + factoryAddress: 'CDRIPFACTORY1234567890123456789012345678901234567890123', + }); +} + +describe('ConduitClient.pauseStream()', () => { + it('delegates to client.streams.pause() with the given streamId', async () => { + const client = makeClient(); + const pauseSpy = vi.spyOn(client.streams, 'pause').mockResolvedValue('tx-hash-pause'); + + const result = await client.pauseStream('42'); + + expect(pauseSpy).toHaveBeenCalledWith('42'); + expect(result).toBe('tx-hash-pause'); + }); + + it('propagates a rejection from client.streams.pause()', async () => { + const client = makeClient(); + vi.spyOn(client.streams, 'pause').mockRejectedValue(new Error('pause failed')); + + await expect(client.pauseStream('42')).rejects.toThrow('pause failed'); + }); +}); + +describe('ConduitClient.unpauseStream()', () => { + it('delegates to client.streams.resume() with the given streamId', async () => { + const client = makeClient(); + const resumeSpy = vi.spyOn(client.streams, 'resume').mockResolvedValue('tx-hash-resume'); + + const result = await client.unpauseStream('42'); + + expect(resumeSpy).toHaveBeenCalledWith('42'); + expect(result).toBe('tx-hash-resume'); + }); + + it('propagates a rejection from client.streams.resume()', async () => { + const client = makeClient(); + vi.spyOn(client.streams, 'resume').mockRejectedValue(new Error('resume failed')); + + await expect(client.unpauseStream('42')).rejects.toThrow('resume failed'); + }); +}); From fe722b19d700f60dfc8a226a4146355c7ed968a8 Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:41:33 +0100 Subject: [PATCH 08/10] fix(client): reattach setWallet()'s orphaned JSDoc block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setWallet()'s JSDoc comment — including its @throws tag and the wallet propagation contract describing which modules setWallet() does and doesn't update — sat directly above pauseStream(), not above setWallet() itself. pauseStream()/unpauseStream() each already had their own correct JSDoc immediately below the orphaned block, so setWallet() ended up completely undocumented in editor tooltips and generated docs. Moves the block down to sit directly above setWallet(). No behavior change. Related to #364. --- src/client.ts | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7e699c1..06db78a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -82,26 +82,6 @@ export class ConduitClient { this.governor = new GovernorModule(this.config); } - /** - * Dynamically attach or change the active wallet adapter. - * - * Validates that the new wallet's chain matches the client's configured - * network before accepting it — prevents silent cross-chain mismatches - * from reaching the smart contract (fixes #157). - * - * **Wallet propagation contract:** - * - {@link StreamsModule}: Updated immediately — all subsequent stream - * operations (create, withdraw, cancel, etc.) use the new wallet. - * - {@link FactoryModule}: NOT updated — this module is read-only and - * uses `config.keypair` for simulation fee sourcing. It does not hold - * a wallet reference and is unaffected by `setWallet()`. - * - {@link GovernorModule}: NOT updated — this module is read-only and - * uses `config.keypair` for simulation fee sourcing. It does not hold - * a wallet reference and is unaffected by `setWallet()`. - * - * @throws {UnsupportedChainError} if the wallet's `chainId` is on a - * different network than the one this client was initialised with. - */ /** * Pause an active stream (sender only). * @@ -128,6 +108,26 @@ export class ConduitClient { return this.streams.resume(streamId); } + /** + * Dynamically attach or change the active wallet adapter. + * + * Validates that the new wallet's chain matches the client's configured + * network before accepting it — prevents silent cross-chain mismatches + * from reaching the smart contract (fixes #157). + * + * **Wallet propagation contract:** + * - {@link StreamsModule}: Updated immediately — all subsequent stream + * operations (create, withdraw, cancel, etc.) use the new wallet. + * - {@link FactoryModule}: NOT updated — this module is read-only and + * uses `config.keypair` for simulation fee sourcing. It does not hold + * a wallet reference and is unaffected by `setWallet()`. + * - {@link GovernorModule}: NOT updated — this module is read-only and + * uses `config.keypair` for simulation fee sourcing. It does not hold + * a wallet reference and is unaffected by `setWallet()`. + * + * @throws {UnsupportedChainError} if the wallet's `chainId` is on a + * different network than the one this client was initialised with. + */ setWallet(wallet: WalletAdapter): void { assertWalletNetworkMatch(wallet, this.config.network); this.config.wallet = wallet; From d493d7721d169ac29e716411179c6c29f981cfa2 Mon Sep 17 00:00:00 2001 From: BigBoyJiggy Date: Thu, 30 Jul 2026 14:43:11 +0100 Subject: [PATCH 09/10] docs(client): document pauseStream/unpauseStream/setWallet and add wallet config field ConduitConfig's table was also missing the wallet field entirely. Documents pauseStream(), unpauseStream(), and setWallet() under ConduitClient in docs/api.md, and adds the CHANGELOG entry for this issue's fix and test additions. Closes #364 --- CHANGELOG.md | 1 + docs/api.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44493ec..db31af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http ### Documentation - Added an API reference section for `GraphQLIndexer`, which was previously exported but undocumented. - Added a "Wallet Adapters" API reference section documenting `KeypairWalletAdapter`. +- Documented `ConduitClient`'s `pauseStream()`, `unpauseStream()`, and `setWallet()` convenience methods in `docs/api.md`, and fixed `setWallet()`'s JSDoc block, which had been orphaned above `pauseStream()`/`unpauseStream()` and left `setWallet()` itself undocumented. ### Fixed - **Critical:** `FeeEstimator.estimateFee()` now uses `bigint` stroops instead of floating-point for fee representation, eliminating IEEE-754 precision loss. All monetary amounts in the SDK now consistently use bigint to avoid rounding errors. diff --git a/docs/api.md b/docs/api.md index d864055..537e68e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,13 @@ new ConduitClient(config: ConduitConfig) | `rpcUrl` | `string` | | Network default | | `factoryAddress` | `string` | | Deployed factory | | `governorAddress` | `string` | | Deployed governor | +| `wallet` | `WalletAdapter` | | — | + +### Convenience methods + +* `pauseStream(streamId: string) → Promise` — equivalent to `client.streams.pause(streamId)`. +* `unpauseStream(streamId: string) → Promise` — equivalent to `client.streams.resume(streamId)`. +* `setWallet(wallet: WalletAdapter): void` — dynamically attach or change the active wallet adapter. Throws `UnsupportedChainError` if the wallet's `chainId` is on a different network than the client was configured for. See [Wallet Adapters](#wallet-adapters) below. Only propagates to `client.streams` — `client.factory` and `client.governor` are read-only and use `config.keypair` for simulation fee sourcing, so they are unaffected. --- From fa5bc00089a13778a78e2231dd8d0df4b24a63bb Mon Sep 17 00:00:00 2001 From: bade22brazy Date: Thu, 30 Jul 2026 15:39:06 +0100 Subject: [PATCH 10/10] fix: satisfy exactOptionalPropertyTypes in batch-tx-rpc-simulation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spreading a destructured optional field straight into a new object literal (e.g. { rpcUrl, contractId, sourceAccount, network }) produces a type where that key is present-but-possibly-undefined, which exactOptionalPropertyTypes rejects against BatchTransactionContext's optional fields — the key must be entirely absent when the value is undefined, not present with value undefined. --- src/tests/batch-tx-rpc-simulation.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tests/batch-tx-rpc-simulation.test.ts b/src/tests/batch-tx-rpc-simulation.test.ts index d7e01ab..29b6dd9 100644 --- a/src/tests/batch-tx-rpc-simulation.test.ts +++ b/src/tests/batch-tx-rpc-simulation.test.ts @@ -92,7 +92,12 @@ describe('buildBatchTransactions() — RPC-prepared path', () => { const { rpcUrl, contractId, sourceAccount, network } = CONTEXT; const built = await buildBatchTransactions( [{ method: 'a' }, { method: 'b' }], - { rpcUrl, contractId, sourceAccount, network }, + { + contractId, + sourceAccount, + ...(rpcUrl !== undefined ? { rpcUrl } : {}), + ...(network !== undefined ? { network } : {}), + }, ); expect(mockGetAccount).toHaveBeenCalledTimes(1); @@ -123,8 +128,8 @@ describe('buildBatchTransactions() — RPC-prepared path', () => { const built = await buildBatchTransactions([{ method: 'ping' }], { contractId, sourceAccount, - network, - sequence, + ...(network !== undefined ? { network } : {}), + ...(sequence !== undefined ? { sequence } : {}), }); expect(mockSimulateTransaction).not.toHaveBeenCalled();