diff --git a/src/anomalyDetector.ts b/src/anomalyDetector.ts index aa7862a..7f69bbb 100644 --- a/src/anomalyDetector.ts +++ b/src/anomalyDetector.ts @@ -35,6 +35,8 @@ export interface AnomalyDetectorOptions { * Triggers HIGH_AMOUNT_VARIANCE when exceeded. Default: 0.8 */ maxAmountVariance?: number; + /** Threshold for classifying an anomaly score as alert. Must be in the range (0, 1]. */ + sensitivityThreshold?: number; /** Override the time source (Unix seconds). Inject in tests to control time. */ now?: () => number; } @@ -60,6 +62,7 @@ export class AnomalyDetector { private readonly maxRapidCycles: number; private readonly rapidCycleSeconds: number; private readonly maxAmountVariance: number; + private readonly sensitivityThreshold: number; private readonly now: () => number; private payments: TimedPayment[] = []; @@ -73,9 +76,17 @@ export class AnomalyDetector { this.maxRapidCycles = options.maxRapidCycles ?? 3; this.rapidCycleSeconds = options.rapidCycleSeconds ?? 300; this.maxAmountVariance = options.maxAmountVariance ?? 0.8; + this.sensitivityThreshold = options.sensitivityThreshold ?? 0.8; + if (this.sensitivityThreshold <= 0 || this.sensitivityThreshold > 1) { + throw new RangeError("sensitivityThreshold must be in the range (0, 1]"); + } this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); } + classifyScore(score: number): "alert" | "normal" { + return score > this.sensitivityThreshold ? "alert" : "normal"; + } + /** * Record a payment for anomaly tracking. * Falls back to the current clock if `payment.timestamp` is absent. diff --git a/src/configValidator.ts b/src/configValidator.ts index 6e871b1..b99248d 100644 --- a/src/configValidator.ts +++ b/src/configValidator.ts @@ -21,10 +21,55 @@ const KNOWN_NETWORKS = [ "Soroban Future Network ; October 2024", ]; +const KNOWN_CONFIG_KEYS = new Set([ + "rpcUrl", + "networkPassphrase", + "contractId", + "adapter", + "container", + "signingKeypair", + "retry", + "maxRetries", + "horizonUrl", + "sponsorAccount", + "cache", + "hooks", + "idempotency", + "timeout", + "degradation", + "rateLimit", + "telemetry", + "traceIdGenerator", +]); + +export class UnknownConfigKeyError extends StellarSplitError { + readonly unknownKeys: string[]; + + constructor(unknownKeys: string[]) { + super( + `Unknown configuration keys: ${unknownKeys.join(", ")}`, + "UNKNOWN_CONFIG_KEY", + { unknownKeys }, + ); + this.name = "UnknownConfigKeyError"; + this.unknownKeys = unknownKeys; + Object.setPrototypeOf(this, new.target.prototype); + } +} + export function validateClientConfig( config: StellarSplitClientConfig ): ConfigValidation { const errors: ConfigValidationErrorType[] = []; + const unknownKeys = Object.keys(config).filter((key) => !KNOWN_CONFIG_KEYS.has(key)); + + if (unknownKeys.length > 0) { + errors.push({ + field: unknownKeys.join(","), + message: `Unknown top-level config keys: ${unknownKeys.join(", ")}`, + severity: "error", + }); + } if (!config.rpcUrl) { errors.push({ @@ -235,6 +280,11 @@ export function validateClientConfig( export function validateOrThrow(config: StellarSplitClientConfig): void { const validation = validateClientConfig(config); + const unknownKeys = Object.keys(config).filter((key) => !KNOWN_CONFIG_KEYS.has(key)); + if (unknownKeys.length > 0) { + throw new UnknownConfigKeyError(unknownKeys); + } + if (!validation.valid) { const errorMessages = validation.errors .filter((e) => e.severity === "error") @@ -261,6 +311,10 @@ export function validateOrThrow(config: StellarSplitClientConfig): void { } } +export function validateConfig(config: StellarSplitClientConfig): void { + validateOrThrow(config); +} + export class InvalidConfigError extends StellarSplitError { readonly validationErrors: ConfigValidationErrorType[]; diff --git a/src/feeEstimator.ts b/src/feeEstimator.ts index e104da9..e08da18 100644 --- a/src/feeEstimator.ts +++ b/src/feeEstimator.ts @@ -30,6 +30,31 @@ export interface FeeEstimateError { total: string; } +export interface FeeEstimationStrategy { + estimate(params: TParams): number; +} + +export const SigningAlgorithmRegistry = new Map(); + +const feeStrategyRegistry = new Map>([ + ["fixed", { estimate: (params: { fee: number }) => params.fee }], + ["percentile", { estimate: (params: { samples: number[]; percentile: number }) => { + const sorted = [...params.samples].sort((a, b) => a - b); + if (sorted.length === 0) return 0; + const index = Math.min(sorted.length - 1, Math.floor((params.percentile / 100) * sorted.length)); + return sorted[index] ?? 0; + } }], + ["surge", { estimate: (params: { baseFee: number; multiplier: number }) => params.baseFee * params.multiplier }], +]); + +export function estimateFee(type: string, params: unknown): number { + const strategy = feeStrategyRegistry.get(type); + if (!strategy) { + throw new RangeError(`Unknown fee estimation strategy: ${type}`); + } + return strategy.estimate(params); +} + /** * Estimate operation cost by simulating it. * diff --git a/src/storageUsageEstimator.ts b/src/storageUsageEstimator.ts index 6369a18..8b5520b 100644 --- a/src/storageUsageEstimator.ts +++ b/src/storageUsageEstimator.ts @@ -28,6 +28,32 @@ export interface StorageEstimationResult { estimatedRentStroops: number; } +export interface StorageReport { + totalBytes: number; + breakdown: Record; +} + +export class StorageUsageEstimator { + private readonly categories = new Map number>(); + + registerCategory(name: string, estimateFn: () => number): void { + this.categories.set(name, estimateFn); + } + + estimateUsage(): StorageReport { + const breakdown: Record = {}; + let totalBytes = 0; + + for (const [name, estimateFn] of this.categories) { + const bytes = estimateFn(); + breakdown[name] = bytes; + totalBytes += bytes; + } + + return { totalBytes, breakdown }; + } +} + // --------------------------------------------------------------------------- // Soroban / Stellar type-size constants // --------------------------------------------------------------------------- diff --git a/test/anomalyDetector.test.ts b/test/anomalyDetector.test.ts index 7293de4..dd5ee0a 100644 --- a/test/anomalyDetector.test.ts +++ b/test/anomalyDetector.test.ts @@ -30,6 +30,22 @@ const CREATOR = "GCREATOR00000000000000000000000000000000000000000000000000"; const PAYER = "GPAYER000000000000000000000000000000000000000000000000000"; const OTHER = "GOTHER0000000000000000000000000000000000000000000000000000"; +describe("classifyScore", () => { + it("treats scores equal to the threshold as normal", () => { + const detector = new AnomalyDetector({ sensitivityThreshold: 0.5 }); + expect(detector.classifyScore(0.5)).toBe("normal"); + }); + + it("treats scores above the threshold as alert", () => { + const detector = new AnomalyDetector({ sensitivityThreshold: 0.5 }); + expect(detector.classifyScore(0.5001)).toBe("alert"); + }); + + it("throws for thresholds outside (0, 1]", () => { + expect(() => new AnomalyDetector({ sensitivityThreshold: 0 })).toThrow(RangeError); + }); +}); + // --------------------------------------------------------------------------- // HIGH_FREQUENCY // --------------------------------------------------------------------------- diff --git a/test/configValidator.test.ts b/test/configValidator.test.ts index ce0554a..ca1f738 100644 --- a/test/configValidator.test.ts +++ b/test/configValidator.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { Keypair, StrKey } from "@stellar/stellar-sdk"; import { validateClientConfig, + validateConfig, validateOrThrow, InvalidConfigError, + UnknownConfigKeyError, } from "../src/configValidator.js"; import type { StellarSplitClientConfig } from "../src/client.js"; import { Keypair } from "@stellar/stellar-sdk"; @@ -160,6 +162,16 @@ describe("validateClientConfig", () => { expect(result.valid).toBe(false); expect(result.errors.some((e) => e.field === "adapter")).toBe(true); }); + + it("catches unknown top-level config keys", () => { + const config = validConfig() as StellarSplitClientConfig & { rpcUrll?: string }; + config.rpcUrll = "https://typo.example.com"; + + const result = validateClientConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.message.includes("Unknown top-level config keys"))).toBe(true); + }); }); describe("validateOrThrow", () => { @@ -183,4 +195,11 @@ describe("validateOrThrow", () => { expect(() => validateOrThrow(config)).toThrow(/rpcUrl/); expect(() => validateOrThrow(config2)).toThrow(/rpcUrl/); }); + + it("throws UnknownConfigKeyError for typo keys", () => { + const config = validConfig() as StellarSplitClientConfig & { rpcUrll?: string }; + config.rpcUrll = "https://typo.example.com"; + + expect(() => validateConfig(config)).toThrow(UnknownConfigKeyError); + }); }); diff --git a/test/feeEstimator.test.ts b/test/feeEstimator.test.ts index e089391..3c8a4dd 100644 --- a/test/feeEstimator.test.ts +++ b/test/feeEstimator.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { estimateOperationCost, type FeeEstimate } from "../src/feeEstimator.js"; +import { estimateFee, estimateOperationCost, type FeeEstimate } from "../src/feeEstimator.js"; import { rpc as SorobanRpc, BASE_FEE, Operation, Asset } from "@stellar/stellar-sdk"; describe("feeEstimator", () => { @@ -75,4 +75,16 @@ describe("feeEstimator", () => { expect(result).toHaveProperty("error"); expect(result).toHaveProperty("baseFee", BASE_FEE.toString()); }); + + it("estimates fees via a registered fixed strategy", () => { + expect(estimateFee("fixed", { fee: 123 })).toBe(123); + }); + + it("estimates fees via a percentile strategy", () => { + expect(estimateFee("percentile", { samples: [100, 200, 300], percentile: 95 })).toBe(300); + }); + + it("estimates fees via a surge strategy", () => { + expect(estimateFee("surge", { baseFee: 100, multiplier: 2 })).toBe(200); + }); }); diff --git a/test/storageUsageEstimator.test.ts b/test/storageUsageEstimator.test.ts index 246685c..3ace5af 100644 --- a/test/storageUsageEstimator.test.ts +++ b/test/storageUsageEstimator.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { estimateStorageFootprint, + StorageUsageEstimator, } from "../src/storageUsageEstimator.js"; // Derived from the implementation constants: @@ -65,4 +66,16 @@ describe("estimateStorageFootprint", () => { expect(() => estimateStorageFootprint(0, {})).toThrow(RangeError); expect(() => estimateStorageFootprint(-1, {})).toThrow(RangeError); }); + + it("returns a breakdown whose parts sum to totalBytes", () => { + const estimator = new StorageUsageEstimator(); + estimator.registerCategory("invoices", () => 100); + estimator.registerCategory("events", () => 40); + estimator.registerCategory("cache", () => 10); + + const report = estimator.estimateUsage(); + + expect(report.breakdown).toEqual({ invoices: 100, events: 40, cache: 10 }); + expect(report.totalBytes).toBe(150); + }); });