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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http
- `StreamsModule.transferRecipient()` — wraps the contract's `transfer_recipient()` so the current recipient can reassign the recipient role (previously only a prose TODO) (#454)
- `NonceManager` (the bigint-based, queue-with-cancellation implementation) is now exported from the package entry point, along with its `NonceLock`/`NonceManagerOptions` types. Previously neither `NonceManager` implementation was reachable outside SDK source (#483).
- `subscribeToStream()` / `client.streams.subscribe()` now accept `maxBackoffMs` and `maxConsecutiveFailures` options controlling the new exponential-backoff-with-cutoff polling behaviour (#485).
- `paramToScVal(value, type?)` accepts an explicit ScVal type hint (`'u64'`, `'i128'`, ...) and passes already-encoded `xdr.ScVal`s through untouched; `BatchOperation.types` supplies per-field type information for the `params` map (e.g. `{ streamId: 'u64', amount: 'i128' }`) so a u64 stream ID or an i128 amount encodes with the exact width the contract expects instead of the default inference (#497).

### 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 @@ -29,6 +30,7 @@ All notable changes are documented here. Format based on [Keep a Changelog](http
- `u64ToScVal()` now rejects a non-integer `number` or a negative value with a clear `RangeError` naming the argument, and `estimateRequiredFee()` guards every `BigInt(...)` coercion (truncating a numeric input, catching an un-parseable one) and falls through to `fallbackStroops` — a non-conforming RPC response with a float `minResourceFee` no longer aborts a `create()` with a raw `RangeError` out of the fee-estimation path (#577).
- `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).
- **Breaking (encoded ScVal types):** `paramToScVal()` no longer forces every integer `number` to `i64` and every `bigint` to `i128`. Untyped positive integers now encode as `u64` and negatives as `i64` — matching the contract's u64 ABI fields (`create_stream`'s `start_time`/`end_time`, stream IDs), which previously arrived with the wrong ScVal type and were rejected contract-side (#497).

### Removed
- Removed orphaned `RoomManager` (`src/room-manager.js`) and `src/server.js` WebSocket server, along with unused `dotenv` production dependency (#442).
Expand All @@ -49,6 +51,8 @@ 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
- `ConduitBatcher.execute()`'s `create_stream` path now builds ABI-exact positional args — `deposit_amount`/`rate_per_sec` as `i128`, `start_time`/`end_time` as `u64` (honoring `startTime`/`endTime`/`clawbackEnabled` when present) — instead of feeding raw values through the old blanket i64/i128 encoding (#497).
- Removed a duplicate `let consecutiveFailures = 0;` declaration in `src/events.ts` that made the module a `SyntaxError` at load time, breaking `npm run typecheck`, the `npm ci` build, and every event-subscription test.
- `GraphQLIndexer.subscribe()` no longer tears down silently when the WebSocket closes. An unexpected close calls `onError` and retries with the same linear backoff as `WebSocketRelayer` (`reconnectDelayMs * attempt`, default 5 attempts / 1000ms). The subscription is removed only after the retry budget is exhausted or the caller unsubscribes. Optional `maxReconnectAttempts` / `reconnectDelayMs` are rejected if they are not integers in range. SSE fallback is unchanged (#514).
- `FactoryModule` now resolves its read-simulation source address from a configured `wallet` adapter (lazily, `async`, cached, invalidated on the new `FactoryModule.setWallet()`), matching `StreamsModule._resolveCallerAddress`. Previously it pinned `keypair?.publicKey() ?? ZERO_ADDR` at construction, so a wallet-configured client used `ZERO_ADDR` for every factory read while `StreamsModule` resolved the wallet — the same logical caller resolved differently per module (#570).
- `FactoryModule.streamAddress()` now caches a `null` (not-found) result for a short TTL (`NEGATIVE_ADDRESS_CACHE_TTL_MS`, 30s), and adds `FactoryModule.clearAddressCache()`. Previously only a *found* address was cached, so a dashboard polling a `list()` page containing a few archived/pending ids re-issued a `stream_address` simulation for each of them on every refresh (#568).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ The `StreamBuilder` class exposes the following chainable methods:

### Batching Streams

You can bundle multiple stream operations together and compile them using `ConduitBatcher`. `execute()` alone only knows how to turn arbitrary objects into a generic map argument — to invoke the real `create_stream` contract, build each stream's `BatchOperation` with `toBatchOperation()` and submit through `executeAsync()`:
You can bundle multiple stream operations together and compile them using `ConduitBatcher`. `execute()` turns each item into the ABI-exact positional `create_stream` args when the default method is used (amount/rate encoded as `i128`, `startTime`/`endTime` as `u64`), falling back to a generic sorted-map argument for other methods. To invoke the real `create_stream` contract with full control (including validated, defaulted `start_time`/`end_time`/`clawback_enabled`), build each stream's `BatchOperation` with `toBatchOperation()` and submit through `executeAsync()`:

```typescript
import { StreamBuilder, ConduitBatcher } from '@conduit-protocol/sdk';
Expand Down
6 changes: 4 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,9 @@ const result = await new StreamBuilder()

A utility class to bundle multiple stream operations with mandatory client-side validation. `execute`/`executeAsync` are **instance methods** — instantiate with `new ConduitBatcher()` first (see [`examples/fluent-builder.ts`](../examples/fluent-builder.ts)).

> **Building real `create_stream` calls:** `execute()` takes plain `Record<string, unknown>[]` and, with no `args`, encodes each item as a single sorted map keyed by whatever properties it happens to have — it has no knowledge of any contract's ABI. Passing raw `StreamBuilder.build()` output to it therefore does **not** produce a valid `create_stream` invocation (wrong key casing, `amount` encoded as `i64` instead of `i128`, and no `start_time`/`end_time`/`clawback_enabled` at all). To actually invoke `create_stream`, build a `BatchOperation` with `StreamBuilder.toBatchOperation()` (which supplies the correct positional, ABI-typed `args`) and pass it to `executeAsync()`.
> **Building real `create_stream` calls:** `execute()` takes plain `Record<string, unknown>[]`. With the default `create_stream` method it builds the exact positional, ABI-typed args (`deposit_amount`/`rate_per_sec` as `i128`, `start_time`/`end_time` as `u64`, honoring `startTime`/`endTime`/`clawbackEnabled` when present); for other methods it encodes each item as a single sorted map keyed by whatever properties it happens to have. `execute()` does no ABI validation (a missing `ratePerSecond` silently becomes `0`, for example), so for a fully validated `create_stream` invocation build a `BatchOperation` with `StreamBuilder.toBatchOperation()` (which supplies the correct positional, ABI-typed `args`) and pass it to `executeAsync()`.
>
> **Integer encoding:** `paramToScVal()` no longer forces every integer `number` to `i64` and every `bigint` to `i128`. Untyped positive integers now encode as `u64` (matching the contract's `start_time`/`end_time`/stream-ID types) and negatives as `i64`; pass an explicit type (`paramToScVal(value, 'i128')`) or a per-field `BatchOperation.types` hint (e.g. `{ streamId: 'u64', amount: 'i128' }`) to force a specific width, and already-encoded `xdr.ScVal`s pass through untouched (#497).

#### Methods

Expand Down Expand Up @@ -671,7 +673,7 @@ if (!result.success) {
}
```

* `executeAsync(operations: BatchOperation[], signalOrOptions?: AbortSignal | BatchExecuteAsyncOptions): Promise<BatchResult>` - Asynchronously execute a batch with abort signal / options support. `BatchOperation.args`, when present, is used verbatim as the contract's positional arguments (see `StreamBuilder.toBatchOperation()`).
* `executeAsync(operations: BatchOperation[], signalOrOptions?: AbortSignal | BatchExecuteAsyncOptions): Promise<BatchResult>` - Asynchronously execute a batch with abort signal / options support. `BatchOperation.args`, when present, is used verbatim as the contract's positional arguments (see `StreamBuilder.toBatchOperation()`). `BatchOperation.types` supplies per-field ScVal type hints for the `params` map (e.g. `{ method: 'withdraw', params: { streamId: 1n }, types: { streamId: 'u64' } }`) so u64 stream IDs and i128 amounts encode with the correct width instead of the default inference (#497).

**Throws:** `Error` if batcher is destroyed.

Expand Down
56 changes: 43 additions & 13 deletions src/batch-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,33 @@ export function validateContext(context: BatchTransactionContext): string[] {
return errors;
}

/**
* ScVal type names accepted by {@link paramToScVal} (and per-field `types` on
* batch operations) to force a specific encoding instead of the default
* inference.
*/
export type ScValType =
| 'u32' | 'i32' | 'u64' | 'i64' | 'u128' | 'i128' | 'u256' | 'i256'
| 'string' | 'symbol' | 'address' | 'bool' | 'bytes';

/**
* Convert a single parameter to an ScVal.
*
* Strings that are valid Stellar addresses become `Address` values rather than
* string values — passing a G- or C-address as a plain string is a common way
* to build a transaction the contract then rejects.
* - Already-encoded `xdr.ScVal` values pass through untouched — their type is
* already explicit, so re-encoding would destroy it.
* - An explicit `type` hint wins over every heuristic (see
* {@link BatchOperation.types} for per-field hints in the params map).
* - Strings that are valid Stellar addresses become `Address` values rather
* than string values — passing a G- or C-address as a plain string is a
* common way to build a transaction the contract then rejects.
* - Everything else falls back to the SDK's natural encoding: positive
* integers become `u64` and negatives `i64`. This is what the contract
* expects for the u64-heavy ABI fields such as `create_stream`'s
* `start_time`/`end_time` and stream IDs; previously every integer `number`
* was forced to `i64` and every `bigint` to `i128`, producing the wrong
* ScVal type and a contract-side type error (see #497).
*/
export function paramToScVal(value: unknown): xdr.ScVal {
export function paramToScVal(value: unknown, type?: ScValType): xdr.ScVal {
// Values with no ScVal representation (symbols, functions, undefined) map to
// void rather than throwing — validation has already accepted the payload, so
// a stray non-serialisable field must not take the whole batch down.
Expand All @@ -165,15 +184,20 @@ export function paramToScVal(value: unknown): xdr.ScVal {
if (value === null) {
return xdr.ScVal.scvVoid();
}
if (typeof value === 'string' && (StrKey.isValidEd25519PublicKey(value) || StrKey.isValidContract(value))) {
return new Address(value).toScVal();
if (value instanceof xdr.ScVal) {
return value;
}
if (typeof value === 'bigint') {
return nativeToScVal(value, { type: 'i128' });

// An explicit type hint means the caller knows the contract ABI — trust it
// over any heuristic below (e.g. `{ streamId: 'u64' }`, `{ amount: 'i128' }`).
if (type !== undefined) {
return nativeToScVal(value, { type });
}
if (typeof value === 'number' && Number.isInteger(value)) {
return nativeToScVal(value, { type: 'i64' });

if (typeof value === 'string' && (StrKey.isValidEd25519PublicKey(value) || StrKey.isValidContract(value))) {
return new Address(value).toScVal();
}

return nativeToScVal(value);
}

Expand All @@ -182,14 +206,18 @@ export function paramToScVal(value: unknown): xdr.ScVal {
*
* `args` wins when present, so a caller who knows the contract ABI controls the
* positional arguments exactly. Otherwise `params` is passed as a single map
* argument, keyed by field name.
* argument, keyed by field name, with `types` supplying per-field ScVal type
* information when the default inference would pick the wrong type (e.g. a
* u64 stream ID, which untyped would encode as a string after bigint
* serialisation, or an i128 amount).
*/
export function operationToScVals(operation: {
params?: Record<string, unknown> | undefined;
types?: Record<string, ScValType> | undefined;
args?: unknown[] | undefined;
}): xdr.ScVal[] {
if (Array.isArray(operation.args)) {
return operation.args.map(paramToScVal);
return operation.args.map(arg => paramToScVal(arg));
}

const params = operation.params ?? {};
Expand All @@ -204,7 +232,7 @@ export function operationToScVals(operation: {
.map(([key, value]) =>
new xdr.ScMapEntry({
key: nativeToScVal(key, { type: 'symbol' }),
val: paramToScVal(value),
val: paramToScVal(value, operation.types?.[key]),
}),
),
),
Expand All @@ -214,6 +242,8 @@ export function operationToScVals(operation: {
interface BuildableOperation {
method: string;
params?: Record<string, unknown> | undefined;
/** Per-field ScVal type hints for `params` map entries (see #497). */
types?: Record<string, ScValType> | undefined;
args?: unknown[] | undefined;
}

Expand Down
32 changes: 23 additions & 9 deletions src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { boolToScVal } from './soroban.js';
import {
buildBatchTransactions,
buildBatchTransactionsSync,
paramToScVal,
validateContext,
} from './batch-tx.js';
import type { BatchTransactionContext, BuiltBatchTransaction } from './batch-tx.js';
import type { BatchTransactionContext, BuiltBatchTransaction, ScValType } from './batch-tx.js';

export interface SubmitOptions {
maxRetries?: number;
Expand Down Expand Up @@ -440,6 +441,13 @@ export interface BatchOperation {
* single map argument.
*/
args?: unknown[];
/**
* Per-field ScVal type hints for the `params` map, keyed by field name
* (e.g. `{ streamId: 'u64' }` for a u64 stream ID, `{ amount: 'i128' }`
* for an i128 amount). Without a hint the default inference applies —
* positive integers encode as `u64`, negatives as `i64` (see #497).
*/
types?: Record<string, ScValType>;
}

export interface BatchExecuteOptions {
Expand Down Expand Up @@ -657,15 +665,21 @@ export class ConduitBatcher {
const method = options.method ?? 'create_stream';
const operations = sanitized.map(params => {
if (method === 'create_stream') {
// ABI-exact create_stream args: (sender, recipient, token,
// deposit_amount: i128, rate_per_sec: i128, start_time: u64,
// end_time: u64, clawback_enabled: bool). Previously these raw values
// were run through paramToScVal's blanket i64/i128 encoding, so
// start_time/end_time arrived as i64 and amounts as the wrong width;
// each arg is now typed explicitly (see #497).
const args = [
params.sender,
params.recipient,
params.token,
params.amount,
params.ratePerSecond,
0, // start_time
0, // end_time
false, // clawback
paramToScVal(params.sender),
paramToScVal(params.recipient),
paramToScVal(params.token),
paramToScVal(params.amount, 'i128'),
paramToScVal(params.ratePerSecond, 'i128'),
paramToScVal(params.startTime ?? 0, 'u64'),
paramToScVal(params.endTime ?? 0, 'u64'),
boolToScVal(params.clawbackEnabled === true),
];
return { method, args };
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
export type {
BatchTransactionContext,
BuiltBatchTransaction,
ScValType,
BatchSubmitResult,
BatchTxOutcome,
BatchTxStatus,
Expand Down
Loading