From 82419009812e42b793bfe9a18658c0d28071ed1c Mon Sep 17 00:00:00 2001 From: karagozemin Date: Mon, 31 Aug 2026 04:41:05 +0300 Subject: [PATCH] fix: harden coordinator state and deployment validation --- coordinator/src/persistence/orders-repo.ts | 20 ----- coordinator/src/server/app.ts | 6 +- .../src/server/readiness-rate-limit.ts | 53 +++++++++++++ coordinator/src/server/routes/health.ts | 6 +- coordinator/src/server/routes/orders.ts | 15 +++- coordinator/src/services/order-service.ts | 43 +++++++++-- .../src/state-machine/order-machine.ts | 28 +++++++ coordinator/test/order-service.test.ts | 46 +++++++++++- coordinator/test/order-transitions.test.ts | 18 ++++- coordinator/test/readiness.test.ts | 27 +++++-- coordinator/test/snapshot.test.ts | 6 +- e2e/sim.ts | 11 +++ packages/sdk/src/secrets/index.ts | 24 +++++- packages/sdk/test/secrets.test.ts | 11 ++- scripts/deployed-hash-verifier.mjs | 75 +++++++++++++++++++ scripts/deployed-hash-verifier.test.mjs | 29 +++++++ scripts/validate-deployments.mjs | 13 ++++ scripts/verify-deployed-hashes.mjs | 7 ++ 18 files changed, 390 insertions(+), 48 deletions(-) create mode 100644 coordinator/src/server/readiness-rate-limit.ts create mode 100644 scripts/deployed-hash-verifier.mjs create mode 100644 scripts/deployed-hash-verifier.test.mjs create mode 100644 scripts/verify-deployed-hashes.mjs diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index f9c2606..f4a09e5 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -595,24 +595,4 @@ function deriveTransitions(status: OrderStatus): string[] { default: return [status]; } - - async getMetrics(): Promise { - const byStatus = await this.all<{ status: string; count: number }>(this.metricsByStatus); - const totalRow = await this.get<{ count: number }>(this.metricsTotal); - const lastUpdatedRow = await this.get<{ ts: number | null }>(this.metricsLastUpdated); - - const statusMap: Record = {}; - for (const row of byStatus) { - statusMap[row.status] = Number(row.count); - } - - return { - totalOrders: Number(totalRow?.count ?? 0), - byStatus: statusMap, - completedOrders: statusMap["completed"] ?? 0, - refundedOrders: statusMap["refunded"] ?? 0, - staleExpiredOrders: (statusMap["expired"] ?? 0) + (statusMap["failed"] ?? 0), - lastUpdatedTimestamp: lastUpdatedRow?.ts != null ? Number(lastUpdatedRow.ts) : null - }; - } } 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 23d9b8f..92934ac 100644 --- a/coordinator/src/server/routes/health.ts +++ b/coordinator/src/server/routes/health.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { createReadinessRateLimiter, type ReadinessRateLimitOptions } from "../readiness-rate-limit.js"; function getBuildEnv(): "testnet" | "mainnet" { const v = (process.env.NETWORK_MODE ?? "testnet").toLowerCase(); @@ -58,9 +59,10 @@ function stellarNetworkLabel(passphrase: string | undefined): string { return "unknown"; } -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) => { @@ -170,4 +172,4 @@ export function healthRoutes(): Router { }); return router; -} \ No newline at end of file +} diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 30509a7..0b4daef 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -130,6 +130,19 @@ export function ordersRoutes(orders: OrderService): Router { } }); + router.get("/orders/:id/transitions", async (req, res, next) => { + try { + const order = await orders.get(req.params.id); + if (!order) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json({ id: order.publicId, transitions: await orders.getTransitions(order.publicId) }); + } catch (err) { + next(err); + } + }); + // Parameterized routes come AFTER specific routes router.get("/orders/:id", async (req, res, next) => { const id = req.params.id; @@ -196,4 +209,4 @@ export function ordersRoutes(orders: OrderService): Router { }); return router; -} \ No newline at end of file +} diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index a3b8e36..e342509 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -50,6 +50,14 @@ export class OrderValidationError extends Error { } } +/** 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`); @@ -164,8 +172,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}`); } await this.repo.recordSrcLock(input); this.log.info({ publicId: input.publicId, srcOrderId: input.orderId }, "src lock recorded"); @@ -182,8 +199,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) { @@ -207,8 +234,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 6a34d3d..6293cfb 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/coordinator/test/order-transitions.test.ts b/coordinator/test/order-transitions.test.ts index c494ea3..e08cda1 100644 --- a/coordinator/test/order-transitions.test.ts +++ b/coordinator/test/order-transitions.test.ts @@ -9,6 +9,7 @@ import { openDatabase } from "../src/persistence/db.js"; import { OrdersRepository } from "../src/persistence/orders-repo.js"; import { OrderService } from "../src/services/order-service.js"; import { ordersRoutes } from "../src/server/routes/orders.js"; +import { compareStatus, isStaleTransition } from "../src/state-machine/order-machine.js"; const log = pino({ level: "silent" }); const VALID_HASHLOCK = "0x" + "a".repeat(64); @@ -16,6 +17,15 @@ const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; const PREIMAGE = "0x" + "d".repeat(64); +describe("monotonic order status helpers", () => { + it("identifies delayed lifecycle transitions", () => { + expect(isStaleTransition("dst_locked", "src_locked")).toBe(true); + expect(isStaleTransition("src_locked", "dst_locked")).toBe(false); + expect(compareStatus("announced", "secret_revealed")).toBe(-1); + expect(compareStatus("completed", "refunded")).toBe(0); + }); +}); + async function freshDb() { const dir = mkdtempSync(resolve(tmpdir(), "oversync-transition-test-")); return openDatabase(`file:${dir}/test.db`); @@ -55,14 +65,14 @@ describe("OrderService transition summaries", () => { orderId: "src-1", txHash: "0xsrc", blockNumber: 1, - timelock: 1000 + timelock: 2000 }); await orders.recordDstLock({ publicId: order.publicId, orderId: "dst-1", txHash: "0xdst", blockNumber: 2, - timelock: 2000, + timelock: 1000, resolver: null }); await orders.recordSecret(order.publicId, PREIMAGE, "0xsecret"); @@ -144,14 +154,14 @@ describe("GET /api/orders/:id/transitions", () => { orderId: "src-3", txHash: "0xsrc3", blockNumber: 4, - timelock: 4000 + timelock: 5000 }); await orders.recordDstLock({ publicId: order.publicId, orderId: "dst-3", txHash: "0xdst3", blockNumber: 5, - timelock: 5000, + timelock: 4000, resolver: null }); await orders.recordSecret(order.publicId, PREIMAGE, "0xsecret3"); diff --git a/coordinator/test/readiness.test.ts b/coordinator/test/readiness.test.ts index 5613e5b..2b60ab0 100644 --- a/coordinator/test/readiness.test.ts +++ b/coordinator/test/readiness.test.ts @@ -3,13 +3,13 @@ import request from "supertest"; import express from "express"; import { healthRoutes } from "../src/server/routes/health.js"; -function makeApp() { - const app = express(); - app.use(healthRoutes()); +function makeApp(options?: { limit?: number; windowMs?: number; now?: () => number }) { + const app = express(); + app.use(healthRoutes(options)); return app; } -describe("GET /readiness", () => { +describe("GET /readiness", () => { const ORIGINAL_ENV = process.env; beforeEach(() => { @@ -142,10 +142,23 @@ describe("GET /readiness", () => { expect(json).not.toContain("USER"); }); - it("does not return order-shaped fields", async () => { + it("does not return order-shaped fields", async () => { const res = await request(makeApp()).get("/readiness").expect(200); expect(res.body.orders).toBeUndefined(); expect(res.body.secret).toBeUndefined(); expect(res.body.hashlock).toBeUndefined(); - }); -}); \ No newline at end of file + }); + + it("returns a stable 429 response after the per-client diagnostic limit", async () => { + let timestamp = 1_000; + const app = makeApp({ limit: 2, windowMs: 10_000, now: () => timestamp }); + await request(app).get("/readiness").expect(200); + await request(app).get("/readiness").expect(200); + const res = await request(app).get("/readiness").expect(429); + expect(res.body).toMatchObject({ error: "rate_limited", retryAfterSeconds: 10 }); + expect(res.headers["retry-after"]).toBe("10"); + expect(res.headers["x-ratelimit-remaining"]).toBe("0"); + timestamp += 10_000; + await request(app).get("/readiness").expect(200); + }); +}); diff --git a/coordinator/test/snapshot.test.ts b/coordinator/test/snapshot.test.ts index 3d9279d..06d79ce 100644 --- a/coordinator/test/snapshot.test.ts +++ b/coordinator/test/snapshot.test.ts @@ -65,7 +65,7 @@ describe("OrderService.getSnapshots", () => { orderId: "0", txHash: "0xsrctx", blockNumber: 100, - timelock: 200 + timelock: 1000 }); await orders.recordDstLock({ publicId: order.publicId, @@ -136,14 +136,14 @@ describe("OrderService.getSnapshots", () => { orderId: "2", txHash: "0xsrcrtx", blockNumber: 50, - timelock: 150 + timelock: 1000 }); await orders.recordDstLock({ publicId: order.publicId, orderId: "2", txHash: "0xdstrtx", blockNumber: 100, - timelock: 200, + timelock: 300, resolver: null }); await orders.markStatus(order.publicId, "refunded"); 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/validate-deployments.mjs b/scripts/validate-deployments.mjs index 8d90cfd..bfb0b1d 100644 --- a/scripts/validate-deployments.mjs +++ b/scripts/validate-deployments.mjs @@ -9,6 +9,7 @@ const EVM_ADDRESS = /^0x[a-fA-F0-9]{40}$/; const STELLAR_ACCOUNT_ID = /^G[A-Z2-7]{55}$/; const SOROBAN_CONTRACT_ID = /^C[A-Z2-7]{55}$/; const HEX_64 = /^[a-fA-F0-9]{64}$/; +const CODE_HASH = /^(?:0x)?[a-fA-F0-9]{64}$/; function valueType(value) { if (Array.isArray(value)) return "array"; @@ -119,6 +120,7 @@ function validateEthereum(v, ethereum, network) { EVM_ADDRESS, "EVM address" ); + validateCodeHashes(v, ethereum.codeHashes, Object.keys(ethereum.contracts), "$.ethereum.codeHashes"); } function validateStellar(v, stellar, network) { @@ -144,6 +146,7 @@ function validateStellar(v, stellar, network) { SOROBAN_CONTRACT_ID, "Soroban contract ID" ); + validateCodeHashes(v, stellar.codeHashes, Object.keys(stellar.contracts), "$.stellar.codeHashes"); if (stellar.deployTransactions != null && v.object(stellar.deployTransactions, "$.stellar.deployTransactions")) { for (const [key, txHash] of Object.entries(stellar.deployTransactions)) { @@ -173,6 +176,16 @@ function validateStellar(v, stellar, network) { } } +function validateCodeHashes(v, hashes, contractNames, path) { + if (hashes == null) return; + if (!v.object(hashes, path)) return; + for (const [name, value] of Object.entries(hashes)) { + if (!contractNames.includes(name)) v.fail(`${path}.${name}`, "must name a declared contract"); + const hash = v.string(value, `${path}.${name}`); + if (hash != null && !CODE_HASH.test(hash)) v.fail(`${path}.${name}`, "must be a 32-byte code hash"); + } +} + async function validateFile(fileName) { const v = new Validator(fileName); let parsed; 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; +});