Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ sub.unsubscribe();

### `streamCount() → Promise<bigint>`
### `streamAddress(id) → Promise<string | null>`

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<number>`

---
Expand Down
16 changes: 15 additions & 1 deletion src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

constructor(private readonly config: ConduitConfig) {
// Guard against direct construction with an unsupported network, which
// would bypass the ConduitClient validation gate and produce a confusing
Expand Down Expand Up @@ -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<string | null> {
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',
Expand All @@ -67,7 +79,9 @@ export class FactoryModule {
// Contract returns Option<Address> — 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;
}
Expand Down
51 changes: 51 additions & 0 deletions src/tests/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down