From 46671ad9a5c866d8b2a9f497f0f7a4b1569e4015 Mon Sep 17 00:00:00 2001 From: Chidubem Mac-Anthony Date: Wed, 29 Jul 2026 18:14:46 +0100 Subject: [PATCH] Add timeout, retry-with-backoff to Horizon calls in stellar.ts The Horizon.Server constructor now receives an explicit 10s timeout for every request. Idempotent reads (getAccount/getBalances) are wrapped in retryWithBackoff (up to 3 retries, 500ms base delay, exponential backoff). sendPayment's submitTransaction is NOT retried to avoid user-perceived double-processing risk, though the timeout still applies. Tests cover: timeout config, retry on transient failure, retry exhaustion, non-retry of submission, and balance parsing. --- src/services/__tests__/stellar.test.ts | 147 +++++++++++++++++++++++++ src/services/stellar.ts | 33 +++++- 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/stellar.test.ts diff --git a/src/services/__tests__/stellar.test.ts b/src/services/__tests__/stellar.test.ts new file mode 100644 index 0000000..6215c6c --- /dev/null +++ b/src/services/__tests__/stellar.test.ts @@ -0,0 +1,147 @@ +jest.mock("@stellar/stellar-sdk", () => { + const actual = jest.requireActual("@stellar/stellar-sdk"); + return { + ...actual, + Horizon: { + Server: jest.fn().mockReturnValue({ + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }), + }, + }; +}); + +import { getAccount, getBalances, sendPayment } from "@/services/stellar"; +import * as StellarSdk from "@stellar/stellar-sdk"; + +const MockServer = StellarSdk.Horizon.Server as jest.Mock; + +let loadAccountMock: jest.Mock; +let submitTransactionMock: jest.Mock; +let serverTimeout: number; + +beforeAll(() => { + const instance = MockServer.mock.results[0].value; + loadAccountMock = instance.loadAccount; + submitTransactionMock = instance.submitTransaction; + const [, opts] = MockServer.mock.calls[0]; + serverTimeout = opts.timeout; +}); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +function mockAccount(key: string, sequence = "100") { + return new StellarSdk.Account(key, sequence); +} + +function mockBalances(key: string, balances: Array>) { + const acc = mockAccount(key); + Object.assign(acc, { balances }); + return acc as any; +} + +describe("Horizon timeout configuration", () => { + it("passes a timeout to the Server constructor", () => { + expect(serverTimeout).toEqual(expect.any(Number)); + }); +}); + +describe("getAccount", () => { + const key = StellarSdk.Keypair.random().publicKey(); + + it("retries on transient failure and succeeds", async () => { + const acc = mockAccount(key); + + loadAccountMock + .mockRejectedValueOnce(new Error("timeout")) + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce(acc); + + const result = await getAccount(key); + + expect(loadAccountMock).toHaveBeenCalledTimes(3); + expect(result).toBe(acc); + }); + + it("throws after exhausting retries", async () => { + loadAccountMock.mockRejectedValue(new Error("persistent error")); + + await expect(getAccount(key)).rejects.toThrow("persistent error"); + + expect(loadAccountMock).toHaveBeenCalledTimes(4); + }); +}); + +describe("getBalances", () => { + const key = StellarSdk.Keypair.random().publicKey(); + + it("retries via getAccount on underlying failure", async () => { + loadAccountMock + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce( + mockBalances(key, [{ asset_type: "native", balance: "100.0000000" }]), + ); + + const result = await getBalances(key); + + expect(loadAccountMock).toHaveBeenCalledTimes(2); + expect(result).toEqual({ XLM: "100.0000000" }); + }); + + it("parses native and non-native balances", async () => { + loadAccountMock.mockResolvedValue( + mockBalances(key, [ + { asset_type: "native", balance: "500.0000000" }, + { asset_code: "USDC", asset_type: "credit_alphanum4", balance: "200.0000000" }, + { asset_code: "ETH", asset_type: "credit_alphanum4", balance: "1.5000000" }, + ]), + ); + + const result = await getBalances(key); + + expect(result).toEqual({ XLM: "500.0000000", USDC: "200.0000000", ETH: "1.5000000" }); + }); +}); + +describe("sendPayment", () => { + it("retries getAccount (idempotent read) but not submitTransaction", async () => { + const keypair = StellarSdk.Keypair.random(); + const destKeypair = StellarSdk.Keypair.random(); + + loadAccountMock + .mockRejectedValueOnce(new Error("timeout")) + .mockResolvedValueOnce(mockAccount(keypair.publicKey())); + submitTransactionMock.mockRejectedValue(new Error("submission failed")); + + await expect( + sendPayment({ + sourceSecretKey: keypair.secret(), + destinationPublicKey: destKeypair.publicKey(), + asset: StellarSdk.Asset.native(), + amount: "10", + }), + ).rejects.toThrow("submission failed"); + + expect(loadAccountMock).toHaveBeenCalledTimes(2); + expect(submitTransactionMock).toHaveBeenCalledTimes(1); + }); + + it("happy path — returns submission result", async () => { + const keypair = StellarSdk.Keypair.random(); + + loadAccountMock.mockResolvedValue(mockAccount(keypair.publicKey())); + submitTransactionMock.mockResolvedValue({ hash: "abc", ledger: 1 }); + + const result = await sendPayment({ + sourceSecretKey: keypair.secret(), + destinationPublicKey: StellarSdk.Keypair.random().publicKey(), + asset: StellarSdk.Asset.native(), + amount: "10", + }); + + expect(result).toEqual({ hash: "abc", ledger: 1 }); + expect(submitTransactionMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 2f23879..f12db68 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -3,14 +3,43 @@ import * as StellarSdk from "@stellar/stellar-sdk"; const HORIZON_URL = process.env.EXPO_PUBLIC_HORIZON_URL ?? "https://horizon-testnet.stellar.org"; const NETWORK_PASSPHRASE = process.env.EXPO_PUBLIC_NETWORK_PASSPHRASE ?? StellarSdk.Networks.TESTNET; -const server = new StellarSdk.Horizon.Server(HORIZON_URL); +const HORIZON_TIMEOUT = 10_000; +const MAX_RETRIES = 3; +const BASE_BACKOFF_MS = 500; + +const server = new StellarSdk.Horizon.Server(HORIZON_URL, { + timeout: HORIZON_TIMEOUT, +} as any); + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function retryWithBackoff( + fn: () => Promise, + maxRetries: number = MAX_RETRIES, + baseDelay: number = BASE_BACKOFF_MS, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt < maxRetries) { + await delay(baseDelay * Math.pow(2, attempt)); + } + } + } + throw lastError; +} export function generateKeypair(): StellarSdk.Keypair { return StellarSdk.Keypair.random(); } export async function getAccount(publicKey: string) { - return server.loadAccount(publicKey); + return retryWithBackoff(() => server.loadAccount(publicKey)); } export async function getBalances(publicKey: string): Promise> {