Skip to content

Commit 055a959

Browse files
authored
Merge pull request #75 from gregemax/feat/socket-location-broadcast
feat(sockets/stellar): WebSocket health checks, offline sync, location broadcast & Soroban RPC setup
2 parents 5452516 + 001a103 commit 055a959

22 files changed

Lines changed: 3555 additions & 51 deletions

.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,20 @@ LOG_LEVEL=debug
99
CORS_ORIGIN=http://localhost:3000,http://localhost:5173
1010
RATE_LIMIT_WINDOW_MS=900000
1111
RATE_LIMIT_MAX_REQUESTS=100
12+
13+
# ─── Stellar / Soroban ─────────────────────────────────────────────────────────
14+
# Soroban RPC endpoint.
15+
# Testnet : https://soroban-testnet.stellar.org
16+
# Mainnet : https://soroban-mainnet.stellar.org (or a custom Horizon/RPC node)
17+
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
18+
19+
# Network passphrase — must match SOROBAN_RPC_URL.
20+
# Testnet : Test SDF Network ; September 2015
21+
# Mainnet : Public Global Stellar Network ; September 2015
22+
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
23+
24+
# Friendly alias used in logs/responses ("mainnet" | "testnet" | "futurenet")
25+
STELLAR_NETWORK=testnet
26+
27+
# Request timeout (ms) for Soroban RPC calls. Default: 10000
28+
SOROBAN_RPC_TIMEOUT_MS=10000

jest.config.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ module.exports = {
88
transform: {
99
...tsJestTransformCfg,
1010
},
11-
// Set mongodb-memory-server env vars before any test file is loaded.
12-
// setupFiles runs inside each worker process, so env vars are visible to MMS.
13-
// This pins the binary to MongoDB 7.0 / ubuntu2204 to avoid glibc
14-
// compatibility issues with the default 6.0.9 build on this machine.
15-
setupFiles: ['./jest.setup.js'],
16-
// Individual test timeout — generous enough for the in-memory MongoDB to
17-
// start on first run (binary download already done after that).
18-
testTimeout: 30_000,
11+
// Allow enough time for MongoMemoryServer to start (and download the binary
12+
// on first run in a fresh environment).
13+
testTimeout: 30000,
1914
};

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"prepare": "husky install"
1717
},
1818
"dependencies": {
19+
"@stellar/stellar-sdk": "13.1.0",
1920
"bcryptjs": "2.4.3",
2021
"compression": "1.7.4",
2122
"cors": "2.8.5",
@@ -40,6 +41,7 @@
4041
"@types/jsonwebtoken": "9.0.5",
4142
"@types/mongoose": "5.11.97",
4243
"@types/node": "20.10.0",
44+
"@types/socket.io": "3.0.2",
4345
"@types/supertest": "^7.2.0",
4446
"@typescript-eslint/eslint-plugin": "6.13.2",
4547
"@typescript-eslint/parser": "6.13.2",

pnpm-lock.yaml

Lines changed: 486 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/blockchain/soroban.service.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { rpc as StellarRpc } from '@stellar/stellar-sdk';
2+
import logger from '../config/logger';
3+
import { sorobanRpcClient, stellarConfig } from '../config/stellar';
4+
5+
/**
6+
* Result returned by a successful connectivity check.
7+
*/
8+
export interface ConnectivityCheckResult {
9+
/** Whether the RPC node is reachable and healthy. */
10+
connected: boolean;
11+
/** Human-readable network alias. */
12+
network: string;
13+
/** Network passphrase used. */
14+
networkPassphrase: string;
15+
/** RPC endpoint that was queried. */
16+
rpcUrl: string;
17+
/** Health status string returned by the node (e.g. "healthy"). */
18+
status: string;
19+
/** Latest ledger number at time of check. */
20+
latestLedger: number;
21+
/** ISO timestamp of when the check was performed. */
22+
checkedAt: string;
23+
/** Round-trip latency in milliseconds. */
24+
latencyMs: number;
25+
}
26+
27+
/**
28+
* Result returned when the connectivity check fails.
29+
*/
30+
export interface ConnectivityCheckError {
31+
connected: false;
32+
network: string;
33+
rpcUrl: string;
34+
checkedAt: string;
35+
error: string;
36+
}
37+
38+
/**
39+
* SorobanService provides the business-logic layer for all Stellar / Soroban
40+
* RPC interactions.
41+
*
42+
* Responsibilities:
43+
* - Perform a live connectivity check against the configured RPC node.
44+
* - Surface health, network, and ledger data for API responses.
45+
* - Abstract the raw SDK client behind a typed interface so higher layers
46+
* (controllers, other services) are decoupled from the SDK.
47+
*/
48+
export class SorobanService {
49+
private readonly client: StellarRpc.Server;
50+
51+
constructor(client: StellarRpc.Server = sorobanRpcClient) {
52+
this.client = client;
53+
}
54+
55+
/**
56+
* Perform a connectivity check against the Soroban RPC node.
57+
*
58+
* Calls `getHealth()` and `getLatestLedger()` in parallel. Both must
59+
* succeed for the check to be considered healthy.
60+
*
61+
* @returns A `ConnectivityCheckResult` on success, or a
62+
* `ConnectivityCheckError` on failure.
63+
*/
64+
public async checkConnectivity(): Promise<
65+
ConnectivityCheckResult | ConnectivityCheckError
66+
> {
67+
const checkedAt = new Date().toISOString();
68+
const start = Date.now();
69+
70+
logger.debug(
71+
`[Soroban] Connectivity check — network=${stellarConfig.network} url=${stellarConfig.rpcUrl}`,
72+
);
73+
74+
try {
75+
const [health, ledger] = await Promise.all([
76+
this.client.getHealth(),
77+
this.client.getLatestLedger(),
78+
]);
79+
80+
const latencyMs = Date.now() - start;
81+
82+
const result: ConnectivityCheckResult = {
83+
connected: true,
84+
network: stellarConfig.network,
85+
networkPassphrase: stellarConfig.networkPassphrase,
86+
rpcUrl: stellarConfig.rpcUrl,
87+
status: health.status,
88+
latestLedger: ledger.sequence,
89+
checkedAt,
90+
latencyMs,
91+
};
92+
93+
logger.info(
94+
`[Soroban] Connectivity OK — network=${stellarConfig.network} ` +
95+
`ledger=${ledger.sequence} latency=${latencyMs}ms`,
96+
);
97+
98+
return result;
99+
} catch (err) {
100+
const latencyMs = Date.now() - start;
101+
const message = err instanceof Error ? err.message : 'Unknown error';
102+
103+
logger.error(
104+
`[Soroban] Connectivity FAILED — network=${stellarConfig.network} ` +
105+
`latency=${latencyMs}ms error="${message}"`,
106+
);
107+
108+
const errorResult: ConnectivityCheckError = {
109+
connected: false,
110+
network: stellarConfig.network,
111+
rpcUrl: stellarConfig.rpcUrl,
112+
checkedAt,
113+
error: message,
114+
};
115+
116+
return errorResult;
117+
}
118+
}
119+
120+
/**
121+
* Fetch the latest ledger sequence number from the RPC node.
122+
*
123+
* @returns The ledger sequence number.
124+
* @throws If the RPC call fails.
125+
*/
126+
public async getLatestLedger(): Promise<number> {
127+
const ledger = await this.client.getLatestLedger();
128+
return ledger.sequence;
129+
}
130+
131+
/**
132+
* Fetch network information (passphrase, protocol version) from the RPC node.
133+
*
134+
* @returns The raw `getNetwork` response from the SDK.
135+
*/
136+
public async getNetworkInfo(): Promise<StellarRpc.Api.GetNetworkResponse> {
137+
return this.client.getNetwork();
138+
}
139+
}
140+
141+
/** Singleton instance for use across the application. */
142+
export const sorobanService = new SorobanService();

src/config/stellar.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { rpc as StellarRpc, Networks } from '@stellar/stellar-sdk';
2+
import logger from './logger';
3+
4+
/**
5+
* Supported Stellar network aliases.
6+
*/
7+
export type StellarNetwork = 'mainnet' | 'testnet' | 'futurenet';
8+
9+
/**
10+
* Resolved Stellar configuration derived from environment variables.
11+
*/
12+
export interface StellarConfig {
13+
/** Soroban RPC endpoint URL. */
14+
rpcUrl: string;
15+
/** Network passphrase used when signing/verifying transactions. */
16+
networkPassphrase: string;
17+
/** Human-readable network alias (for logs and API responses). */
18+
network: StellarNetwork;
19+
/** HTTP request timeout in milliseconds for RPC calls. */
20+
timeoutMs: number;
21+
}
22+
23+
// ─── Network passphrase map ────────────────────────────────────────────────────
24+
25+
const NETWORK_PASSPHRASES: Record<StellarNetwork, string> = {
26+
mainnet: Networks.PUBLIC,
27+
testnet: Networks.TESTNET,
28+
futurenet: Networks.FUTURENET,
29+
};
30+
31+
const DEFAULT_RPC_URLS: Record<StellarNetwork, string> = {
32+
mainnet: 'https://soroban-mainnet.stellar.org',
33+
testnet: 'https://soroban-testnet.stellar.org',
34+
futurenet: 'https://rpc-futurenet.stellar.org',
35+
};
36+
37+
// ─── Resolve config from env ───────────────────────────────────────────────────
38+
39+
/**
40+
* Build the Stellar configuration from environment variables with sensible
41+
* defaults. Validated at startup so misconfiguration fails fast.
42+
*/
43+
function resolveStellarConfig(): StellarConfig {
44+
const network = (process.env.STELLAR_NETWORK?.toLowerCase() ?? 'testnet') as StellarNetwork;
45+
46+
if (!['mainnet', 'testnet', 'futurenet'].includes(network)) {
47+
throw new Error(
48+
`Invalid STELLAR_NETWORK="${process.env.STELLAR_NETWORK}". ` +
49+
'Must be one of: mainnet | testnet | futurenet',
50+
);
51+
}
52+
53+
const rpcUrl =
54+
process.env.SOROBAN_RPC_URL?.trim() || DEFAULT_RPC_URLS[network];
55+
56+
// Prefer explicit passphrase env var; fall back to the well-known value for
57+
// the configured network.
58+
const networkPassphrase =
59+
process.env.STELLAR_NETWORK_PASSPHRASE?.trim() ||
60+
NETWORK_PASSPHRASES[network];
61+
62+
const timeoutMs = parseInt(process.env.SOROBAN_RPC_TIMEOUT_MS ?? '10000', 10);
63+
64+
if (!rpcUrl) {
65+
throw new Error('SOROBAN_RPC_URL is required and could not be resolved.');
66+
}
67+
68+
if (!networkPassphrase) {
69+
throw new Error('STELLAR_NETWORK_PASSPHRASE is required and could not be resolved.');
70+
}
71+
72+
return { rpcUrl, networkPassphrase, network, timeoutMs };
73+
}
74+
75+
// ─── Singleton config ──────────────────────────────────────────────────────────
76+
77+
export const stellarConfig: StellarConfig = resolveStellarConfig();
78+
79+
// ─── Soroban RPC client factory ────────────────────────────────────────────────
80+
81+
/**
82+
* Create a new `rpc.Server` instance using the resolved configuration.
83+
*
84+
* A factory function (rather than a singleton) is used so that callers in
85+
* tests can construct fresh instances with custom options without mutating
86+
* shared state.
87+
*
88+
* @param options - Optional overrides forwarded to `rpc.Server`.
89+
* @returns A configured Soroban RPC client.
90+
*/
91+
export function createSorobanRpcClient(
92+
options?: Partial<ConstructorParameters<typeof StellarRpc.Server>[1]>,
93+
): StellarRpc.Server {
94+
return new StellarRpc.Server(stellarConfig.rpcUrl, {
95+
allowHttp: stellarConfig.rpcUrl.startsWith('http://'),
96+
...options,
97+
});
98+
}
99+
100+
/**
101+
* Pre-built default RPC client singleton.
102+
* Use this for all production code paths.
103+
*/
104+
export const sorobanRpcClient: StellarRpc.Server = createSorobanRpcClient();
105+
106+
logger.info(
107+
`[Stellar] Soroban RPC client initialised — network=${stellarConfig.network} ` +
108+
`url=${stellarConfig.rpcUrl}`,
109+
);

0 commit comments

Comments
 (0)