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
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
"types": "./dist/testing/index.d.ts",
"import": "./dist/testing/index.js",
"require": "./dist/testing/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
}
},
"files": [
Expand Down
23 changes: 21 additions & 2 deletions src/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface CacheStats {
misses: number;
size: number;
keys: string[];
evictions: number;
}

export interface MethodCacheEntry {
Expand All @@ -23,9 +24,12 @@ export class SimpleCache<T> {
private enabled: boolean;
private hits = 0;
private misses = 0;
private evictions = 0;
private maxEntries: number;

constructor(config?: { enabled?: boolean; ttl?: Record<string, number>; ttlMs?: number }) {
this.enabled = config?.enabled ?? (config?.ttl ? true : false) ?? (config?.ttlMs !== undefined ? true : false);
constructor(config?: { enabled?: boolean; ttl?: Record<string, number>; ttlMs?: number; maxEntries?: number }) {
this.enabled = config?.enabled ?? (config?.ttl !== undefined || config?.ttlMs !== undefined);
this.maxEntries = config?.maxEntries ?? (this.enabled ? 1000 : 0);
this.ttlConfig = config?.ttl ?? {};
if (config?.ttlMs !== undefined) {
this.ttlConfig["default"] = config.ttlMs;
Expand All @@ -44,6 +48,11 @@ export class SimpleCache<T> {
this.misses++;
return undefined;
}

// Update LRU order
this.store.delete(key);
this.store.set(key, entry);

this.hits++;
return entry.value;
}
Expand All @@ -53,6 +62,15 @@ export class SimpleCache<T> {
const method = key.split(":")[0] || key;
const ttl = this.ttlConfig[method] ?? this.ttlConfig["default"] ?? 0;
if (ttl <= 0) return;

if (this.maxEntries > 0 && this.store.size >= this.maxEntries && !this.store.has(key)) {
const oldestKey = this.store.keys().next().value;
if (oldestKey !== undefined) {
this.store.delete(oldestKey);
this.evictions++;
}
}

this.store.set(key, { value, expiresAt: Date.now() + ttl });
}

Expand Down Expand Up @@ -97,6 +115,7 @@ export class SimpleCache<T> {
misses: this.misses,
size: this.store.size,
keys: Array.from(this.store.keys()),
evictions: this.evictions,
};
}

Expand Down
73 changes: 69 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { SdkPlugin } from "./plugin.js";
import { checkRPCHealth } from "./health.js";
import { Deduplicator } from "./dedup.js";
import { verifyBatchPayments } from "./batchVerifier.js";
import { type HealthCheckResult, HealthCheckTimeoutError } from "./types.js";
import type {
BatchVerificationResult,
BatchInvoiceValidation,
Expand Down Expand Up @@ -573,6 +574,70 @@ export class StellarSplitClient {
}
}

/**
* Performs a health check of the client's RPC connection and contract.
* Resolves with status information or throws HealthCheckTimeoutError if taking > 5000ms.
*/
async healthCheck(): Promise<HealthCheckResult> {
const start = Date.now();
try {
return await Promise.race([
this._doHealthCheck(start),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new HealthCheckTimeoutError("Health check timed out after 5000ms")),
5000
)
),
]);
} catch (e: any) {
if (e instanceof HealthCheckTimeoutError) {
throw e;
}
return {
rpcReachable: false,
latencyMs: Date.now() - start,
network: "unknown",
contractDeployed: false,
error: e.message || String(e),
};
}
}

private async _doHealthCheck(start: number): Promise<HealthCheckResult> {
try {
const ledger = await this.server.getLatestLedger();
const networkRes = await this.server.getNetwork();
const latencyMs = Date.now() - start;
const network = networkRes.passphrase;

let contractDeployed = false;
let errorMsg: string | undefined;

try {
await this.server.getContractWasmByContractId(this.config.contractId);
contractDeployed = true;
} catch (err: any) {
if (!err.message?.includes("Could not obtain contract hash")) {
// If we get here, it might be deployed but we couldn't fetch the wasm,
// or it threw some other error. We'll conservatively say true if it's
// an unrelated error, or just false. Let's say false and log error.
errorMsg = err.message || String(err);
}
}

return {
rpcReachable: true,
latencyMs,
network,
contractDeployed,
error: errorMsg,
};
} catch (err: any) {
throw err; // caught by outer catch
}
}

/**
* Enable or disable request batching for read methods (getInvoice, getPaymentHistory, getInvoiceExt).
* Disabled by default — opt-in to batch concurrent RPC calls within a 10 ms window.
Expand All @@ -582,9 +647,9 @@ export class StellarSplitClient {
if (enabled) {
if (!this._batcher) {
this._batcher = new BatchedRpcClient({
fetchInvoice: (id) => this._fetchInvoice(id),
fetchPaymentHistory: (id) => this._fetchPaymentHistory(id),
fetchInvoiceExt: (id) => this._fetchInvoiceExt(id),
fetchInvoice: (id: string) => this._fetchInvoice(id),
fetchPaymentHistory: (id: string) => this._fetchPaymentHistory(id),
fetchInvoiceExt: (id: string) => this._fetchInvoiceExt(id),
});
}
} else {
Expand Down Expand Up @@ -1163,7 +1228,7 @@ export class StellarSplitClient {
const key = `${methodName}:${JSON.stringify(args)}`;
(this._cache as any).set(key, result);
} else if (this._cache && methodName === "getInvoice") {
this._cache.set(args[0], result);
this._cache.set(args[0], result as any);
}

return result;
Expand Down
10 changes: 5 additions & 5 deletions src/configValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { StellarSplitClientConfig } from "./client.js";
import { isValidAddress } from "./utils.js";
import { isValidStellarAddress } from "./utils.js";
import { StrKey } from "@stellar/stellar-sdk";
import { StellarSplitError } from "./errors.js";

Expand Down Expand Up @@ -199,7 +199,7 @@ export function validateClientConfig(
}
}

if (config.sponsorAccount && !isValidAddress(config.sponsorAccount)) {
if (config.sponsorAccount && !isValidStellarAddress(config.sponsorAccount)) {
errors.push({
field: "sponsorAccount",
message: `sponsorAccount "${config.sponsorAccount}" is not a valid Stellar G... address`,
Expand Down Expand Up @@ -257,16 +257,16 @@ export function validateOrThrow(config: StellarSplitClientConfig): void {
);
}

throw new ConfigValidationError(parts.join("\n"), validation.errors);
throw new InvalidConfigError(parts.join("\n"), validation.errors);
}
}

export class ConfigValidationError extends StellarSplitError {
export class InvalidConfigError extends StellarSplitError {
readonly validationErrors: ConfigValidationErrorType[];

constructor(message: string, validationErrors: ConfigValidationErrorType[]) {
super(message, "CONFIG_VALIDATION_ERROR", { fieldErrors: validationErrors.length }, message);
this.name = "ConfigValidationError";
this.name = "InvalidConfigError";
this.validationErrors = validationErrors;
Object.setPrototypeOf(this, new.target.prototype);
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ export type {
export {
validateClientConfig,
validateOrThrow,
ConfigValidationError,
InvalidConfigError,
} from "./configValidator.js";
export type {
ConfigValidation,
Expand Down
21 changes: 21 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ export interface Recipient {
amount: bigint;
}

import { StellarSplitError } from "./errors.js";

export interface HealthCheckResult {
rpcReachable: boolean;
latencyMs: number;
network: string;
contractDeployed: boolean;
error?: string;
}

export class HealthCheckTimeoutError extends StellarSplitError {
constructor(message: string) {
super(message, "HEALTH_CHECK_TIMEOUT", {}, message);
this.name = "HealthCheckTimeoutError";
Object.setPrototypeOf(this, new.target.prototype);
}
}

/**
* Basic invoice data structure mirroring the Soroban contract.
*/
/** An on-chain StellarSplit invoice. */
export interface Invoice {
/** Invoice ID (u64 from the contract). */
Expand Down
34 changes: 31 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Utility helpers for StellarSplit SDK.
*/
import { Invoice } from "./types";
import { Account, MuxedAccount, StrKey } from "@stellar/stellar-sdk";

/** Number of decimal places used by Stellar token amounts (stroops). */
const STROOPS_PER_UNIT = 10_000_000n;
Expand Down Expand Up @@ -31,10 +32,37 @@ export function parseAmount(value: string): bigint {
/**
* Validate a Stellar public key (G... address).
*
* Uses a simple regex; for full validation use stellar-sdk StrKey.
* Uses stellar-sdk StrKey.
*/
export function isValidAddress(address: string): boolean {
return /^G[A-Z2-7]{54,55}$/.test(address);
export function isValidStellarAddress(address: string): boolean {
return StrKey.isValidEd25519PublicKey(address);
}

/**
* Case-insensitive comparison of two addresses.
*/
export function addressesEqual(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase();
}

/**
* Convert a base G address and an ID into a muxed M address.
*/
export function toMuxedAddress(address: string, id: bigint): string {
const account = new Account(address, "0");
const muxed = new MuxedAccount(account, id.toString());
return muxed.accountId();
}

/**
* Parse an M address back to its base G address and ID.
*/
export function fromMuxedAddress(muxed: string): { address: string; id: bigint } {
const parsed = MuxedAccount.fromAddress(muxed, "0");
return {
address: parsed.baseAccount().accountId(),
id: BigInt(parsed.id()),
};
}

/**
Expand Down
32 changes: 32 additions & 0 deletions test-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { rpc, Contract, xdr } from "@stellar/stellar-sdk";

const server = new rpc.Server("https://soroban-testnet.stellar.org:443");
// We can create a valid contract ID
import { StrKey } from "@stellar/stellar-sdk";
const contractId = StrKey.encodeContract(Buffer.alloc(32));

async function run() {
console.log("Testing getAccount...");
try {
const acc = await server.getAccount(contractId);
console.log("getAccount success", acc);
} catch(e) {
console.log("getAccount err:", e.message);
}

console.log("Testing getLedgerEntries...");
try {
const { Address } = require("@stellar/stellar-sdk");
const ledgerKey = xdr.LedgerKey.contractData(new xdr.LedgerKeyContractData({
contract: new Address(contractId).toScAddress(),
key: xdr.ScVal.scvLedgerKeyContractInstance(),
durability: xdr.ContractDataDurability.persistent(),
}));
const data = await server.getLedgerEntries(ledgerKey);
console.log("getLedgerEntries result:", data);
} catch(e) {
console.log("getLedgerEntries err:", e);
if (e.errors) console.log("Aggregate errors:", e.errors);
}
}
run();
2 changes: 2 additions & 0 deletions test-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { rpc } from "@stellar/stellar-sdk";
console.log(Object.getOwnPropertyNames(rpc.Server.prototype));
Loading