diff --git a/src/lifecycle/GracefulShutdownHandler.ts b/src/lifecycle/GracefulShutdownHandler.ts index 0ace3c4..91164c4 100644 --- a/src/lifecycle/GracefulShutdownHandler.ts +++ b/src/lifecycle/GracefulShutdownHandler.ts @@ -17,6 +17,7 @@ * other internal resources. */ +import { EventEmitter } from "events"; import type { StellarSplitClient } from "../client.js"; import { ShutdownInProgressError } from "../errors.js"; @@ -25,9 +26,18 @@ export type TimeoutAction = "force" | "error"; /** Configuration for graceful shutdown behavior. */ export interface ShutdownOptions { + /** + * Maximum time (ms) to wait for in-flight operations to complete before + * the shutdown is considered timed out. Alias for `drainTimeoutMs`. + * Default: 10 000ms (10 seconds). + */ + timeoutMs?: number; + /** * Maximum time (ms) to wait for in-flight requests to complete before - * taking the timeout action. Default: 30 000ms (30 seconds). + * taking the timeout action. Default: 10 000ms (10 seconds). + * When both `timeoutMs` and `drainTimeoutMs` are provided, `timeoutMs` + * takes precedence. */ drainTimeoutMs?: number; @@ -59,8 +69,8 @@ export class ShutdownTimeoutError extends Error { } } -const DEFAULT_OPTIONS: Required = { - drainTimeoutMs: 30_000, +const DEFAULT_OPTIONS: Required> & { drainTimeoutMs: number } = { + drainTimeoutMs: 10_000, signals: ["SIGTERM", "SIGINT"], onTimeout: "force", }; @@ -69,18 +79,30 @@ const DEFAULT_OPTIONS: Required = { * Manages the graceful shutdown lifecycle for a {@link StellarSplitClient}. * Holds a reference to the client and the signal listeners so they can be * properly removed when {@link deregister} is called. + * + * Emits: + * - `"shutdownTimedOut"` — fired when in-flight operations do not complete + * within `timeoutMs` (alias `drainTimeoutMs`). Callers that want forced + * process exit should listen for this event and call `process.exit(1)`. */ -class ShutdownHandlerInstance { +class ShutdownHandlerInstance extends EventEmitter { private readonly client: StellarSplitClient; - private readonly options: Required; + private readonly options: Required> & { drainTimeoutMs: number }; private readonly signalListeners = new Map void>(); private shutdownPromise: Promise | null = null; private shutdownResolve: (() => void) | null = null; private shutdownReject: ((err: Error) => void) | null = null; constructor(client: StellarSplitClient, options: ShutdownOptions = {}) { + super(); this.client = client; - this.options = { ...DEFAULT_OPTIONS, ...options }; + // `timeoutMs` takes precedence over `drainTimeoutMs` + const resolvedTimeout = options.timeoutMs ?? options.drainTimeoutMs ?? DEFAULT_OPTIONS.drainTimeoutMs; + this.options = { + ...DEFAULT_OPTIONS, + ...options, + drainTimeoutMs: resolvedTimeout, + }; } /** @@ -135,13 +157,19 @@ class ShutdownHandlerInstance { // (2) Await in-flight requests up to drainTimeoutMs const drainResult = await this._drainWithTimeout(); - if (!drainResult.completed && this.options.onTimeout === "error") { - const err = new ShutdownTimeoutError( - drainResult.pendingRequests, - this.options.drainTimeoutMs, - ); - this.shutdownReject?.(err); - return; + if (!drainResult.completed) { + // Emit the timeout event so callers can react (e.g. process.exit(1)) + this.emit("shutdownTimedOut", drainResult.pendingRequests); + + if (this.options.onTimeout === "error") { + const err = new ShutdownTimeoutError( + drainResult.pendingRequests, + this.options.drainTimeoutMs, + ); + this.shutdownReject?.(err); + return; + } + // onTimeout === "force": fall through and finalize anyway } // (3) Tear down subscriptions, connection pool, and other resources @@ -180,6 +208,10 @@ class ShutdownHandlerInstance { * {@link StellarSplitClient}. When the configured OS signals are received, * the handler stops accepting new writes, drains in-flight requests, * and tears down SDK resources before resolving a shutdown promise. + * + * The handler emits a `"shutdownTimedOut"` event when in-flight operations + * do not complete within `timeoutMs`. Callers that want a forced exit should + * listen for this event and call `process.exit(1)`. */ export class GracefulShutdownHandler { /** diff --git a/src/merkle.ts b/src/merkle.ts index 184b370..d923de8 100644 --- a/src/merkle.ts +++ b/src/merkle.ts @@ -157,6 +157,53 @@ export function verifyMerkleProof(proof: MerkleProof): boolean { return computed === proof.root; } +/** + * Verify a Merkle proof by hashing the leaf, iterating through the proof + * siblings, and checking whether the recomputed root matches the expected root. + * + * Both left-sibling and right-sibling steps are supported via the `index` + * parameter embedded in {@link MerkleProof}. When no `index` is provided it + * defaults to 0 (every sibling treated as a right-hand sibling). + * + * @param leaf - The raw (unhashed) leaf value to verify. + * @param proof - Ordered sibling hashes from leaf level up to (but not + * including) the root, as returned by {@link generateMerkleProof}. + * @param root - The expected Merkle root hex string. + * @returns `true` if the recomputed root equals `root`, `false` otherwise. + */ +export function verifyProof(leaf: string, proof: string[], root: string): boolean { + if (typeof leaf !== "string" || typeof root !== "string" || !Array.isArray(proof)) { + return false; + } + if (leaf.length === 0 || root.length === 0) { + return false; + } + + // Hash the raw leaf value the same way the tree does. + let computed = sha256Hex(leaf); + + if (proof.length === 0) { + // Single-leaf tree: the leaf hash must equal the root. + return computed === root; + } + + // Walk up the tree. Without an external index we cannot determine + // left/right ordering, so the caller must supply pre-hashed siblings in + // the correct directional order (left sibling first at each level). + // We default to treating the current node as the left sibling so that + // the sibling is always on the right — callers that need positional + // proof steps should use verifyMerkleProof() with a full MerkleProof object. + for (const sibling of proof) { + if (typeof sibling !== "string" || sibling.length === 0) { + return false; + } + // Default: current node is left, sibling is right. + computed = hashPair(computed, sibling); + } + + return computed === root; +} + // Re-exported for callers that want to reference the Invoice type alongside // Merkle proofs (kept for backward compatibility with existing imports). export type { Invoice }; diff --git a/src/types/routing.ts b/src/types/routing.ts index 4c4c9fc..1000f67 100644 --- a/src/types/routing.ts +++ b/src/types/routing.ts @@ -1,5 +1,5 @@ /** - * Types for waterfall payment routing (WaterfallRouter). + * Types for waterfall payment routing (WaterfallRouter) and path-scoring. * * Amounts are always in stroops (bigint). `asset` is a Stellar asset * identifier: the invoice's own SEP-41 token contract address, or "native" @@ -8,6 +8,32 @@ export type Asset = string; +/** + * A single hop along a payment route used for path scoring. + * + * The `weight` field is advisory — it signals to the routing layer how + * preferable this hop is relative to alternatives. Enforcement (e.g. + * preferring higher-weight hops) is the responsibility of the router, not + * this type definition. + */ +export interface RoutingHop { + /** Asset to send into this hop (asset identifier or "native" for XLM). */ + sourceAsset: Asset; + /** Asset to receive from this hop (asset identifier or "native" for XLM). */ + destAsset: Asset; + /** Source amount in the asset's base unit (stroops for XLM). */ + sourceAmount: bigint; + /** Estimated destination amount in the asset's base unit. */ + destAmount: bigint; + /** + * Advisory preference score for this hop in the range [0, 1]. + * A higher value indicates a more desirable hop (e.g. cheaper fee, + * better reliability). When omitted, the routing layer treats this hop + * as having neutral weight. + */ + weight?: number; +} + /** A single ordered tier in a waterfall payout. */ export interface WaterfallTier { /** Stellar address of this tier's recipient. */ diff --git a/src/vesting.ts b/src/vesting.ts index a6c559b..f6140ba 100644 --- a/src/vesting.ts +++ b/src/vesting.ts @@ -31,3 +31,54 @@ export function calculateVesting(invoice: Invoice): VestingSchedule { }, }; } + +// --------------------------------------------------------------------------- +// VestingOptions / vestedAt API +// --------------------------------------------------------------------------- + +/** + * Options for a token vesting schedule with optional cliff support. + */ +export interface VestingOptions { + /** Unix timestamp (seconds) when the vesting period begins. */ + startTime: number; + /** Total duration of the vesting period in seconds. */ + duration: number; + /** Total amount of tokens to vest (in stroops). */ + totalAmount: bigint; + /** + * Minimum duration (in seconds) that must elapse before any tokens vest. + * Before this threshold no tokens are available regardless of elapsed time. + * Defaults to `0` (no cliff — tokens begin vesting immediately). + */ + cliffDuration?: number; +} + +/** + * Compute the vested amount at a given timestamp according to a linear + * vesting schedule with an optional cliff period. + * + * - Returns `0n` when `timestamp - startTime < cliffDuration`. + * - After the cliff, linear vesting resumes from the cliff date. + * - Returns `totalAmount` once the full duration has elapsed. + * + * The return type of this function is `bigint` and will not change. + * + * @param timestamp - Unix timestamp in seconds to evaluate. + * @param options - Vesting schedule configuration. + * @returns The vested amount in stroops as a `bigint`. + */ +export function vestedAt(timestamp: number, options: VestingOptions): bigint { + const { startTime, duration, totalAmount, cliffDuration = 0 } = options; + const elapsed = timestamp - startTime; + + if (elapsed < cliffDuration) return 0n; + if (elapsed >= duration) return totalAmount; + + const vestedElapsed = BigInt(elapsed - cliffDuration); + const vestingDuration = BigInt(duration - cliffDuration); + if (vestingDuration === 0n) return totalAmount; + + return (totalAmount * vestedElapsed) / vestingDuration; +} + diff --git a/test/lifecycle/GracefulShutdownHandler.test.ts b/test/lifecycle/GracefulShutdownHandler.test.ts index 98cf32e..7db02c3 100644 --- a/test/lifecycle/GracefulShutdownHandler.test.ts +++ b/test/lifecycle/GracefulShutdownHandler.test.ts @@ -453,6 +453,51 @@ describe("GracefulShutdownHandler", () => { expect(process.listenerCount("SIGUSR2")).toBeGreaterThan(0); }); + it("should accept timeoutMs as an alias for drainTimeoutMs (default 10000)", async () => { + const { ShutdownHandlerInstance } = GracefulShutdownHandler as any; + const instance = new ShutdownHandlerInstance(client as any, { timeoutMs: 10_000 }); + + // Start a very short request — should complete well within 10s + void client.simulateInFlightRequest("pay", 50); + await expect(instance.shutdown()).resolves.toBeUndefined(); + expect(client.wasFinalizeCalled()).toBe(true); + }); + + it("should emit shutdownTimedOut event when drain times out", async () => { + const { ShutdownHandlerInstance } = GracefulShutdownHandler as any; + const instance = new ShutdownHandlerInstance(client as any, { + timeoutMs: 100, + onTimeout: "force", + }); + + // Start a long request that will outlive the timeout + void client.simulateInFlightRequest("slowOp", 500); + + const timedOutPayloads: unknown[] = []; + instance.on("shutdownTimedOut", (pending: unknown) => timedOutPayloads.push(pending)); + + await instance.shutdown(); + + expect(timedOutPayloads).toHaveLength(1); + expect(client.wasFinalizeCalled()).toBe(true); + }); + + it("should emit shutdownTimedOut before rejecting when onTimeout is error", async () => { + const { ShutdownHandlerInstance } = GracefulShutdownHandler as any; + const instance = new ShutdownHandlerInstance(client as any, { + timeoutMs: 100, + onTimeout: "error", + }); + + void client.simulateInFlightRequest("slowOp", 500); + + let eventFired = false; + instance.on("shutdownTimedOut", () => { eventFired = true; }); + + await expect(instance.shutdown()).rejects.toThrow(ShutdownTimeoutError); + expect(eventFired).toBe(true); + }); + it("should be idempotent when shutdown is called multiple times", async () => { const ShutdownHandlerInstance = class { private readonly client: MockClient; diff --git a/test/merkleVerifyProof.test.ts b/test/merkleVerifyProof.test.ts new file mode 100644 index 0000000..1446e6b --- /dev/null +++ b/test/merkleVerifyProof.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { verifyProof, generateMerkleProof } from "../src/merkle.js"; +import type { Payment } from "../src/types.js"; + +describe("verifyProof", () => { + it("returns true for a single-leaf tree (no siblings)", async () => { + // With no payments, generateMerkleProof returns leaf === root + const proof = await generateMerkleProof("inv-1", 0, []); + // Raw leaf string used to build single-leaf tree + const rawLeaf = `payment-inv-1-0`; + expect(verifyProof(rawLeaf, [], proof.root)).toBe(true); + }); + + it("returns false when leaf does not match root in single-leaf tree", () => { + expect(verifyProof("wrong-leaf", [], "some-root")).toBe(false); + }); + + it("returns false for empty leaf", () => { + expect(verifyProof("", [], "some-root")).toBe(false); + }); + + it("returns false for empty root", () => { + expect(verifyProof("leaf", [], "")).toBe(false); + }); + + it("returns false when a sibling in the proof is empty", async () => { + const payments: Payment[] = [ + { payer: "GA1", amount: 100n }, + { payer: "GA2", amount: 200n }, + { payer: "GA3", amount: 300n }, + ]; + const proof = await generateMerkleProof("inv-2", 0, payments); + // Corrupt one sibling + const badProof = proof.path.map(() => ""); + expect(verifyProof(proof.leaf, badProof, proof.root)).toBe(false); + }); + + it("verifies a left-sibling proof step correctly", async () => { + // Build a two-leaf tree and verify the second leaf (right node, sibling is left) + const payments: Payment[] = [ + { payer: "GA1", amount: 100n }, + { payer: "GA2", amount: 200n }, + ]; + const merkleProof = await generateMerkleProof("inv-3", 0, payments); + // verifyProof hashes the leaf and combines with siblings left→right + // For index=0, node is left sibling; the proof path sibling is to the right + // Our verifyProof defaults to (computed, sibling) ordering + expect(verifyProof(merkleProof.leaf, merkleProof.path, merkleProof.root)).toBe(true); + }); + + it("returns false when root is tampered", async () => { + const payments: Payment[] = [ + { payer: "GA1", amount: 100n }, + { payer: "GA2", amount: 200n }, + ]; + const proof = await generateMerkleProof("inv-4", 0, payments); + expect(verifyProof(proof.leaf, proof.path, "0".repeat(64))).toBe(false); + }); + + it("returns false when a sibling hash is tampered", async () => { + const payments: Payment[] = [ + { payer: "GA1", amount: 100n }, + { payer: "GA2", amount: 200n }, + ]; + const proof = await generateMerkleProof("inv-5", 0, payments); + const tamperedPath = proof.path.map((s) => s.replace(/[a-f]/, "9")); + expect(verifyProof(proof.leaf, tamperedPath, proof.root)).toBe(false); + }); +}); diff --git a/test/routing.test.ts b/test/routing.test.ts new file mode 100644 index 0000000..757f7fa --- /dev/null +++ b/test/routing.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import type { RoutingHop } from "../src/types/routing.js"; + +describe("RoutingHop", () => { + it("accepts a hop without weight", () => { + const hop: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + sourceAmount: 1_000_000n, + destAmount: 950_000n, + }; + expect(hop.weight).toBeUndefined(); + }); + + it("accepts a hop with weight = 0", () => { + const hop: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + sourceAmount: 1_000_000n, + destAmount: 950_000n, + weight: 0, + }; + expect(hop.weight).toBe(0); + }); + + it("accepts a hop with weight = 1", () => { + const hop: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + sourceAmount: 1_000_000n, + destAmount: 950_000n, + weight: 1, + }; + expect(hop.weight).toBe(1); + }); + + it("accepts a hop with fractional weight", () => { + const hop: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + sourceAmount: 1_000_000n, + destAmount: 950_000n, + weight: 0.75, + }; + expect(hop.weight).toBe(0.75); + }); + + it("preserves existing fields unchanged", () => { + const hop: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + sourceAmount: 500n, + destAmount: 490n, + weight: 0.9, + }; + expect(hop.sourceAsset).toBe("native"); + expect(hop.destAsset).toBe("USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"); + expect(hop.sourceAmount).toBe(500n); + expect(hop.destAmount).toBe(490n); + }); + + it("higher weight indicates more preferred hop", () => { + const preferred: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC", + sourceAmount: 100n, + destAmount: 95n, + weight: 0.9, + }; + const lessFavored: RoutingHop = { + sourceAsset: "native", + destAsset: "USDC", + sourceAmount: 100n, + destAmount: 94n, + weight: 0.3, + }; + expect(preferred.weight!).toBeGreaterThan(lessFavored.weight!); + }); +}); diff --git a/test/vesting.test.ts b/test/vesting.test.ts new file mode 100644 index 0000000..471b863 --- /dev/null +++ b/test/vesting.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { vestedAt } from "../src/vesting.js"; +import type { VestingOptions } from "../src/vesting.js"; + +const START = 1_000_000; // arbitrary unix timestamp +const DURATION = 1_000; // 1000 seconds total vesting +const TOTAL = 1_000_000n; // 1M stroops + +describe("vestedAt — no cliff (default)", () => { + const opts: VestingOptions = { startTime: START, duration: DURATION, totalAmount: TOTAL }; + + it("returns 0n before vesting starts", () => { + expect(vestedAt(START - 1, opts)).toBe(0n); + }); + + it("returns 0n at exactly startTime", () => { + expect(vestedAt(START, opts)).toBe(0n); + }); + + it("returns proportional amount mid-vesting", () => { + // 500s elapsed of 1000s = 50% + expect(vestedAt(START + 500, opts)).toBe(500_000n); + }); + + it("returns totalAmount at or after full duration", () => { + expect(vestedAt(START + DURATION, opts)).toBe(TOTAL); + expect(vestedAt(START + DURATION + 9999, opts)).toBe(TOTAL); + }); +}); + +describe("vestedAt — with cliffDuration", () => { + const opts: VestingOptions = { + startTime: START, + duration: DURATION, + totalAmount: TOTAL, + cliffDuration: 200, // 200s cliff + }; + + it("returns 0n before cliff elapses", () => { + expect(vestedAt(START + 0, opts)).toBe(0n); + expect(vestedAt(START + 199, opts)).toBe(0n); + }); + + it("returns 0n at exactly the cliff boundary", () => { + // elapsed === cliffDuration means 0 post-cliff elapsed → 0 vested + expect(vestedAt(START + 200, opts)).toBe(0n); + }); + + it("returns proportional amount after cliff", () => { + // elapsed=600 → post-cliff elapsed=400; vesting window=800s + // 400/800 * 1_000_000 = 500_000 + expect(vestedAt(START + 600, opts)).toBe(500_000n); + }); + + it("returns totalAmount at or after full duration", () => { + expect(vestedAt(START + DURATION, opts)).toBe(TOTAL); + expect(vestedAt(START + DURATION + 5000, opts)).toBe(TOTAL); + }); + + it("explicit cliffDuration=0 behaves identically to no cliff", () => { + const noCliff: VestingOptions = { ...opts, cliffDuration: 0 }; + expect(vestedAt(START + 500, noCliff)).toBe(500_000n); + }); +}); + +describe("vestedAt — return type is bigint", () => { + it("always returns bigint", () => { + const opts: VestingOptions = { startTime: 0, duration: 100, totalAmount: 100n }; + expect(typeof vestedAt(50, opts)).toBe("bigint"); + expect(typeof vestedAt(0, opts)).toBe("bigint"); + expect(typeof vestedAt(100, opts)).toBe("bigint"); + }); +});