Skip to content
Closed
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,8 @@ 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
- `Module26` stream portfolio aggregator with LRU memoization for Feature #26 (#360); `getPerformanceMetrics()` reports an honest, workload-dependent measured speedup rather than a fixed percentage
- Direct unit tests closing the `src/soroban.ts` test gaps: `queryXlmBalance()` (mocked `simulateTransaction` responses, incl. the error path) and `estimateRequiredFee()` (fallback value + `minResourceFee`/`fee` extraction shapes) (#460, #461)
- Direct unit tests for `resolvePassphrase()` covering explicit passphrase present/blank, named network known/unknown, and neither-provided branches (#462)

### 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.
Expand All @@ -15,6 +17,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http
### Documentation
- Added an API reference section for `GraphQLIndexer`, which was previously exported but undocumented.
- Added a "Wallet Adapters" API reference section documenting `KeypairWalletAdapter`.
- Documented `StreamBuilder.ratePerSecond()` and `StreamBuilder.submit()` (with full `SubmitOptions`) in `docs/api.md`, previously omitted from the Fluent Builder reference (#463).
- Documented `ConduitClient`'s `pauseStream()`, `unpauseStream()`, and `setWallet()` convenience methods in `docs/api.md`, and fixed `setWallet()`'s JSDoc block, which had been orphaned above `pauseStream()`/`unpauseStream()` and left `setWallet()` itself undocumented.

### Fixed
Expand Down
27 changes: 26 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,16 @@ A helper class to build stream configurations with method chaining.
* `sender(address: string): this` - Sets the sender address.
* `recipient(address: string): this` - Sets the recipient address.
* `amount(val: number): this` - Sets the deposit amount in the smallest unit (stroops).
* `build(): StreamConfig` - Validates and returns the built stream configuration. Throws if any required field is missing.
* `ratePerSecond(val: number | bigint): this` - Sets the stream rate in stroops per second, as an alternative to `amount()`-only streams. Accepts a `number` or `bigint`; `bigint` values are serialised to strings before network submission to avoid Safari/WebKit `JSON.stringify` quirks.
* `build(): StreamConfig` - Validates and returns the built stream configuration. Throws if any required field is missing. Includes `ratePerSecond` in the result when it was set.
* `submit(submitFn, options?): Promise<unknown>` - Builds the payload and submits it through `submitFn` with automatic retries (exponential backoff), concurrency control via an internal semaphore, a pending queue with backpressure, and `AbortSignal` support. Throws if the builder was destroyed or the queue is full.

Options (`SubmitOptions`):
* `maxRetries?: number` - Max retry attempts per payload (default `3`).
* `retryDelayMs?: number` - Base backoff delay in ms, doubled per retry (default `100`).
* `concurrency?: number` - Max concurrent in-flight submissions (default `10`).
* `maxQueueSize?: number` - Max pending queue size before backpressure kicks in (default `100`).
* `signal?: AbortSignal` - Aborts an in-flight submission.

```typescript
import { StreamBuilder } from '@conduit-protocol/sdk';
Expand All @@ -498,6 +507,22 @@ const stream = new StreamBuilder()
.recipient('GB...')
.amount(1000)
.build();

// ratePerSecond is an alternative to amount():
const drip = new StreamBuilder()
.token('USDC')
.sender('GD...')
.recipient('GB...')
.ratePerSecond(10n) // 10 stroops/sec
.build();

// submit() handles retries, backpressure and abort for you:
const result = await new StreamBuilder()
.token('USDC')
.sender('GD...')
.recipient('GB...')
.amount(1000)
.submit(async (payload) => submitToNetwork(payload), { maxRetries: 5 });
```

### `ConduitBatcher`
Expand Down
63 changes: 63 additions & 0 deletions src/tests/batch-tx-resolve-passphrase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Direct unit tests for `resolvePassphrase()` (src/batch-tx.ts) — issue #462.
*
* A small pure function with several distinct branches (explicit passphrase
* present/blank, named network known/unknown, neither provided). Before this
* file it was only exercised incidentally through
* `buildBatchTransactionsSync`/`buildBatchTransactions` tests; covering it
* directly is cheap and locks in each branch's behaviour.
*/

import { describe, it, expect } from 'vitest';
import { resolvePassphrase, BatchBuildError } from '../batch-tx.js';
import { NETWORK_PASSPHRASE } from '../soroban.js';
import type { BatchTransactionContext } from '../batch-tx.js';
import type { Network } from '../types/index.js';

const CONTRACT_ID = 'CAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQC526';
const SOURCE = 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H';

function ctx(overrides: Partial<BatchTransactionContext>): BatchTransactionContext {
return { contractId: CONTRACT_ID, sourceAccount: SOURCE, ...overrides };
}

describe('resolvePassphrase', () => {
it('returns the explicit networkPassphrase when present', () => {
expect(resolvePassphrase(ctx({ networkPassphrase: 'My Custom Net' }))).toBe('My Custom Net');
});

it('takes the explicit passphrase over a named network', () => {
expect(resolvePassphrase(ctx({ network: 'mainnet', networkPassphrase: 'Explicit' }))).toBe('Explicit');
});

it('ignores a blank or whitespace-only networkPassphrase', () => {
expect(resolvePassphrase(ctx({ network: 'testnet', networkPassphrase: '' }))).toBe(
NETWORK_PASSPHRASE.testnet,
);
expect(resolvePassphrase(ctx({ network: 'testnet', networkPassphrase: ' ' }))).toBe(
NETWORK_PASSPHRASE.testnet,
);
});

it('resolves each known named network to its passphrase', () => {
for (const network of ['mainnet', 'testnet', 'local'] as Network[]) {
expect(resolvePassphrase(ctx({ network }))).toBe(NETWORK_PASSPHRASE[network]);
}
});

it('throws BatchBuildError for an unknown network', () => {
expect(() => resolvePassphrase(ctx({ network: 'invalid-net' as Network }))).toThrow(
BatchBuildError,
);
expect(() => resolvePassphrase(ctx({ network: 'invalid-net' as Network }))).toThrow(
'Unknown network "invalid-net"',
);
});

it('throws BatchBuildError when neither networkPassphrase nor network is provided', () => {
expect(() => resolvePassphrase(ctx({}))).toThrow(BatchBuildError);
expect(() => resolvePassphrase(ctx({}))).toThrow(
'BatchTransactionContext requires either networkPassphrase or network',
);
});
});
76 changes: 76 additions & 0 deletions src/tests/soroban-estimate-required-fee.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Direct unit tests for `estimateRequiredFee()` (src/soroban.ts) — issue #461.
*
* The function's fallback behaviour is the root cause of a separate bug where
* `InsufficientBalanceError` overstates the required balance by ~500 XLM in
* the common case: a WasmVm/InvalidAction simulation error carries neither
* `minResourceFee` nor `fee`, so it always falls through to the
* `5_000_000_000n` fallback. These tests lock in the fallback value and the
* extraction logic for both the `minResourceFee` and `fee` shapes.
*/

import { describe, it, expect } from 'vitest';
import { estimateRequiredFee } from '../soroban.js';

const DEFAULT_FALLBACK = 5_000_000_000n; // ~500 XLM default upper-bound estimate

describe('estimateRequiredFee', () => {
it('falls back to ~500 XLM for a WasmVm/InvalidAction simulation error (no fee fields)', () => {
// Error-shaped simulation results carry neither minResourceFee nor fee —
// this is the shape that used to reach the fallback far more often than
// intended, overstating the required balance by ~500 XLM.
const simError = { error: 'host error: WasmVm', errorCode: 1 };
expect(estimateRequiredFee(simError)).toBe(DEFAULT_FALLBACK);
});

it('extracts minResourceFee as a string', () => {
expect(estimateRequiredFee({ minResourceFee: '123456789' })).toBe(123_456_789n);
});

it('extracts minResourceFee as a number', () => {
expect(estimateRequiredFee({ minResourceFee: 250_000_000 })).toBe(250_000_000n);
});

it('extracts minResourceFee as a bigint', () => {
expect(estimateRequiredFee({ minResourceFee: 250_000_000n })).toBe(250_000_000n);
});

it('extracts fee when minResourceFee is absent', () => {
expect(estimateRequiredFee({ fee: '987654321' })).toBe(987_654_321n);
expect(estimateRequiredFee({ fee: 42 })).toBe(42n);
expect(estimateRequiredFee({ fee: 42n })).toBe(42n);
});

it('prefers minResourceFee over fee when both are present', () => {
expect(estimateRequiredFee({ minResourceFee: '1000', fee: '2000' })).toBe(1000n);
});

it('skips a zero minResourceFee and falls through to fee', () => {
expect(estimateRequiredFee({ minResourceFee: 0, fee: '5000' })).toBe(5000n);
});

it('skips a negative minResourceFee and falls through to fee', () => {
expect(estimateRequiredFee({ minResourceFee: -10, fee: '5000' })).toBe(5000n);
});

it('ignores zero or negative fee and falls back', () => {
expect(estimateRequiredFee({ fee: 0 })).toBe(DEFAULT_FALLBACK);
expect(estimateRequiredFee({ fee: -5 })).toBe(DEFAULT_FALLBACK);
});

it('returns the fallback for non-object inputs', () => {
expect(estimateRequiredFee(null)).toBe(DEFAULT_FALLBACK);
expect(estimateRequiredFee(undefined)).toBe(DEFAULT_FALLBACK);
expect(estimateRequiredFee('nope')).toBe(DEFAULT_FALLBACK);
expect(estimateRequiredFee(42)).toBe(DEFAULT_FALLBACK);
});

it('respects a custom fallback value', () => {
expect(estimateRequiredFee({}, 123n)).toBe(123n);
expect(estimateRequiredFee({ error: 'boom' }, 456n)).toBe(456n);
});

it('falls back when only zero-valued fee fields are present', () => {
expect(estimateRequiredFee({ minResourceFee: 0, fee: 0 })).toBe(DEFAULT_FALLBACK);
});
});
91 changes: 91 additions & 0 deletions src/tests/soroban-query-xlm-balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Direct unit tests for `queryXlmBalance()` (src/soroban.ts) — issue #460.
*
* Before this file the function had no dedicated test: it was only exercised
* indirectly (if at all) through `StreamsModule.create()`'s
* insufficient-balance branch, so a regression in the RPC simulation → i128
* extraction pipeline could slip through unnoticed. These tests mock
* `SorobanRpc.Server`'s `simulateTransaction()`/`getAccount()` and assert the
* exact stroop value returned for a range of balances, plus the error path.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

const { mockSimulateTransaction, mockGetAccount } = vi.hoisted(() => ({
mockSimulateTransaction: vi.fn(),
mockGetAccount: vi.fn(),
}));

vi.mock('@stellar/stellar-sdk', async () => {
const actual = await vi.importActual('@stellar/stellar-sdk');
return {
...actual,
SorobanRpc: {
...(actual as any).SorobanRpc,
Server: vi.fn().mockImplementation(function MockServer() {
return {
simulateTransaction: mockSimulateTransaction,
getAccount: mockGetAccount,
};
}),
},
};
});

import { Account, nativeToScVal } from '@stellar/stellar-sdk';
import { queryXlmBalance, clearServerCache } from '../soroban.js';

const RPC_URL = 'http://localhost:8000/soroban/rpc';
const PASSPHRASE = 'Test SDF Network ; September 2015';
const ACCOUNT = 'GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H';

/** Build the simulateTransaction success shape `simulateReadOnly` expects. */
function simOk(balance: bigint): unknown {
return { result: { retval: nativeToScVal(balance, { type: 'i128' }) } };
}

beforeEach(() => {
clearServerCache();
mockSimulateTransaction.mockReset();
// getAccount() returns a real Account, which TransactionBuilder.build()
// needs for accountId()/sequenceNumber()/incrementSequenceNumber().
mockGetAccount.mockReset().mockResolvedValue(new Account(ACCOUNT, '100'));
});

describe('queryXlmBalance', () => {
it('returns the simulated balance in stroops (1 XLM = 10_000_000 stroops)', async () => {
mockSimulateTransaction.mockResolvedValue(simOk(500_000_000n)); // 50 XLM
await expect(queryXlmBalance(RPC_URL, PASSPHRASE, ACCOUNT)).resolves.toBe(500_000_000n);
});

it('returns 0n when the account holds no XLM', async () => {
mockSimulateTransaction.mockResolvedValue(simOk(0n));
await expect(queryXlmBalance(RPC_URL, PASSPHRASE, ACCOUNT)).resolves.toBe(0n);
});

it('decodes balances spanning the 64-bit boundary exactly', async () => {
const big = (1n << 70n) + 12345n;
mockSimulateTransaction.mockResolvedValue(simOk(big));
await expect(queryXlmBalance(RPC_URL, PASSPHRASE, ACCOUNT)).resolves.toBe(big);
});

it('rejects when the simulation returns an error instead of a result', async () => {
mockSimulateTransaction.mockResolvedValue({ error: 'host error: wasm vm', errorCode: 1 });
await expect(queryXlmBalance(RPC_URL, PASSPHRASE, ACCOUNT)).rejects.toThrow('Simulation error');
});

it('fetches the caller account via getAccount to build the balance() call', async () => {
mockSimulateTransaction.mockResolvedValue(simOk(1n));
await queryXlmBalance(RPC_URL, PASSPHRASE, ACCOUNT);
expect(mockGetAccount).toHaveBeenCalledWith(ACCOUNT);
});

it('passes a built transaction to simulateTransaction', async () => {
mockSimulateTransaction.mockResolvedValue(simOk(123_456_789n));
await queryXlmBalance(RPC_URL, PASSPHRASE, ACCOUNT);

expect(mockSimulateTransaction).toHaveBeenCalledTimes(1);
const tx = mockSimulateTransaction.mock.calls[0]![0] as { toXDR: unknown };
expect(typeof tx.toXDR).toBe('function');
});
});