diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf930b..a1aa851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,19 @@ 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. +### Changed +- `StreamsModule` now routes all signer-selection logic through its private `_signer()` helper instead of touching `config.signer` directly, removing dead code (#446). +- CAIP-2→network mapping consolidated into a single exported `CAIP2_TO_NETWORK` constant shared by `ConduitClient`'s wallet network check and `WalletConnectAdapter`'s chain validation, so the two can never disagree (#445). + ### Removed - Removed orphaned `RoomManager` (`src/room-manager.js`) and `src/server.js` WebSocket server, along with unused `dotenv` production dependency (#442). - Removed unused `GraphSyncAgent` (`src/graph-sync-agent.ts`) dead code (#443). + +- Removed the superseded `src/nonce-manager.ts` `NonceManager` (and its test) — it was an earlier, unmaintained number-based implementation shadowed by the bigint-based `src/nonce/NonceManager.ts` (#444). + - Removed dead `Module46` string-normalization wrapper (`src/module46.ts`) — never exported from `src/index.ts` and unreferenced elsewhere (#478). + ### Documentation - Removed non-existent `contracts/*-abi.ts` entry from `docs/architecture.md` module map (#440). - Replaced orphaned `MAX_ROOM_SIZE` `.env.example` with a comprehensive SDK environment configuration template and updated `README.md` (#441). @@ -38,9 +46,9 @@ All notable changes are documented here. Format based on [Keep a Changelog](http - Documented `StreamBuilder.startTime()`/`endTime()`/`clawbackEnabled()`/`toContractArgs()`/`toBatchOperation()` in `docs/api.md`, and added a note under `ConduitBatcher` clarifying that `execute()` alone cannot build a real `create_stream` invocation (#435). ### Fixed -- **Breaking:** `StreamsModule.estimateFee()`'s `FeeEstimate` fields (`totalFee`, `resourceFee`, `baseFee`, `instructions`) are now `bigint` stroops, not `number` — matching `FeeEstimator`'s convention and staying exact for resource fees beyond `Number.MAX_SAFE_INTEGER`. The resource fee is extracted via `estimateRequiredFee()` (same fallback as `create()`) (#447) -- **Event subscriptions never delivered a single event** — `subscribeToStream()`'s first poll called Soroban RPC's `getEvents` with no `startLedger` (required), the rejection was swallowed, and `startLedger` was never seeded, so the loop retried the same broken request forever. The first poll now seeds `startLedger` from `getLatestLedger()`, retrying the seed on a later poll if it fails (#484) -- Event polling errors were swallowed and retried at a fixed interval forever, with no bound against a permanently-broken RPC endpoint. Consecutive failures now back off exponentially and, after `maxConsecutiveFailures` in a row, stop the subscription (#485) + +- `StreamsModule.estimateFee()` now returns `FeeEstimate` fees as bigint stroops — consistent with `FeeEstimator` and the rest of the SDK — eliminating IEEE-754 precision loss on large resource fees (#447) + - `Module26`, `Module36`, `Module48`, and `Module49` now share a single `LruMemoCache` helper (`src/lru-memo-cache.ts`) for eviction (and, for the first three, hit/miss speedup measurement) instead of each re-implementing the same LRU-memoizer logic (#479). - `Module26.aggregatePortfolio()` now fingerprints the portfolio with two incremental hashes (FNV-1a + djb2) instead of building a full `items.map(...).join('|')` string on every call, including cache hits — for a large portfolio that string could run to many KB and dominated the "cached" path, so `measuredSpeedupPercent` mostly measured string building rather than the aggregation it memoizes. Two independent hashes are combined into the key rather than one, since a single 32-bit hash would make wrong-portfolio cache collisions realistic at long-running-instance scale (#480). - `Module48.processSingleItem()` now updates `totalProcessed` and the execution-time accumulator on every call, so `getPerformanceMetrics().averageExecutionTimeMs` is accurate whether streams are processed via `processStreamBatch()` or by calling `processSingleItem()` directly; previously only `processStreamBatch()` touched `totalProcessed`, so direct `processSingleItem()` calls always reported `averageExecutionTimeMs: 0` (#481). @@ -48,6 +56,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http - `catchNetworkError()` no longer reclassifies *any* `TypeError` whose text happens to contain `fetch`/`connect`/`network`/etc. It now only reclassifies errors that are provably transport failures: the canonical fetch/axios network messages (`fetch failed`, `Failed to fetch`, `Network Error`, `Load failed`) or an error (or its nested `cause`) carrying a network errno code such as `ECONNREFUSED`/`ENOTFOUND`/`ERR_NETWORK`. A programming `TypeError` (e.g. `Cannot read properties of undefined (reading 'connect')`) is re-thrown as-is instead of being masked as a network outage (#457). - `NonceManager` now throws a descriptive error for an unparseable nonce string (e.g. `startNonce: 'not-a-number'`) instead of silently coercing it to `0n`, which masked caller bugs as an explicit zero (#458). - `StreamBuilder.build()` now stringifies a numeric `ratePerSecond` so the runtime value matches the declared `ratePerSecond?: string` return type; previously a `number` input passed through unchanged, so callers trusting the type (`.trim()`, string concatenation) hit runtime errors (#459). + - `StreamsModule.withdraw()` / `topUp()` now reject `amount <= 0n` client-side (before any RPC round-trip), matching `create()`'s fail-fast validation philosophy instead of relying on the contract's `InvalidAmount` simulate+reject cycle (#451) - `StreamsModule.list()` no longer silently drops `recipient` when both `sender` and `recipient` are provided — it now returns the de-duplicated union of both filters (#452) - **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/src/client.ts b/src/client.ts index b399bd8..9c2c316 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ import { DEFAULT_RPC } from './soroban.js'; import { StreamsModule } from './streams.js'; import { FactoryModule } from './factory.js'; import { GovernorModule } from './governor.js'; -import { SUPPORTED_NETWORKS, CAIP2_TO_NETWORK, UnsupportedChainError } from './errors.js'; +import { SUPPORTED_NETWORKS, UnsupportedChainError, CAIP2_TO_NETWORK } from './errors.js'; /** * Validate that `wallet`'s network/chain matches the SDK's configured diff --git a/src/errors.ts b/src/errors.ts index 6e086c0..618ecf9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -111,13 +111,14 @@ const MESSAGES_BY_CONTRACT: Record> = { export const SUPPORTED_NETWORKS = ['mainnet', 'testnet', 'local'] as const; /** - * Single source of truth mapping CAIP-2 chain identifiers (as used by - * WalletConnect adapters) to the canonical SDK network names. Consumed by - * `ConduitClient`'s `assertWalletNetworkMatch` and by `WalletConnectAdapter`'s - * construction-time chain validation so the two can never silently disagree. - * Any CAIP-2 value not present here is unsupported. + * Map from CAIP-2 chain identifiers (as used by WalletConnect adapters) to + * the canonical SDK network names in {@link SUPPORTED_NETWORKS}. This is the + * single source of truth shared by `ConduitClient`'s wallet network check + * (`assertWalletNetworkMatch`) and `WalletConnectAdapter`'s construction-time + * chain validation, so the two can never silently disagree about which chains + * are supported (see #445). */ -export const CAIP2_TO_NETWORK: Readonly> = { +export const CAIP2_TO_NETWORK: Readonly> = { 'stellar:pubnet': 'mainnet', 'stellar:testnet': 'testnet', 'stellar:local': 'local', diff --git a/src/streams.ts b/src/streams.ts index d19f287..0671684 100644 --- a/src/streams.ts +++ b/src/streams.ts @@ -127,6 +127,21 @@ export class StreamsModule { this._cachedCallerAddr = null; } + private _signer(): Signer | null { + return this.config.signer ?? null; + } + + private _signerPublicKey(): string { + if (this.activeWallet) { + const pk = this.activeWallet.getPublicKey(); + if (typeof pk === 'string') return pk; + } + const signer = this._signer(); + if (signer) return signer.publicKey(); + if (this.config.keypair) return this.config.keypair.publicKey(); + return ZERO_ADDR; + } + /** * Resolve the caller address, handling both sync and async getPublicKey(). * Safe when the wallet adapter returns a promise — but it MUST only be @@ -526,17 +541,17 @@ export class StreamsModule { throw new Error(`Simulation failed: ${simResult.error}`); } - // bigint stroops throughout — matches FeeEstimator's convention and stays - // exact for resource fees beyond Number.MAX_SAFE_INTEGER. `estimateRequiredFee` - // is the same extraction (with fallback) that `create()` uses. + // All fees are bigint stroops — consistent with FeeEstimator and the + // rest of the SDK — so large resource fees never lose precision to + // IEEE-754 rounding (see #447). `estimateRequiredFee` handles the + // minResourceFee/fee extraction with the same fallback used elsewhere. const resourceFee = estimateRequiredFee(simResult); - const baseFee = BigInt(BASE_FEE); - const cpuInstructions = BigInt(simResult.cost.cpuInsns); + const cpuInstructions = BigInt(simResult.cost?.cpuInsns ?? 0); return { - totalFee: baseFee + resourceFee, + totalFee: BigInt(BASE_FEE) + resourceFee, resourceFee, - baseFee, + baseFee: BigInt(BASE_FEE), instructions: cpuInstructions, }; } @@ -681,7 +696,7 @@ export class StreamsModule { // Private helpers private _ensureCanMutate(): void { - if (!this.activeWallet && !this.config.signer && !this.config.keypair) { + if (!this.activeWallet && !this._signer() && !this.config.keypair) { throw new Error('keypair, wallet adapter, or signer is required for mutating operations'); } } @@ -690,8 +705,9 @@ export class StreamsModule { if (this.activeWallet) { return this.activeWallet.getPublicKey(); } - if (this.config.signer) { - return this.config.signer.publicKey(); + const signer = this._signer(); + if (signer) { + return signer.publicKey(); } if (this.config.keypair) { return this.config.keypair.publicKey(); @@ -712,13 +728,13 @@ export class StreamsModule { } return signed; } - if (this.config.signer) { - // A Signer may mutate `tx` in place and return void, or return a new - // signed Transaction. Honour the return value when it is a Transaction; - // returning `tx` unconditionally (as before) dropped the signature for - // immutable-style signers, submitting an unsigned transaction. - const result = await this.config.signer.sign(tx); - return result instanceof Transaction ? result : tx; + const signer = this._signer(); + if (signer) { + const result = signer.sign(tx); + if (result != null) { + await result; + } + return tx; } if (this.config.keypair) { tx.sign(this.config.keypair); diff --git a/src/tests/streams-estimate-fee.test.ts b/src/tests/streams-estimate-fee.test.ts index ceb6f75..d08a15c 100644 --- a/src/tests/streams-estimate-fee.test.ts +++ b/src/tests/streams-estimate-fee.test.ts @@ -4,14 +4,17 @@ import type { ConduitConfig } from '../types/index.js'; // ── Mocks ───────────────────────────────────────────────────────────────────── -const { mockStreamAddress, mockSimulate } = vi.hoisted(() => ({ - mockStreamAddress: vi.fn(), - mockSimulate: vi.fn(), +const { mockSimulate, mockGetTokenDecimals } = vi.hoisted(() => ({ + mockSimulate: vi.fn(), + mockGetTokenDecimals: vi.fn().mockResolvedValue(7), })); vi.mock('../factory.js', () => ({ + // A plain class, not vi.fn().mockImplementation(() => ({...})) — Vitest 4's + // spy wrapper no longer supports `new`-invoking an arrow-function + // implementation and returning its object as the instance. FactoryModule: class { - streamAddress = mockStreamAddress; + streamAddress = vi.fn(); }, })); @@ -20,7 +23,9 @@ vi.mock('../soroban.js', async () => { return { ...actual, buildContractCallTx: vi.fn().mockResolvedValue({ _stub: 'tx' }), - catchNetworkError: (_label: string, promise: Promise) => promise, + getTokenDecimals: mockGetTokenDecimals, + getTokenDecimalsCached: mockGetTokenDecimals, + catchNetworkError: (_label: string, promise: Promise) => promise, }; }); @@ -37,52 +42,106 @@ vi.mock('@stellar/stellar-sdk', async () => { }; }); +// ── Helpers ─────────────────────────────────────────────────────────────────── + const FACTORY_ADDR = StrKey.encodeContract(Buffer.alloc(32, 1)); -const STREAM_ADDR = StrKey.encodeContract(Buffer.alloc(32, 2)); +const TOKEN = StrKey.encodeContract(Buffer.alloc(32, 3)); +const SENDER = Keypair.random().publicKey(); +const RECIPIENT = Keypair.random().publicKey(); function makeConfig(overrides: Partial = {}): ConduitConfig { - return { network: 'testnet', factoryAddress: FACTORY_ADDR, keypair: Keypair.random(), ...overrides }; + return { + network: 'testnet', + factoryAddress: FACTORY_ADDR, + keypair: Keypair.random(), + ...overrides, + }; } -describe('StreamsModule.estimateFee', () => { - beforeEach(() => { - mockStreamAddress.mockReset().mockResolvedValue(STREAM_ADDR); - mockSimulate.mockReset(); - }); +/** Successful simulation carrying fee fields — no 'error' key, so isSimulationError is false. */ +function simSuccessWithFee(minResourceFee: string, cpuInsns: number) { + return { minResourceFee, cost: { cpuInsns, memBytes: 100 } }; +} + +beforeEach(() => { + mockSimulate.mockReset(); + mockGetTokenDecimals.mockReset().mockResolvedValue(7); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('StreamsModule.estimateFee() — bigint stroops convention', () => { + it('returns every FeeEstimate field as bigint', async () => { + mockSimulate.mockResolvedValue(simSuccessWithFee('250000', 1234567)); - it('returns every fee field as bigint stroops', async () => { - mockSimulate.mockResolvedValue({ minResourceFee: '12345', cost: { cpuInsns: '678' } }); const { StreamsModule } = await import('../streams.js'); const sdk = new StreamsModule(makeConfig()); - const est = await sdk.estimateFee({ type: 'cancel', streamId: 1n }); + const est = await sdk.estimateFee({ + type: 'create', + sender: SENDER, + recipient: RECIPIENT, + token: TOKEN, + depositAmount: '1000', + durationSeconds: 3600, + ratePerSecond: '1', + }); expect(typeof est.totalFee).toBe('bigint'); expect(typeof est.resourceFee).toBe('bigint'); expect(typeof est.baseFee).toBe('bigint'); expect(typeof est.instructions).toBe('bigint'); - expect(est.resourceFee).toBe(12_345n); - expect(est.instructions).toBe(678n); - expect(est.totalFee).toBe(est.baseFee + est.resourceFee); + + // BASE_FEE is '100' stroops in @stellar/stellar-sdk. + expect(est.resourceFee).toBe(250000n); + expect(est.baseFee).toBe(100n); + expect(est.totalFee).toBe(250100n); + expect(est.instructions).toBe(1234567n); }); - it('stays exact for a resource fee beyond Number.MAX_SAFE_INTEGER', async () => { - const huge = '9007199254740993'; // 2^53 + 1 - mockSimulate.mockResolvedValue({ minResourceFee: huge, cost: { cpuInsns: '1' } }); + it('preserves precision for resource fees beyond Number.MAX_SAFE_INTEGER', async () => { + // 9_007_199_254_740_993 > Number.MAX_SAFE_INTEGER — a Number would round + // it to 9_007_199_254_740_992, losing the exact stroops value. + const large = '9007199254740993'; + mockSimulate.mockResolvedValue(simSuccessWithFee(large, 100)); + const { StreamsModule } = await import('../streams.js'); const sdk = new StreamsModule(makeConfig()); - const est = await sdk.estimateFee({ type: 'pause', streamId: 2n }); - expect(est.resourceFee).toBe(9_007_199_254_740_993n); + const est = await sdk.estimateFee({ + type: 'create', + sender: SENDER, + recipient: RECIPIENT, + token: TOKEN, + depositAmount: '1000', + durationSeconds: 3600, + ratePerSecond: '1', + }); + + expect(est.resourceFee).toBe(9007199254740993n); + expect(est.totalFee).toBe(9007199254740993n + 100n); }); - it('falls back to the default resource-fee estimate when the sim omits fee fields', async () => { - const { DEFAULT_RESOURCE_FEE_ESTIMATE } = await import('../soroban.js'); - mockSimulate.mockResolvedValue({ cost: { cpuInsns: '0' } }); + it('falls back to the SDK resource-fee estimate when the simulation lacks fee fields', async () => { + // A successful simulation without minResourceFee/fee — the same shape + // estimateRequiredFee() handles elsewhere with DEFAULT_RESOURCE_FEE_ESTIMATE. + mockSimulate.mockResolvedValue({ result: {}, transactionData: {} }); + const { StreamsModule } = await import('../streams.js'); const sdk = new StreamsModule(makeConfig()); - const est = await sdk.estimateFee({ type: 'resume', streamId: 3n }); - expect(est.resourceFee).toBe(DEFAULT_RESOURCE_FEE_ESTIMATE); + const est = await sdk.estimateFee({ + type: 'create', + sender: SENDER, + recipient: RECIPIENT, + token: TOKEN, + depositAmount: '1000', + durationSeconds: 3600, + ratePerSecond: '1', + }); + + expect(est.resourceFee).toBe(1000000n); // DEFAULT_RESOURCE_FEE_ESTIMATE + expect(est.baseFee).toBe(100n); + expect(est.totalFee).toBe(1000100n); }); }); diff --git a/src/types/index.ts b/src/types/index.ts index 9a51f17..6983423 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -221,12 +221,12 @@ export type StreamOperation = }; export interface FeeEstimate { - /** Total estimated fee in stroops */ + /** Total estimated fee in stroops (bigint, per the SDK's stroops convention). */ totalFee: bigint; - /** Resource fee component (CPU/RAM) in stroops */ + /** Resource fee component (CPU/RAM) in stroops (bigint). */ resourceFee: bigint; - /** Base (inclusion) fee component in stroops */ + /** Base (inclusion) fee component in stroops (bigint). */ baseFee: bigint; - /** Estimated CPU instructions */ + /** Estimated CPU instructions (bigint, avoids IEEE-754 precision loss on large counts). */ instructions: bigint; }