From 337b3a709740157b5d962745dead4d3ff1dd86eb Mon Sep 17 00:00:00 2001 From: OkeQueen Date: Thu, 30 Jul 2026 15:46:55 +0200 Subject: [PATCH] feat: Enhancement: Detailed SDK Feature #38 --- CHANGELOG.md | 3 +++ docs/api.md | 7 ++++++ src/factory.ts | 16 +++++++++++- src/tests/factory.test.ts | 51 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52f0483..86b2373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ All notable changes are documented here. Format based on [Keep a Changelog](http ### Added - `Module36` stream snapshot diff engine with LRU memoization for Feature #36 (#370); `getPerformanceMetrics()` reports an honest, workload-dependent measured speedup rather than a fixed percentage +### 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. + ### 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 91941e7..d3b4d19 100644 --- a/docs/api.md +++ b/docs/api.md @@ -154,6 +154,13 @@ sub.unsubscribe(); ### `streamCount() → Promise` ### `streamAddress(id) → Promise` + +Resolved (non-null) addresses are cached in-memory for the lifetime of the client, since a +stream's contract address is fixed at creation and never changes. A `null` result (stream not +yet found) is not cached, so a later call for the same `id` will still hit the network. This +cache is what `StreamsModule` relies on to avoid re-resolving the same address on every +`get`/`withdraw`/`cancel`/`pause`/`resume`/`topUp`/`clawback` call and when paginating `list()`. + ### `protocolFeeBps() → Promise` --- diff --git a/src/factory.ts b/src/factory.ts index c88041c..5bf4f2d 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -20,6 +20,13 @@ export class FactoryModule { private readonly factoryId: string; private readonly callerAddr: string; + // streamId -> contract address is set once at creation and never changes, + // so a resolved (non-null) address can be cached for the lifetime of this + // module instance. This avoids re-resolving the same address on every + // stream operation (get/withdraw/cancel/pause/... all call streamAddress() + // via StreamsModule._resolveAddr, and list() fans this out over a full page). + private readonly addressCache = new Map(); + constructor(private readonly config: ConduitConfig) { // Guard against direct construction with an unsupported network, which // would bypass the ConduitClient validation gate and produce a confusing @@ -57,6 +64,11 @@ export class FactoryModule { /** Resolve a stream ID to its deployed contract address. Returns null if not found. */ async streamAddress(streamId: bigint | string): Promise { const id = BigInt(streamId); + const key = id.toString(); + + const cached = this.addressCache.get(key); + if (cached !== undefined) return cached; + const tx = await buildContractCallTx( this.rpcUrl, this.passphrase, this.callerAddr, this.factoryId, 'stream_address', @@ -67,7 +79,9 @@ export class FactoryModule { // Contract returns Option
— void = None if (val.switch().name === 'scvVoid') return null; try { - return Address.fromScVal(val).toString(); + const addr = Address.fromScVal(val).toString(); + this.addressCache.set(key, addr); + return addr; } catch { return null; } diff --git a/src/tests/factory.test.ts b/src/tests/factory.test.ts index 276dd6f..99989fb 100644 --- a/src/tests/factory.test.ts +++ b/src/tests/factory.test.ts @@ -111,6 +111,57 @@ describe('FactoryModule — streamAddress()', () => { const addr = await new FactoryModule(cfg()).streamAddress(999n); expect(addr).toBeNull(); }); + + it('caches a resolved address and does not re-hit the network on the next call', async () => { + const { FactoryModule } = await import('../factory.js'); + mockSimulate.mockResolvedValueOnce(makeU32ScVal(1)); // any non-void scval + const factory = new FactoryModule(cfg()); + + const first = await factory.streamAddress(1n); + const second = await factory.streamAddress(1n); + + expect(first).toBe(second); + expect(mockSimulate).toHaveBeenCalledTimes(1); + }); + + it('caches per streamId — a different id still hits the network', async () => { + const { FactoryModule } = await import('../factory.js'); + mockSimulate + .mockResolvedValueOnce(makeU32ScVal(1)) + .mockResolvedValueOnce(makeU32ScVal(1)); + const factory = new FactoryModule(cfg()); + + await factory.streamAddress(1n); + await factory.streamAddress(2n); + + expect(mockSimulate).toHaveBeenCalledTimes(2); + }); + + it('accepts string and bigint streamId forms as the same cache key', async () => { + const { FactoryModule } = await import('../factory.js'); + mockSimulate.mockResolvedValueOnce(makeU32ScVal(1)); + const factory = new FactoryModule(cfg()); + + await factory.streamAddress(5n); + await factory.streamAddress('5'); + + expect(mockSimulate).toHaveBeenCalledTimes(1); + }); + + it('does not cache a not-found (void) result, so a later resolution still hits the network', async () => { + const { FactoryModule } = await import('../factory.js'); + mockSimulate + .mockResolvedValueOnce(makeVoidScVal()) + .mockResolvedValueOnce(makeU32ScVal(1)); + const factory = new FactoryModule(cfg()); + + const first = await factory.streamAddress(7n); + const second = await factory.streamAddress(7n); + + expect(first).toBeNull(); + expect(second).not.toBeNull(); + expect(mockSimulate).toHaveBeenCalledTimes(2); + }); }); describe('FactoryModule — protocolFeeBps()', () => {