From 6e4137a8ef7f3475eaf381507af418e5989a106f Mon Sep 17 00:00:00 2001 From: Horlarmmy Date: Mon, 31 Aug 2026 01:56:58 +0100 Subject: [PATCH 1/4] reject zero-value and reused HTLC secrets --- coordinator/src/persistence/orders-repo.ts | 7 + coordinator/src/server/routes/secrets.ts | 2 +- coordinator/src/services/order-service.ts | 10 +- coordinator/src/services/secret-service.ts | 13 ++ coordinator/test/order-service.test.ts | 21 +++ coordinator/test/secret-validation.test.ts | 178 +++++++++++++++++++++ packages/sdk/src/secrets/index.ts | 3 + packages/sdk/test/secrets.test.ts | 5 + 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 coordinator/test/secret-validation.test.ts 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/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 e2dc2c6..1dcf755 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), @@ -176,6 +180,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..72ac1c2 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}`; } @@ -69,6 +72,16 @@ export class SecretService { ); throw new Error("preimage does not match order hashlock"); } + + const existing = await this.orders.findByPreimage(preimage); + 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, preimage, txHash); return { ok: true }; } diff --git a/coordinator/test/order-service.test.ts b/coordinator/test/order-service.test.ts index 536292b..5c1bd9f 100644 --- a/coordinator/test/order-service.test.ts +++ b/coordinator/test/order-service.test.ts @@ -102,6 +102,27 @@ 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); + }); }); describe("SecretService", () => { diff --git a/coordinator/test/secret-validation.test.ts b/coordinator/test/secret-validation.test.ts new file mode 100644 index 0000000..62945bf --- /dev/null +++ b/coordinator/test/secret-validation.test.ts @@ -0,0 +1,178 @@ +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 }); + }); +}); + +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 2fd544a..9da43b4 100644 --- a/packages/sdk/src/secrets/index.ts +++ b/packages/sdk/src/secrets/index.ts @@ -95,5 +95,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 f7388d9..634bcc8 100644 --- a/packages/sdk/test/secrets.test.ts +++ b/packages/sdk/test/secrets.test.ts @@ -55,6 +55,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"'); }); From 2649acb690c1333d50d8db679b4234db25727ef4 Mon Sep 17 00:00:00 2001 From: Horlarmmy Date: Mon, 31 Aug 2026 16:15:53 +0100 Subject: [PATCH 2/4] normalize preimage to lowercase before storage and reuse check --- coordinator/src/services/order-service.ts | 4 ++ coordinator/src/services/secret-service.ts | 7 +-- coordinator/test/secret-validation.test.ts | 55 ++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 1dcf755..407002c 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -127,6 +127,10 @@ 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"); + } + // --- Quote freshness gate ------------------------------------------- if (input.quoteId) { if (!this.quoteService) { diff --git a/coordinator/src/services/secret-service.ts b/coordinator/src/services/secret-service.ts index 72ac1c2..c79495c 100644 --- a/coordinator/src/services/secret-service.ts +++ b/coordinator/src/services/secret-service.ts @@ -58,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) { @@ -73,7 +74,7 @@ export class SecretService { throw new Error("preimage does not match order hashlock"); } - const existing = await this.orders.findByPreimage(preimage); + const existing = await this.orders.findByPreimage(canonical); if (existing && existing.publicId !== publicId) { this.log.warn( { publicId, reusedBy: existing.publicId }, @@ -82,7 +83,7 @@ export class SecretService { throw new Error("preimage already used in another order"); } - await this.orders.recordSecret(publicId, preimage, txHash); + await this.orders.recordSecret(publicId, canonical, txHash); return { ok: true }; } diff --git a/coordinator/test/secret-validation.test.ts b/coordinator/test/secret-validation.test.ts index 62945bf..eb60733 100644 --- a/coordinator/test/secret-validation.test.ts +++ b/coordinator/test/secret-validation.test.ts @@ -151,6 +151,61 @@ describe("SecretService – reused preimage rejection", () => { 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", () => { From b8ed5e66f984e936f549ff5ccc7ed9936e167b00 Mon Sep 17 00:00:00 2001 From: Horlarmmy Date: Mon, 31 Aug 2026 17:26:31 +0100 Subject: [PATCH 3/4] canonicalize hashlocks, fix timelock test fixtures, add transitions route --- coordinator/src/server/routes/orders.ts | 9 ++++ coordinator/src/services/order-service.ts | 8 ++-- coordinator/test/order-service.test.ts | 53 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) 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/services/order-service.ts b/coordinator/src/services/order-service.ts index 407002c..fd65f86 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -131,6 +131,8 @@ export class OrderService { 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 +152,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" diff --git a/coordinator/test/order-service.test.ts b/coordinator/test/order-service.test.ts index 5c1bd9f..585c2b1 100644 --- a/coordinator/test/order-service.test.ts +++ b/coordinator/test/order-service.test.ts @@ -123,6 +123,59 @@ describe("OrderService", () => { }) ).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); + }); }); describe("SecretService", () => { From 68b28ad2650837227a2ac7b1e5a493adb581f17e Mon Sep 17 00:00:00 2001 From: Horlarmmy Date: Mon, 31 Aug 2026 17:52:24 +0100 Subject: [PATCH 4/4] trigger GitHub conflict re-evaluation