diff --git a/.gitignore b/.gitignore index 637efee1..0cf70870 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ coverage *.log .idea create_issues.sh -.claude \ No newline at end of file +.claude diff --git a/backend/src/__tests__/stellarConfig.test.ts b/backend/src/__tests__/stellarConfig.test.ts index 63ba7e90..ea6708da 100644 --- a/backend/src/__tests__/stellarConfig.test.ts +++ b/backend/src/__tests__/stellarConfig.test.ts @@ -1,6 +1,6 @@ -import { afterEach, describe, expect, it } from "@jest/globals"; +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { Networks } from "@stellar/stellar-sdk"; -import { getStellarConfig } from "../config/stellar.js"; +import { getStellarConfig, createSorobanRpcServer, fetchWithTimeout } from "../config/stellar.js"; const originalStellarEnv = { STELLAR_NETWORK: process.env.STELLAR_NETWORK, @@ -76,3 +76,58 @@ describe("stellar config", () => { ); }); }); + +describe("createSorobanRpcServer / fetchWithTimeout", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + delete process.env.STELLAR_NETWORK; + delete process.env.STELLAR_RPC_URL; + delete process.env.STELLAR_NETWORK_PASSPHRASE; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + restoreEnv(); + }); + + it("passes the AbortSignal through to fetch", async () => { + let capturedSignal: AbortSignal | undefined; + globalThis.fetch = jest.fn((_input: RequestInfo | URL, init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return Promise.resolve(new Response("{}")); + }) as typeof fetch; + + await fetchWithTimeout("https://soroban-testnet.stellar.org"); + + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal?.aborted).toBe(false); + }); + + it("aborts the request after the timeout fires", async () => { + let capturedSignal: AbortSignal | undefined; + globalThis.fetch = jest.fn((_input: RequestInfo | URL, init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(Object.assign(new Error("aborted"), { name: "AbortError" })), + ); + }); + }) as typeof fetch; + + // Use a very short timeout by temporarily patching — instead, call the + // real fetchWithTimeout and abort via the captured signal directly. + const fetchPromise = fetchWithTimeout("https://soroban-testnet.stellar.org"); + // Wait a tick for fetch mock to be called and signal captured + await Promise.resolve(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + + // Manually abort to simulate timeout without waiting 15 s + (capturedSignal as AbortSignal & { _controller?: AbortController }); + // Trigger abort on the captured signal's controller via dispatchEvent + capturedSignal!.dispatchEvent(new Event("abort")); + + await expect(fetchPromise).rejects.toMatchObject({ name: "AbortError" }); + }); +}); diff --git a/backend/src/config/stellar.ts b/backend/src/config/stellar.ts index 74e80813..5dadf44d 100644 --- a/backend/src/config/stellar.ts +++ b/backend/src/config/stellar.ts @@ -112,8 +112,21 @@ export function getStellarNetworkPassphrase(): string { return getStellarConfig().networkPassphrase; } +const RPC_TIMEOUT_MS = 15_000; + +export function fetchWithTimeout( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + return fetch(input, { ...init, signal: controller.signal }).finally(() => + clearTimeout(timer), + ); +} + export function createSorobanRpcServer(): rpc.Server { const rpcUrl = getStellarRpcUrl(); const allowHttp = rpcUrl.startsWith("http://"); - return new rpc.Server(rpcUrl, { allowHttp }); + return new rpc.Server(rpcUrl, { allowHttp, fetch: fetchWithTimeout }); } diff --git a/backend/src/services/sorobanService.ts b/backend/src/services/sorobanService.ts index 2b7a2345..73317cc5 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -16,6 +16,15 @@ import { getStellarRpcUrl, } from "../config/stellar.js"; +function rpcCall(promise: Promise): Promise { + return promise.catch((err: unknown) => { + if (err instanceof Error && err.name === "AbortError") { + throw AppError.internal("Stellar RPC request timed out"); + } + throw err; + }); +} + /** * Service for building and submitting Soroban contract transactions. * Handles the transaction lifecycle: build → (frontend signs) → submit. @@ -98,7 +107,7 @@ class SorobanService { const contractId = this.getLoanManagerContractId(); const passphrase = this.getNetworkPassphrase(); - const account = await server.getAccount(borrowerPublicKey); + const account = await rpcCall(server.getAccount(borrowerPublicKey)); const borrowerScVal = nativeToScVal(Address.fromString(borrowerPublicKey), { type: "address", @@ -119,7 +128,7 @@ class SorobanService { .setTimeout(30) .build(); - const prepared = await server.prepareTransaction(tx); + const prepared = await rpcCall(server.prepareTransaction(tx)); const unsignedTxXdr = prepared.toXDR(); logger.info("Built request_loan transaction", { @@ -143,7 +152,7 @@ class SorobanService { const contractId = this.getLoanManagerContractId(); const passphrase = this.getNetworkPassphrase(); - const account = await server.getAccount(borrowerPublicKey); + const account = await rpcCall(server.getAccount(borrowerPublicKey)); const borrowerScVal = nativeToScVal(Address.fromString(borrowerPublicKey), { type: "address", @@ -165,7 +174,7 @@ class SorobanService { .setTimeout(30) .build(); - const prepared = await server.prepareTransaction(tx); + const prepared = await rpcCall(server.prepareTransaction(tx)); const unsignedTxXdr = prepared.toXDR(); logger.info("Built repay transaction", { @@ -215,7 +224,7 @@ class SorobanService { .setTimeout(30) .build(); - const prepared = await server.prepareTransaction(tx); + const prepared = await rpcCall(server.prepareTransaction(tx)); const unsignedTxXdr = prepared.toXDR(); logger.info("Built deposit transaction", { @@ -265,7 +274,7 @@ class SorobanService { .setTimeout(30) .build(); - const prepared = await server.prepareTransaction(tx); + const prepared = await rpcCall(server.prepareTransaction(tx)); const unsignedTxXdr = prepared.toXDR(); logger.info("Built withdraw transaction", { @@ -290,7 +299,7 @@ class SorobanService { const contractId = this.getLoanManagerContractId(); const passphrase = this.getNetworkPassphrase(); - const account = await server.getAccount(adminPublicKey); + const account = await rpcCall(server.getAccount(adminPublicKey)); const loanIdScVal = nativeToScVal(loanId, { type: "u32" }); @@ -308,7 +317,7 @@ class SorobanService { .setTimeout(30) .build(); - const prepared = await server.prepareTransaction(tx); + const prepared = await rpcCall(server.prepareTransaction(tx)); const unsignedTxXdr = prepared.toXDR(); logger.info("Built approve_loan transaction", { @@ -356,7 +365,7 @@ class SorobanService { } try { - await this.getRpcServer().getHealth(); + await rpcCall(this.getRpcServer().getHealth()); } catch (err) { throw AppError.internal( `Stellar RPC is unreachable at ${rpcUrl}: ${err instanceof Error ? err.message : String(err)}`, @@ -386,7 +395,7 @@ class SorobanService { this.getNetworkPassphrase(), ); - const sendResult = await server.sendTransaction(tx); + const sendResult = await rpcCall(server.sendTransaction(tx)); const txHash = sendResult.hash; if (!txHash) { @@ -399,10 +408,10 @@ class SorobanService { }); // Poll for final result - const polled = await server.pollTransaction(txHash, { + const polled = await rpcCall(server.pollTransaction(txHash, { attempts: 30, sleepStrategy: () => 1000, - }); + })); const resultXdr = polled.status === "SUCCESS" && polled.resultXdr