From 1c5a9446d58ecc594a26c2297ca1d00d672f652e Mon Sep 17 00:00:00 2001 From: smog123 <125459251+smog123@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:14:35 +0000 Subject: [PATCH 1/2] Add non-sensitive order transition audit, endpoint, and tests --- coordinator/src/persistence/orders-repo.ts | 76 ++++++++++++++++++++ coordinator/src/persistence/schema.sql | 14 ++++ coordinator/src/server/routes/orders.ts | 10 ++- coordinator/src/services/order-service.ts | 4 ++ coordinator/test/order-transitions.test.ts | 83 ++++++++++++++++++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 coordinator/test/order-transitions.test.ts diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index 4d591b1..4139575 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -53,6 +53,14 @@ export interface OrderRow { updatedAt: number; } +export interface OrderTransition { + from: OrderStatus | null; + to: OrderStatus; + txHash: string | null; + category: string | null; + createdAt: number; +} + export interface AnnounceOrderInput { direction: Direction; hashlock: string; @@ -140,6 +148,8 @@ export class OrdersRepository { private readonly updateSrcLock: Statement; private readonly updateDstLock: Statement; private readonly updateSecret: Statement; + private readonly insertTransitionStmt: Statement; + private readonly selectTransitionsByPublicId: Statement; constructor(private readonly db: DatabaseT) { this.insertStmt = db.prepare(` @@ -201,6 +211,28 @@ export class OrdersRepository { updated_at = CAST(strftime('%s','now') AS INTEGER) WHERE public_id = :publicId `); + this.insertTransitionStmt = db.prepare(` + INSERT INTO order_transitions (order_id, from_status, to_status, tx_hash, category) + VALUES ( + (SELECT id FROM orders WHERE public_id = :publicId), + :fromStatus, :toStatus, :txHash, :category + ) + `); + this.selectTransitionsByPublicId = db.prepare(` + SELECT + from_status AS from_status, + to_status AS to_status, + tx_hash AS tx_hash, + category AS category, + created_at AS created_at + FROM order_transitions + WHERE order_id = (SELECT id FROM orders WHERE public_id = :publicId) + ORDER BY created_at ASC + `); + } + + private async insertTransition(publicId: string, fromStatus: OrderStatus | null, toStatus: OrderStatus, txHash: string | null, category: string | null): Promise { + await this.run(this.insertTransitionStmt, { publicId, fromStatus, toStatus, txHash, category }); } private async run(stmt: Statement, ...params: any[]): Promise { @@ -237,6 +269,8 @@ export class OrdersRepository { await this.run(this.insertStmt, { publicId, ...input }); const row = await this.get(this.byPublicId, publicId); if (!row) throw new Error("Failed to insert order"); + // Record initial announced transition (non-sensitive) + await this.insertTransition(publicId, null, "announced", null, "announce"); return rowToOrder(row); } @@ -266,7 +300,14 @@ export class OrdersRepository { } async setStatus(publicId: string, status: OrderStatus): Promise { + const before = await this.findByPublicId(publicId); + const beforeStatus = before?.status ?? null; await this.run(this.updateStatus, { publicId, status }); + const after = await this.findByPublicId(publicId); + const afterStatus = after?.status ?? null; + if (beforeStatus !== afterStatus && afterStatus !== null) { + await this.insertTransition(publicId, beforeStatus as OrderStatus | null, afterStatus as OrderStatus, null, "manual_status"); + } } async recordSrcLock(input: { @@ -276,7 +317,14 @@ export class OrdersRepository { blockNumber: number; timelock: number; }): Promise { + const before = await this.findByPublicId(input.publicId); + const beforeStatus = before?.status ?? null; await this.run(this.updateSrcLock, input); + const after = await this.findByPublicId(input.publicId); + const afterStatus = after?.status ?? null; + if (beforeStatus !== afterStatus && afterStatus !== null) { + await this.insertTransition(input.publicId, beforeStatus as OrderStatus | null, afterStatus as OrderStatus, input.txHash, "src_lock"); + } } async recordDstLock(input: { @@ -287,7 +335,14 @@ export class OrdersRepository { timelock: number; resolver: string | null; }): Promise { + const before = await this.findByPublicId(input.publicId); + const beforeStatus = before?.status ?? null; await this.run(this.updateDstLock, input); + const after = await this.findByPublicId(input.publicId); + const afterStatus = after?.status ?? null; + if (beforeStatus !== afterStatus && afterStatus !== null) { + await this.insertTransition(input.publicId, beforeStatus as OrderStatus | null, afterStatus as OrderStatus, input.txHash, "dst_lock"); + } } async recordSecretRevealed(input: { @@ -295,6 +350,27 @@ export class OrdersRepository { preimage: string; txHash: string; }): Promise { + const before = await this.findByPublicId(input.publicId); + const beforeStatus = before?.status ?? null; await this.run(this.updateSecret, input); + const after = await this.findByPublicId(input.publicId); + const afterStatus = after?.status ?? null; + if (beforeStatus !== afterStatus && afterStatus !== null) { + await this.insertTransition(input.publicId, beforeStatus as OrderStatus | null, afterStatus as OrderStatus, input.txHash, "secret_reveal"); + } + } + + async getTransitions(publicId: string): Promise { + const rows = await this.all<{ from_status: string | null; to_status: string; tx_hash: string | null; category: string | null; created_at: number }>( + this.selectTransitionsByPublicId, + { publicId } + ); + return rows.map((r) => ({ + from: (r.from_status as OrderStatus) ?? null, + to: r.to_status as OrderStatus, + txHash: r.tx_hash ?? null, + category: r.category ?? null, + createdAt: Number(r.created_at) + })); } } diff --git a/coordinator/src/persistence/schema.sql b/coordinator/src/persistence/schema.sql index 567cc28..3c8b6aa 100644 --- a/coordinator/src/persistence/schema.sql +++ b/coordinator/src/persistence/schema.sql @@ -67,6 +67,20 @@ CREATE TABLE IF NOT EXISTS order_events ( CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events (order_id, created_at); +-- Read-only, non-sensitive audit of status transitions. `from_status` is +-- nullable to allow recording the initial "announced" transition. +CREATE TABLE IF NOT EXISTS order_transitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + from_status TEXT, + to_status TEXT NOT NULL CHECK (to_status IN ('announced', 'src_locked', 'dst_locked', 'secret_revealed', 'completed', 'refunded', 'failed', 'expired')), + tx_hash TEXT, + category TEXT, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)) +); + +CREATE INDEX IF NOT EXISTS idx_order_transitions_order ON order_transitions (order_id, created_at); + CREATE TABLE IF NOT EXISTS resolver_heartbeats ( address TEXT PRIMARY KEY, chain TEXT NOT NULL CHECK (chain IN ('ethereum', 'stellar')), diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 21043e9..3bb9438 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -33,7 +33,6 @@ function serialiseOrder(order: OrderRow | null) { }, secret: { revealed: order.preimage !== null, - preimage: order.preimage, revealedTx: order.secretRevealedTx }, resolver: order.resolverAddress, @@ -77,6 +76,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); + } + }); + router.get("/orders/history", async (req, res, next) => { const address = (req.query.address as string | undefined) ?? ""; if (!address) { diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 6d99374..68447d2 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -152,4 +152,8 @@ export class OrderService { this.log.info({ publicId, status }, "status updated"); ordersTotal.inc({ status }); } + + async getTransitions(publicId: string) { + return this.repo.getTransitions(publicId); + } } diff --git a/coordinator/test/order-transitions.test.ts b/coordinator/test/order-transitions.test.ts new file mode 100644 index 0000000..8ac5532 --- /dev/null +++ b/coordinator/test/order-transitions.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import pino from "pino"; +import { dirname, resolve } from "node:path"; +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"; + +const log = pino({ level: "silent" }); + +const VALID_HASHLOCK = "0x" + "a".repeat(64); +const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; +const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; + +async function freshDb() { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-test-")); + return openDatabase(`file:${dir}/test.db`); +} + +describe("Order transitions", () => { + it("records happy-path transitions (announce → src_locked → dst_locked → secret_revealed → completed)", 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" + }); + + await orders.recordSrcLock({ publicId: order.publicId, orderId: "1", txHash: "0xsrc", blockNumber: 1, timelock: 0 }); + await orders.recordDstLock({ publicId: order.publicId, orderId: "2", txHash: "0xdst", blockNumber: 2, timelock: 0, resolver: null }); + await orders.recordSecret(order.publicId, "0x" + "c".repeat(64), "0xsecret"); + await orders.markStatus(order.publicId, "completed"); + + const transitions = await orders.getTransitions(order.publicId); + expect(transitions.map((t) => t.to)).toEqual(["announced", "src_locked", "dst_locked", "secret_revealed", "completed"]); + const src = transitions.find((t) => t.category === "src_lock"); + const dst = transitions.find((t) => t.category === "dst_lock"); + const secret = transitions.find((t) => t.category === "secret_reveal"); + expect(src?.txHash).toBe("0xsrc"); + expect(dst?.txHash).toBe("0xdst"); + expect(secret?.txHash).toBe("0xsecret"); + // Ensure no preimages are present in transition summaries + for (const t of transitions) { + expect((t as any).preimage).toBeUndefined(); + } + }); + + it("records refund transitions", 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" + }); + + await orders.recordSrcLock({ publicId: order.publicId, orderId: "1", txHash: "0xsrc", blockNumber: 1, timelock: 0 }); + await orders.markStatus(order.publicId, "refunded"); + + const transitions = await orders.getTransitions(order.publicId); + expect(transitions.map((t) => t.to)).toContain("refunded"); + }); +}); From d963815014f57deb6760d9b14fae9c183e737536 Mon Sep 17 00:00:00 2001 From: smog123 <125459251+smog123@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:33:15 +0000 Subject: [PATCH 2/2] Add focused coordinator transition summary tests for happy and refund paths --- coordinator/test/order-transitions.test.ts | 56 ++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/coordinator/test/order-transitions.test.ts b/coordinator/test/order-transitions.test.ts index 8ac5532..592e815 100644 --- a/coordinator/test/order-transitions.test.ts +++ b/coordinator/test/order-transitions.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import request from "supertest"; +import express from "express"; import pino from "pino"; import { dirname, resolve } from "node:path"; import { mkdtempSync } from "node:fs"; @@ -6,9 +8,17 @@ 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 { ordersRoutes } from "../src/server/routes/orders.js"; const log = pino({ level: "silent" }); +function createOrdersApp(orders: OrderService) { + const app = express(); + app.use(express.json()); + app.use("/api", ordersRoutes(orders)); + return app; +} + const VALID_HASHLOCK = "0x" + "a".repeat(64); const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; @@ -56,9 +66,45 @@ describe("Order transitions", () => { } }); - it("records refund transitions", async () => { + it("exposes transitions through the HTTP endpoint without secrets", async () => { const db = await freshDb(); const orders = new OrderService(new OrdersRepository(db), log); + const app = createOrdersApp(orders); + + 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" + }); + + await orders.recordSrcLock({ publicId: order.publicId, orderId: "1", txHash: "0xsrc", blockNumber: 1, timelock: 0 }); + await orders.recordDstLock({ publicId: order.publicId, orderId: "2", txHash: "0xdst", blockNumber: 2, timelock: 0, resolver: null }); + await orders.recordSecret(order.publicId, "0x" + "c".repeat(64), "0xsecret"); + await orders.markStatus(order.publicId, "completed"); + + const res = await request(app).get(`/api/orders/${order.publicId}/transitions`).expect(200); + expect(res.body).toHaveProperty("transitions"); + expect(Array.isArray(res.body.transitions)).toBe(true); + const names = res.body.transitions.map((t: any) => t.to); + expect(names).toEqual(["announced", "src_locked", "dst_locked", "secret_revealed", "completed"]); + for (const transition of res.body.transitions) { + expect(transition.preimage).toBeUndefined(); + expect(transition.txHash).toBeDefined(); + } + }); + + it("records refund transitions and returns refunded in the endpoint summary", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + const app = createOrdersApp(orders); const order = await orders.announce({ direction: "eth_to_xlm", @@ -77,7 +123,11 @@ describe("Order transitions", () => { await orders.recordSrcLock({ publicId: order.publicId, orderId: "1", txHash: "0xsrc", blockNumber: 1, timelock: 0 }); await orders.markStatus(order.publicId, "refunded"); - const transitions = await orders.getTransitions(order.publicId); - expect(transitions.map((t) => t.to)).toContain("refunded"); + const res = await request(app).get(`/api/orders/${order.publicId}/transitions`).expect(200); + expect(res.body.transitions.map((t: any) => t.to)).toContain("refunded"); + expect(res.body.transitions.some((t: any) => t.to === "refunded")).toBe(true); + for (const transition of res.body.transitions) { + expect(transition.preimage).toBeUndefined(); + } }); });