From 16b9d36aa02753add6736bc5db498188bb0c6ff5 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:51:51 +0800 Subject: [PATCH 1/2] feat(exports): re-export resolveFee and document fee precedence (#606) - Export resolveFee from src/index.ts so consumers can compute effective fees - Add fee and feeMultiplier rows to ConduitClient configuration table - Add Fee Precedence section to README explaining the resolution order: explicit fee > feeMultiplier * BASE_FEE > BASE_FEE Closes #606 --- README.md | 12 ++++++++++++ src/index.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e19889..7635fba 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,18 @@ const client = new ConduitClient(config: ConduitConfig); | `rpcUrl` | `string` | No | Override default Soroban RPC URL | | `factoryAddress` | `string` | No | Override deployed factory contract ID | | `governorAddress` | `string` | No | Override deployed governor contract ID | +| `fee` | `string` | No | Explicit inclusion fee in stroops (overrides feeMultiplier) | +| `feeMultiplier` | `number` | No | Multiplier applied to BASE_FEE when fee is not set | + +### Fee Precedence + +When both `fee` and `feeMultiplier` are present, `fee` wins. The precedence is: + +1. **`fee`** — explicit inclusion fee in stroops (e.g. `"5000"`) +2. **`feeMultiplier`** — multiplied by `BASE_FEE` when `fee` is absent +3. **`BASE_FEE`** — hardcoded fallback when neither is set + +Use `resolveFee(config)` to compute the effective fee for a given `ConduitConfig`. ### WalletConnect v2 Integration (Mobile & Browser Wallets) diff --git a/src/index.ts b/src/index.ts index 0600c69..a69e711 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,7 +69,7 @@ export { } from './utils.js'; // RPC server lifecycle -export { getServer, clearServerCache } from './soroban.js'; +export { getServer, clearServerCache, resolveFee } from './soroban.js'; export { getTokenDecimals, clearTokenDecimalsCache } from './soroban.js'; export { From 140143193530ece4f839c82a338d5ea6ed91a487 Mon Sep 17 00:00:00 2001 From: Zac Lou <97340247+ZacLou@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:53:36 +0800 Subject: [PATCH 2/2] feat(indexer): add MockGraphQLIndexer and createMockIndexer (#607) - MockGraphQLIndexer serves preset query responses and subscription events - createMockIndexer factory for quick test setup - Exported from src/index.ts alongside GraphQLIndexer - Supports strict mode (throws on unknown queries) and non-strict mode (returns undefined) - Subscription events emitted on next tick with configurable timing - Add unit tests for queries, subscriptions, strict mode, unsubscribe, and destruction Closes #607 --- src/index.ts | 2 + src/mock-indexer.ts | 117 +++++++++++++++++++++++++++++++++ src/tests/mock-indexer.test.ts | 75 +++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 src/mock-indexer.ts create mode 100644 src/tests/mock-indexer.test.ts diff --git a/src/index.ts b/src/index.ts index a69e711..edbde51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,8 @@ export type { BatchSubmitOptions, } from './batch-tx.js'; export { GraphQLIndexer, DEFAULT_INDEXER_TIMEOUT_MS } from './indexer.js'; +export { MockGraphQLIndexer, createMockIndexer } from './mock-indexer.js'; +export type { MockQueryMap, MockSubscriptionMap, MockIndexerOptions } from './mock-indexer.js'; export type { GraphQLQueryOptions, GraphQLSubscriptionOptions, diff --git a/src/mock-indexer.ts b/src/mock-indexer.ts new file mode 100644 index 0000000..41f6cb5 --- /dev/null +++ b/src/mock-indexer.ts @@ -0,0 +1,117 @@ +/** + * In-memory mock transport for {@link GraphQLIndexer}. + * + * Lets consumers unit-test indexer-backed code without stubbing the global + * `fetch` or running a real GraphQL server. Query results and subscription + * events are injected up-front and returned deterministically. + * + * @example + * ```ts + * const indexer = createMockIndexer({ + * 'query GetStreams { streams { id } }': { streams: [{ id: '1' }] }, + * }); + * + * const result = await indexer.query({ query: 'query GetStreams { streams { id } }' }); + * // result === { streams: [{ id: '1' }] } + * ``` + */ + +import type { GraphQLQueryOptions, GraphQLSubscriptionOptions, IndexerSubscription } from './indexer.js'; + +/** Preset response map: query string → resolved data. */ +export type MockQueryMap = Record; + +/** Preset subscription event map: query string → array of events to emit. */ +export type MockSubscriptionMap = Record; + +export interface MockIndexerOptions { + /** Pre-baked responses for `query()` calls. */ + queries?: MockQueryMap; + /** Pre-baked event arrays for `subscribe()` calls. */ + subscriptions?: MockSubscriptionMap; + /** When true, unknown queries throw instead of returning undefined. Default false. */ + strict?: boolean; +} + +/** + * A drop-in replacement for {@link GraphQLIndexer} that serves injected + * responses from memory. Useful in unit tests and offline demos. + */ +export class MockGraphQLIndexer { + private readonly queries: MockQueryMap; + private readonly subscriptions: MockSubscriptionMap; + private readonly strict: boolean; + private _destroyed = false; + + constructor(options: MockIndexerOptions = {}) { + this.queries = options.queries ?? {}; + this.subscriptions = options.subscriptions ?? {}; + this.strict = options.strict ?? false; + } + + async query(options: GraphQLQueryOptions): Promise { + if (this._destroyed) { + throw new Error('MockGraphQLIndexer has been destroyed'); + } + const q = options.query.trim(); + if (q in this.queries) { + return this.queries[q]; + } + if (this.strict) { + throw new Error(`MockGraphQLIndexer: no preset response for query "${q}"`); + } + return undefined; + } + + subscribe(options: GraphQLSubscriptionOptions): IndexerSubscription { + if (this._destroyed) { + throw new Error('MockGraphQLIndexer has been destroyed'); + } + const q = options.query.trim(); + const events = this.subscriptions[q] ?? []; + + // Emit events on next tick so the caller can attach listeners first. + const timers = events.map((evt, i) => + setTimeout(() => { + if (!unsubscribed) { + options.onData(evt); + } + }, i * 10), + ); + + let unsubscribed = false; + return { + unsubscribe: () => { + if (unsubscribed) return; + unsubscribed = true; + for (const timer of timers) { + clearTimeout(timer); + } + }, + }; + } + + getSubscriptionCount(): number { + return 0; + } + + cleanup(): void { + this._destroyed = true; + } +} + +/** + * Convenience factory for {@link MockGraphQLIndexer}. + * + * @example + * ```ts + * const indexer = createMockIndexer({ + * queries: { + * 'query { streams }': { streams: [] }, + * }, + * }); + * ``` + */ +export function createMockIndexer(options?: MockIndexerOptions): MockGraphQLIndexer { + return new MockGraphQLIndexer(options); +} diff --git a/src/tests/mock-indexer.test.ts b/src/tests/mock-indexer.test.ts new file mode 100644 index 0000000..1405e0c --- /dev/null +++ b/src/tests/mock-indexer.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { MockGraphQLIndexer, createMockIndexer } from '../mock-indexer.js'; + +describe('MockGraphQLIndexer', () => { + it('returns preset query responses', async () => { + const indexer = new MockGraphQLIndexer({ + queries: { + 'query GetStreams { streams { id } }': { streams: [{ id: '1' }] }, + }, + }); + + const result = await indexer.query({ query: 'query GetStreams { streams { id } }' }); + expect(result).toEqual({ streams: [{ id: '1' }] }); + }); + + it('returns undefined for unknown queries in non-strict mode', async () => { + const indexer = new MockGraphQLIndexer(); + const result = await indexer.query({ query: 'query Unknown { unknown }' }); + expect(result).toBeUndefined(); + }); + + it('throws for unknown queries in strict mode', async () => { + const indexer = new MockGraphQLIndexer({ strict: true }); + await expect(indexer.query({ query: 'query Unknown { unknown }' })).rejects.toThrow( + 'MockGraphQLIndexer: no preset response', + ); + }); + + it('emits subscription events in order', async () => { + const indexer = new MockGraphQLIndexer({ + subscriptions: { + 'subscription OnStream { onStream { id } }': [{ id: '1' }, { id: '2' }], + }, + }); + + const events: unknown[] = []; + const sub = indexer.subscribe({ + query: 'subscription OnStream { onStream { id } }', + onData: (data) => events.push(data), + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(events).toEqual([{ id: '1' }, { id: '2' }]); + sub.unsubscribe(); + }); + + it('stops emitting after unsubscribe', async () => { + const indexer = new MockGraphQLIndexer({ + subscriptions: { + 'sub': [{ id: '1' }, { id: '2' }], + }, + }); + + const events: unknown[] = []; + const sub = indexer.subscribe({ + query: 'sub', + onData: (data) => events.push(data), + }); + + sub.unsubscribe(); + await new Promise((r) => setTimeout(r, 50)); + expect(events).toEqual([]); + }); + + it('createMockIndexer is a convenience factory', () => { + const indexer = createMockIndexer({ queries: { 'q': 'data' } }); + expect(indexer).toBeInstanceOf(MockGraphQLIndexer); + }); + + it('throws when destroyed', async () => { + const indexer = new MockGraphQLIndexer(); + indexer.cleanup(); + await expect(indexer.query({ query: 'q' })).rejects.toThrow('destroyed'); + }); +});