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
58 changes: 45 additions & 13 deletions src/lifecycle/GracefulShutdownHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* other internal resources.
*/

import { EventEmitter } from "events";
import type { StellarSplitClient } from "../client.js";
import { ShutdownInProgressError } from "../errors.js";

Expand All @@ -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;

Expand Down Expand Up @@ -59,8 +69,8 @@ export class ShutdownTimeoutError extends Error {
}
}

const DEFAULT_OPTIONS: Required<ShutdownOptions> = {
drainTimeoutMs: 30_000,
const DEFAULT_OPTIONS: Required<Omit<ShutdownOptions, "timeoutMs">> & { drainTimeoutMs: number } = {
drainTimeoutMs: 10_000,
signals: ["SIGTERM", "SIGINT"],
onTimeout: "force",
};
Expand All @@ -69,18 +79,30 @@ const DEFAULT_OPTIONS: Required<ShutdownOptions> = {
* 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<ShutdownOptions>;
private readonly options: Required<Omit<ShutdownOptions, "timeoutMs">> & { drainTimeoutMs: number };
private readonly signalListeners = new Map<NodeJS.Signals, () => void>();
private shutdownPromise: Promise<void> | 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,
};
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
/**
Expand Down
47 changes: 47 additions & 0 deletions src/merkle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
28 changes: 27 additions & 1 deletion src/types/routing.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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. */
Expand Down
51 changes: 51 additions & 0 deletions src/vesting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

45 changes: 45 additions & 0 deletions test/lifecycle/GracefulShutdownHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions test/merkleVerifyProof.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading