Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@ LOG_LEVEL=debug
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

# ─── Stellar / Soroban ─────────────────────────────────────────────────────────
# Soroban RPC endpoint.
# Testnet : https://soroban-testnet.stellar.org
# Mainnet : https://soroban-mainnet.stellar.org (or a custom Horizon/RPC node)
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Network passphrase — must match SOROBAN_RPC_URL.
# Testnet : Test SDF Network ; September 2015
# Mainnet : Public Global Stellar Network ; September 2015
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015

# Friendly alias used in logs/responses ("mainnet" | "testnet" | "futurenet")
STELLAR_NETWORK=testnet

# Request timeout (ms) for Soroban RPC calls. Default: 10000
SOROBAN_RPC_TIMEOUT_MS=10000
11 changes: 3 additions & 8 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,7 @@ module.exports = {
transform: {
...tsJestTransformCfg,
},
// Set mongodb-memory-server env vars before any test file is loaded.
// setupFiles runs inside each worker process, so env vars are visible to MMS.
// This pins the binary to MongoDB 7.0 / ubuntu2204 to avoid glibc
// compatibility issues with the default 6.0.9 build on this machine.
setupFiles: ['./jest.setup.js'],
// Individual test timeout — generous enough for the in-memory MongoDB to
// start on first run (binary download already done after that).
testTimeout: 30_000,
// Allow enough time for MongoMemoryServer to start (and download the binary
// on first run in a fresh environment).
testTimeout: 30000,
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"prepare": "husky install"
},
"dependencies": {
"@stellar/stellar-sdk": "13.1.0",
"bcryptjs": "2.4.3",
"compression": "1.7.4",
"cors": "2.8.5",
Expand All @@ -40,6 +41,7 @@
"@types/jsonwebtoken": "9.0.5",
"@types/mongoose": "5.11.97",
"@types/node": "20.10.0",
"@types/socket.io": "3.0.2",
"@types/supertest": "^7.2.0",
"@typescript-eslint/eslint-plugin": "6.13.2",
"@typescript-eslint/parser": "6.13.2",
Expand Down
492 changes: 486 additions & 6 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

142 changes: 142 additions & 0 deletions src/blockchain/soroban.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { rpc as StellarRpc } from '@stellar/stellar-sdk';
import logger from '../config/logger';
import { sorobanRpcClient, stellarConfig } from '../config/stellar';

/**
* Result returned by a successful connectivity check.
*/
export interface ConnectivityCheckResult {
/** Whether the RPC node is reachable and healthy. */
connected: boolean;
/** Human-readable network alias. */
network: string;
/** Network passphrase used. */
networkPassphrase: string;
/** RPC endpoint that was queried. */
rpcUrl: string;
/** Health status string returned by the node (e.g. "healthy"). */
status: string;
/** Latest ledger number at time of check. */
latestLedger: number;
/** ISO timestamp of when the check was performed. */
checkedAt: string;
/** Round-trip latency in milliseconds. */
latencyMs: number;
}

/**
* Result returned when the connectivity check fails.
*/
export interface ConnectivityCheckError {
connected: false;
network: string;
rpcUrl: string;
checkedAt: string;
error: string;
}

/**
* SorobanService provides the business-logic layer for all Stellar / Soroban
* RPC interactions.
*
* Responsibilities:
* - Perform a live connectivity check against the configured RPC node.
* - Surface health, network, and ledger data for API responses.
* - Abstract the raw SDK client behind a typed interface so higher layers
* (controllers, other services) are decoupled from the SDK.
*/
export class SorobanService {
private readonly client: StellarRpc.Server;

constructor(client: StellarRpc.Server = sorobanRpcClient) {
this.client = client;
}

/**
* Perform a connectivity check against the Soroban RPC node.
*
* Calls `getHealth()` and `getLatestLedger()` in parallel. Both must
* succeed for the check to be considered healthy.
*
* @returns A `ConnectivityCheckResult` on success, or a
* `ConnectivityCheckError` on failure.
*/
public async checkConnectivity(): Promise<
ConnectivityCheckResult | ConnectivityCheckError
> {
const checkedAt = new Date().toISOString();
const start = Date.now();

logger.debug(
`[Soroban] Connectivity check — network=${stellarConfig.network} url=${stellarConfig.rpcUrl}`,
);

try {
const [health, ledger] = await Promise.all([
this.client.getHealth(),
this.client.getLatestLedger(),
]);

const latencyMs = Date.now() - start;

const result: ConnectivityCheckResult = {
connected: true,
network: stellarConfig.network,
networkPassphrase: stellarConfig.networkPassphrase,
rpcUrl: stellarConfig.rpcUrl,
status: health.status,
latestLedger: ledger.sequence,
checkedAt,
latencyMs,
};

logger.info(
`[Soroban] Connectivity OK — network=${stellarConfig.network} ` +
`ledger=${ledger.sequence} latency=${latencyMs}ms`,
);

return result;
} catch (err) {
const latencyMs = Date.now() - start;
const message = err instanceof Error ? err.message : 'Unknown error';

logger.error(
`[Soroban] Connectivity FAILED — network=${stellarConfig.network} ` +
`latency=${latencyMs}ms error="${message}"`,
);

const errorResult: ConnectivityCheckError = {
connected: false,
network: stellarConfig.network,
rpcUrl: stellarConfig.rpcUrl,
checkedAt,
error: message,
};

return errorResult;
}
}

/**
* Fetch the latest ledger sequence number from the RPC node.
*
* @returns The ledger sequence number.
* @throws If the RPC call fails.
*/
public async getLatestLedger(): Promise<number> {
const ledger = await this.client.getLatestLedger();
return ledger.sequence;
}

/**
* Fetch network information (passphrase, protocol version) from the RPC node.
*
* @returns The raw `getNetwork` response from the SDK.
*/
public async getNetworkInfo(): Promise<StellarRpc.Api.GetNetworkResponse> {
return this.client.getNetwork();
}
}

/** Singleton instance for use across the application. */
export const sorobanService = new SorobanService();
109 changes: 109 additions & 0 deletions src/config/stellar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { rpc as StellarRpc, Networks } from '@stellar/stellar-sdk';
import logger from './logger';

/**
* Supported Stellar network aliases.
*/
export type StellarNetwork = 'mainnet' | 'testnet' | 'futurenet';

/**
* Resolved Stellar configuration derived from environment variables.
*/
export interface StellarConfig {
/** Soroban RPC endpoint URL. */
rpcUrl: string;
/** Network passphrase used when signing/verifying transactions. */
networkPassphrase: string;
/** Human-readable network alias (for logs and API responses). */
network: StellarNetwork;
/** HTTP request timeout in milliseconds for RPC calls. */
timeoutMs: number;
}

// ─── Network passphrase map ────────────────────────────────────────────────────

const NETWORK_PASSPHRASES: Record<StellarNetwork, string> = {
mainnet: Networks.PUBLIC,
testnet: Networks.TESTNET,
futurenet: Networks.FUTURENET,
};

const DEFAULT_RPC_URLS: Record<StellarNetwork, string> = {
mainnet: 'https://soroban-mainnet.stellar.org',
testnet: 'https://soroban-testnet.stellar.org',
futurenet: 'https://rpc-futurenet.stellar.org',
};

// ─── Resolve config from env ───────────────────────────────────────────────────

/**
* Build the Stellar configuration from environment variables with sensible
* defaults. Validated at startup so misconfiguration fails fast.
*/
function resolveStellarConfig(): StellarConfig {
const network = (process.env.STELLAR_NETWORK?.toLowerCase() ?? 'testnet') as StellarNetwork;

if (!['mainnet', 'testnet', 'futurenet'].includes(network)) {
throw new Error(
`Invalid STELLAR_NETWORK="${process.env.STELLAR_NETWORK}". ` +
'Must be one of: mainnet | testnet | futurenet',
);
}

const rpcUrl =
process.env.SOROBAN_RPC_URL?.trim() || DEFAULT_RPC_URLS[network];

// Prefer explicit passphrase env var; fall back to the well-known value for
// the configured network.
const networkPassphrase =
process.env.STELLAR_NETWORK_PASSPHRASE?.trim() ||
NETWORK_PASSPHRASES[network];

const timeoutMs = parseInt(process.env.SOROBAN_RPC_TIMEOUT_MS ?? '10000', 10);

if (!rpcUrl) {
throw new Error('SOROBAN_RPC_URL is required and could not be resolved.');
}

if (!networkPassphrase) {
throw new Error('STELLAR_NETWORK_PASSPHRASE is required and could not be resolved.');
}

return { rpcUrl, networkPassphrase, network, timeoutMs };
}

// ─── Singleton config ──────────────────────────────────────────────────────────

export const stellarConfig: StellarConfig = resolveStellarConfig();

// ─── Soroban RPC client factory ────────────────────────────────────────────────

/**
* Create a new `rpc.Server` instance using the resolved configuration.
*
* A factory function (rather than a singleton) is used so that callers in
* tests can construct fresh instances with custom options without mutating
* shared state.
*
* @param options - Optional overrides forwarded to `rpc.Server`.
* @returns A configured Soroban RPC client.
*/
export function createSorobanRpcClient(
options?: Partial<ConstructorParameters<typeof StellarRpc.Server>[1]>,
): StellarRpc.Server {
return new StellarRpc.Server(stellarConfig.rpcUrl, {
allowHttp: stellarConfig.rpcUrl.startsWith('http://'),
...options,
});
}

/**
* Pre-built default RPC client singleton.
* Use this for all production code paths.
*/
export const sorobanRpcClient: StellarRpc.Server = createSorobanRpcClient();

logger.info(
`[Stellar] Soroban RPC client initialised — network=${stellarConfig.network} ` +
`url=${stellarConfig.rpcUrl}`,
);
Loading