Skip to content
Open
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
76 changes: 76 additions & 0 deletions coordinator/src/persistence/orders-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(`
Expand Down Expand Up @@ -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<void> {
await this.run(this.insertTransitionStmt, { publicId, fromStatus, toStatus, txHash, category });
}

private async run(stmt: Statement, ...params: any[]): Promise<StatementResult> {
Expand Down Expand Up @@ -237,6 +269,8 @@ export class OrdersRepository {
await this.run(this.insertStmt, { publicId, ...input });
const row = await this.get<OrderDbRow>(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);
}

Expand Down Expand Up @@ -266,7 +300,14 @@ export class OrdersRepository {
}

async setStatus(publicId: string, status: OrderStatus): Promise<void> {
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: {
Expand All @@ -276,7 +317,14 @@ export class OrdersRepository {
blockNumber: number;
timelock: number;
}): Promise<void> {
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: {
Expand All @@ -287,14 +335,42 @@ export class OrdersRepository {
timelock: number;
resolver: string | null;
}): Promise<void> {
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: {
publicId: string;
preimage: string;
txHash: string;
}): Promise<void> {
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<OrderTransition[]> {
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)
}));
}
}
14 changes: 14 additions & 0 deletions coordinator/src/persistence/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
10 changes: 9 additions & 1 deletion coordinator/src/server/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ function serialiseOrder(order: OrderRow | null) {
},
secret: {
revealed: order.preimage !== null,
preimage: order.preimage,
revealedTx: order.secretRevealedTx
},
resolver: order.resolverAddress,
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions coordinator/src/services/order-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
133 changes: 133 additions & 0 deletions coordinator/test/order-transitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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";
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";

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("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",
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 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();
}
});
});