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
34 changes: 34 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,37 @@ export class OperationAbortedError extends Error {
}
}

// ── Confirmation timeout error (#596) ─────────────────────────────────────────

/**
* Thrown by `invokeContract()` when confirmation polling exceeds `maxAttempts`
* without reaching a terminal `SUCCESS` or `FAILED` status, and `strict: true`
* is enabled in the polling options.
*
* This allows callers to distinguish a confirmed transaction from an unconfirmed
* or timed-out submission, while preserving access to the submitted transaction `hash`.
*/
export class ConfirmationTimeoutError extends Error {
/** The transaction hash that was submitted to the network. */
readonly hash: string;
/** The number of poll attempts performed before timing out. */
readonly attempts: number;
/** The total polling duration in milliseconds before timing out. */
readonly timeoutMs: number;

constructor(hash: string, attempts: number, timeoutMs: number) {
super(
`Transaction confirmation timed out after ${attempts} attempts (${timeoutMs}ms): ${hash}. ` +
`The transaction was submitted and may still confirm on-chain.`,
);
this.name = 'ConfirmationTimeoutError';
this.hash = hash;
this.attempts = attempts;
this.timeoutMs = timeoutMs;
Object.setPrototypeOf(this, new.target.prototype);
}
}

// ── Type guard ────────────────────────────────────────────────────────────────

/**
Expand All @@ -506,6 +537,7 @@ export class OperationAbortedError extends Error {
* - {@link RpcServiceUnavailableError}
* - {@link IndexerTimeoutError}
* - {@link OperationAbortedError}
* - {@link ConfirmationTimeoutError}
*/
export function isConduitError(value: unknown): value is Error {
if (!(value instanceof Error)) return false;
Expand All @@ -518,5 +550,7 @@ export function isConduitError(value: unknown): value is Error {
'RpcServiceUnavailableError',
'IndexerTimeoutError',
'OperationAbortedError',
'ConfirmationTimeoutError',
].includes(value.name);
}

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ export {
RpcServiceUnavailableError,
IndexerTimeoutError,
OperationAbortedError,
ConfirmationTimeoutError,
isConduitError,
SUPPORTED_NETWORKS,

CAIP2_TO_NETWORK,
UNKNOWN_CONTRACT_ERROR_CODE,
} from './errors.js';
Expand Down
25 changes: 24 additions & 1 deletion src/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@stellar/stellar-sdk';
import type { Network } from './types/index.js';
import type { Signer } from './signer.js';
import { RateLimitError, StreamFiNetworkError, InsufficientBalanceError } from './errors.js';
import { RateLimitError, StreamFiNetworkError, InsufficientBalanceError, ConfirmationTimeoutError } from './errors.js';
import { withRetry } from './with-retry.js';

// ── RPC Server cache ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -76,6 +76,12 @@ export const NETWORK_PASSPHRASE: Record<Network, string> = {
export interface ConfirmationPollingOptions {
pollIntervalMs?: number;
maxAttempts?: number;
/**
* When `true`, `invokeContract` rejects with a typed {@link ConfirmationTimeoutError}
* if `maxAttempts` is reached without a terminal SUCCESS or FAILED status, instead
* of resolving the unconfirmed transaction hash as pending. Default is `false`.
*/
strict?: boolean;
}

export const DEFAULT_CONFIRMATION_POLL_INTERVAL_MS = 1000;
Expand All @@ -85,9 +91,11 @@ function normalizePollingOptions(options: ConfirmationPollingOptions = {}): Requ
return {
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_CONFIRMATION_POLL_INTERVAL_MS,
maxAttempts: options.maxAttempts ?? DEFAULT_CONFIRMATION_MAX_ATTEMPTS,
strict: options.strict ?? false,
};
}


/**
* Creates a SorobanRpc.Server instance wrapped with an exponential backoff retry mechanism.
* Retries on HTTP 429 rate limits (throttled — back off and retry the same
Expand Down Expand Up @@ -229,6 +237,13 @@ export async function invokeContract(
try {
status = await catchNetworkError('getTransaction', server.getTransaction(hash));
} catch (err) {
if (polling.strict) {
throw new ConfirmationTimeoutError(
hash,
i + 1,
(i + 1) * polling.pollIntervalMs,
);
}
// Transaction was already submitted; return the hash as pending.
// Polling failures don't indicate submission failure.
return hash;
Expand All @@ -240,8 +255,16 @@ export async function invokeContract(
throw new Error(`Transaction failed: ${hash}`);
}
}
if (polling.strict) {
throw new ConfirmationTimeoutError(
hash,
polling.maxAttempts,
polling.maxAttempts * polling.pollIntervalMs,
);
}
// Polling timed out but transaction was submitted; return hash as pending.
return hash;

}

/**
Expand Down
192 changes: 192 additions & 0 deletions src/tests/invoke-contract-confirmation-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ConfirmationTimeoutError, isConduitError } from '../errors.js';

const {
mockSimulateTransaction,
mockGetAccount,
mockSendTransaction,
mockGetTransaction,
mockAssembleTransaction,
} = vi.hoisted(() => ({
mockSimulateTransaction: vi.fn(),
mockGetAccount: vi.fn(),
mockSendTransaction: vi.fn(),
mockGetTransaction: vi.fn(),
mockAssembleTransaction: 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,
sendTransaction: mockSendTransaction,
getTransaction: mockGetTransaction,
};
}),
Api: (actual as any).SorobanRpc.Api,
assembleTransaction: mockAssembleTransaction,
},
};
});

import { invokeContract, clearServerCache } from '../soroban.js';

describe('invokeContract confirmation timeout (#596)', () => {
const TEST_RPC = 'http://localhost:8000/test-timeout';
const TEST_HASH = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';

beforeEach(() => {
vi.useFakeTimers();
clearServerCache();
mockSimulateTransaction.mockReset();
mockGetAccount.mockReset();
mockSendTransaction.mockReset();
mockGetTransaction.mockReset();
mockAssembleTransaction.mockReset().mockReturnValue({ build: () => ({ sign: vi.fn() }) });
});

afterEach(() => {
vi.useRealTimers();
});

it('ConfirmationTimeoutError creates well-formed error and is recognised by isConduitError', () => {
const err = new ConfirmationTimeoutError(TEST_HASH, 5, 5000);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(ConfirmationTimeoutError);
expect(err.name).toBe('ConfirmationTimeoutError');
expect(err.hash).toBe(TEST_HASH);
expect(err.attempts).toBe(5);
expect(err.timeoutMs).toBe(5000);
expect(err.message).toContain(TEST_HASH);
expect(err.message).toContain('timed out after 5 attempts (5000ms)');
expect(isConduitError(err)).toBe(true);
});

it('default behavior without strict: true resolves hash as pending on timeout', async () => {
const signer = { publicKey: () => 'GTEST', sign: vi.fn() };
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} }, transactionData: {} });
mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: TEST_HASH });
mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' });

const promise = invokeContract(
TEST_RPC,
'passphrase',
signer as any,
{} as any,
{ pollIntervalMs: 100, maxAttempts: 3 },
);

await vi.advanceTimersByTimeAsync(300);

const result = await promise;
expect(result).toBe(TEST_HASH);
expect(mockGetTransaction).toHaveBeenCalledTimes(3);
});

it('with strict: true rejects with ConfirmationTimeoutError after maxAttempts', async () => {
const signer = { publicKey: () => 'GTEST', sign: vi.fn() };
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} }, transactionData: {} });
mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: TEST_HASH });
mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' });

let caughtError: unknown;
const promise = invokeContract(
TEST_RPC,
'passphrase',
signer as any,
{} as any,
{ pollIntervalMs: 100, maxAttempts: 3, strict: true },
).catch((err) => {
caughtError = err;
});

await vi.advanceTimersByTimeAsync(300);
await promise;

expect(caughtError).toBeInstanceOf(ConfirmationTimeoutError);
const timeoutErr = caughtError as ConfirmationTimeoutError;
expect(timeoutErr.hash).toBe(TEST_HASH);
expect(timeoutErr.attempts).toBe(3);
expect(timeoutErr.timeoutMs).toBe(300);
expect(mockGetTransaction).toHaveBeenCalledTimes(3);
});

it('with strict: true resolves immediately when status is SUCCESS', async () => {
const signer = { publicKey: () => 'GTEST', sign: vi.fn() };
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} }, transactionData: {} });
mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: TEST_HASH });
mockGetTransaction
.mockResolvedValueOnce({ status: 'NOT_FOUND' })
.mockResolvedValueOnce({ status: 'SUCCESS' });

const promise = invokeContract(
TEST_RPC,
'passphrase',
signer as any,
{} as any,
{ pollIntervalMs: 100, maxAttempts: 5, strict: true },
);

await vi.advanceTimersByTimeAsync(200);
const result = await promise;

expect(result).toBe(TEST_HASH);
expect(mockGetTransaction).toHaveBeenCalledTimes(2);
});

it('with strict: true rejects with Error when status is FAILED', async () => {
const signer = { publicKey: () => 'GTEST', sign: vi.fn() };
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} }, transactionData: {} });
mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: TEST_HASH });
mockGetTransaction.mockResolvedValue({ status: 'FAILED' });

let caughtError: unknown;
const promise = invokeContract(
TEST_RPC,
'passphrase',
signer as any,
{} as any,
{ pollIntervalMs: 100, maxAttempts: 5, strict: true },
).catch((err) => {
caughtError = err;
});

await vi.advanceTimersByTimeAsync(100);
await promise;

expect(caughtError).toBeInstanceOf(Error);
expect((caughtError as Error).message).toContain(`Transaction failed: ${TEST_HASH}`);
});

it('with strict: true rejects with ConfirmationTimeoutError when polling network error occurs', async () => {
const signer = { publicKey: () => 'GTEST', sign: vi.fn() };
mockSimulateTransaction.mockResolvedValue({ result: { retval: {} }, transactionData: {} });
mockSendTransaction.mockResolvedValue({ status: 'PENDING', hash: TEST_HASH });
mockGetTransaction.mockRejectedValue(new Error('Network connection dropped'));

let caughtError: unknown;
const promise = invokeContract(
TEST_RPC,
'passphrase',
signer as any,
{} as any,
{ pollIntervalMs: 100, maxAttempts: 5, strict: true },
).catch((err) => {
caughtError = err;
});

await vi.advanceTimersByTimeAsync(100);
await promise;

expect(caughtError).toBeInstanceOf(ConfirmationTimeoutError);
const timeoutErr = caughtError as ConfirmationTimeoutError;
expect(timeoutErr.hash).toBe(TEST_HASH);
expect(timeoutErr.attempts).toBe(1);
});
});