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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ e2e/load-test/reports/*.json
e2e/load-test/reports/*.md
# Fallback if LOAD_TEST_OUTPUT_DIR is set to an absolute path outside the tree

# Local SQLite databases (generated at runtime)
*.db

# Miscellaneous
.cache/
.parcel-cache/
Expand Down
Binary file removed coordinator/oversync.db
Binary file not shown.
1 change: 1 addition & 0 deletions coordinator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"fixtures:remove": "tsx src/remove-fixtures.ts"
},
"dependencies": {
"@oversync/sdk": "workspace:*",
"@stellar/stellar-sdk": "^13.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
Expand Down
9 changes: 6 additions & 3 deletions coordinator/src/persistence/orders-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface OrderRow {
publicId: string;
direction: Direction;
status: OrderStatus;
failureCode: string | null;
hashlock: string;
srcChain: Chain;
srcAddress: string;
Expand Down Expand Up @@ -110,6 +111,7 @@ interface OrderDbRow {
public_id: string;
direction: Direction;
status: OrderStatus;
failure_code: string | null;
hashlock: string;
src_chain: Chain;
src_address: string;
Expand Down Expand Up @@ -142,6 +144,7 @@ function rowToOrder(r: OrderDbRow): OrderRow {
publicId: r.public_id,
direction: r.direction,
status: r.status,
failureCode: r.failure_code,
hashlock: r.hashlock,
srcChain: r.src_chain,
srcAddress: r.src_address,
Expand Down Expand Up @@ -227,7 +230,7 @@ export class OrdersRepository {
`);
this.updateStatus = db.prepare(`
UPDATE orders
SET status = :status, updated_at = CAST(strftime('%s','now') AS INTEGER)
SET status = :status, failure_code = :failureCode, updated_at = CAST(strftime('%s','now') AS INTEGER)
WHERE public_id = :publicId
`);
this.updateSrcLock = db.prepare(`
Expand Down Expand Up @@ -383,10 +386,10 @@ export class OrdersRepository {
});
}

async setStatus(publicId: string, status: OrderStatus): Promise<void> {
async setStatus(publicId: string, status: OrderStatus, failureCode: string | null = null): Promise<void> {
const order = await this.findByPublicId(publicId);
if (!order) throw new Error("Unknown order");
await this.run(this.updateStatus, { publicId, status });
await this.run(this.updateStatus, { publicId, status, failureCode });
await this.recordTransition(order.id, order.status, status, null, status);
}

Expand Down
1 change: 1 addition & 0 deletions coordinator/src/persistence/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS orders (

direction TEXT NOT NULL CHECK (direction IN ('eth_to_xlm', 'xlm_to_eth')),
status TEXT NOT NULL CHECK (status IN ('announced', 'src_locked', 'dst_locked', 'secret_revealed', 'completed', 'refunded', 'failed', 'expired')),
failure_code TEXT,

-- Cross-chain link.
hashlock TEXT NOT NULL, -- 0x-prefixed 32-byte hex.
Expand Down
46 changes: 36 additions & 10 deletions coordinator/src/server/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router } from "express";
import { z } from "zod";
import type { OrderRow, OrderSnapshot } from "../../persistence/orders-repo.js";
import { announceSchema, OrderService, OrderValidationError } from "../../services/order-service.js";
import { FAILURE_CODE_CATALOG, type FailureCode } from "@oversync/sdk";
import { encodeCursor, decodeCursor } from "./cursor-utils.js";

function serialiseOrder(order: OrderRow | null) {
Expand All @@ -10,6 +11,7 @@ function serialiseOrder(order: OrderRow | null) {
id: order.publicId,
direction: order.direction,
status: order.status,
failureCode: order.failureCode,
hashlock: order.hashlock,
src: {
chain: order.srcChain,
Expand Down Expand Up @@ -43,6 +45,14 @@ function serialiseOrder(order: OrderRow | null) {
};
}

export function sendError(res: any, code: FailureCode, status: number = 400) {
const detail = FAILURE_CODE_CATALOG[code];
res.status(status).json({
error: code,
message: detail.message
});
}

export function ordersRoutes(orders: OrderService): Router {
const router = Router();

Expand All @@ -53,11 +63,11 @@ export function ordersRoutes(orders: OrderService): Router {
res.status(201).json(serialiseOrder(order));
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: "validation_error", details: err.errors });
sendError(res, "VALIDATION_FAILED");
return;
}
if (err instanceof OrderValidationError) {
res.status(400).json({ error: "order_validation_error", message: err.message });
sendError(res, err.code);
return;
}
next(err);
Expand All @@ -68,15 +78,15 @@ export function ordersRoutes(orders: OrderService): Router {
router.get("/orders/history", async (req, res, next) => {
const address = (req.query.address as string | undefined) ?? "";
if (!address) {
res.status(400).json({ error: "address_required" });
sendError(res, "VALIDATION_FAILED");
return;
}

// Validate and parse limit
const limitStr = req.query.limit as string | undefined;
const limit = limitStr ? Number(limitStr) : 50;
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
res.status(400).json({ error: "invalid_limit", message: "limit must be an integer between 1 and 200" });
sendError(res, "VALIDATION_FAILED");
return;
}

Expand All @@ -86,7 +96,7 @@ export function ordersRoutes(orders: OrderService): Router {
if (cursorStr) {
const decoded = decodeCursor(cursorStr);
if (!decoded) {
res.status(400).json({ error: "invalid_cursor", message: "cursor is malformed or expired" });
sendError(res, "VALIDATION_FAILED");
return;
}
offset = decoded.offset;
Expand Down Expand Up @@ -136,7 +146,7 @@ export function ordersRoutes(orders: OrderService): Router {
try {
const order = await orders.get(id);
if (!order) {
res.status(404).json({ error: "not_found" });
sendError(res, "ORDER_NOT_FOUND", 404);
return;
}
res.json(serialiseOrder(order));
Expand All @@ -145,6 +155,22 @@ export function ordersRoutes(orders: OrderService): Router {
}
});

router.get("/orders/:id/transitions", async (req, res, next) => {
const id = req.params.id;
try {
const transitions = await orders.getTransitions(id);
if (!transitions.length) {
const order = await orders.get(id);
if (!order) {
sendError(res, "ORDER_NOT_FOUND", 404);
return;
}
}
res.json({ transitions });
} catch (err) {
next(err);
}
});
const lockSchema = z.object({
orderId: z.string().min(1),
txHash: z.string().min(1),
Expand All @@ -159,11 +185,11 @@ export function ordersRoutes(orders: OrderService): Router {
res.json({ ok: true });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: "validation_error", details: err.errors });
sendError(res, "VALIDATION_FAILED");
return;
}
if (err instanceof OrderValidationError) {
res.status(400).json({ error: "order_validation_error", message: err.message });
sendError(res, err.code);
return;
}
next(err);
Expand All @@ -184,11 +210,11 @@ export function ordersRoutes(orders: OrderService): Router {
res.json({ ok: true });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: "validation_error", details: err.errors });
sendError(res, "VALIDATION_FAILED");
return;
}
if (err instanceof OrderValidationError) {
res.status(400).json({ error: "order_validation_error", message: err.message });
sendError(res, err.code);
return;
}
next(err);
Expand Down
3 changes: 2 additions & 1 deletion coordinator/src/server/routes/secrets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Router } from "express";
import { z } from "zod";
import { sendError } from "./orders.js";
import type { SecretService } from "../../services/secret-service.js";

export function secretsRoutes(secrets: SecretService): Router {
Expand All @@ -18,7 +19,7 @@ export function secretsRoutes(secrets: SecretService): Router {
res.json({ ok: true });
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: "validation_error", details: err.errors });
sendError(res, "VALIDATION_FAILED");
return;
}
if (err instanceof Error) {
Expand Down
35 changes: 19 additions & 16 deletions coordinator/src/services/order-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type Direction,
type Chain
} from "../persistence/orders-repo.js";
import { type FailureCode } from "@oversync/sdk";
import { canTransition } from "../state-machine/order-machine.js";
import { ordersTotal } from "../metrics.js";
import { QuoteService, QuoteExpiredError, QuoteNotFoundError } from "./quote-service.js";
Expand Down Expand Up @@ -52,10 +53,10 @@ export class OrderValidationError extends Error {

function validateChainAddress(chain: Chain, addr: string): void {
if (chain === "ethereum" && !HEX_ADDRESS.test(addr)) {
throw new OrderValidationError(`${addr} is not a valid Ethereum address`);
throw new OrderValidationError(`${addr} is not a valid Ethereum address`, "VALIDATION_FAILED");
}
if (chain === "stellar" && !STELLAR_ADDRESS.test(addr)) {
throw new OrderValidationError(`${addr} is not a valid Stellar account`);
throw new OrderValidationError(`${addr} is not a valid Stellar account`, "VALIDATION_FAILED");
}
}

Expand All @@ -67,7 +68,8 @@ function validateDirectionAgainstChains(input: AnnounceInput): void {
const want = expected[input.direction];
if (want.src !== input.srcChain || want.dst !== input.dstChain) {
throw new OrderValidationError(
`Direction ${input.direction} requires src=${want.src} and dst=${want.dst}`
`Direction ${input.direction} requires src=${want.src} and dst=${want.dst}`,
"VALIDATION_FAILED"
);
}
}
Expand Down Expand Up @@ -124,7 +126,8 @@ export class OrderService {
const existing = await this.repo.findByHashlock(input.hashlock);
if (existing) {
throw new OrderValidationError(
`An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})`
`An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})`,
"VALIDATION_FAILED"
);
}

Expand Down Expand Up @@ -163,9 +166,9 @@ export class OrderService {
timelock: number;
}): Promise<void> {
const order = await this.repo.findByPublicId(input.publicId);
if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`);
if (!canTransition(order.status, "src_locked") && order.status !== "src_locked") {
throw new OrderValidationError(`cannot record src lock from status ${order.status}`);
if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`, "ORDER_NOT_FOUND");
if (!canTransition(order.status, "src_locked")) {
throw new OrderValidationError(`cannot record src lock from status ${order.status}`, "VALIDATION_FAILED");
}
await this.repo.recordSrcLock(input);
this.log.info({ publicId: input.publicId, srcOrderId: input.orderId }, "src lock recorded");
Expand All @@ -181,9 +184,9 @@ export class OrderService {
resolver: string | null;
}): Promise<void> {
const order = await this.repo.findByPublicId(input.publicId);
if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`);
if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`, "ORDER_NOT_FOUND");
if (!canTransition(order.status, "dst_locked") && order.status !== "dst_locked") {
throw new OrderValidationError(`cannot record dst lock from status ${order.status}`);
throw new OrderValidationError(`cannot record dst lock from status ${order.status}`, "VALIDATION_FAILED");
}

if (order.srcTimelock) {
Expand All @@ -206,9 +209,9 @@ export class OrderService {

async recordSecret(publicId: string, preimage: string, txHash: string): Promise<void> {
const order = await this.repo.findByPublicId(publicId);
if (!order) throw new OrderValidationError(`unknown order ${publicId}`);
if (!order) throw new OrderValidationError(`unknown order ${publicId}`, "ORDER_NOT_FOUND");
if (!canTransition(order.status, "secret_revealed") && order.status !== "secret_revealed") {
throw new OrderValidationError(`cannot record secret from status ${order.status}`);
throw new OrderValidationError(`cannot record secret from status ${order.status}`, "VALIDATION_FAILED");
}
await this.repo.recordSecretRevealed({ publicId, preimage, txHash });
this.log.info({ publicId }, "secret recorded");
Expand All @@ -219,14 +222,14 @@ export class OrderService {
return this.repo.getMetrics();
}

async markStatus(publicId: string, status: OrderRow["status"]): Promise<void> {
async markStatus(publicId: string, status: OrderRow["status"], failureCode: string | null = null): Promise<void> {
const order = await this.repo.findByPublicId(publicId);
if (!order) throw new OrderValidationError(`unknown order ${publicId}`);
if (!order) throw new OrderValidationError(`unknown order ${publicId}`, "ORDER_NOT_FOUND");
if (!canTransition(order.status, status)) {
throw new OrderValidationError(`cannot transition from ${order.status} to ${status}`);
throw new OrderValidationError(`cannot transition from ${order.status} to ${status}`, "VALIDATION_FAILED");
}
await this.repo.setStatus(publicId, status);
this.log.info({ publicId, status }, "status updated");
await this.repo.setStatus(publicId, status, failureCode);
this.log.info({ publicId, status, failureCode }, "status updated");
ordersTotal.inc({ status });
}

Expand Down
Loading