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
11 changes: 11 additions & 0 deletions src/anomalyDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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[] = [];
Expand All @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions src/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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")
Expand All @@ -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[];

Expand Down
25 changes: 25 additions & 0 deletions src/feeEstimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ export interface FeeEstimateError {
total: string;
}

export interface FeeEstimationStrategy<TParams = unknown> {
estimate(params: TParams): number;
}

export const SigningAlgorithmRegistry = new Map<string, never>();

const feeStrategyRegistry = new Map<string, FeeEstimationStrategy<any>>([
["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.
*
Expand Down
26 changes: 26 additions & 0 deletions src/storageUsageEstimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@ export interface StorageEstimationResult {
estimatedRentStroops: number;
}

export interface StorageReport {
totalBytes: number;
breakdown: Record<string, number>;
}

export class StorageUsageEstimator {
private readonly categories = new Map<string, () => number>();

registerCategory(name: string, estimateFn: () => number): void {
this.categories.set(name, estimateFn);
}

estimateUsage(): StorageReport {
const breakdown: Record<string, number> = {};
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
// ---------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions test/anomalyDetector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
19 changes: 19 additions & 0 deletions test/configValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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);
});
});
14 changes: 13 additions & 1 deletion test/feeEstimator.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
13 changes: 13 additions & 0 deletions test/storageUsageEstimator.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
estimateStorageFootprint,
StorageUsageEstimator,
} from "../src/storageUsageEstimator.js";

// Derived from the implementation constants:
Expand Down Expand Up @@ -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);
});
});