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
8 changes: 1 addition & 7 deletions src/account/getAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ import { CircuitBreakerRegistry } from "../network/circuitBreaker";

// Shared circuit breaker registry for Horizon operations
const horizonCircuitBreaker = new CircuitBreakerRegistry({
requestWindow: 10,
failureRateThreshold: 0.5,
failureThreshold: 5,
recoveryWindowMs: 30_000,
});
import { CircuitBreakerRegistry } from "../network/circuitBreaker";

/**
* Fetch full account details including all balances from Horizon.
Expand Down Expand Up @@ -51,10 +49,6 @@ export function getAccount(
});
});

if (account.status === "error") {
return account;
}

const balances: AssetBalance[] = account.balances.map((b) => {
// Note: parseFloat is used here for convenience/backward compatibility.
// IEEE-754 doubles can represent integers up to ~9e15 exactly, and
Expand Down
82 changes: 17 additions & 65 deletions src/network/circuitBreaker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* called immediately (fail-fast) instead of retrying for 30+ seconds.
*
* State machine:
* CLOSED ──(50% failure rate across 10 requests)──▶ OPEN
* OPEN ──(30 s recovery window)───▶ HALF_OPEN
* CLOSED ──(5 consecutive transient failures)──▶ OPEN
* OPEN ──(60 s recovery window)───▶ HALF_OPEN
* HALF_OPEN ──(probe succeeds)─────▶ CLOSED
* HALF_OPEN ──(probe fails)────────▶ OPEN
*
Expand All @@ -15,27 +15,20 @@
*/

import { isTransientError } from "../shared/errors";
import { err, SorokitErrorCode } from "../shared/response";
import type { SorokitResult } from "../shared/response";

// ─── Public types ─────────────────────────────────────────────────────────────

export type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";

export interface CircuitBreakerConfig {
/**
* Number of requests to track for failure rate calculation.
* @default 10
* Number of consecutive transient failures required to trip the circuit.
* @default 5
*/
requestWindow?: number;
/**
* Failure rate threshold (0-1) that trips the circuit.
* @default 0.5 (50%)
*/
failureRateThreshold?: number;
failureThreshold?: number;
/**
* Milliseconds to wait in OPEN state before transitioning to HALF_OPEN.
* @default 30_000
* @default 60_000
*/
recoveryWindowMs?: number;
/**
Expand All @@ -58,10 +51,6 @@ export interface CircuitBreakerMetrics {
consecutiveFailures: number;
totalFailures: number;
totalSuccesses: number;
/** Number of requests in the current window. */
requestCount: number;
/** Current failure rate in the window (0-1). */
failureRate: number;
/** Epoch ms when the circuit was last opened, or null if never opened. */
lastOpenedAt: number | null;
/** Epoch ms when the circuit last transitioned to any state. */
Expand All @@ -86,9 +75,8 @@ export class CircuitOpenError extends Error {

// ─── Defaults ─────────────────────────────────────────────────────────────────

const DEFAULT_REQUEST_WINDOW = 10;
const DEFAULT_FAILURE_RATE_THRESHOLD = 0.5;
const DEFAULT_RECOVERY_WINDOW_MS = 30_000;
const DEFAULT_FAILURE_THRESHOLD = 5;
const DEFAULT_RECOVERY_WINDOW_MS = 60_000;

// ─── Implementation ───────────────────────────────────────────────────────────

Expand All @@ -106,12 +94,10 @@ export class CircuitBreaker {
private consecutiveFailures = 0;
private totalFailures = 0;
private totalSuccesses = 0;
private requestHistory: boolean[] = []; // true = success, false = failure
private lastOpenedAt: number | null = null;
private lastTransitionAt: number = Date.now();

private readonly requestWindow: number;
private readonly failureRateThreshold: number;
private readonly failureThreshold: number;
private readonly recoveryWindowMs: number;
private readonly onStateChange:
| ((event: CircuitStateChangeEvent) => void)
Expand All @@ -121,10 +107,8 @@ export class CircuitBreaker {
readonly endpoint: string,
config: CircuitBreakerConfig = {},
) {
this.requestWindow =
config.requestWindow ?? DEFAULT_REQUEST_WINDOW;
this.failureRateThreshold =
config.failureRateThreshold ?? DEFAULT_FAILURE_RATE_THRESHOLD;
this.failureThreshold =
config.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD;
this.recoveryWindowMs =
config.recoveryWindowMs ?? DEFAULT_RECOVERY_WINDOW_MS;
this.onStateChange = config.onStateChange ?? undefined;
Expand All @@ -138,14 +122,11 @@ export class CircuitBreaker {
}

getMetrics(): CircuitBreakerMetrics {
const failureRate = this.calculateFailureRate();
return {
state: this.currentState,
consecutiveFailures: this.consecutiveFailures,
totalFailures: this.totalFailures,
totalSuccesses: this.totalSuccesses,
requestCount: this.requestHistory.length,
failureRate,
lastOpenedAt: this.lastOpenedAt,
lastTransitionAt: this.lastTransitionAt,
};
Expand All @@ -157,24 +138,20 @@ export class CircuitBreaker {
* Execute `fn` through the circuit breaker.
*
* - CLOSED: execute normally; track failures and successes.
* - OPEN: return SERVICE_UNAVAILABLE error immediately without calling `fn`.
* - OPEN: throw `CircuitOpenError` immediately without calling `fn`.
* - HALF_OPEN: execute one probe; close on success, reopen on failure.
*/
async call<T>(fn: () => Promise<T>): Promise<SorokitResult<T>> {
async call<T>(fn: () => Promise<T>): Promise<T> {
this.checkRecovery();

if (this.state === "OPEN") {
return err(
SorokitErrorCode.SERVICE_UNAVAILABLE,
`Circuit breaker OPEN for "${this.endpoint}" — failing fast. ` +
`Opened at ${new Date(this.lastOpenedAt!).toISOString()}.`,
);
throw new CircuitOpenError(this.endpoint, this.lastOpenedAt!);
}

try {
const result = await fn();
this.onSuccess();
return { status: "ok", data: result };
return result;
} catch (error) {
// Only transient errors (5xx, timeout, network) count as circuit failures.
// Permanent errors (bad params, 404) pass through without tripping the circuit.
Expand All @@ -191,31 +168,10 @@ export class CircuitBreaker {
reset(): void {
this.transition("CLOSED");
this.consecutiveFailures = 0;
this.requestHistory = [];
}

// ─── Private helpers ────────────────────────────────────────────────────────

/**
* Calculate the current failure rate based on the request history window.
*/
private calculateFailureRate(): number {
if (this.requestHistory.length === 0) return 0;
const failures = this.requestHistory.filter((success) => !success).length;
return failures / this.requestHistory.length;
}

/**
* Record a request result in the sliding window.
*/
private recordRequest(success: boolean): void {
this.requestHistory.push(success);
// Keep only the most recent requests within the window
if (this.requestHistory.length > this.requestWindow) {
this.requestHistory.shift();
}
}

/**
* If the circuit is OPEN and the recovery window has elapsed,
* transition to HALF_OPEN to allow a probe request.
Expand All @@ -232,12 +188,10 @@ export class CircuitBreaker {

private onSuccess(): void {
this.totalSuccesses += 1;
this.recordRequest(true);

if (this.state === "HALF_OPEN") {
// Probe succeeded — close the circuit
this.consecutiveFailures = 0;
this.requestHistory = [];
this.transition("CLOSED");
return;
}
Expand All @@ -249,7 +203,6 @@ export class CircuitBreaker {
private onFailure(): void {
this.totalFailures += 1;
this.consecutiveFailures += 1;
this.recordRequest(false);

if (this.state === "HALF_OPEN") {
// Probe failed — reopen the circuit and restart the recovery clock
Expand All @@ -260,8 +213,7 @@ export class CircuitBreaker {

if (
this.state === "CLOSED" &&
this.calculateFailureRate() >= this.failureRateThreshold &&
this.requestHistory.length >= this.requestWindow
this.consecutiveFailures >= this.failureThreshold
) {
this.lastOpenedAt = Date.now();
this.transition("OPEN");
Expand Down Expand Up @@ -317,7 +269,7 @@ export class CircuitBreakerRegistry {
}

/** Convenience wrapper: call `fn` through the breaker for `endpoint`. */
async call<T>(endpoint: string, fn: () => Promise<T>): Promise<SorokitResult<T>> {
async call<T>(endpoint: string, fn: () => Promise<T>): Promise<T> {
return this.getBreakerFor(endpoint).call(fn);
}

Expand Down
3 changes: 1 addition & 2 deletions src/soroban/prepareCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ import { CircuitBreakerRegistry } from "../network/circuitBreaker";

// Shared circuit breaker registry for RPC operations
const rpcCircuitBreaker = new CircuitBreakerRegistry({
requestWindow: 10,
failureRateThreshold: 0.5,
failureThreshold: 5,
recoveryWindowMs: 30_000,
});

Expand Down
7 changes: 6 additions & 1 deletion src/tests/integration/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ describe("Integration: Account Streaming", () => {
expect(events[1].balances[0].balance).toBe("5.0");

expect(onBalanceChangeSpy).toHaveBeenCalledOnce();
expect(onBalanceChangeSpy).toHaveBeenCalledWith("XLM", "0.0", "5.0");
expect(onBalanceChangeSpy).toHaveBeenCalledWith(
expect.objectContaining({ code: "XLM" }),
"0.0",
"5.0",
"5",
);

expect(onAlertSpy).toHaveBeenCalledOnce();
expect(onAlertSpy).toHaveBeenCalledWith(
Expand Down
63 changes: 31 additions & 32 deletions src/tests/transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
afterEach,
type SpyInstance,
} from "vitest";
import { Asset, Horizon, Account, Keypair, Networks, StrKey, FeeBumpTransaction, Operation } from "@stellar/stellar-sdk";
import { Asset, Horizon, Account, Keypair, Networks, StrKey, FeeBumpTransaction, Operation, TransactionBuilder } from "@stellar/stellar-sdk";
import * as serverFactory from "../shared/serverFactory";
import { createHash } from "crypto";
import {
Expand Down Expand Up @@ -131,8 +131,10 @@ vi.mock("@stellar/stellar-sdk", async (importOriginal) => {
class MockTransactionBuilder {
static fromXDR = mocks.fromXDR;
memo?: unknown;
sourceAccount?: any;

constructor(_sourceAccount: unknown, _options: unknown) {
constructor(sourceAccount: unknown, _options: unknown) {
this.sourceAccount = sourceAccount;
transactionBuilderInstances.push(this);
}

Expand All @@ -155,7 +157,14 @@ vi.mock("@stellar/stellar-sdk", async (importOriginal) => {
build(...args: any[]) {
const customBuild = mockBuild(...args);
if (customBuild) return customBuild;
return { toXDR: () => MOCK_XDR };
const source = typeof this.sourceAccount === "string" ? this.sourceAccount : (this.sourceAccount as any)?.accountId?.() ?? (this.sourceAccount as any)?.publicKey;
return {
source,
toXDR: () => MOCK_XDR,
sign: vi.fn(),
hash: () => Buffer.alloc(32),
signatures: [],
};
}
}

Expand Down Expand Up @@ -399,26 +408,17 @@ describe("memo builders (#114)", () => {

describe("muxed account network passphrase detection (#381)", () => {
it("detects network mismatch for regular G-address accounts", async () => {
const sourcePublicKey = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA";
const keypair = Keypair.fromSecret(
"SAAPQAMBGM7T4KLLH6EJIFRFSLEOTBGYHSCIG47ETBAMKBRF42C2J7OZ",
);
const keypair = Keypair.random();
const sourcePublicKey = keypair.publicKey();

// Create a transaction signed for testnet
const testnetTx = new TransactionBuilder(
new Account(sourcePublicKey, "1"),
{ fee: "100", networkPassphrase: Networks.TESTNET },
)
.addOperation(Operation.payment({
destination: "GABBZAB7XBYRSX2NH6RQ5ZFAK3LWOO4SR7WKC6ANM5WFCZJDH6VTLTT",
asset: Asset.native(),
amount: "10",
}))
.setTimeout(30)
.build();
const mockTx = {
source: sourcePublicKey,
hash: () => Buffer.alloc(32),
signatures: [{ hint: () => Buffer.from(keypair.rawPublicKey().slice(-4)), signature: () => Buffer.alloc(64) }],
};
mocks.fromXDR.mockReturnValueOnce(mockTx);

testnetTx.sign(keypair);
const signedXdr = testnetTx.toXDR();
const signedXdr = "AAAAAQAAAAA=";

// Try to submit with mainnet passphrase
const result = await submitTransaction(
Expand All @@ -438,7 +438,8 @@ describe("muxed account network passphrase detection (#381)", () => {
it("handles muxed M-address accounts by extracting inner G-address", async () => {
// Test that the implementation can handle muxed addresses without crashing
// by mocking a transaction with a muxed source
const sourcePublicKey = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA";
const keypair = Keypair.random();
const sourcePublicKey = keypair.publicKey();

// Create a mock transaction with muxed source
const mockTx = {
Expand Down Expand Up @@ -468,17 +469,16 @@ describe("muxed account network passphrase detection (#381)", () => {
});

it("allows transactions with correct network passphrase for regular accounts", async () => {
const sourcePublicKey = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA";
const keypair = Keypair.fromSecret(
"SAAPQAMBGM7T4KLLH6EJIFRFSLEOTBGYHSCIG47ETBAMKBRF42C2J7OZ",
);
const keypair = Keypair.random();
const sourcePublicKey = keypair.publicKey();
const destPublicKey = Keypair.random().publicKey();

const testnetTx = new TransactionBuilder(
new Account(sourcePublicKey, "1"),
{ fee: "100", networkPassphrase: Networks.TESTNET },
)
.addOperation(Operation.payment({
destination: "GABBZAB7XBYRSX2NH6RQ5ZFAK3LWOO4SR7WKC6ANM5WFCZJDH6VTLTT",
destination: destPublicKey,
asset: Asset.native(),
amount: "10",
}))
Expand All @@ -505,17 +505,16 @@ describe("muxed account network passphrase detection (#381)", () => {
});

it("handles invalid muxed addresses gracefully", async () => {
const sourcePublicKey = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA";
const keypair = Keypair.fromSecret(
"SAAPQAMBGM7T4KLLH6EJIFRFSLEOTBGYHSCIG47ETBAMKBRF42C2J7OZ",
);
const keypair = Keypair.random();
const sourcePublicKey = keypair.publicKey();
const destPublicKey = Keypair.random().publicKey();

const testnetTx = new TransactionBuilder(
new Account(sourcePublicKey, "1"),
{ fee: "100", networkPassphrase: Networks.TESTNET },
)
.addOperation(Operation.payment({
destination: "GABBZAB7XBYRSX2NH6RQ5ZFAK3LWOO4SR7WKC6ANM5WFCZJDH6VTLTT",
destination: destPublicKey,
asset: Asset.native(),
amount: "10",
}))
Expand Down
Loading
Loading