From baa8f3f2660b70c4f43bb91b4440f449b3fb90cf Mon Sep 17 00:00:00 2001 From: Blessings Abel Date: Mon, 30 Mar 2026 14:47:36 +0000 Subject: [PATCH 1/5] chore: remove Kiro IDE settings, add to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a7045c46..885428fc 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage *.log .idea create_issues.sh +.kiro/ From 7113ce404da6e4aec43659d17df7388d92e7d8db Mon Sep 17 00:00:00 2001 From: Blessings Abel Date: Sun, 29 Mar 2026 22:14:32 +0000 Subject: [PATCH 2/5] fix(backend): enforce 15s connection timeout on all Stellar RPC calls - Add fetchWithTimeout in stellar.ts and inject it into rpc.Server so every SDK HTTP call (getAccount, prepareTransaction, sendTransaction, getHealth, pollTransaction) is subject to a hard 15-second abort. - Wrap all server.* calls in sorobanService.ts with rpcCall() which converts AbortError into a clean AppError.internal timeout message. - Add two tests to stellarConfig.test.ts covering signal injection and abort-on-timeout behaviour. --- backend/src/__tests__/stellarConfig.test.ts | 66 +++++++++++++++++++-- backend/src/config/stellar.ts | 15 ++++- backend/src/services/sorobanService.ts | 37 +++++++----- 3 files changed, 99 insertions(+), 19 deletions(-) diff --git a/backend/src/__tests__/stellarConfig.test.ts b/backend/src/__tests__/stellarConfig.test.ts index 63ba7e90..bb338094 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 } from "../config/stellar.js"; const originalStellarEnv = { STELLAR_NETWORK: process.env.STELLAR_NETWORK, @@ -33,8 +33,7 @@ afterEach(() => { restoreEnv(); }); -describe("stellar config", () => { - it("defaults to testnet settings when env vars are absent", () => { +describe("stellar config", () => { it("defaults to testnet settings when env vars are absent", () => { delete process.env.STELLAR_NETWORK; delete process.env.STELLAR_RPC_URL; delete process.env.STELLAR_NETWORK_PASSPHRASE; @@ -76,3 +75,62 @@ 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; + + const server = createSorobanRpcServer(); + // Trigger any HTTP call; getHealth is the lightest one + await (server as unknown as { _fetch: typeof fetch })._fetch?.("https://soroban-testnet.stellar.org", {}); + + // Directly invoke the custom fetch that was injected + const customFetch = (globalThis.fetch as jest.Mock); + expect(customFetch).toBeDefined(); + }); + + 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; + + const TIMEOUT = 50; + function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT); + return globalThis.fetch(input, { ...init, signal: controller.signal }).finally(() => + clearTimeout(timer), + ); + } + + await expect( + fetchWithTimeout("https://soroban-testnet.stellar.org"), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(capturedSignal?.aborted).toBe(true); + }); +}); diff --git a/backend/src/config/stellar.ts b/backend/src/config/stellar.ts index 74e80813..deaca775 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; + +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 545cec2b..07682165 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -14,6 +14,15 @@ import { getStellarNetworkPassphrase, } 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. @@ -69,7 +78,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), @@ -91,7 +100,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", { @@ -115,7 +124,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), @@ -138,7 +147,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", { @@ -163,7 +172,7 @@ class SorobanService { const contractId = this.getLendingPoolContractId(); const passphrase = this.getNetworkPassphrase(); - const account = await server.getAccount(depositorPublicKey); + const account = await rpcCall(server.getAccount(depositorPublicKey)); const providerScVal = nativeToScVal( Address.fromString(depositorPublicKey), @@ -189,7 +198,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", { @@ -213,7 +222,7 @@ class SorobanService { const contractId = this.getLendingPoolContractId(); const passphrase = this.getNetworkPassphrase(); - const account = await server.getAccount(depositorPublicKey); + const account = await rpcCall(server.getAccount(depositorPublicKey)); const providerScVal = nativeToScVal( Address.fromString(depositorPublicKey), @@ -239,7 +248,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", { @@ -263,7 +272,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" }); @@ -281,7 +290,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", { @@ -318,7 +327,7 @@ class SorobanService { } try { - await this.getRpcServer().getHealth(); + await rpcCall(this.getRpcServer().getHealth()); } catch (err) { throw AppError.internal( `Stellar RPC is unreachable at ${process.env.STELLAR_RPC_URL || "https://soroban-testnet.stellar.org"}: ${err instanceof Error ? err.message : String(err)}`, @@ -349,7 +358,7 @@ class SorobanService { this.getNetworkPassphrase(), ); - const sendResult = await server.sendTransaction(tx); + const sendResult = await rpcCall(server.sendTransaction(tx)); const txHash = sendResult.hash; if (!txHash) { @@ -362,7 +371,7 @@ class SorobanService { }); // Poll for final result - const polled = await server.pollTransaction(txHash, { + const polled = await rpcCall(server.pollTransaction(txHash, { attempts: 30, sleepStrategy: () => 1000, }); @@ -385,7 +394,7 @@ class SorobanService { async ping(): Promise<"ok" | "error"> { try { const server = this.getRpcServer(); - await server.getHealth(); + await rpcCall(server.getHealth()); return "ok"; } catch { return "error"; From 430c12666b20e498c0e2be9683972bb200caadeb Mon Sep 17 00:00:00 2001 From: Blessings Abel Date: Tue, 31 Mar 2026 03:13:37 +0000 Subject: [PATCH 3/5] fix: export fetchWithTimeout, fix unclosed paren in rpcCall, test real function --- backend/src/__tests__/stellarConfig.test.ts | 37 ++++++++++----------- backend/src/config/stellar.ts | 2 +- backend/src/services/sorobanService.ts | 2 +- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/backend/src/__tests__/stellarConfig.test.ts b/backend/src/__tests__/stellarConfig.test.ts index bb338094..ea6708da 100644 --- a/backend/src/__tests__/stellarConfig.test.ts +++ b/backend/src/__tests__/stellarConfig.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; import { Networks } from "@stellar/stellar-sdk"; -import { getStellarConfig, createSorobanRpcServer } from "../config/stellar.js"; +import { getStellarConfig, createSorobanRpcServer, fetchWithTimeout } from "../config/stellar.js"; const originalStellarEnv = { STELLAR_NETWORK: process.env.STELLAR_NETWORK, @@ -33,7 +33,8 @@ afterEach(() => { restoreEnv(); }); -describe("stellar config", () => { it("defaults to testnet settings when env vars are absent", () => { +describe("stellar config", () => { + it("defaults to testnet settings when env vars are absent", () => { delete process.env.STELLAR_NETWORK; delete process.env.STELLAR_RPC_URL; delete process.env.STELLAR_NETWORK_PASSPHRASE; @@ -98,13 +99,10 @@ describe("createSorobanRpcServer / fetchWithTimeout", () => { return Promise.resolve(new Response("{}")); }) as typeof fetch; - const server = createSorobanRpcServer(); - // Trigger any HTTP call; getHealth is the lightest one - await (server as unknown as { _fetch: typeof fetch })._fetch?.("https://soroban-testnet.stellar.org", {}); + await fetchWithTimeout("https://soroban-testnet.stellar.org"); - // Directly invoke the custom fetch that was injected - const customFetch = (globalThis.fetch as jest.Mock); - expect(customFetch).toBeDefined(); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal?.aborted).toBe(false); }); it("aborts the request after the timeout fires", async () => { @@ -118,19 +116,18 @@ describe("createSorobanRpcServer / fetchWithTimeout", () => { }); }) as typeof fetch; - const TIMEOUT = 50; - function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), TIMEOUT); - return globalThis.fetch(input, { ...init, signal: controller.signal }).finally(() => - clearTimeout(timer), - ); - } + // 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); - await expect( - fetchWithTimeout("https://soroban-testnet.stellar.org"), - ).rejects.toMatchObject({ name: "AbortError" }); + // 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")); - expect(capturedSignal?.aborted).toBe(true); + await expect(fetchPromise).rejects.toMatchObject({ name: "AbortError" }); }); }); diff --git a/backend/src/config/stellar.ts b/backend/src/config/stellar.ts index deaca775..5dadf44d 100644 --- a/backend/src/config/stellar.ts +++ b/backend/src/config/stellar.ts @@ -114,7 +114,7 @@ export function getStellarNetworkPassphrase(): string { const RPC_TIMEOUT_MS = 15_000; -function fetchWithTimeout( +export function fetchWithTimeout( input: RequestInfo | URL, init?: RequestInit, ): Promise { diff --git a/backend/src/services/sorobanService.ts b/backend/src/services/sorobanService.ts index 07682165..2d527351 100644 --- a/backend/src/services/sorobanService.ts +++ b/backend/src/services/sorobanService.ts @@ -374,7 +374,7 @@ class SorobanService { const polled = await rpcCall(server.pollTransaction(txHash, { attempts: 30, sleepStrategy: () => 1000, - }); + })); const resultXdr = polled.status === "SUCCESS" && polled.resultXdr From c6846a792240b8c841716b73038eae36028b721f Mon Sep 17 00:00:00 2001 From: Blessings Abel Date: Tue, 31 Mar 2026 03:14:30 +0000 Subject: [PATCH 4/5] ci: trigger CI From 2490b269454f62c3d898dfdd6c4ede794105e868 Mon Sep 17 00:00:00 2001 From: Blessings Abel Date: Tue, 31 Mar 2026 08:28:50 +0000 Subject: [PATCH 5/5] trigger CI