diff --git a/coordinator/src/server/app.ts b/coordinator/src/server/app.ts index 5912bfe..8944385 100644 --- a/coordinator/src/server/app.ts +++ b/coordinator/src/server/app.ts @@ -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)); @@ -85,4 +87,4 @@ export function createApp(deps: AppDeps): Express { ); return app; -} \ No newline at end of file +} diff --git a/coordinator/src/server/readiness-rate-limit.ts b/coordinator/src/server/readiness-rate-limit.ts new file mode 100644 index 0000000..c03afdb --- /dev/null +++ b/coordinator/src/server/readiness-rate-limit.ts @@ -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(); + + 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); + + 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(); + }; +} diff --git a/coordinator/src/server/routes/health.ts b/coordinator/src/server/routes/health.ts index b8aa736..58e4988 100644 --- a/coordinator/src/server/routes/health.ts +++ b/coordinator/src/server/routes/health.ts @@ -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(); @@ -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) => { diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index e2dc2c6..c3c9a5b 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -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`); @@ -185,8 +193,17 @@ export class OrderService { }): Promise { 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; + 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) { @@ -208,8 +225,18 @@ export class OrderService { }): Promise { 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) { @@ -224,8 +251,12 @@ export class OrderService { async recordSecret(publicId: string, preimage: string, txHash: string): Promise { 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"); diff --git a/coordinator/src/state-machine/order-machine.ts b/coordinator/src/state-machine/order-machine.ts index 01ef0ec..3e3b8d1 100644 --- a/coordinator/src/state-machine/order-machine.ts +++ b/coordinator/src/state-machine/order-machine.ts @@ -30,6 +30,22 @@ const TRANSITIONS: Record = { 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 = { + 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}`); @@ -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); diff --git a/coordinator/test/order-service.test.ts b/coordinator/test/order-service.test.ts index 536292b..77f2823 100644 --- a/coordinator/test/order-service.test.ts +++ b/coordinator/test/order-service.test.ts @@ -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" }); @@ -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", () => { diff --git a/e2e/sim.ts b/e2e/sim.ts index e022446..7cdc14b 100644 --- a/e2e/sim.ts +++ b/e2e/sim.ts @@ -1,4 +1,5 @@ import { keccak256, sha256 } from "viem"; +import { assertValidSecretFormat } from "@oversync/sdk/secrets"; export type Hex = `0x${string}`; @@ -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) { @@ -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"); diff --git a/packages/sdk/src/secrets/index.ts b/packages/sdk/src/secrets/index.ts index 2fd544a..e271f22 100644 --- a/packages/sdk/src/secrets/index.ts +++ b/packages/sdk/src/secrets/index.ts @@ -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"; } @@ -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. diff --git a/packages/sdk/test/secrets.test.ts b/packages/sdk/test/secrets.test.ts index f7388d9..1f63f02 100644 --- a/packages/sdk/test/secrets.test.ts +++ b/packages/sdk/test/secrets.test.ts @@ -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", () => { @@ -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); diff --git a/scripts/deployed-hash-verifier.mjs b/scripts/deployed-hash-verifier.mjs new file mode 100644 index 0000000..494c8de --- /dev/null +++ b/scripts/deployed-hash-verifier.mjs @@ -0,0 +1,75 @@ +import { readFile } from "node:fs/promises"; + +const CODE_HASH = /^(?:0x)?[a-fA-F0-9]{64}$/; + +function normalizeHash(value) { + const raw = typeof value === "string" ? value : value?.codeHash; + if (typeof raw !== "string" || !CODE_HASH.test(raw)) return null; + return raw.replace(/^0x/, "").toLowerCase(); +} + +function expectedHashes(manifest) { + return { + ethereum: manifest?.ethereum?.codeHashes ?? {}, + stellar: manifest?.stellar?.codeHashes ?? {} + }; +} + +/** + * Compare operator-collected runtime bytecode hashes with deployment + * evidence. The function is pure so CI and deployment tooling can use the + * same fail-closed comparison without making RPC calls from this repository. + */ +export function compareDeployedHashes(manifest, observed) { + const mismatches = []; + const expected = expectedHashes(manifest); + for (const chain of ["ethereum", "stellar"]) { + for (const [contract, expectedValue] of Object.entries(expected[chain])) { + const wanted = normalizeHash(expectedValue); + const actual = normalizeHash(observed?.[chain]?.[contract]); + const path = `${chain}.codeHashes.${contract}`; + if (!wanted) { + mismatches.push({ chain, contract, path, reason: "invalid_manifest_hash" }); + } else if (!actual) { + mismatches.push({ chain, contract, path, reason: "missing_observed_hash", expected: wanted }); + } else if (wanted !== actual) { + mismatches.push({ chain, contract, path, reason: "hash_mismatch", expected: wanted, observed: actual }); + } + } + } + return { ok: mismatches.length === 0, mismatches }; +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +export async function main() { + const manifestPath = argument("--manifest"); + const observedPath = argument("--observed"); + if (!manifestPath || !observedPath) { + console.error("Usage: node scripts/verify-deployed-hashes.mjs --manifest --observed "); + process.exitCode = 2; + return; + } + const result = compareDeployedHashes(await readJson(manifestPath), await readJson(observedPath)); + if (!result.ok) { + console.error("Deployed code hash verification failed:"); + for (const mismatch of result.mismatches) console.error(`- ${mismatch.path}: ${mismatch.reason}`); + process.exitCode = 1; + return; + } + console.log("Deployed code hashes match deployment manifest."); +} + +if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/scripts/deployed-hash-verifier.test.mjs b/scripts/deployed-hash-verifier.test.mjs new file mode 100644 index 0000000..1a9257b --- /dev/null +++ b/scripts/deployed-hash-verifier.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { compareDeployedHashes } from "./deployed-hash-verifier.mjs"; + +const manifest = { + ethereum: { codeHashes: { HTLCEscrow: "0x" + "a".repeat(64) } }, + stellar: { codeHashes: { HTLC: "b".repeat(64) } } +}; + +test("accepts normalized EVM and Soroban code hashes", () => { + const result = compareDeployedHashes(manifest, { + ethereum: { HTLCEscrow: { codeHash: "0x" + "A".repeat(64) } }, + stellar: { HTLC: "0x" + "B".repeat(64) } + }); + assert.equal(result.ok, true); + assert.deepEqual(result.mismatches, []); +}); + +test("reports missing and mismatched hashes without accepting partial evidence", () => { + const result = compareDeployedHashes(manifest, { ethereum: { HTLCEscrow: "0x" + "c".repeat(64) } }); + assert.equal(result.ok, false); + assert.deepEqual(result.mismatches.map((item) => item.reason), ["hash_mismatch", "missing_observed_hash"]); +}); + +test("fails closed on malformed manifest hashes", () => { + const result = compareDeployedHashes({ ethereum: { codeHashes: { HTLC: "not-a-hash" } } }, { ethereum: { HTLC: "0x" + "a".repeat(64) } }); + assert.equal(result.ok, false); + assert.equal(result.mismatches[0].reason, "invalid_manifest_hash"); +}); diff --git a/scripts/verify-deployed-hashes.mjs b/scripts/verify-deployed-hashes.mjs new file mode 100644 index 0000000..3bb5f4e --- /dev/null +++ b/scripts/verify-deployed-hashes.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { main } from "./deployed-hash-verifier.mjs"; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});