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
87 changes: 87 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ export class ConduitError extends Error {
* host traps don't carry one.
*/
static fromSorobanMessage(contract: ConduitContract, message: string): Error {
// Check for WasmVm / InvalidAction which signals an insufficient balance
// on the Soroban host side (missing XLM for deposit + rent bump).
if (/Error\(WasmVm,\s*InvalidAction\)/.test(message)) {
return new InsufficientBalanceError(0n, 0n, message);
}
const match = /Error\(Contract,\s*#(\d+)\)/.exec(message);
if (!match || !match[1]) return new Error(message);
const code = Number(match[1]);
Expand All @@ -194,6 +199,88 @@ export class ConduitError extends Error {
}
}

// ── Network error ──────────────────────────────────────────────────────────────

/**
* Thrown when an RPC call fails due to a network-level error (e.g. Horizon /
* Soroban RPC is unreachable, DNS resolution failure, connection refused).
*
* Frontends can catch this and display a 'Network Offline' banner.
*
* @example
* ```ts
* try {
* const stream = await client.streams.get(1n);
* } catch (err) {
* if (err instanceof StreamFiNetworkError) {
* console.warn('Network offline:', err.cause);
* }
* }
* ```
*/
export class StreamFiNetworkError extends Error {
/** The underlying error that caused the network failure. */
readonly cause: unknown;

constructor(message: string, cause?: unknown) {
super(message);
this.name = 'StreamFiNetworkError';
this.cause = cause;
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ── Insufficient balance error ────────────────────────────────────────────────

/**
* Thrown when the user does not have enough XLM in their account to cover the
* deposit + rent bump (Soroban resource fees) required to create a stream.
*
* @example
* ```ts
* try {
* const result = await client.streams.create({ ... });
* } catch (err) {
* if (err instanceof InsufficientBalanceError) {
* console.error(err.message); // 'You need at least 5.123 XLM to create this stream.'
* console.error(err.currentBalance); // 1234567890n (in stroops)
* console.error(err.requiredBalance); // 5123456789n (in stroops)
* }
* }
* ```
*/
export class InsufficientBalanceError extends Error {
/** The account's current XLM balance in stroops. */
readonly currentBalance: bigint;
/** The minimum XLM balance required in stroops. */
readonly requiredBalance: bigint;

constructor(currentBalance: bigint, requiredBalance: bigint, detail?: string) {
const current = fromStroopsInternal(currentBalance, 7);
const required = fromStroopsInternal(requiredBalance, 7);
super(
detail
? `You need at least ${required} XLM to create this stream (you have ${current} XLM). (${detail})`
: `You need at least ${required} XLM to create this stream (you have ${current} XLM).`,
);
this.name = 'InsufficientBalanceError';
this.currentBalance = currentBalance;
this.requiredBalance = requiredBalance;
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Minimal internal version of `fromStroops` to avoid a circular dependency
* with `utils.ts` (which imports StreamInfo type).
*/
function fromStroopsInternal(stroops: bigint, decimals: number): string {
const factor = BigInt(10 ** decimals);
const whole = stroops / factor;
const frac = (stroops % factor).toString().padStart(decimals, '0').replace(/0+$/, '') || '0';
return `${whole}.${frac}`;
}


/**
* Thrown when an RPC node responds with HTTP 429 (Too Many Requests) or
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export {
FactoryErrorCode,
GovernorErrorCode,
UnsupportedChainError,
StreamFiNetworkError,
InsufficientBalanceError,
RateLimitError,
SUPPORTED_NETWORKS,
UNKNOWN_CONTRACT_ERROR_CODE,
} from './errors.js';
Expand Down
91 changes: 85 additions & 6 deletions src/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
TransactionBuilder,
Networks,
Contract,
Address,
Asset,
xdr,
BASE_FEE,
} from '@stellar/stellar-sdk';
import type { Network } from './types/index.js';
import type { Signer } from './signer.js';
import { RateLimitError } from './errors.js';
import { RateLimitError, StreamFiNetworkError, InsufficientBalanceError } from './errors.js';

// ── RPC Server cache ─────────────────────────────────────────────────────────
// Reusing SorobanRpc.Server instances avoids creating a new HTTP agent per
Expand Down Expand Up @@ -155,7 +157,7 @@ export async function buildContractCallTx(

let account;
try {
account = await server.getAccount(caller);
account = await catchNetworkError('getAccount', server.getAccount(caller));
} catch (err) {
throw RateLimitError.fromRpcError(err) ?? err;
}
Expand Down Expand Up @@ -188,7 +190,7 @@ export async function invokeContract(
// Simulate
let simResult;
try {
simResult = await server.simulateTransaction(tx);
simResult = await catchNetworkError('simulateTransaction (invoke)', server.simulateTransaction(tx));
} catch (err) {
throw RateLimitError.fromRpcError(err) ?? err;
}
Expand All @@ -205,7 +207,7 @@ export async function invokeContract(
// Submit
let sent;
try {
sent = await server.sendTransaction(assembled);
sent = await catchNetworkError('sendTransaction', server.sendTransaction(assembled));
} catch (err) {
throw RateLimitError.fromRpcError(err) ?? err;
}
Expand All @@ -219,7 +221,7 @@ export async function invokeContract(
await sleep(polling.pollIntervalMs);
let status;
try {
status = await server.getTransaction(hash);
status = await catchNetworkError('getTransaction', server.getTransaction(hash));
} catch (err) {
throw RateLimitError.fromRpcError(err) ?? err;
}
Expand All @@ -245,7 +247,7 @@ export async function simulateReadOnly(

let result;
try {
result = await server.simulateTransaction(tx);
result = await catchNetworkError('simulateTransaction (readonly)', server.simulateTransaction(tx));
} catch (err) {
throw RateLimitError.fromRpcError(err) ?? err;
}
Expand Down Expand Up @@ -341,3 +343,80 @@ export function boolToScVal(val: boolean): xdr.ScVal {
function sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms));
}

// ── Network error helpers ─────────────────────────────────────────────────────

/**
* Catches an RPC-level error (e.g. `TypeError: fetch failed`) and re-throws it
* as a `StreamFiNetworkError` so callers can distinguish network outages from
* contract-logic failures.
*/
export function catchNetworkError<T>(label: string, promise: Promise<T>): Promise<T> {
return promise.catch((cause: unknown) => {
if (cause instanceof StreamFiNetworkError || cause instanceof InsufficientBalanceError) {
throw cause;
}
if (cause instanceof TypeError && /fetch|network|connect|refused|dns|econnrefused|enotfound|etimedout/i.test(String(cause))) {
throw new StreamFiNetworkError(`Network error during ${label}: ${(cause as Error).message}`, cause);
}
if (cause && typeof cause === 'object' && 'code' in cause) {
const code = String((cause as { code: unknown }).code);
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|ERR_CONN/i.test(code)) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new StreamFiNetworkError(`Network error during ${label}: ${message}`, cause);
}
}
// Re-throw non-network errors as-is
throw cause;
});
}

// ── Account / balance helpers ──────────────────────────────────────────────────

/**
* Query the native XLM balance of an account, in stroops (1 XLM =
* 10_000_000 stroops).
*
* Soroban RPC's getAccount() returns an XDR-level Account (sequence number
* etc.) with no `.balances` field -- that's a Horizon-only concept. Native
* XLM balance on Soroban is queried the same way any SEP-41 token balance
* is: simulate a `balance(id)` call against the native asset's Stellar
* Asset Contract.
*/
export async function queryXlmBalance(
rpcUrl: string,
passphrase: string,
accountId: string,
): Promise<bigint> {
const nativeContractId = Asset.native().contractId(passphrase);
const tx = await buildContractCallTx(
rpcUrl, passphrase, accountId, nativeContractId, 'balance',
[new Address(accountId).toScVal()],
);
const val = await simulateReadOnly(rpcUrl, passphrase, tx);
return scValToI128(val);
}

/**
* Estimate the minimum resource fee required for a transaction simulation.
* If the simulation already failed with WasmVm, we return a lower bound based
* on typical Soroban contract call costs (this is a best-effort estimate).
*/
export function estimateRequiredFee(simResult: unknown, fallbackStroops = 5_000_000_000n): bigint {
if (simResult && typeof simResult === 'object') {
const r = simResult as { minResourceFee?: unknown; fee?: unknown };
// Try to extract minResourceFee from the simulation result. Note: an
// error-response simulation (the expected caller here, since this is
// only reached from within an isSimulationError() branch) carries
// neither field, so this intentionally falls through to the fallback.
if (r.minResourceFee !== undefined) {
const fee = BigInt(r.minResourceFee as string | number | bigint);
if (fee > 0n) return fee;
}
if (r.fee !== undefined) {
const fee = BigInt(r.fee as string | number | bigint);
if (fee > 0n) return fee;
}
}
return fallbackStroops; // ~500 XLM default upper-bound estimate
}
Loading
Loading