diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index f4a09e5..3db8e3f 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -177,6 +177,7 @@ export class OrdersRepository { private readonly byAddress: Statement; private readonly bySrcOrderId: Statement; private readonly byDstOrderId: Statement; + private readonly byPreimage: Statement; private readonly insertOrderEvent: Statement; private readonly transitionsByOrderId: Statement; private readonly updateStatus: Statement; @@ -218,6 +219,7 @@ export class OrdersRepository { this.byDstOrderId = db.prepare(` SELECT * FROM orders WHERE dst_chain = :chain AND dst_order_id = :orderId `); + this.byPreimage = db.prepare("SELECT * FROM orders WHERE preimage = ?"); this.insertOrderEvent = db.prepare(` INSERT INTO order_events (order_id, event_type, payload_json) VALUES (:orderId, :eventType, :payloadJson) @@ -347,6 +349,11 @@ export class OrdersRepository { return row ? rowToOrder(row) : null; } + async findByPreimage(preimage: string): Promise { + const row = await this.get(this.byPreimage, preimage); + return row ? rowToOrder(row) : null; + } + async findBySrcOrderId(chain: Chain, orderId: string): Promise { const row = await this.get(this.bySrcOrderId, { chain, orderId }); return row ? rowToOrder(row) : null; diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 8fbd69a..7aab3de 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -168,6 +168,15 @@ export function ordersRoutes(orders: OrderService): Router { } }); + router.get("/orders/:id/transitions", async (req, res, next) => { + try { + const transitions = await orders.getTransitions(req.params.id); + res.json({ transitions }); + } catch (err) { + next(err); + } + }); + const lockSchema = z.object({ orderId: z.string().min(1), txHash: z.string().min(1), diff --git a/coordinator/src/server/routes/secrets.ts b/coordinator/src/server/routes/secrets.ts index 397774b..76827d6 100644 --- a/coordinator/src/server/routes/secrets.ts +++ b/coordinator/src/server/routes/secrets.ts @@ -7,7 +7,7 @@ export function secretsRoutes(secrets: SecretService): Router { const revealSchema = z.object({ publicId: z.string().min(1), - preimage: z.string().regex(/^0x[0-9a-fA-F]+$/), + preimage: z.string().regex(/^0x[0-9a-fA-F]{64}$/, "preimage must be 0x + 64 hex chars"), txHash: z.string().min(1) }); diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index c3c9a5b..19fd5e8 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -20,12 +20,16 @@ import { } from "../utils/timelock-validator.js"; const HEX32 = /^0x[0-9a-fA-F]{64}$/; +const ZERO_HASHLOCK = "0x" + "0".repeat(64); const HEX_ADDRESS = /^0x[0-9a-fA-F]{40}$/; const STELLAR_ADDRESS = /^G[A-Z2-7]{55}$/; export const announceSchema = z.object({ direction: z.enum(["eth_to_xlm", "xlm_to_eth"]), - hashlock: z.string().regex(HEX32, "hashlock must be 0x + 64 hex chars"), + hashlock: z.string().regex(HEX32, "hashlock must be 0x + 64 hex chars").refine( + (v) => v.toLowerCase() !== ZERO_HASHLOCK.toLowerCase(), + "hashlock must not be all zeros" + ), srcChain: z.enum(["ethereum", "stellar"]), srcAddress: z.string(), srcAsset: z.string().min(1), @@ -131,6 +135,12 @@ export class OrderService { validateChainAddress(input.dstChain, input.dstAddress); validateDirectionAgainstChains(input); + if (input.hashlock.toLowerCase() === ZERO_HASHLOCK.toLowerCase()) { + throw new OrderValidationError("hashlock must not be all zeros"); + } + + const hashlock = input.hashlock.toLowerCase() as `0x${string}`; + // --- Quote freshness gate ------------------------------------------- if (input.quoteId) { if (!this.quoteService) { @@ -150,16 +160,16 @@ export class OrderService { } // ------------------------------------------------------------------- - const existing = await this.repo.findByHashlock(input.hashlock); + const existing = await this.repo.findByHashlock(hashlock); if (existing) { throw new OrderValidationError( - `An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})` + `An order with hashlock ${hashlock} already exists (publicId=${existing.publicId})` ); } // Strip quoteId — it's not a persisted column, just a freshness gate. const { quoteId: _q, ...repoInput } = input; - const order = await this.repo.announce(repoInput as AnnounceOrderInput); + const order = await this.repo.announce({ ...repoInput, hashlock } as AnnounceOrderInput); this.log.info( { publicId: order.publicId, direction: order.direction, quoteId: input.quoteId ?? null }, "order announced" @@ -184,6 +194,10 @@ export class OrderService { return this.repo.findByHashlock(hashlock); } + findByPreimage(preimage: string): Promise { + return this.repo.findByPreimage(preimage); + } + async recordSrcLock(input: { publicId: string; orderId: string; diff --git a/coordinator/src/services/secret-service.ts b/coordinator/src/services/secret-service.ts index e507176..c79495c 100644 --- a/coordinator/src/services/secret-service.ts +++ b/coordinator/src/services/secret-service.ts @@ -25,6 +25,9 @@ function assertValidSecretFormat(value: unknown, fieldName: string = "secret"): if (!/^[0-9a-fA-F]+$/.test(hexPart)) { throw new Error(`${fieldName} contains invalid hex characters`); } + if (/^0+$/.test(hexPart)) { + throw new Error(`${fieldName} must not be all zeros`); + } return value as `0x${string}`; } @@ -55,11 +58,12 @@ export class SecretService { */ async reveal(publicId: string, preimage: string, txHash: string): Promise<{ ok: true }> { assertValidSecretFormat(preimage, "preimage"); + const canonical = preimage.toLowerCase() as `0x${string}`; const order = await this.orders.get(publicId); if (!order) { throw new Error(`unknown order ${publicId}`); } - const buf = bufferFromHex(preimage); + const buf = bufferFromHex(canonical); const shaHash = sha256Hex(buf); const kekHash = keccak256Hex(buf); if (shaHash !== order.hashlock && kekHash !== order.hashlock) { @@ -69,7 +73,17 @@ export class SecretService { ); throw new Error("preimage does not match order hashlock"); } - await this.orders.recordSecret(publicId, preimage, txHash); + + const existing = await this.orders.findByPreimage(canonical); + if (existing && existing.publicId !== publicId) { + this.log.warn( + { publicId, reusedBy: existing.publicId }, + "rejected reused preimage" + ); + throw new Error("preimage already used in another order"); + } + + await this.orders.recordSecret(publicId, canonical, txHash); return { ok: true }; } diff --git a/coordinator/test/order-service.test.ts b/coordinator/test/order-service.test.ts index 77f2823..0bc12a9 100644 --- a/coordinator/test/order-service.test.ts +++ b/coordinator/test/order-service.test.ts @@ -103,6 +103,80 @@ describe("OrderService", () => { ).rejects.toThrowError(OrderValidationError); }); + it("rejects all-zero hashlocks", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + const zeroHashlock = "0x" + "0".repeat(64); + await expect( + orders.announce({ + direction: "eth_to_xlm", + hashlock: zeroHashlock, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "1" + }) + ).rejects.toThrowError(OrderValidationError); + }); + + it("normalizes uppercase hashlocks to lowercase before storage", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + const uppercaseHashlock = "0x" + "A".repeat(64); + const order = await orders.announce({ + direction: "eth_to_xlm", + hashlock: uppercaseHashlock, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "1" + }); + expect(order.hashlock).toBe("0x" + "a".repeat(64)); + }); + + it("detects duplicate hashlocks across different casings", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + await orders.announce({ + direction: "eth_to_xlm", + hashlock: "0x" + "A".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 expect( + orders.announce({ + direction: "eth_to_xlm", + hashlock: "0x" + "a".repeat(64), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "1" + }) + ).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); @@ -146,6 +220,7 @@ describe("OrderService", () => { 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", () => { @@ -316,4 +391,3 @@ describe("OrderService timelock ordering", () => { ).resolves.toBeUndefined(); }); }); - diff --git a/coordinator/test/secret-validation.test.ts b/coordinator/test/secret-validation.test.ts new file mode 100644 index 0000000..eb60733 --- /dev/null +++ b/coordinator/test/secret-validation.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from "vitest"; +import { sha256, toHex } from "viem"; +import pino from "pino"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { openDatabase } from "../src/persistence/db.js"; +import { OrdersRepository } from "../src/persistence/orders-repo.js"; +import { OrderService } from "../src/services/order-service.js"; +import { SecretService } from "../src/services/secret-service.js"; + +const log = pino({ level: "silent" }); + +const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; +const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; + +function hexToUint8(hex: string): Uint8Array { + const clean = hex.startsWith("0x") ? hex.slice(2) : hex; + const buf = new Uint8Array(clean.length / 2); + for (let i = 0; i < buf.length; i++) { + buf[i] = parseInt(clean.substr(i * 2, 2), 16); + } + return buf; +} + +function computeHashlock(preimage: string): string { + const bytes = hexToUint8(preimage); + return sha256(toHex(bytes)); +} + +async function freshDb() { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-test-")); + return openDatabase(`file:${dir}/test.db`); +} + +import { resolve } from "node:path"; + +function makeAnnounceInput(hashlock: string) { + return { + direction: "eth_to_xlm" as const, + hashlock, + srcChain: "ethereum" as const, + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar" as const, + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "1" + }; +} + +describe("SecretService – zero-value preimage rejection", () => { + it("rejects an all-zero preimage", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + const secrets = new SecretService(orders, log); + const zeroPreimage = "0x" + "0".repeat(64); + + const order = await orders.announce(makeAnnounceInput("0x" + "a".repeat(64))); + await orders.recordSrcLock({ + publicId: order.publicId, + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + + await expect( + secrets.reveal(order.publicId, zeroPreimage, "0xtx") + ).rejects.toThrow("preimage must not be all zeros"); + }); +}); + +describe("SecretService – reused preimage rejection", () => { + it("rejects a preimage that was already revealed for another order", async () => { + const db = await freshDb(); + const repo = new OrdersRepository(db); + const orders = new OrderService(repo, log); + const secrets = new SecretService(orders, log); + + const preimage = "0x" + "ab".repeat(32); + const hashlock = computeHashlock(preimage); + + // First order — reveal succeeds + const order1 = await orders.announce(makeAnnounceInput(hashlock)); + await orders.recordSrcLock({ + publicId: order1.publicId, + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + await secrets.reveal(order1.publicId, preimage, "0xtx1"); + + // Insert a second order with the same hashlock directly via repo + // (bypasses announce duplicate-hashlock check to simulate a scenario + // where the same preimage could be reused) + await repo.insertOrder({ + publicId: "order3", + direction: "xlm_to_eth", + status: "src_locked", + hashlock, + srcChain: "stellar", + srcAddress: VALID_STELLAR_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + srcOrderId: "3", + srcLockTx: "0xdead3", + srcLockBlock: 3, + srcTimelock: 0, + dstChain: "ethereum", + dstAddress: VALID_ETH_ADDR, + dstAsset: "native", + dstAmount: "1", + dstOrderId: null, + dstLockTx: null, + dstLockBlock: null, + dstTimelock: null, + preimage: null, + secretRevealedTx: null, + resolverAddress: null, + fixture: false + }); + + await expect( + secrets.reveal("order3", preimage, "0xtx2") + ).rejects.toThrow("preimage already used in another order"); + }); + + it("allows re-revealing the same preimage for the same order (idempotent)", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + const secrets = new SecretService(orders, log); + + const preimage = "0x" + "cd".repeat(32); + const hashlock = computeHashlock(preimage); + + const order = await orders.announce(makeAnnounceInput(hashlock)); + await orders.recordSrcLock({ + publicId: order.publicId, + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + + await secrets.reveal(order.publicId, preimage, "0xtx1"); + await expect( + secrets.reveal(order.publicId, preimage, "0xtx2") + ).resolves.toEqual({ ok: true }); + }); + + it("detects reuse across different casings of the same preimage", async () => { + const db = await freshDb(); + const repo = new OrdersRepository(db); + const orders = new OrderService(repo, log); + const secrets = new SecretService(orders, log); + + const mixedCase = "0x" + "aBcD".repeat(16); + const lowerCase = mixedCase.toLowerCase(); + const hashlock = computeHashlock(lowerCase); + + // First order — reveal with mixed case + const order1 = await orders.announce(makeAnnounceInput(hashlock)); + await orders.recordSrcLock({ + publicId: order1.publicId, + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + await secrets.reveal(order1.publicId, mixedCase, "0xtx1"); + + // Second order with same hashlock — try lowercase version + await repo.insertOrder({ + publicId: "order-lc", + direction: "xlm_to_eth", + status: "src_locked", + hashlock, + srcChain: "stellar", + srcAddress: VALID_STELLAR_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + srcOrderId: "2", + srcLockTx: "0xdead2", + srcLockBlock: 2, + srcTimelock: 0, + dstChain: "ethereum", + dstAddress: VALID_ETH_ADDR, + dstAsset: "native", + dstAmount: "1", + dstOrderId: null, + dstLockTx: null, + dstLockBlock: null, + dstTimelock: null, + preimage: null, + secretRevealedTx: null, + resolverAddress: null, + fixture: false + }); + + await expect( + secrets.reveal("order-lc", lowerCase, "0xtx2") + ).rejects.toThrow("preimage already used in another order"); + }); +}); + +describe("SecretService – valid secret acceptance", () => { + it("accepts a valid non-zero preimage that matches the hashlock", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + const secrets = new SecretService(orders, log); + + const preimage = "0x" + "ef".repeat(32); + const hashlock = computeHashlock(preimage); + + const order = await orders.announce(makeAnnounceInput(hashlock)); + await orders.recordSrcLock({ + publicId: order.publicId, + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + + await expect( + secrets.reveal(order.publicId, preimage, "0xtx") + ).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/sdk/src/secrets/index.ts b/packages/sdk/src/secrets/index.ts index e271f22..b7c58e6 100644 --- a/packages/sdk/src/secrets/index.ts +++ b/packages/sdk/src/secrets/index.ts @@ -117,5 +117,8 @@ export function assertValidSecretFormat(value: unknown, fieldName: string = "sec if (!/^[0-9a-fA-F]+$/.test(hexPart)) { throw new Error(`${fieldName} contains invalid hex characters`); } + if (/^0+$/.test(hexPart)) { + throw new Error(`${fieldName} must not be all zeros`); + } return value as `0x${string}`; } diff --git a/packages/sdk/test/secrets.test.ts b/packages/sdk/test/secrets.test.ts index 1f63f02..a44def1 100644 --- a/packages/sdk/test/secrets.test.ts +++ b/packages/sdk/test/secrets.test.ts @@ -64,6 +64,11 @@ describe("secrets", () => { expect(() => assertValidSecretFormat(invalid)).toThrow("secret contains invalid hex characters"); }); + it("rejects all-zero secrets", () => { + const zero = "0x" + "0".repeat(64); + expect(() => assertValidSecretFormat(zero)).toThrow("secret must not be all zeros"); + }); + it("uses custom field name in errors", () => { expect(() => assertValidSecretFormat("invalid", "hashlock")).toThrow('hashlock must start with "0x"'); });