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
6 changes: 4 additions & 2 deletions coordinator/src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export function createApp(deps: AppDeps): Express {
next();
});

app.use(healthRoutes());
const readinessLimit = Number(process.env.COORDINATOR_READINESS_RATE_LIMIT ?? 30);
const readinessWindowMs = Number(process.env.COORDINATOR_READINESS_RATE_WINDOW_MS ?? 60_000);
app.use(healthRoutes({ limit: readinessLimit, windowMs: readinessWindowMs }));
app.use(metricsRoutes());
app.use("/api", ordersRoutes(deps.orders));
app.use("/api", secretsRoutes(deps.secrets));
Expand Down Expand Up @@ -85,4 +87,4 @@ export function createApp(deps: AppDeps): Express {
);

return app;
}
}
53 changes: 53 additions & 0 deletions coordinator/src/server/readiness-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { NextFunction, Request, RequestHandler, Response } from "express";

export interface ReadinessRateLimitOptions {
/** Maximum requests per client in the window. */
limit?: number;
/** Window duration in milliseconds. */
windowMs?: number;
/** Injectable clock for deterministic tests. */
now?: () => number;
}

/**
* Small in-process limiter for public diagnostics. Health/readiness are
* intentionally cheap, but unrestricted polling can still exhaust logs and
* upstream dependency checks. A bounded map is sufficient for one process;
* deployments with multiple replicas should enforce the same policy at the
* edge as well.
*/
export function createReadinessRateLimiter(options: ReadinessRateLimitOptions = {}): RequestHandler {
const limit = Math.max(1, Math.floor(options.limit ?? 30));
const windowMs = Math.max(1_000, Math.floor(options.windowMs ?? 60_000));
const now = options.now ?? (() => Date.now());
const buckets = new Map<string, { startedAt: number; count: number }>();
Comment on lines +19 to +23

return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || req.socket.remoteAddress || "unknown";
const timestamp = now();
const current = buckets.get(key);
const bucket = !current || timestamp - current.startedAt >= windowMs
? { startedAt: timestamp, count: 0 }
: current;

bucket.count += 1;
buckets.set(key, bucket);

Comment on lines +22 to +35
res.setHeader("X-RateLimit-Limit", String(limit));
res.setHeader("X-RateLimit-Remaining", String(Math.max(0, limit - bucket.count)));
res.setHeader("X-RateLimit-Reset", String(Math.ceil((bucket.startedAt + windowMs) / 1000)));

if (bucket.count > limit) {
const retryAfter = Math.max(1, Math.ceil((bucket.startedAt + windowMs - timestamp) / 1000));
res.setHeader("Retry-After", String(retryAfter));
res.status(429).json({
error: "rate_limited",
message: "Too many health or readiness requests",
retryAfterSeconds: retryAfter
});
return;
}

next();
};
}
4 changes: 3 additions & 1 deletion coordinator/src/server/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router } from "express";
import { createHash } from "node:crypto";
import { createReadinessRateLimiter, type ReadinessRateLimitOptions } from "../readiness-rate-limit.js";

function getBuildEnv(): "testnet" | "mainnet" {
const v = (process.env.NETWORK_MODE ?? "testnet").toLowerCase();
Expand Down Expand Up @@ -74,9 +75,10 @@ function configuredStellarPassphrase(): string {
);
}

export function healthRoutes(): Router {
export function healthRoutes(rateLimit?: ReadinessRateLimitOptions): Router {
const router = Router();
const startedAt = Date.now();
router.use(createReadinessRateLimiter(rateLimit));

// ── GET /health — existing contract, unchanged ───────────────────────────
router.get("/health", (_req, res) => {
Expand Down
43 changes: 37 additions & 6 deletions coordinator/src/services/order-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ function assertTimelocksAtCreation(
}
}

/** A chain event was validly shaped but older than the persisted state. */
export class StaleOrderEventError extends OrderValidationError {
constructor(message: string) {
super(message);
this.name = "StaleOrderEventError";
}
}

function validateChainAddress(chain: Chain, addr: string): void {
if (chain === "ethereum" && !HEX_ADDRESS.test(addr)) {
throw new OrderValidationError(`${addr} is not a valid Ethereum address`);
Expand Down Expand Up @@ -185,8 +193,17 @@ export class OrderService {
}): Promise<void> {
const order = await this.repo.findByPublicId(input.publicId);
if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`);
if (!canTransition(order.status, "src_locked") && order.status !== "src_locked") {
throw new OrderValidationError(`cannot record src lock from status ${order.status}`);
if (order.status === "src_locked") {
const sameEvent =
order.srcOrderId === input.orderId &&
order.srcLockTx === input.txHash &&
order.srcLockBlock === input.blockNumber &&
order.srcTimelock === input.timelock;
Comment on lines +197 to +201
if (sameEvent) return;
throw new StaleOrderEventError(`conflicting src lock event for ${input.publicId}`);
}
if (!canTransition(order.status, "src_locked")) {
throw new StaleOrderEventError(`stale src lock event for order in status ${order.status}`);
}

if (order.dstTimelock != null) {
Expand All @@ -208,8 +225,18 @@ export class OrderService {
}): Promise<void> {
const order = await this.repo.findByPublicId(input.publicId);
if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`);
if (!canTransition(order.status, "dst_locked") && order.status !== "dst_locked") {
throw new OrderValidationError(`cannot record dst lock from status ${order.status}`);
if (order.status === "dst_locked") {
const sameEvent =
order.dstOrderId === input.orderId &&
order.dstLockTx === input.txHash &&
order.dstLockBlock === input.blockNumber &&
order.dstTimelock === input.timelock &&
order.resolverAddress === input.resolver;
if (sameEvent) return;
throw new StaleOrderEventError(`conflicting dst lock event for ${input.publicId}`);
}
if (!canTransition(order.status, "dst_locked")) {
throw new StaleOrderEventError(`stale dst lock event for order in status ${order.status}`);
}

if (order.srcTimelock != null) {
Expand All @@ -224,8 +251,12 @@ export class OrderService {
async recordSecret(publicId: string, preimage: string, txHash: string): Promise<void> {
const order = await this.repo.findByPublicId(publicId);
if (!order) throw new OrderValidationError(`unknown order ${publicId}`);
if (!canTransition(order.status, "secret_revealed") && order.status !== "secret_revealed") {
throw new OrderValidationError(`cannot record secret from status ${order.status}`);
if (order.status === "secret_revealed") {
if (order.preimage === preimage && order.secretRevealedTx === txHash) return;
throw new StaleOrderEventError(`conflicting secret event for ${publicId}`);
}
if (!canTransition(order.status, "secret_revealed")) {
throw new StaleOrderEventError(`stale secret event for order in status ${order.status}`);
}
await this.repo.recordSecretRevealed({ publicId, preimage, txHash });
this.log.info({ publicId }, "secret recorded");
Expand Down
28 changes: 28 additions & 0 deletions coordinator/src/state-machine/order-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ const TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
expired: ["refunded", "failed"]
};

/**
* Lifecycle rank used when chain listeners deliver events out of order.
* Terminal outcomes intentionally rank after the happy-path states so a
* delayed lock/reveal can never move an order backwards.
*/
const STATUS_RANK: Record<OrderStatus, number> = {
announced: 0,
src_locked: 1,
dst_locked: 2,
secret_revealed: 3,
completed: 4,
refunded: 4,
failed: 4,
expired: 4
};

export class InvalidTransitionError extends Error {
constructor(public readonly from: OrderStatus, public readonly to: OrderStatus) {
super(`Invalid order transition: ${from} -> ${to}`);
Expand All @@ -40,6 +56,18 @@ export function canTransition(from: OrderStatus, to: OrderStatus): boolean {
return TRANSITIONS[from].includes(to);
}

/** True when `to` is an older lifecycle state than `from`. */
export function isStaleTransition(from: OrderStatus, to: OrderStatus): boolean {
return STATUS_RANK[to] < STATUS_RANK[from];
}

/** Compare two statuses without allowing terminal states to regress. */
export function compareStatus(a: OrderStatus, b: OrderStatus): -1 | 0 | 1 {
const left = STATUS_RANK[a];
const right = STATUS_RANK[b];
return left < right ? -1 : left > right ? 1 : 0;
}

export function requireTransition(from: OrderStatus, to: OrderStatus): void {
if (!canTransition(from, to)) {
throw new InvalidTransitionError(from, to);
Expand Down
46 changes: 45 additions & 1 deletion coordinator/test/order-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { openDatabase, PostgresStatement } from "../src/persistence/db.js";
import { OrdersRepository } from "../src/persistence/orders-repo.js";
import { OrderService, OrderValidationError } from "../src/services/order-service.js";
import { OrderService, OrderValidationError, StaleOrderEventError } from "../src/services/order-service.js";
import { SecretService } from "../src/services/secret-service.js";

const log = pino({ level: "silent" });
Expand Down Expand Up @@ -102,6 +102,50 @@ describe("OrderService", () => {
})
).rejects.toThrowError(OrderValidationError);
});

it("ignores an exact duplicate lock event but rejects a conflicting one", async () => {
const db = await freshDb();
const orders = new OrderService(new OrdersRepository(db), log);
const order = await orders.announce({
direction: "eth_to_xlm",
hashlock: VALID_HASHLOCK,
srcChain: "ethereum",
srcAddress: VALID_ETH_ADDR,
srcAsset: "native",
srcAmount: "1",
srcSafetyDeposit: "1",
dstChain: "stellar",
dstAddress: VALID_STELLAR_ADDR,
dstAsset: "native",
dstAmount: "1"
});
const event = { publicId: order.publicId, orderId: "src-1", txHash: "0xsrc", blockNumber: 4, timelock: 1000 };
await orders.recordSrcLock(event);
await expect(orders.recordSrcLock(event)).resolves.toBeUndefined();
await expect(orders.recordSrcLock({ ...event, txHash: "0xother" })).rejects.toBeInstanceOf(StaleOrderEventError);
expect((await orders.getTransitions(order.publicId)).map((transition) => transition.to)).toEqual(["announced", "src_locked"]);
});

it("rejects delayed source events after the destination has advanced", async () => {
const db = await freshDb();
const orders = new OrderService(new OrdersRepository(db), log);
const order = await orders.announce({
direction: "eth_to_xlm",
hashlock: "0x" + "e".repeat(64),
srcChain: "ethereum",
srcAddress: VALID_ETH_ADDR,
srcAsset: "native",
srcAmount: "1",
srcSafetyDeposit: "1",
dstChain: "stellar",
dstAddress: VALID_STELLAR_ADDR,
dstAsset: "native",
dstAmount: "1"
});
await orders.recordSrcLock({ publicId: order.publicId, orderId: "src-1", txHash: "0xsrc", blockNumber: 4, timelock: 3000 });
await orders.recordDstLock({ publicId: order.publicId, orderId: "dst-1", txHash: "0xdst", blockNumber: 5, timelock: 2000, resolver: null });
await expect(orders.recordSrcLock({ publicId: order.publicId, orderId: "src-old", txHash: "0xold", blockNumber: 3, timelock: 2000 })).rejects.toBeInstanceOf(StaleOrderEventError);
});
});

describe("SecretService", () => {
Expand Down
11 changes: 11 additions & 0 deletions e2e/sim.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { keccak256, sha256 } from "viem";
import { assertValidSecretFormat } from "@oversync/sdk/secrets";

export type Hex = `0x${string}`;

Expand Down Expand Up @@ -117,6 +118,11 @@ export class EvmHtlcSim extends BaseHtlcSim implements HtlcSim {
const o = this.getMutable(id);
if (o.status !== "Funded") throw new SimError("OrderNotClaimable");
if (this.now > o.timelockAbsolute) throw new SimError("Expired");
try {
assertValidSecretFormat(preimage, "preimage");
} catch {
throw new SimError("InvalidPreimage");
}
const sha = sha256(preimage);
const kek = keccak256(preimage);
if (sha !== o.hashlock && kek !== o.hashlock) {
Expand All @@ -139,6 +145,11 @@ export class SorobanHtlcSim extends BaseHtlcSim implements HtlcSim {
const o = this.getMutable(id);
if (o.status !== "Funded") throw new SimError("OrderNotClaimable");
if (this.now > o.timelockAbsolute) throw new SimError("Expired");
try {
assertValidSecretFormat(preimage, "preimage");
} catch {
throw new SimError("InvalidPreimage");
}
const sha = sha256(preimage);
if (sha !== o.hashlock) {
throw new SimError("InvalidPreimage");
Expand Down
24 changes: 23 additions & 1 deletion packages/sdk/src/secrets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface Secret {
keccak256: `0x${string}`;
}

export type SecretHashAlgorithm = "sha256" | "keccak256";

function isCryptoEnvAvailable(): boolean {
return typeof globalThis.crypto !== "undefined" && typeof globalThis.crypto.getRandomValues === "function";
}
Expand Down Expand Up @@ -70,13 +72,33 @@ export function hashSecret(preimage: `0x${string}` | Uint8Array): Secret {
export function verifyPreimage(
preimage: `0x${string}`,
expected: `0x${string}`
): "sha256" | "keccak256" | null {
): SecretHashAlgorithm | null {
assertValidSecretFormat(preimage, "preimage");
assertValidSecretFormat(expected, "hashlock");
const s = hashSecret(preimage);
if (s.sha256 === expected) return "sha256";
if (s.keccak256 === expected) return "keccak256";
return null;
}

/**
* Validate both sides of an HTLC commitment before any chain call. Returning
* the matched algorithm makes the proof explicit to callers and avoids
* silently accepting a correctly sized but unrelated preimage.
*/
export function assertPreimageMatchesHashlock(
preimage: unknown,
hashlock: unknown
): SecretHashAlgorithm {
const checkedPreimage = assertValidSecretFormat(preimage, "preimage");
const checkedHashlock = assertValidSecretFormat(hashlock, "hashlock");
const algorithm = verifyPreimage(checkedPreimage, checkedHashlock);
if (!algorithm) {
throw new Error("preimage does not match hashlock");
}
return algorithm;
}

/**
* Validates that a string is a well-formed 32-byte hex string with a 0x prefix.
* Throws a clear error if the format is invalid.
Expand Down
11 changes: 10 additions & 1 deletion packages/sdk/test/secrets.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { generateSecret, hashSecret, verifyPreimage, assertValidSecretFormat } from "../src/secrets/index.js";
import { generateSecret, hashSecret, verifyPreimage, assertValidSecretFormat, assertPreimageMatchesHashlock } from "../src/secrets/index.js";

describe("secrets", () => {
it("generates a 32-byte secret with both digests", () => {
Expand All @@ -25,6 +25,15 @@ describe("secrets", () => {
expect(verifyPreimage(s.preimage, other.sha256)).toBeNull();
});

it("validates the complete preimage/hashlock pair", () => {
const secret = generateSecret();
expect(assertPreimageMatchesHashlock(secret.preimage, secret.sha256)).toBe("sha256");
expect(assertPreimageMatchesHashlock(secret.preimage, secret.keccak256)).toBe("keccak256");
expect(() => assertPreimageMatchesHashlock("0x" + "b".repeat(64), secret.sha256)).toThrow("does not match");
expect(() => assertPreimageMatchesHashlock("0x12", secret.sha256)).toThrow("preimage must be exactly 32 bytes");
expect(() => assertPreimageMatchesHashlock(secret.preimage, "0x12")).toThrow("hashlock must be exactly 32 bytes");
});

describe("assertValidSecretFormat", () => {
it("accepts valid 32-byte hex strings with 0x prefix", () => {
const valid = "0x" + "a".repeat(64);
Expand Down
Loading
Loading