diff --git a/src/account/getAccount.ts b/src/account/getAccount.ts index 597c04b..604eae3 100644 --- a/src/account/getAccount.ts +++ b/src/account/getAccount.ts @@ -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. @@ -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 diff --git a/src/network/circuitBreaker.ts b/src/network/circuitBreaker.ts index 61f2758..67bcf12 100644 --- a/src/network/circuitBreaker.ts +++ b/src/network/circuitBreaker.ts @@ -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 * @@ -15,8 +15,6 @@ */ import { isTransientError } from "../shared/errors"; -import { err, SorokitErrorCode } from "../shared/response"; -import type { SorokitResult } from "../shared/response"; // ─── Public types ───────────────────────────────────────────────────────────── @@ -24,18 +22,13 @@ 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; /** @@ -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. */ @@ -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 ─────────────────────────────────────────────────────────── @@ -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) @@ -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; @@ -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, }; @@ -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(fn: () => Promise): Promise> { + async call(fn: () => Promise): Promise { 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. @@ -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. @@ -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; } @@ -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 @@ -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"); @@ -317,7 +269,7 @@ export class CircuitBreakerRegistry { } /** Convenience wrapper: call `fn` through the breaker for `endpoint`. */ - async call(endpoint: string, fn: () => Promise): Promise> { + async call(endpoint: string, fn: () => Promise): Promise { return this.getBreakerFor(endpoint).call(fn); } diff --git a/src/soroban/prepareCall.ts b/src/soroban/prepareCall.ts index 58f869f..a85bf60 100644 --- a/src/soroban/prepareCall.ts +++ b/src/soroban/prepareCall.ts @@ -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, }); diff --git a/src/tests/integration/streaming.test.ts b/src/tests/integration/streaming.test.ts index 8095d62..f983bc2 100644 --- a/src/tests/integration/streaming.test.ts +++ b/src/tests/integration/streaming.test.ts @@ -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( diff --git a/src/tests/transaction.test.ts b/src/tests/transaction.test.ts index 1b3dffb..04e2d62 100644 --- a/src/tests/transaction.test.ts +++ b/src/tests/transaction.test.ts @@ -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 { @@ -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); } @@ -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: [], + }; } } @@ -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( @@ -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 = { @@ -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", })) @@ -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", })) diff --git a/src/transaction/submitTransaction.ts b/src/transaction/submitTransaction.ts index 2092f51..d44ceff 100644 --- a/src/transaction/submitTransaction.ts +++ b/src/transaction/submitTransaction.ts @@ -17,8 +17,7 @@ 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, }); @@ -53,7 +52,9 @@ function detectNetworkPassphraseMismatch( let sourceAccountId = source; if (source.startsWith("M")) { try { - sourceAccountId = StrKey.decodeEd25519PublicKey(source); + sourceAccountId = StrKey.encodeEd25519PublicKey( + StrKey.decodeMed25519PublicKey(source).subarray(0, 32), + ); } catch { // If muxed account decoding fails, fall back to Horizon validation return false;