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
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -38,16 +46,17 @@ 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).
- **Breaking:** `RateLimitError.fromRpcError()` no longer conflates HTTP 503 (Service Unavailable) with 429 (Too Many Requests). A 503 is now reported as a distinct exported `RpcServiceUnavailableError`, and the internal RPC retry wrapper only backoff-retries genuine `RateLimitError`s — a 503 fails fast so consumers can fail over to a different RPC URL instead of retrying a dead endpoint (#456).
- `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.
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,14 @@ const MESSAGES_BY_CONTRACT: Record<ConduitContract, Record<number, string>> = {
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<Record<string, (typeof SUPPORTED_NETWORKS)[number]>> = {
export const CAIP2_TO_NETWORK: Readonly<Record<string, string>> = {
'stellar:pubnet': 'mainnet',
'stellar:testnet': 'testnet',
'stellar:local': 'local',
Expand Down
50 changes: 33 additions & 17 deletions src/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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');
}
}
Expand All @@ -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();
Expand All @@ -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);
Expand Down
115 changes: 87 additions & 28 deletions src/tests/streams-estimate-fee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
}));

Expand All @@ -20,7 +23,9 @@ vi.mock('../soroban.js', async () => {
return {
...actual,
buildContractCallTx: vi.fn().mockResolvedValue({ _stub: 'tx' }),
catchNetworkError: <T>(_label: string, promise: Promise<T>) => promise,
getTokenDecimals: mockGetTokenDecimals,
getTokenDecimalsCached: mockGetTokenDecimals,
catchNetworkError: <T>(_label: string, promise: Promise<T>) => promise,
};
});

Expand All @@ -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> = {}): 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);
});
});
8 changes: 4 additions & 4 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}