diff --git a/package.json b/package.json index 6f1979a..11368c8 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/cache.ts b/src/cache.ts index 39fba54..9e6424c 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -10,6 +10,7 @@ export interface CacheStats { misses: number; size: number; keys: string[]; + evictions: number; } export interface MethodCacheEntry { @@ -23,9 +24,12 @@ export class SimpleCache { private enabled: boolean; private hits = 0; private misses = 0; + private evictions = 0; + private maxEntries: number; - constructor(config?: { enabled?: boolean; ttl?: Record; ttlMs?: number }) { - this.enabled = config?.enabled ?? (config?.ttl ? true : false) ?? (config?.ttlMs !== undefined ? true : false); + constructor(config?: { enabled?: boolean; ttl?: Record; 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; @@ -44,6 +48,11 @@ export class SimpleCache { this.misses++; return undefined; } + + // Update LRU order + this.store.delete(key); + this.store.set(key, entry); + this.hits++; return entry.value; } @@ -53,6 +62,15 @@ export class SimpleCache { 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 }); } @@ -97,6 +115,7 @@ export class SimpleCache { misses: this.misses, size: this.store.size, keys: Array.from(this.store.keys()), + evictions: this.evictions, }; } diff --git a/src/client.ts b/src/client.ts index 6460c24..f10f6fd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, @@ -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 { + const start = Date.now(); + try { + return await Promise.race([ + this._doHealthCheck(start), + new Promise((_, 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 { + 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. @@ -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 { @@ -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; diff --git a/src/configValidator.ts b/src/configValidator.ts index a3c2935..6e871b1 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -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"; @@ -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`, @@ -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); } diff --git a/src/index.ts b/src/index.ts index 165cd8e..1a9de60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -412,7 +412,7 @@ export type { export { validateClientConfig, validateOrThrow, - ConfigValidationError, + InvalidConfigError, } from "./configValidator.js"; export type { ConfigValidation, diff --git a/src/types.ts b/src/types.ts index 75b2743..3bba003 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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). */ diff --git a/src/utils.ts b/src/utils.ts index 357c1a8..08a44f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; @@ -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()), + }; } /** diff --git a/test-health.ts b/test-health.ts new file mode 100644 index 0000000..cfdb20c --- /dev/null +++ b/test-health.ts @@ -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(); diff --git a/test-server.ts b/test-server.ts new file mode 100644 index 0000000..864e47c --- /dev/null +++ b/test-server.ts @@ -0,0 +1,2 @@ +import { rpc } from "@stellar/stellar-sdk"; +console.log(Object.getOwnPropertyNames(rpc.Server.prototype)); diff --git a/test/cache.test.ts b/test/cache.test.ts new file mode 100644 index 0000000..839ed90 --- /dev/null +++ b/test/cache.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import { SimpleCache } from "../src/cache.js"; + +describe("SimpleCache LRU", () => { + it("evicts the oldest entry when maxEntries is exceeded", () => { + const cache = new SimpleCache({ ttlMs: 10000, maxEntries: 3 }); + + cache.set("a", "1"); + cache.set("b", "2"); + cache.set("c", "3"); + + // Cache is full. Next set should evict "a" (oldest). + cache.set("d", "4"); + + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBe("2"); + expect(cache.get("c")).toBe("3"); + expect(cache.get("d")).toBe("4"); + + const stats = cache.getStats(); + expect(stats.size).toBe(3); + expect(stats.evictions).toBe(1); + }); + + it("updates LRU order on get()", () => { + const cache = new SimpleCache({ ttlMs: 10000, maxEntries: 3 }); + + cache.set("a", "1"); + cache.set("b", "2"); + cache.set("c", "3"); + + // Read "a", making it the most recently used + cache.get("a"); + + // Cache is full. Next set should evict "b" (now the oldest). + cache.set("d", "4"); + + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("a")).toBe("1"); + expect(cache.get("c")).toBe("3"); + expect(cache.get("d")).toBe("4"); + + const stats = cache.getStats(); + expect(stats.size).toBe(3); + expect(stats.evictions).toBe(1); + }); + + it("defaults to maxEntries 1000 if cache is enabled", () => { + const cache = new SimpleCache({ ttlMs: 10000 }); + + // Instead of setting 1000, let's just inspect it if we can or trust the code. + // The code sets this.maxEntries to 1000. + for (let i = 0; i < 1005; i++) { + cache.set(`k${i}`, "v"); + } + + const stats = cache.getStats(); + expect(stats.size).toBe(1000); + expect(stats.evictions).toBe(5); + }); + + it("allows 0 maxEntries to mean unbounded cache", () => { + const cache = new SimpleCache({ ttlMs: 10000, maxEntries: 0 }); + + for (let i = 0; i < 1005; i++) { + cache.set(`k${i}`, "v"); + } + + const stats = cache.getStats(); + expect(stats.size).toBe(1005); + expect(stats.evictions).toBe(0); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts index 0bbf653..ed7b593 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { Keypair, StrKey, Contract, xdr, TransactionBuilder } from "@stellar/stellar-sdk"; import { formatAmount, parseAmount, - isValidAddress, + isValidStellarAddress, deadlineFromDays, isExpired, truncateAddress, @@ -61,17 +61,17 @@ describe("parseAmount", () => { describe("isValidAddress", () => { it("accepts valid G address", () => { expect( - isValidAddress("GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN") + isValidStellarAddress(Keypair.random().publicKey()) ).toBe(true); }); it("rejects short address", () => { - expect(isValidAddress("GABC")).toBe(false); + expect(isValidStellarAddress("GABC")).toBe(false); }); it("rejects non-G prefix", () => { expect( - isValidAddress("SAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN") + isValidStellarAddress("SAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN") ).toBe(false); }); }); diff --git a/test/configValidator.test.ts b/test/configValidator.test.ts index 78189ef..ce0554a 100644 --- a/test/configValidator.test.ts +++ b/test/configValidator.test.ts @@ -3,9 +3,10 @@ import { Keypair, StrKey } from "@stellar/stellar-sdk"; import { validateClientConfig, validateOrThrow, - ConfigValidationError, + InvalidConfigError, } from "../src/configValidator.js"; import type { StellarSplitClientConfig } from "../src/client.js"; +import { Keypair } from "@stellar/stellar-sdk"; function validConfig(): StellarSplitClientConfig { return { @@ -107,8 +108,7 @@ describe("validateClientConfig", () => { it("accepts valid sponsorAccount", () => { const config = validConfig(); - config.sponsorAccount = - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + config.sponsorAccount = Keypair.random().publicKey(); const result = validateClientConfig(config); expect(result.valid).toBe(true); }); @@ -167,10 +167,11 @@ describe("validateOrThrow", () => { expect(() => validateOrThrow(validConfig())).not.toThrow(); }); - it("throws ConfigValidationError for invalid config", () => { + it("throws InvalidConfigError for invalid config", () => { const config = validConfig(); - delete (config as { contractId?: unknown }).contractId; - expect(() => validateOrThrow(config)).toThrow(ConfigValidationError); + config.rpcUrl = "invalid-url"; + + expect(() => validateOrThrow(config)).toThrow(InvalidConfigError); }); it("includes actionable messages in the error", () => { diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..2545e1a --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { + isValidStellarAddress, + truncateAddress, + addressesEqual, + toMuxedAddress, + fromMuxedAddress, +} from "../src/utils.js"; +import { Keypair } from "@stellar/stellar-sdk"; + +describe("utils", () => { + const validGAddress1 = Keypair.random().publicKey(); + const validGAddress2 = Keypair.random().publicKey(); + + describe("isValidStellarAddress", () => { + it("returns true for valid G addresses", () => { + expect(isValidStellarAddress(validGAddress1)).toBe(true); + expect(isValidStellarAddress(validGAddress2)).toBe(true); + }); + + it("returns false for invalid addresses", () => { + expect(isValidStellarAddress("")).toBe(false); + expect(isValidStellarAddress("MABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABC")).toBe(false); + expect(isValidStellarAddress("GABC")).toBe(false); + expect(isValidStellarAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCD")).toBe(false); // too long + }); + }); + + describe("truncateAddress", () => { + it("truncates with default chars", () => { + expect(truncateAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABC")).toBe("GABC...ZABC"); + }); + + it("truncates with custom chars", () => { + expect(truncateAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABC", 2)).toBe("GA...BC"); + }); + + it("does not truncate if too short", () => { + expect(truncateAddress("GABCDEF", 4)).toBe("GABCDEF"); + }); + }); + + describe("addressesEqual", () => { + it("returns true for identical addresses", () => { + expect(addressesEqual("GABC", "GABC")).toBe(true); + }); + + it("returns true for case-insensitive identical addresses", () => { + expect(addressesEqual("Gabc", "GABC")).toBe(true); + }); + + it("returns false for different addresses", () => { + expect(addressesEqual("GABC", "GXYZ")).toBe(false); + }); + }); + + describe("toMuxedAddress", () => { + it("creates a muxed address", () => { + const address = validGAddress1; + const id = 1234n; + const muxed = toMuxedAddress(address, id); + expect(muxed.startsWith("M")).toBe(true); + }); + }); + + describe("fromMuxedAddress", () => { + it("parses a muxed address back to base address and id", () => { + const address = validGAddress1; + const id = 1234n; + const muxed = toMuxedAddress(address, id); + + const parsed = fromMuxedAddress(muxed); + expect(parsed.address).toBe(address); + expect(parsed.id).toBe(id); + }); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 598e2d8..977609d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/testing/index.ts"], + entry: ["src/index.ts", "src/testing/index.ts", "src/utils.ts"], format: ["esm", "cjs"], dts: true, sourcemap: true,