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
7 changes: 7 additions & 0 deletions coordinator/src/persistence/orders-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -347,6 +349,11 @@ export class OrdersRepository {
return row ? rowToOrder(row) : null;
}

async findByPreimage(preimage: string): Promise<OrderRow | null> {
const row = await this.get<OrderDbRow>(this.byPreimage, preimage);
return row ? rowToOrder(row) : null;
}

async findBySrcOrderId(chain: Chain, orderId: string): Promise<OrderRow | null> {
const row = await this.get<OrderDbRow>(this.bySrcOrderId, { chain, orderId });
return row ? rowToOrder(row) : null;
Expand Down
9 changes: 9 additions & 0 deletions coordinator/src/server/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion coordinator/src/server/routes/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});

Expand Down
22 changes: 18 additions & 4 deletions coordinator/src/services/order-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand All @@ -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"
Expand All @@ -184,6 +194,10 @@ export class OrderService {
return this.repo.findByHashlock(hashlock);
}

findByPreimage(preimage: string): Promise<OrderRow | null> {
return this.repo.findByPreimage(preimage);
}

async recordSrcLock(input: {
publicId: string;
orderId: string;
Expand Down
18 changes: 16 additions & 2 deletions coordinator/src/services/secret-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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 };
}

Expand Down
76 changes: 75 additions & 1 deletion coordinator/test/order-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -316,4 +391,3 @@ describe("OrderService timelock ordering", () => {
).resolves.toBeUndefined();
});
});

Loading