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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ coverage
*.log
.idea
create_issues.sh
.claude
.claude
59 changes: 57 additions & 2 deletions backend/src/__tests__/stellarConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Response>((_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" });
});
});
15 changes: 14 additions & 1 deletion backend/src/config/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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 });
}
33 changes: 21 additions & 12 deletions backend/src/services/sorobanService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ import {
getStellarRpcUrl,
} from "../config/stellar.js";

function rpcCall<T>(promise: Promise<T>): Promise<T> {
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.
Expand Down Expand Up @@ -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",
Expand All @@ -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", {
Expand All @@ -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",
Expand All @@ -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", {
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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", {
Expand All @@ -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" });

Expand All @@ -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", {
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down