Skip to content
Open
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
94 changes: 47 additions & 47 deletions sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ import {
} from './utils';

import { SimulationError, RPCError } from './errors';
import {
RetryPolicy,
DEFAULT_RETRY_POLICY,
executeWithRetry,
estimateBaseFee,
IdempotencyTracker,
} from './retry';

// ─── Types ───────────────────────────────────────────────────────────────────

Expand All @@ -39,6 +46,12 @@ export interface bcForgeClientConfig {
networkPassphrase: string;
/** Deployed bc-forge token contract ID */
contractId: string;
/**
* Retry policy for write transactions.
* Partial overrides are merged with DEFAULT_RETRY_POLICY.
* Set `maxAttempts: 1` to disable retries entirely.
*/
retryPolicy?: Partial<RetryPolicy>;
}

export interface TransactionResult {
Expand All @@ -65,13 +78,15 @@ export class bcForgeClient {
private contractId: string;
private server: SorobanRpc.Server;
private contract: Contract;
private retryPolicy: RetryPolicy;

constructor(config: bcForgeClientConfig) {
this.rpcUrl = config.rpcUrl;
this.networkPassphrase = config.networkPassphrase;
this.contractId = config.contractId;
this.server = new SorobanRpc.Server(this.rpcUrl);
this.contract = new Contract(this.contractId);
this.retryPolicy = { ...DEFAULT_RETRY_POLICY, ...(config.retryPolicy ?? {}) };
}

// ─── Read-Only Queries ───────────────────────────────────────────────────
Expand Down Expand Up @@ -647,31 +662,13 @@ export class bcForgeClient {

// ─── Internal Helpers ────────────────────────────────────────────────────

/**
* Internal helper to execute a task with retries.
*/
private async withRetry<T>(fn: () => Promise<T>, retries: number = 3): Promise<T> {
let lastError: any;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
// Only retry on certain errors (e.g., network/RPC errors)
// For now, we retry on any error that isn't a known terminal error
if (i < retries - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
throw lastError;
}

/**
* Simulates a read-only contract call (no transaction submission).
* Uses a simple 3-attempt retry for transient RPC failures.
*/
private async queryContract(method: string, args: xdr.ScVal[]): Promise<xdr.ScVal> {
return this.withRetry(async () => {
let lastError: any;
for (let i = 0; i < 3; i++) {
try {
const account = new (await import('@stellar/stellar-sdk')).Account(
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
Expand Down Expand Up @@ -699,49 +696,52 @@ export class bcForgeClient {
return simulated.result.retval;
} catch (error: any) {
if (error instanceof SimulationError) throw error;
throw new RPCError('RPC call failed', error);
lastError = new RPCError('RPC call failed', error);
if (i < 2) await new Promise((r) => setTimeout(r, 1000 * (i + 1)));
}
});
}
throw lastError;
}

/**
* Builds, signs, submits, and polls a contract invocation transaction.
* Builds, signs, submits, and polls a contract invocation transaction with
* automatic retry, fee bumping, and nonce management via `executeWithRetry`.
*/
private async invokeContract(
method: string,
args: xdr.ScVal[],
source: Keypair,
): Promise<TransactionResult> {
return this.withRetry(async () => {
try {
const txXdr = await buildInvokeTransaction(
const initialFee = await estimateBaseFee(this.server);

const response = await executeWithRetry({
policy: this.retryPolicy,
initialFee,
buildTx: (fee) =>
buildInvokeTransaction(
this.rpcUrl,
this.networkPassphrase,
this.contractId,
method,
args,
source,
);

const response = await submitTransaction(this.rpcUrl, txXdr);
fee,
),
submitTx: (txXdr, tracker: IdempotencyTracker) =>
submitTransaction(this.rpcUrl, txXdr, tracker, this.networkPassphrase),
});

if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
return {
success: true,
hash: (response as any).hash,
returnValue: response.returnValue ? scValToNative(response.returnValue) : undefined,
};
}
if (response.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
return {
success: true,
hash: (response as any).hash,
returnValue: response.returnValue ? scValToNative(response.returnValue) : undefined,
};
}

return {
success: false,
hash: (response as any).hash,
};
} catch (error: any) {
// Don't retry on simulation errors (usually logic errors)
if (error instanceof SimulationError) throw error;
throw error;
}
});
return {
success: false,
hash: (response as any).hash,
};
}
}
63 changes: 63 additions & 0 deletions sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,66 @@ export class RPCError extends bcForgeError {
this.name = 'RPCError';
}
}

// ─── Soroban Transaction Error Taxonomy ──────────────────────────────────────

/**
* Thrown when a transaction is rejected because it arrived after its
* validity window closed (tx_too_late / txTOO_LATE).
*/
export class TxTooLateError extends bcForgeError {
readonly code = 'tx_too_late';
constructor(message: string, public readonly hash?: string) {
super(message);
this.name = 'TxTooLateError';
}
}

/**
* Thrown when the transaction fee is below the current network minimum
* (tx_insufficient_fee / txINSUFFICIENT_FEE).
*/
export class InsufficientFeeError extends bcForgeError {
readonly code = 'tx_insufficient_fee';
constructor(message: string, public readonly hash?: string) {
super(message);
this.name = 'InsufficientFeeError';
}
}

/**
* Thrown when the transaction sequence number does not match the account's
* current sequence (tx_bad_seq / txBAD_SEQ).
*/
export class BadSequenceError extends bcForgeError {
readonly code = 'tx_bad_seq';
constructor(message: string) {
super(message);
this.name = 'BadSequenceError';
}
}

/**
* Thrown when all retry attempts are exhausted without a successful result.
*/
export class MaxRetriesExceededError extends bcForgeError {
constructor(
message: string,
public readonly attempts: number,
public readonly lastError?: Error,
) {
super(message);
this.name = 'MaxRetriesExceededError';
}
}

/**
* Thrown when a fee bump is blocked because the fee has already reached
* the configured maxFeeStroops cap.
*/
export class FeeLimitExceededError extends bcForgeError {
constructor(message: string, public readonly currentFee: string) {
super(message);
this.name = 'FeeLimitExceededError';
}
}
27 changes: 27 additions & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,30 @@ export { buildInvokeTransaction, submitTransaction, scValToNative } from './util
export { bcForgeEventType, decodeEvent, decodeDiagnosticEvent, subscribeEvents } from './events';
export type { bcForgeEvent, SubscriptionOptions } from './events';
export * from './mockClient';

// Retry / fee-bumping
export {
DEFAULT_RETRY_POLICY,
executeWithRetry,
estimateBaseFee,
bumpFee,
classifyTxError,
isTransientError,
calculateBackoffDelay,
IdempotencyTracker,
} from './retry';
export type { RetryPolicy, SorobanTxErrorCode, RetryExecutorOptions } from './retry';

// Extended error taxonomy
export {
bcForgeError,
SimulationError,
TransactionSubmissionError,
TransactionTimeoutError,
RPCError,
TxTooLateError,
InsufficientFeeError,
BadSequenceError,
MaxRetriesExceededError,
FeeLimitExceededError,
} from './errors';
Loading
Loading