From 04eece1eee8cc9af7fb66dcd095dad9955d9a2ea Mon Sep 17 00:00:00 2001 From: Jerry Musaga Date: Sat, 30 May 2026 17:45:02 +0100 Subject: [PATCH] feat(sdk): add transaction retry with fee bumping and nonce management --- sdk/src/client.ts | 94 ++++++------ sdk/src/errors.ts | 63 ++++++++ sdk/src/index.ts | 27 ++++ sdk/src/retry.test.ts | 323 ++++++++++++++++++++++++++++++++++++++++++ sdk/src/retry.ts | 287 +++++++++++++++++++++++++++++++++++++ sdk/src/utils.ts | 24 +++- 6 files changed, 764 insertions(+), 54 deletions(-) create mode 100644 sdk/src/retry.test.ts create mode 100644 sdk/src/retry.ts diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 5afb91c..15cd321 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -29,6 +29,13 @@ import { } from './utils'; import { SimulationError, RPCError } from './errors'; +import { + RetryPolicy, + DEFAULT_RETRY_POLICY, + executeWithRetry, + estimateBaseFee, + IdempotencyTracker, +} from './retry'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -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; } export interface TransactionResult { @@ -65,6 +78,7 @@ export class bcForgeClient { private contractId: string; private server: SorobanRpc.Server; private contract: Contract; + private retryPolicy: RetryPolicy; constructor(config: bcForgeClientConfig) { this.rpcUrl = config.rpcUrl; @@ -72,6 +86,7 @@ export class bcForgeClient { 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 ─────────────────────────────────────────────────── @@ -647,31 +662,13 @@ export class bcForgeClient { // ─── Internal Helpers ──────────────────────────────────────────────────── - /** - * Internal helper to execute a task with retries. - */ - private async withRetry(fn: () => Promise, retries: number = 3): Promise { - 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 { - 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', @@ -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 { - 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, + }; } } diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 03c9a73..6df938d 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -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'; + } +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d88a55e..a7341ab 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -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'; diff --git a/sdk/src/retry.test.ts b/sdk/src/retry.test.ts new file mode 100644 index 0000000..074c25f --- /dev/null +++ b/sdk/src/retry.test.ts @@ -0,0 +1,323 @@ +/** + * @bc-forge/sdk — Tests for retry logic, fee bumping, and nonce management + */ + +import { SorobanRpc } from '@stellar/stellar-sdk'; +import { + bumpFee, + calculateBackoffDelay, + classifyTxError, + executeWithRetry, + isTransientError, + IdempotencyTracker, + DEFAULT_RETRY_POLICY, +} from './retry'; +import { + InsufficientFeeError, + TxTooLateError, + BadSequenceError, + MaxRetriesExceededError, + FeeLimitExceededError, + RPCError, +} from './errors'; + +// ─── classifyTxError ───────────────────────────────────────────────────────── + +// Mock mirrors what xdrgen produces: .result() returns a union with .switch() +const makeResult = (codeName: string) => + ({ + result: () => ({ switch: () => ({ name: codeName }) }), + }) as any; + +describe('classifyTxError', () => { + it('maps txTOO_LATE', () => { + expect(classifyTxError(makeResult('txTOO_LATE'))).toBe('tx_too_late'); + }); + + it('maps txINSUFFICIENT_FEE', () => { + expect(classifyTxError(makeResult('txINSUFFICIENT_FEE'))).toBe('tx_insufficient_fee'); + }); + + it('maps txBAD_SEQ', () => { + expect(classifyTxError(makeResult('txBAD_SEQ'))).toBe('tx_bad_seq'); + }); + + it('returns fatal for unknown codes', () => { + expect(classifyTxError(makeResult('txBAD_AUTH'))).toBe('fatal'); + }); + + it('returns fatal when errorResult is undefined', () => { + expect(classifyTxError(undefined)).toBe('fatal'); + }); + + it('returns fatal when xdr parsing throws', () => { + expect( + classifyTxError({ result: () => { throw new Error('parse error'); } } as any), + ).toBe('fatal'); + }); +}); + +// ─── bumpFee ────────────────────────────────────────────────────────────────── + +describe('bumpFee', () => { + it('multiplies the fee', () => { + expect(bumpFee('100', 1.5, '10000000')).toBe('150'); + }); + + it('rounds up fractional stroops', () => { + expect(bumpFee('101', 1.5, '10000000')).toBe('152'); + }); + + it('caps at maxFeeStroops', () => { + expect(bumpFee('9000000', 1.5, '10000000')).toBe('10000000'); + }); + + it('throws FeeLimitExceededError when already at cap', () => { + expect(() => bumpFee('10000000', 1.5, '10000000')).toThrow(FeeLimitExceededError); + }); + + it('throws FeeLimitExceededError when above cap', () => { + expect(() => bumpFee('10000001', 1.5, '10000000')).toThrow(FeeLimitExceededError); + }); +}); + +// ─── calculateBackoffDelay ──────────────────────────────────────────────────── + +describe('calculateBackoffDelay', () => { + const policy = { + ...DEFAULT_RETRY_POLICY, + initialDelayMs: 1000, + backoffMultiplier: 2, + maxDelayMs: 30000, + }; + + it('returns initialDelayMs on first retry (attempt 0)', () => { + expect(calculateBackoffDelay(0, policy)).toBe(1000); + }); + + it('doubles on each attempt', () => { + expect(calculateBackoffDelay(1, policy)).toBe(2000); + expect(calculateBackoffDelay(2, policy)).toBe(4000); + }); + + it('caps at maxDelayMs', () => { + expect(calculateBackoffDelay(10, policy)).toBe(30000); + }); +}); + +// ─── isTransientError ──────────────────────────────────────────────────────── + +describe('isTransientError', () => { + it('returns true for RPCError', () => { + expect(isTransientError(new RPCError('rpc failed'))).toBe(true); + }); + + it('returns true for network error messages', () => { + expect(isTransientError(new Error('ECONNRESET'))).toBe(true); + expect(isTransientError(new Error('503 service unavailable'))).toBe(true); + expect(isTransientError(new Error('429 too many requests'))).toBe(true); + }); + + it('returns false for non-retryable errors', () => { + expect(isTransientError(new Error('unauthorized'))).toBe(false); + expect(isTransientError(new InsufficientFeeError('fee too low'))).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isTransientError('string error')).toBe(false); + expect(isTransientError(null)).toBe(false); + }); +}); + +// ─── IdempotencyTracker ─────────────────────────────────────────────────────── + +describe('IdempotencyTracker', () => { + it('records and queries hashes', () => { + const tracker = new IdempotencyTracker(); + expect(tracker.has('abc')).toBe(false); + tracker.record('abc'); + expect(tracker.has('abc')).toBe(true); + }); + + it('lists all recorded hashes', () => { + const tracker = new IdempotencyTracker(); + tracker.record('hash1'); + tracker.record('hash2'); + expect(tracker.hashes()).toEqual(expect.arrayContaining(['hash1', 'hash2'])); + expect(tracker.hashes()).toHaveLength(2); + }); +}); + +// ─── executeWithRetry ───────────────────────────────────────────────────────── + +const SUCCESS_RESPONSE = { + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + hash: 'success-hash', +} as unknown as SorobanRpc.Api.GetTransactionResponse; + +describe('executeWithRetry', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('returns result on first attempt when no error', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest.fn().mockResolvedValue(SUCCESS_RESPONSE); + + const result = await executeWithRetry({ buildTx, submitTx }); + expect(result).toBe(SUCCESS_RESPONSE); + expect(buildTx).toHaveBeenCalledTimes(1); + expect(buildTx).toHaveBeenCalledWith('100'); + }); + + it('bumps fee and retries on InsufficientFeeError (no delay)', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest + .fn() + .mockRejectedValueOnce(new InsufficientFeeError('fee too low')) + .mockResolvedValueOnce(SUCCESS_RESPONSE); + + // InsufficientFeeError retries immediately (no setTimeout), so no timer advance needed. + const result = await executeWithRetry({ + buildTx, + submitTx, + policy: { maxAttempts: 3 }, + }); + + expect(result).toBe(SUCCESS_RESPONSE); + expect(buildTx).toHaveBeenCalledTimes(2); + const firstFee = Number(buildTx.mock.calls[0][0]); + const secondFee = Number(buildTx.mock.calls[1][0]); + expect(secondFee).toBeGreaterThan(firstFee); + }); + + it('retries on TxTooLateError with backoff', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest + .fn() + .mockRejectedValueOnce(new TxTooLateError('too late')) + .mockResolvedValueOnce(SUCCESS_RESPONSE); + + const promise = executeWithRetry({ + buildTx, + submitTx, + policy: { maxAttempts: 3, initialDelayMs: 100 }, + }); + + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe(SUCCESS_RESPONSE); + expect(buildTx).toHaveBeenCalledTimes(2); + // Fee should not change on tx_too_late + expect(buildTx.mock.calls[0][0]).toBe(buildTx.mock.calls[1][0]); + }); + + it('retries on BadSequenceError with backoff', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest + .fn() + .mockRejectedValueOnce(new BadSequenceError('bad seq')) + .mockResolvedValueOnce(SUCCESS_RESPONSE); + + const promise = executeWithRetry({ + buildTx, + submitTx, + policy: { maxAttempts: 3, initialDelayMs: 100 }, + }); + + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe(SUCCESS_RESPONSE); + expect(buildTx).toHaveBeenCalledTimes(2); + }); + + it('retries on transient RPCError with backoff', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest + .fn() + .mockRejectedValueOnce(new RPCError('network blip')) + .mockResolvedValueOnce(SUCCESS_RESPONSE); + + const promise = executeWithRetry({ + buildTx, + submitTx, + policy: { maxAttempts: 3, initialDelayMs: 100 }, + }); + + await jest.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe(SUCCESS_RESPONSE); + expect(buildTx).toHaveBeenCalledTimes(2); + }); + + it('throws MaxRetriesExceededError after all attempts exhausted', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest.fn().mockRejectedValue(new RPCError('always failing')); + + const promise = executeWithRetry({ + buildTx, + submitTx, + policy: { maxAttempts: 3, initialDelayMs: 100 }, + }); + + // Attach rejection handler before advancing timers to avoid unhandled rejection warning. + const expectation = expect(promise).rejects.toThrow(MaxRetriesExceededError); + await jest.runAllTimersAsync(); + await expectation; + expect(buildTx).toHaveBeenCalledTimes(3); + }); + + it('throws FeeLimitExceededError immediately when fee cap is reached', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest + .fn() + .mockRejectedValueOnce(new InsufficientFeeError('fee too low')); + + // Already at cap → bumpFee throws FeeLimitExceededError on first retry + await expect( + executeWithRetry({ + buildTx, + submitTx, + initialFee: '10000000', + policy: { maxAttempts: 3, maxFeeStroops: '10000000' }, + }), + ).rejects.toThrow(FeeLimitExceededError); + + expect(buildTx).toHaveBeenCalledTimes(1); + }); + + it('does not retry on non-retryable errors', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const fatalErr = new Error('contract logic error'); + const submitTx = jest.fn().mockRejectedValue(fatalErr); + + await expect( + executeWithRetry({ buildTx, submitTx, policy: { maxAttempts: 5 } }), + ).rejects.toThrow('contract logic error'); + + expect(buildTx).toHaveBeenCalledTimes(1); + }); + + it('records submitted hash in idempotency tracker', async () => { + let capturedTracker: IdempotencyTracker | undefined; + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest.fn().mockImplementation((_xdr, tracker) => { + capturedTracker = tracker; + tracker.record('tx-hash-123'); + return Promise.resolve(SUCCESS_RESPONSE); + }); + + await executeWithRetry({ buildTx, submitTx }); + expect(capturedTracker?.has('tx-hash-123')).toBe(true); + }); + + it('uses initialFee provided by caller', async () => { + const buildTx = jest.fn().mockResolvedValue('xdr'); + const submitTx = jest.fn().mockResolvedValue(SUCCESS_RESPONSE); + + await executeWithRetry({ buildTx, submitTx, initialFee: '5000' }); + expect(buildTx).toHaveBeenCalledWith('5000'); + }); +}); diff --git a/sdk/src/retry.ts b/sdk/src/retry.ts new file mode 100644 index 0000000..0977577 --- /dev/null +++ b/sdk/src/retry.ts @@ -0,0 +1,287 @@ +/** + * @bc-forge/sdk — Transaction retry logic with fee bumping and nonce management. + * + * Handles the three primary Soroban failure modes: + * - tx_too_late → rebuild with fresh timeout + * - tx_insufficient_fee → bump inclusion fee and rebuild + * - tx_bad_seq → refresh account sequence and rebuild + * Plus transient RPC/network errors with exponential backoff. + */ + +import { SorobanRpc, xdr } from '@stellar/stellar-sdk'; +import { + TxTooLateError, + InsufficientFeeError, + BadSequenceError, + MaxRetriesExceededError, + FeeLimitExceededError, + RPCError, + TransactionSubmissionError, +} from './errors'; + +// ─── RetryPolicy ───────────────────────────────────────────────────────────── + +export interface RetryPolicy { + /** Maximum number of submission attempts (including the first try). */ + maxAttempts: number; + /** Milliseconds to wait before the first retry. */ + initialDelayMs: number; + /** Upper bound on delay between retries (milliseconds). */ + maxDelayMs: number; + /** Multiplier applied to delay after each transient failure. */ + backoffMultiplier: number; + /** Factor by which the inclusion fee is multiplied on each fee-bump retry. */ + feeBumpMultiplier: number; + /** Hard ceiling on the inclusion fee (stroops). Prevents runaway fee escalation. */ + maxFeeStroops: string; +} + +export const DEFAULT_RETRY_POLICY: RetryPolicy = { + maxAttempts: 5, + initialDelayMs: 1000, + maxDelayMs: 30_000, + backoffMultiplier: 2, + feeBumpMultiplier: 1.5, + maxFeeStroops: '10000000', // 1 XLM +}; + +// ─── Error taxonomy helpers ─────────────────────────────────────────────────── + +export type SorobanTxErrorCode = + | 'tx_too_late' + | 'tx_insufficient_fee' + | 'tx_bad_seq' + | 'transient_rpc' + | 'fatal'; + +/** + * Map an xdr.TransactionResult error code to our internal taxonomy. + */ +export function classifyTxError(errorResult?: xdr.TransactionResult): SorobanTxErrorCode { + if (!errorResult) return 'fatal'; + try { + // .result() returns the TransactionResultResult union; .switch() returns the discriminant. + const name: string = (errorResult.result() as any).switch().name; + if (name === 'txTOO_LATE') return 'tx_too_late'; + if (name === 'txINSUFFICIENT_FEE') return 'tx_insufficient_fee'; + if (name === 'txBAD_SEQ') return 'tx_bad_seq'; + return 'fatal'; + } catch { + return 'fatal'; + } +} + +/** + * Convert an ERROR send-response into the appropriate typed error. + */ +export function errorFromSendResponse( + sendResponse: SorobanRpc.Api.SendTransactionResponse, +): TxTooLateError | InsufficientFeeError | BadSequenceError | TransactionSubmissionError { + const code = classifyTxError(sendResponse.errorResult); + const hash = sendResponse.hash; + switch (code) { + case 'tx_too_late': + return new TxTooLateError('Transaction rejected: tx_too_late (validity window closed)', hash); + case 'tx_insufficient_fee': + return new InsufficientFeeError( + 'Transaction rejected: tx_insufficient_fee (fee below network minimum)', + hash, + ); + case 'tx_bad_seq': + return new BadSequenceError( + 'Transaction rejected: tx_bad_seq (sequence number conflict)', + ); + default: + return new TransactionSubmissionError( + `Transaction rejected: ${String(sendResponse.errorResult)}`, + hash, + ); + } +} + +/** + * Return true for transient network / RPC errors that are safe to retry. + */ +export function isTransientError(error: unknown): boolean { + if (error instanceof RPCError) return true; + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return ( + msg.includes('econnreset') || + msg.includes('econnrefused') || + msg.includes('etimedout') || + msg.includes('network') || + msg.includes('socket') || + msg.includes('503') || + msg.includes('429') || + msg.includes('too many requests') + ); + } + return false; +} + +// ─── Fee helpers ────────────────────────────────────────────────────────────── + +/** + * Bump an inclusion fee by `multiplier`, capped at `maxFeeStroops`. + * Throws FeeLimitExceededError if already at the cap. + */ +export function bumpFee(currentFee: string, multiplier: number, maxFeeStroops: string): string { + const current = Number(currentFee); + const max = Number(maxFeeStroops); + if (current >= max) { + throw new FeeLimitExceededError( + `Fee is already at the maximum cap of ${maxFeeStroops} stroops`, + currentFee, + ); + } + const bumped = Math.ceil(current * multiplier); + return Math.min(bumped, max).toString(); +} + +/** + * Estimate a good base inclusion fee using the RPC fee statistics endpoint. + * Falls back to `fallbackFee` if the endpoint is unavailable. + */ +export async function estimateBaseFee( + server: SorobanRpc.Server, + fallbackFee: string = '100', +): Promise { + try { + const stats = await server.getFeeStats(); + // Use the p99 inclusion fee from recent ledgers as a safe starting point. + const p99 = stats.inclusionFee?.p99; + if (p99 && Number(p99) > 0) return p99; + return fallbackFee; + } catch { + return fallbackFee; + } +} + +// ─── Backoff helper ─────────────────────────────────────────────────────────── + +/** + * Calculate the exponential backoff delay for a given attempt index (0-based). + */ +export function calculateBackoffDelay(attempt: number, policy: RetryPolicy): number { + const delay = policy.initialDelayMs * Math.pow(policy.backoffMultiplier, attempt); + return Math.min(delay, policy.maxDelayMs); +} + +// ─── Idempotency tracker ────────────────────────────────────────────────────── + +/** + * Tracks transaction hashes submitted within a single retry session so that + * a timed-out transaction can be re-checked before issuing a fresh submission. + */ +export class IdempotencyTracker { + private readonly submitted = new Set(); + + record(hash: string): void { + this.submitted.add(hash); + } + + has(hash: string): boolean { + return this.submitted.has(hash); + } + + hashes(): string[] { + return Array.from(this.submitted); + } +} + +// ─── Core retry executor ────────────────────────────────────────────────────── + +/** + * Options accepted by `executeWithRetry`. + */ +export interface RetryExecutorOptions { + /** Retry policy. Defaults to DEFAULT_RETRY_POLICY. */ + policy?: Partial; + /** + * Called before each (re)attempt to obtain a fresh signed transaction XDR. + * Receives the current inclusion fee (stroops) so the caller can rebuild + * with a bumped fee when needed. + */ + buildTx: (fee: string) => Promise; + /** + * Submits a signed XDR and waits for confirmation. + * Should throw typed errors (TxTooLateError, InsufficientFeeError, etc.) + * on failure so the executor can classify and handle them. + */ + submitTx: (txXdr: string, tracker: IdempotencyTracker) => Promise; + /** + * Optional starting fee (stroops). Defaults to '100'. + * Callers may pass a fee obtained from `estimateBaseFee`. + */ + initialFee?: string; +} + +/** + * Execute a Soroban transaction with intelligent retry, fee bumping, and + * nonce-refresh logic. + * + * The function retries on: + * - tx_too_late → rebuild (fresh timeout / ledger bounds) + * - tx_insufficient_fee → bump fee and rebuild + * - tx_bad_seq → rebuild (caller re-fetches account sequence) + * - transient RPC errors → exponential back-off and rebuild + * + * It tracks submitted hashes for idempotency: if a timeout occurs and the + * original hash later confirms on-chain, that result is returned without + * issuing a duplicate submission. + */ +export async function executeWithRetry( + opts: RetryExecutorOptions, +): Promise { + const policy: RetryPolicy = { ...DEFAULT_RETRY_POLICY, ...(opts.policy ?? {}) }; + const tracker = new IdempotencyTracker(); + let currentFee = opts.initialFee ?? '100'; + let lastError: Error | undefined; + + for (let attempt = 0; attempt < policy.maxAttempts; attempt++) { + try { + const txXdr = await opts.buildTx(currentFee); + return await opts.submitTx(txXdr, tracker); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + + if (err instanceof FeeLimitExceededError) { + // Can't bump further — surface immediately. + throw err; + } + + if (err instanceof InsufficientFeeError) { + currentFee = bumpFee(currentFee, policy.feeBumpMultiplier, policy.maxFeeStroops); + // No delay: rebuild with higher fee immediately. + continue; + } + + if (err instanceof BadSequenceError || err instanceof TxTooLateError) { + // Rebuild picks up a fresh account sequence / timeout; brief pause first. + await sleep(calculateBackoffDelay(attempt, policy)); + continue; + } + + if (isTransientError(err)) { + await sleep(calculateBackoffDelay(attempt, policy)); + continue; + } + + // Non-retryable error (SimulationError, logic error, etc.) + throw err; + } + } + + throw new MaxRetriesExceededError( + `Transaction failed after ${policy.maxAttempts} attempts`, + policy.maxAttempts, + lastError, + ); +} + +// ─── Internal ───────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/sdk/src/utils.ts b/sdk/src/utils.ts index 404686a..5434439 100644 --- a/sdk/src/utils.ts +++ b/sdk/src/utils.ts @@ -16,6 +16,7 @@ import { } from '@stellar/stellar-sdk'; import { SimulationError, TransactionSubmissionError, TransactionTimeoutError } from './errors'; +import { errorFromSendResponse, IdempotencyTracker } from './retry'; /** * Builds an `invokeHostFunction` transaction for a Soroban contract call. @@ -35,6 +36,8 @@ export async function buildInvokeTransaction( method: string, args: xdr.ScVal[], sourceKeypair: Keypair, + /** Inclusion fee in stroops. Defaults to '100'. Bump this on tx_insufficient_fee. */ + inclusionFee: string = '100', ): Promise { const server = new SorobanRpc.Server(rpcUrl); const sourceAccount = await server.getAccount(sourceKeypair.publicKey()); @@ -42,7 +45,7 @@ export async function buildInvokeTransaction( const contract = new Contract(contractId); const tx = new TransactionBuilder(sourceAccount, { - fee: '100', + fee: inclusionFee, networkPassphrase, }) .addOperation(contract.call(method, ...args)) @@ -65,25 +68,32 @@ export async function buildInvokeTransaction( /** * Submits a signed transaction XDR to the Soroban RPC and waits for confirmation. * - * @param rpcUrl - The Soroban RPC endpoint URL. - * @param txXdr - The signed transaction in XDR format. + * Throws typed errors (TxTooLateError, InsufficientFeeError, BadSequenceError) + * so that `executeWithRetry` can classify and handle each failure mode. + * + * @param rpcUrl - The Soroban RPC endpoint URL. + * @param txXdr - The signed transaction in XDR format. + * @param tracker - Optional idempotency tracker for the current retry session. + * @param networkPassphrase - Network passphrase for XDR parsing (defaults to TESTNET). * @returns The transaction result from the ledger. */ export async function submitTransaction( rpcUrl: string, txXdr: string, + tracker?: IdempotencyTracker, + networkPassphrase: string = Networks.TESTNET, ): Promise { const server = new SorobanRpc.Server(rpcUrl); - const tx = TransactionBuilder.fromXDR(txXdr, Networks.TESTNET); + const tx = TransactionBuilder.fromXDR(txXdr, networkPassphrase); const sendResponse = await server.sendTransaction(tx); if (sendResponse.status === 'ERROR') { - throw new TransactionSubmissionError( - `Transaction submission failed: ${sendResponse.errorResult}`, - ); + throw errorFromSendResponse(sendResponse); } + tracker?.record(sendResponse.hash); + // Poll for completion let getResponse: SorobanRpc.Api.GetTransactionResponse; let attempts = 0;