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
7 changes: 5 additions & 2 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ export const prisma = new Proxy({} as PrismaClient, {
if (!connectionString) {
throw new Error("DATABASE_URL must be defined");
}

const pool = new pg.Pool({ connectionString });
const pool = new pg.Pool({
connectionString,
max: 20,
idleTimeoutMillis: 10000,
});
const adapter = new PrismaPg(pool);
globalForPrisma.prisma = new PrismaClient({ adapter });
}
Expand Down
139 changes: 124 additions & 15 deletions src/lib/stellarProvider.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Horizon } from "@stellar/stellar-sdk";
import { Horizon, SorobanRpc } from "@stellar/stellar-sdk";
import dotenv from "dotenv";
import { logger } from "../utils/logger";

dotenv.config();

/**
* Whether an error from the Horizon SDK should trigger a failover to the next node.
* Whether an error from the Horizon SDK or RPC should trigger a failover to the next node.
* Covers HTTP 5xx responses and common network-level errors.
*/
function isFailoverError(error: unknown): boolean {
if (error && typeof error === "object") {
const err = error as Record<string, any>;

// HTTP 5xx from Horizon
// HTTP 5xx from Horizon or RPC
const httpStatus: unknown =
err.response?.status ?? err.status ?? err.statusCode;
if (typeof httpStatus === "number" && httpStatus >= 500) {
Expand All @@ -31,9 +32,17 @@ function isFailoverError(error: unknown): boolean {
return true;
}

// SDK timeout messages
if (typeof err.message === "string" && err.message.includes("timeout")) {
return true;
// SDK timeout messages or RPC errors indicating connection issues
if (typeof err.message === "string") {
const msg = err.message.toLowerCase();
if (
msg.includes("timeout") ||
msg.includes("network error") ||
msg.includes("econnrefused") ||
msg.includes("fetch failed")
) {
return true;
}
}
}

Expand Down Expand Up @@ -67,23 +76,81 @@ function buildHorizonUrls(network: string): string[] {
}

/**
* StellarProvider — singleton that manages a pool of Horizon servers with
* Builds the ordered list of fallback RPC URLs for a given network.
*/
function buildRpcUrls(network: string): string[] {
const isMainnet = network === "PUBLIC";

const sdfUrl = isMainnet
? "https://rpc.mainnet.stellar.org"
: "https://rpc.testnet.stellar.org";

const urls: string[] = [];

const customUrl = process.env.RPC_URL?.trim();
if (customUrl) {
urls.push(customUrl);
}

// Load configurable fallback RPC URLs (comma-separated)
const customFallbacks = process.env.FALLBACK_RPC_URLS?.trim();
if (customFallbacks) {
urls.push(
...customFallbacks
.split(",")
.map((u) => u.trim())
.filter(Boolean)
);
}

// Ensure default SDF node is in the list
if (!urls.includes(sdfUrl)) {
urls.push(sdfUrl);
}

return urls;
}

/**
* StellarProvider — singleton that manages a pool of Horizon and RPC servers with
* automatic failover.
*/
class StellarProvider {
private readonly network: string;

// Horizon properties
private readonly urls: readonly string[];
private currentIndex: number = 0;
private server: Horizon.Server;

// RPC properties
private readonly rpcUrls: readonly string[];
private rpcCurrentIndex: number = 0;
private rpcServer: SorobanRpc.Server;

constructor() {
const network = process.env.STELLAR_NETWORK || "TESTNET";
this.urls = buildHorizonUrls(network);
this.network = process.env.STELLAR_NETWORK || "TESTNET";

// Initialize Horizon
this.urls = buildHorizonUrls(this.network);
this.server = new Horizon.Server(this.urls[0]!);
console.info(
`[StellarProvider] Initialized with ${this.urls.length} node(s). Primary: ${this.urls[0]!}`,
logger.info(
`[StellarProvider] Initialized Horizon with ${this.urls.length} node(s). Primary: ${this.urls[0]!}`,
);

// Initialize RPC
this.rpcUrls = buildRpcUrls(this.network);
this.rpcServer = new SorobanRpc.Server(this.rpcUrls[0]!, {
allowHttp: this.network === "TESTNET",
});
logger.info(
`[StellarProvider] Initialized RPC with ${this.rpcUrls.length} node(s). Primary: ${this.rpcUrls[0]!}`,
);
}

// ==========================================
// Horizon methods
// ==========================================
getServer(): Horizon.Server {
return this.server;
}
Expand All @@ -101,19 +168,61 @@ class StellarProvider {
const nextIndex = (this.currentIndex + 1) % this.urls.length;

if (nextIndex === this.currentIndex) {
console.error(
`[StellarProvider] Node ${failedUrl} failed and no fallback is available.`,
logger.networkError(
`[StellarProvider] Horizon Node ${failedUrl} failed and no fallback is available.`,
);
return false;
}

this.currentIndex = nextIndex;
this.server = new Horizon.Server(this.urls[this.currentIndex]!);

console.warn(
`[StellarProvider] ⚠️ Node "${failedUrl}" returned an error. ` +
logger.warn(
`[StellarProvider] ⚠️ Horizon Node "${failedUrl}" returned an error. ` +
`Failing over to "${this.urls[this.currentIndex]!}" ` +
`(node ${this.currentIndex + 1}/${this.urls.length}).`,
{ isNetwork: true }
);

return true;
}

// ==========================================
// RPC methods
// ==========================================
getRpcServer(): SorobanRpc.Server {
return this.rpcServer;
}

getCurrentRpcUrl(): string {
return this.rpcUrls[this.rpcCurrentIndex]!;
}

reportRpcFailure(error: unknown): boolean {
if (!isFailoverError(error)) {
return false;
}

const failedUrl = this.rpcUrls[this.rpcCurrentIndex]!;
const nextIndex = (this.rpcCurrentIndex + 1) % this.rpcUrls.length;

if (nextIndex === this.rpcCurrentIndex) {
logger.networkError(
`[StellarProvider] RPC Node ${failedUrl} failed and no fallback is available.`,
);
return false;
}

this.rpcCurrentIndex = nextIndex;
this.rpcServer = new SorobanRpc.Server(this.rpcUrls[this.rpcCurrentIndex]!, {
allowHttp: this.network === "TESTNET",
});

logger.warn(
`[StellarProvider] ⚠️ RPC Node "${failedUrl}" returned an error. ` +
`Failing over to "${this.rpcUrls[this.rpcCurrentIndex]!}" ` +
`(node ${this.rpcCurrentIndex + 1}/${this.rpcUrls.length}).`,
{ isNetwork: true }
);

return true;
Expand Down
45 changes: 23 additions & 22 deletions src/services/contractSanityCheckService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import dotenv from "dotenv";
import { SorobanRpc, xdr } from "@stellar/stellar-sdk";
import stellarProvider from "../lib/stellarProvider";
import { logger } from "../utils/logger";

dotenv.config();

Expand All @@ -19,19 +20,11 @@ interface ContractSanityCheckResult {
export class ContractSanityCheckService {
private readonly CONTRACT_ID: string;
private readonly NETWORK: string;
private readonly rpcUrl: string;
private readonly TIMEOUT_MS = 10000; // 10 second timeout for contract reads

constructor() {
this.CONTRACT_ID = process.env.CONTRACT_ID || "";
this.NETWORK = process.env.STELLAR_NETWORK || "TESTNET";

// Configure RPC URL based on network
if (this.NETWORK === "PUBLIC") {
this.rpcUrl = "https://rpc.mainnet.stellar.org";
} else {
this.rpcUrl = "https://rpc.testnet.stellar.org";
}
}

/**
Expand All @@ -46,7 +39,7 @@ export class ContractSanityCheckService {

// Skip check if CONTRACT_ID is not configured
if (!this.CONTRACT_ID) {
console.warn(
logger.warn(
"⚠️ CONTRACT_ID not configured - skipping contract sanity check",
);
return {
Expand All @@ -56,20 +49,19 @@ export class ContractSanityCheckService {
};
}

console.log(
logger.networkInfo(
`🔍 Performing contract sanity check on ${this.CONTRACT_ID} (${this.NETWORK})`,
);

try {
const server = new SorobanRpc.Server(this.rpcUrl, {
allowHttp: this.NETWORK === "TESTNET",
});
// Use the shared StellarProvider so it respects the current RPC failover state
const server = stellarProvider.getRpcServer();

// Attempt to read contract version (low-cost read)
const versionResult = await this.tryGetVersion(server);

if (versionResult.success) {
console.log(
logger.networkInfo(
`✅ Contract sanity check passed - Version: ${versionResult.version}`,
);
return {
Expand All @@ -84,7 +76,7 @@ export class ContractSanityCheckService {
const activeResult = await this.tryIsActive(server);

if (activeResult.success) {
console.log(
logger.networkInfo(
`✅ Contract sanity check passed - Contract is active`,
);
return {
Expand All @@ -95,16 +87,23 @@ export class ContractSanityCheckService {
}

// Both checks failed
const error = versionResult.error || activeResult.error || "Unknown error";
console.error(`❌ Contract sanity check failed: ${error}`);
const errorStr = versionResult.error || activeResult.error || "Unknown error";
const origError = versionResult.originalError || activeResult.originalError;

if (origError) {
stellarProvider.reportRpcFailure(origError);
}

logger.networkError(`❌ Contract sanity check failed: ${errorStr}`);
return {
...result,
error,
error: errorStr,
};
} catch (error) {
stellarProvider.reportRpcFailure(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(`❌ Contract sanity check error: ${errorMessage}`);
logger.networkError(`❌ Contract sanity check error: ${errorMessage}`);
return {
...result,
error: errorMessage,
Expand All @@ -118,7 +117,7 @@ export class ContractSanityCheckService {
*/
private async tryGetVersion(
server: SorobanRpc.Server,
): Promise<{ success: boolean; version?: string; error?: string }> {
): Promise<{ success: boolean; version?: string; error?: string; originalError?: any }> {
try {
// Attempt to read a 'version' function from the contract
// This is a common pattern in Soroban contracts
Expand All @@ -141,6 +140,7 @@ export class ContractSanityCheckService {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
originalError: error,
};
}
}
Expand All @@ -151,7 +151,7 @@ export class ContractSanityCheckService {
*/
private async tryIsActive(
server: SorobanRpc.Server,
): Promise<{ success: boolean; isActive?: boolean; error?: string }> {
): Promise<{ success: boolean; isActive?: boolean; error?: string; originalError?: any }> {
try {
const contractAddress = this.CONTRACT_ID;

Expand Down Expand Up @@ -182,6 +182,7 @@ export class ContractSanityCheckService {
return {
success: false,
error: errorMessage,
originalError: error,
};
}
}
Expand Down
Loading