From 1fcc73a93c303f1b62bc42b0f21d3c1ee4e63f0d Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 30 Jun 2026 11:04:31 +0100 Subject: [PATCH 1/2] feat(coordinator): add coordinator API contract coverage and CORS fix --- coordinator/package.json | 5 +- coordinator/src/config.ts | 3 + coordinator/src/index.ts | 5 +- coordinator/src/persistence/orders-repo.ts | 41 + coordinator/src/replay.ts | 153 ++++ coordinator/src/server/app.ts | 44 +- coordinator/src/server/routes/health.ts | 87 ++- coordinator/src/server/routes/metrics.ts | 16 + coordinator/src/server/routes/orders.ts | 70 +- coordinator/src/server/routes/quotes.ts | 75 +- coordinator/src/services/order-service.ts | 53 +- coordinator/src/services/quote-service.ts | 174 ++++- coordinator/src/services/secret-service.ts | 19 + coordinator/test/api.test.ts | 719 ++++++++++++++++++ coordinator/test/health-route.test.ts | 98 +++ coordinator/test/http-routes.test.ts | 303 ++++++++ coordinator/test/order-metrics.test.ts | 232 ++++++ .../test/public-endpoint-redaction.test.ts | 316 ++++++++ coordinator/test/quote-expiry.test.ts | 322 ++++++++ coordinator/test/replay.test.ts | 50 ++ 20 files changed, 2727 insertions(+), 58 deletions(-) create mode 100644 coordinator/src/replay.ts create mode 100644 coordinator/test/api.test.ts create mode 100644 coordinator/test/health-route.test.ts create mode 100644 coordinator/test/http-routes.test.ts create mode 100644 coordinator/test/order-metrics.test.ts create mode 100644 coordinator/test/public-endpoint-redaction.test.ts create mode 100644 coordinator/test/quote-expiry.test.ts create mode 100644 coordinator/test/replay.test.ts diff --git a/coordinator/package.json b/coordinator/package.json index a0d6d1a..6228a2f 100644 --- a/coordinator/package.json +++ b/coordinator/package.json @@ -12,7 +12,8 @@ "test": "vitest run", "test:watch": "vitest", "lint": "eslint src --ext .ts", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "replay": "tsx src/replay.ts" }, "dependencies": { "@stellar/stellar-sdk": "^13.0.0", @@ -25,10 +26,10 @@ "pg": "^8.11.0", "pino": "^9.5.0", "pino-http": "^10.3.0", + "prom-client": "^15.1.3", "typescript": "^5.6.0", "viem": "^2.21.0", "xstate": "^5.18.0", - "prom-client": "^15.1.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/coordinator/src/config.ts b/coordinator/src/config.ts index 26d5cc1..80e8328 100644 --- a/coordinator/src/config.ts +++ b/coordinator/src/config.ts @@ -15,6 +15,8 @@ const configSchema = z.object({ logLevel: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"), corsOrigin: z.string().default("*"), pollIntervalMs: z.coerce.number().int().positive().default(15_000), + /** Maximum allowed JSON request body size in bytes. Default: 64 KiB. */ + maxRequestBodyBytes: z.coerce.number().int().positive().default(65_536), ethereum: z.object({ rpcUrl: z.string().url(), chainId: z.number().int(), @@ -53,6 +55,7 @@ export function loadConfig(): CoordinatorConfig { logLevel: process.env.LOG_LEVEL ?? "info", corsOrigin: process.env.CORS_ORIGIN ?? "*", pollIntervalMs: process.env.COORDINATOR_POLL_INTERVAL_MS ?? "15000", + maxRequestBodyBytes: process.env.COORDINATOR_MAX_BODY_BYTES ?? "65536", ethereum: { rpcUrl: resolveEthereumRpcUrl(isMainnet ? "mainnet" : "testnet"), chainId: isMainnet ? 1 : 11_155_111, diff --git a/coordinator/src/index.ts b/coordinator/src/index.ts index 8d9ee2d..6dc709a 100644 --- a/coordinator/src/index.ts +++ b/coordinator/src/index.ts @@ -16,13 +16,14 @@ async function main(): Promise { const db = await openDatabase(cfg.databaseUrl); const repo = new OrdersRepository(db); - const orders = new OrderService(repo, log); - const secrets = new SecretService(orders, log); const quotes = new QuoteService(log); + const orders = new OrderService(repo, log, quotes); + const secrets = new SecretService(orders, log); const app = createApp({ log, corsOrigin: cfg.corsOrigin, + maxRequestBodyBytes: cfg.maxRequestBodyBytes, orders, secrets, quotes diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index 4d591b1..ec8e1d0 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -53,6 +53,15 @@ export interface OrderRow { updatedAt: number; } +export interface OrderMetrics { + totalOrders: number; + byStatus: Record; + completedOrders: number; + refundedOrders: number; + staleExpiredOrders: number; + lastUpdatedTimestamp: number | null; +} + export interface AnnounceOrderInput { direction: Direction; hashlock: string; @@ -140,6 +149,9 @@ export class OrdersRepository { private readonly updateSrcLock: Statement; private readonly updateDstLock: Statement; private readonly updateSecret: Statement; + private readonly metricsByStatus: Statement; + private readonly metricsTotal: Statement; + private readonly metricsLastUpdated: Statement; constructor(private readonly db: DatabaseT) { this.insertStmt = db.prepare(` @@ -201,6 +213,15 @@ export class OrdersRepository { updated_at = CAST(strftime('%s','now') AS INTEGER) WHERE public_id = :publicId `); + this.metricsByStatus = db.prepare( + "SELECT status, COUNT(*) as count FROM orders GROUP BY status" + ); + this.metricsTotal = db.prepare( + "SELECT COUNT(*) as count FROM orders" + ); + this.metricsLastUpdated = db.prepare( + "SELECT MAX(updated_at) as ts FROM orders" + ); } private async run(stmt: Statement, ...params: any[]): Promise { @@ -297,4 +318,24 @@ export class OrdersRepository { }): Promise { await this.run(this.updateSecret, input); } + + async getMetrics(): Promise { + const byStatus = await this.all<{ status: string; count: number }>(this.metricsByStatus); + const totalRow = await this.get<{ count: number }>(this.metricsTotal); + const lastUpdatedRow = await this.get<{ ts: number | null }>(this.metricsLastUpdated); + + const statusMap: Record = {}; + for (const row of byStatus) { + statusMap[row.status] = Number(row.count); + } + + return { + totalOrders: Number(totalRow?.count ?? 0), + byStatus: statusMap, + completedOrders: statusMap["completed"] ?? 0, + refundedOrders: statusMap["refunded"] ?? 0, + staleExpiredOrders: (statusMap["expired"] ?? 0) + (statusMap["failed"] ?? 0), + lastUpdatedTimestamp: lastUpdatedRow?.ts != null ? Number(lastUpdatedRow.ts) : null + }; + } } diff --git a/coordinator/src/replay.ts b/coordinator/src/replay.ts new file mode 100644 index 0000000..129e0e0 --- /dev/null +++ b/coordinator/src/replay.ts @@ -0,0 +1,153 @@ +import { createPublicClient, http, parseAbiItem } from "viem"; +import { sepolia, mainnet } from "viem/chains"; +import { rpc } from "@stellar/stellar-sdk"; +import { z } from "zod"; +import { loadConfig } from "./config.js"; + +const ORDER_CREATED = parseAbiItem( + "event OrderCreated(uint256 indexed orderId, address indexed sender, address indexed beneficiary, address token, uint256 amount, uint256 safetyDeposit, bytes32 hashlock, uint64 timelock)" +); +const ORDER_CLAIMED = parseAbiItem( + "event OrderClaimed(uint256 indexed orderId, address indexed claimer, bytes32 preimage, uint256 amount, uint256 safetyDeposit)" +); +const ORDER_REFUNDED = parseAbiItem( + "event OrderRefunded(uint256 indexed orderId, address indexed caller, uint256 amount, uint256 safetyDeposit)" +); + +export const ReplayArgsSchema = z.object({ + chain: z.enum(["ethereum", "soroban"]), + from: z.coerce.number().int().positive(), + to: z.coerce.number().int().positive() +}); + +export type ReplayArgs = z.infer; + +export type ReplaySummary = { + chain: "ethereum" | "soroban"; + from: number; + to: number; + events: Record; + parseFailures: number; +}; + +export async function runReplay(args: ReplayArgs): Promise { + const cfg = loadConfig(); + const summary: ReplaySummary = { + chain: args.chain, + from: args.from, + to: args.to, + events: {}, + parseFailures: 0 + }; + + if (args.chain === "ethereum") { + const address = cfg.ethereum.htlcEscrow; + if (!address) { + throw new Error("ETH_HTLC_ESCROW not configured"); + } + const client = createPublicClient({ + chain: cfg.ethereum.chainId === 1 ? mainnet : sepolia, + transport: http(cfg.ethereum.rpcUrl) + }); + + try { + const fromBlock = BigInt(args.from); + const toBlock = BigInt(args.to); + + const [createdLogs, claimedLogs, refundedLogs] = await Promise.all([ + client.getLogs({ address, event: ORDER_CREATED, fromBlock, toBlock }).catch((e) => { + summary.parseFailures++; + return []; + }), + client.getLogs({ address, event: ORDER_CLAIMED, fromBlock, toBlock }).catch((e) => { + summary.parseFailures++; + return []; + }), + client.getLogs({ address, event: ORDER_REFUNDED, fromBlock, toBlock }).catch((e) => { + summary.parseFailures++; + return []; + }) + ]); + + summary.events["OrderCreated"] = createdLogs.length; + summary.events["OrderClaimed"] = claimedLogs.length; + summary.events["OrderRefunded"] = refundedLogs.length; + } catch (err) { + summary.parseFailures++; + } + } else if (args.chain === "soroban") { + const contractId = cfg.soroban.htlcContract; + if (!contractId) { + throw new Error("SOROBAN_HTLC contract not configured"); + } + const server = new rpc.Server(cfg.soroban.rpcUrl, { + allowHttp: cfg.soroban.rpcUrl.startsWith("http://") + }); + + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + try { + const events = await server.getEvents({ + filters: [{ type: "contract", contractIds: [contractId] }], + startLedger: args.from, + cursor, + limit: 100 + }); + + for (const ev of events.events) { + if (ev.ledger > args.to) { + hasMore = false; + break; + } + if (ev.ledger >= args.from && ev.ledger <= args.to) { + summary.events["ContractEvent"] = (summary.events["ContractEvent"] || 0) + 1; + } + } + + if (events.cursor && hasMore && events.events.length > 0) { + cursor = events.cursor; + } else { + hasMore = false; + } + } catch (err) { + summary.parseFailures++; + hasMore = false; + } + } + } + + return summary; +} + +export async function main(argv: string[]) { + const rawArgs: Record = {}; + for (const arg of argv) { + if (arg.startsWith("--")) { + const parts = arg.slice(2).split("="); + const key = parts[0]; + if (key && parts.length >= 2) { + rawArgs[key] = parts.slice(1).join("="); + } + } + } + + try { + const args = ReplayArgsSchema.parse(rawArgs); + if (args.from > args.to) { + throw new Error("fromBlock/ledger cannot be greater than toBlock/ledger"); + } + const summary = await runReplay(args); + console.log(JSON.stringify(summary, null, 2)); + } catch (err: any) { + console.error(JSON.stringify({ error: err.message || err.toString() }, null, 2)); + process.exit(1); + } +} + +// ESM entry point check +import { fileURLToPath } from 'url'; +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(process.argv).catch(() => process.exit(1)); +} diff --git a/coordinator/src/server/app.ts b/coordinator/src/server/app.ts index 298b714..89fa1e5 100644 --- a/coordinator/src/server/app.ts +++ b/coordinator/src/server/app.ts @@ -3,7 +3,7 @@ import cors from "cors"; import pinoHttp from "pino-http"; import type { Logger } from "pino"; import { healthRoutes } from "./routes/health.js"; -import { metricsRoutes } from "./routes/metrics.js"; +import { metricsRoutes, orderMetricsRoutes } from "./routes/metrics.js"; import { httpRequestDuration } from "../metrics.js"; import { ordersRoutes } from "./routes/orders.js"; import { secretsRoutes } from "./routes/secrets.js"; @@ -15,18 +15,29 @@ import type { QuoteService } from "../services/quote-service.js"; export interface AppDeps { log: Logger; corsOrigin: string; + /** Maximum allowed JSON request body size in bytes. Default: 65536 (64 KiB). */ + maxRequestBodyBytes: number; orders: OrderService; secrets: SecretService; quotes: QuoteService; } export function createApp(deps: AppDeps): Express { + const { maxRequestBodyBytes } = deps; const app = express(); app.use(pinoHttp({ logger: deps.log })); - app.use(express.json({ limit: "1mb" })); + // Reject bodies exceeding the configured limit before any route logic runs. + app.use(express.json({ limit: maxRequestBodyBytes })); app.use( cors({ - origin: deps.corsOrigin === "*" ? true : deps.corsOrigin.split(","), + origin: (origin, callback) => { + if (!origin || !deps.corsOrigin || deps.corsOrigin === "*") { + callback(null, true); + } else { + const allowedOrigins = deps.corsOrigin.split(","); + callback(null, allowedOrigins.includes(origin)); + } + }, credentials: true }) ); @@ -46,6 +57,33 @@ export function createApp(deps: AppDeps): Express { app.use("/api", ordersRoutes(deps.orders)); app.use("/api", secretsRoutes(deps.secrets)); app.use("/api", quotesRoutes(deps.quotes)); + app.use("/api", orderMetricsRoutes(deps.orders)); + + // 413 / 400 handler — catches oversized request bodies and malformed JSON before the generic error handler. + app.use( + ( + err: Error & { type?: string; status?: number }, + _req: express.Request, + res: express.Response, + next: express.NextFunction + ) => { + if (err.type === "entity.too.large" || err.status === 413) { + res.status(413).json({ + error: "payload_too_large", + message: `Request body exceeds the ${maxRequestBodyBytes}-byte limit` + }); + return; + } + if (err.type === "entity.parse.failed" || err.status === 400) { + res.status(400).json({ + error: "validation_error", + message: "Malformed JSON request body" + }); + return; + } + next(err); + } + ); // Final error handler — never leak a stack trace to clients. app.use( diff --git a/coordinator/src/server/routes/health.ts b/coordinator/src/server/routes/health.ts index 3c5c2ee..a44cdc4 100644 --- a/coordinator/src/server/routes/health.ts +++ b/coordinator/src/server/routes/health.ts @@ -1,16 +1,99 @@ import { Router } from "express"; +function getBuildEnv(): "testnet" | "mainnet" { + const v = (process.env.NETWORK_MODE ?? "testnet").toLowerCase(); + return v === "mainnet" ? "mainnet" : "testnet"; +} + +function redactRpcUrl(maybeUrl: string | undefined): string | null { + const raw = (maybeUrl ?? "").trim(); + if (!raw) return null; + + // Handle special static mock flags from our route config + if (raw === "[CONFIGURED_VIA_INFURA_API_KEY]") return raw; + + try { + const parsed = new URL(raw); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return "[REDACTED]"; + } +} + +function inferDatabaseMode( + databaseUrl: string | undefined, +): "sqlite" | "postgres" | "unknown" { + const url = (databaseUrl ?? "").trim(); + if (!url) return "unknown"; + if (url.startsWith("postgres://") || url.startsWith("postgresql://")) + return "postgres"; + if (url.startsWith("file:")) return "sqlite"; + return "unknown"; +} + export function healthRoutes(): Router { const router = Router(); const startedAt = Date.now(); router.get("/health", (_req, res) => { + const version = process.env.npm_package_version ?? "0.1.0"; + const buildEnv = getBuildEnv(); + + const commit = + process.env.GIT_COMMIT ?? + process.env.COMMIT_SHA ?? + process.env.SOURCE_VERSION ?? + null; + + const databaseMode = inferDatabaseMode(process.env.DATABASE_URL); + + const ethereumRpcUrl = + (process.env.ETHEREUM_RPC_URL ?? + process.env.SEPOLIA_RPC_URL ?? + process.env.MAINNET_RPC_URL ?? + process.env.INFURA_API_KEY) + ? "[CONFIGURED_VIA_INFURA_API_KEY]" + : undefined; + + const sorobanRpcUrl = process.env.SOROBAN_RPC_URL ?? undefined; + res.json({ status: "ok", service: "oversync-coordinator", - version: process.env.npm_package_version ?? "0.1.0", + + version, + uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + + build: { + env: buildEnv, + commit: commit || null, + }, + + dependencies: { + database: { + mode: databaseMode, + }, + ethereum: { + rpcUrlConfigured: Boolean( + process.env.ETHEREUM_RPC_URL || + process.env.SEPOLIA_RPC_URL || + process.env.MAINNET_RPC_URL || + process.env.INFURA_API_KEY, + ), + rpcUrl: redactRpcUrl( + process.env.ETHEREUM_RPC_URL || + process.env.SEPOLIA_RPC_URL || + process.env.MAINNET_RPC_URL || + ethereumRpcUrl, + ), + }, + soroban: { + rpcUrlConfigured: Boolean(sorobanRpcUrl), + rpcUrl: redactRpcUrl(sorobanRpcUrl), + }, + }, }); }); diff --git a/coordinator/src/server/routes/metrics.ts b/coordinator/src/server/routes/metrics.ts index 9de69ae..bdb002f 100644 --- a/coordinator/src/server/routes/metrics.ts +++ b/coordinator/src/server/routes/metrics.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { registry } from "../../metrics.js"; +import type { OrderService } from "../../services/order-service.js"; export function metricsRoutes(): Router { const router = Router(); @@ -15,3 +16,18 @@ export function metricsRoutes(): Router { return router; } + +export function orderMetricsRoutes(orders: OrderService): Router { + const router = Router(); + + router.get("/metrics", async (_req, res, next) => { + try { + const metrics = await orders.getOrderMetrics(); + res.json(metrics); + } catch (err) { + next(err); + } + }); + + return router; +} diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 21043e9..36a961d 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -63,39 +63,73 @@ export function ordersRoutes(orders: OrderService): Router { } }); - router.get("/orders/:id", async (req, res, next) => { - const id = req.params.id; - try { - const order = await orders.get(id); - if (!order) { - res.status(404).json({ error: "not_found" }); - return; - } - res.json(serialiseOrder(order)); - } catch (err) { - next(err); + router.get("/orders/history", async (req, res, next) => { + // Support both single address and multiple addresses (eth + stellar) + const address = (req.query.address as string | undefined); + const ethAddress = (req.query.eth as string | undefined); + const stellarAddress = (req.query.stellar as string | undefined); + + const addresses: string[] = []; + if (address) { + addresses.push(address); + } + if (ethAddress) { + addresses.push(ethAddress); + } + if (stellarAddress) { + addresses.push(stellarAddress); } - }); - router.get("/orders/history", async (req, res, next) => { - const address = (req.query.address as string | undefined) ?? ""; - if (!address) { + if (addresses.length === 0) { res.status(400).json({ error: "address_required" }); return; } + const limit = Math.min(Number(req.query.limit ?? 50), 200); const offset = Math.max(Number(req.query.offset ?? 0), 0); + try { - const list = await orders.history(address, limit, offset); + // Fetch orders for each address and deduplicate by publicId + const allOrders = await Promise.all( + addresses.map(addr => orders.history(addr, limit, offset)) + ); + + const seen = new Set(); + const deduped: typeof allOrders[0] = []; + + for (const orderList of allOrders) { + for (const order of orderList) { + if (!seen.has(order.publicId)) { + seen.add(order.publicId); + deduped.push(order); + } + } + } + res.json({ - transactions: list.map((o) => serialiseOrder(o)).filter(Boolean), - pagination: { limit, offset, count: list.length } + transactions: deduped.map((o) => serialiseOrder(o)).filter(Boolean), + pagination: { limit, offset, count: deduped.length } }); } catch (err) { next(err); } }); + router.get("/orders/:id", async (req, res, next) => { + const id = req.params.id; + try { + const order = await orders.get(id); + if (!order) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json(serialiseOrder(order)); + } catch (err) { + next(err); + } + }); + + const lockSchema = z.object({ orderId: z.string().min(1), txHash: z.string().min(1), diff --git a/coordinator/src/server/routes/quotes.ts b/coordinator/src/server/routes/quotes.ts index c4d0514..5809a64 100644 --- a/coordinator/src/server/routes/quotes.ts +++ b/coordinator/src/server/routes/quotes.ts @@ -1,12 +1,81 @@ import { Router } from "express"; +import { QuoteExpiredError, QuoteNotFoundError } from "../../services/quote-service.js"; import type { QuoteService } from "../../services/quote-service.js"; export function quotesRoutes(quotes: QuoteService): Router { const router = Router(); - router.get("/quotes/eth-xlm", async (_req, res) => { - const quote = await quotes.quoteEthXlm(); - res.json(quote); + /** + * GET /api/quotes/eth-xlm + * Returns a freshly-issued (or cache-reissued) price quote for the + * ETH→XLM pair. Every response carries a unique `quoteId` that + * resolvers reference when submitting fills; `expiresAt` is the + * deterministic deadline enforced by `assertFresh`. + */ + router.get("/quotes/eth-xlm", async (_req, res, next) => { + try { + const quote = await quotes.quoteEthXlm(); + res.json({ + quoteId: quote.quoteId, + pair: quote.pair, + ethUsd: quote.srcUsd, + xlmUsd: quote.dstUsd, + source: quote.source, + issuedAt: quote.issuedAt, + expiresAt: quote.expiresAt, + fetchedAt: quote.issuedAt, + /** Convenience: milliseconds remaining until expiry (negative when expired). */ + freshMs: quote.expiresAt - Date.now() + }); + } catch (err) { + next(err); + } + }); + + /** + * GET /api/quotes/:id/status + * Check whether a previously issued quote is still fresh. + * Resolvers can call this before submitting fills to avoid + * wasting gas on an already-expired quote. + * + * 200 → fresh, body includes quote metadata + `freshMs` + * 410 → expired, body explains the staleness + * 404 → unknown quoteId + */ + router.get("/quotes/:id/status", (req, res) => { + const { id } = req.params; + try { + const quote = quotes.assertFresh(id); + res.json({ + quoteId: quote.quoteId, + fresh: true, + issuedAt: quote.issuedAt, + expiresAt: quote.expiresAt, + freshMs: quote.expiresAt - Date.now(), + source: quote.source + }); + } catch (err) { + if (err instanceof QuoteExpiredError) { + res.status(410).json({ + error: "quote_expired", + quoteId: id, + fresh: false, + expiredMs: err.expiredMs, + staleMs: Date.now() - err.expiredMs, + message: err.message + }); + return; + } + if (err instanceof QuoteNotFoundError) { + res.status(404).json({ + error: "quote_not_found", + quoteId: id, + message: err.message + }); + return; + } + throw err; + } }); return router; diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 6d99374..47fd6bb 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -4,11 +4,13 @@ import { OrdersRepository, type OrderRow, type AnnounceOrderInput, + type OrderMetrics, type Direction, type Chain } from "../persistence/orders-repo.js"; import { canTransition } from "../state-machine/order-machine.js"; import { ordersTotal } from "../metrics.js"; +import { QuoteService, QuoteExpiredError, QuoteNotFoundError } from "./quote-service.js"; const HEX32 = /^0x[0-9a-fA-F]{64}$/; const HEX_ADDRESS = /^0x[0-9a-fA-F]{40}$/; @@ -25,7 +27,14 @@ export const announceSchema = z.object({ dstChain: z.enum(["ethereum", "stellar"]), dstAddress: z.string(), dstAsset: z.string().min(1), - dstAmount: z.string().regex(/^\d+$/, "dstAmount must be a decimal integer string") + dstAmount: z.string().regex(/^\d+$/, "dstAmount must be a decimal integer string"), + /** + * Optional: the `quoteId` returned by `GET /api/quotes/eth-xlm`. + * When present, the coordinator validates that the quote has not + * expired before accepting the announcement, ensuring fills cannot + * be based on stale pricing. + */ + quoteId: z.string().optional() }); export type AnnounceInput = z.infer; @@ -57,7 +66,9 @@ function validateDirectionAgainstChains(input: AnnounceInput): void { export class OrderService { constructor( private readonly repo: OrdersRepository, - private readonly log: Logger + private readonly log: Logger, + /** Optional — when supplied, quoteId in announce requests is validated. */ + private readonly quoteService?: QuoteService ) {} /** @@ -65,12 +76,37 @@ export class OrderService { * funds — it simply records the intent so the order book is visible * to all resolvers and the user can later attach the on-chain * `srcOrderId` once they have locked. + * + * When `quoteId` is present in the input, it is validated against + * the QuoteService before the order is persisted. Expired or + * unknown quoteIds are rejected as `OrderValidationError` so the + * error surfaces cleanly to the caller before any chain action is + * attempted. */ async announce(input: AnnounceInput): Promise { validateChainAddress(input.srcChain, input.srcAddress); validateChainAddress(input.dstChain, input.dstAddress); validateDirectionAgainstChains(input); + // --- Quote freshness gate ------------------------------------------- + if (input.quoteId) { + if (!this.quoteService) { + // No QuoteService wired in (e.g. test mode without quotes) — skip. + this.log.debug({ quoteId: input.quoteId }, "quoteId supplied but no QuoteService wired; skipping freshness check"); + } else { + try { + this.quoteService.assertFresh(input.quoteId); + this.log.debug({ quoteId: input.quoteId }, "quote freshness confirmed"); + } catch (err) { + if (err instanceof QuoteExpiredError || err instanceof QuoteNotFoundError) { + throw new OrderValidationError(err.message); + } + throw err; + } + } + } + // ------------------------------------------------------------------- + const existing = await this.repo.findByHashlock(input.hashlock); if (existing) { throw new OrderValidationError( @@ -78,8 +114,13 @@ export class OrderService { ); } - const order = await this.repo.announce(input as AnnounceOrderInput); - this.log.info({ publicId: order.publicId, direction: order.direction }, "order announced"); + // 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); + this.log.info( + { publicId: order.publicId, direction: order.direction, quoteId: input.quoteId ?? null }, + "order announced" + ); ordersTotal.inc({ status: "announced" }); return order; } @@ -142,6 +183,10 @@ export class OrderService { ordersTotal.inc({ status: "secret_revealed" }); } + async getOrderMetrics(): Promise { + return this.repo.getMetrics(); + } + async markStatus(publicId: string, status: OrderRow["status"]): Promise { const order = await this.repo.findByPublicId(publicId); if (!order) throw new OrderValidationError(`unknown order ${publicId}`); diff --git a/coordinator/src/services/quote-service.ts b/coordinator/src/services/quote-service.ts index ca65270..c976935 100644 --- a/coordinator/src/services/quote-service.ts +++ b/coordinator/src/services/quote-service.ts @@ -1,14 +1,37 @@ +import { randomBytes } from "node:crypto"; import type { Logger } from "pino"; export interface PriceQuote { + /** Stable opaque id that callers can reference back to this exact quote. */ + quoteId: string; pair: string; /** Decimal string. `srcUsd` and `dstUsd` are USD per unit of src/dst. */ srcUsd: string | null; dstUsd: string | null; /** Source: coingecko, oneinch, cache, etc. */ source: "coingecko" | "oneinch" | "cache" | "unknown"; - /** Unix ms when the quote was fetched. */ - fetchedAt: number; + /** Unix ms when the quote was first issued. */ + issuedAt: number; + /** Unix ms after which this quote must not be used to fill an order. */ + expiresAt: number; +} + +export class QuoteExpiredError extends Error { + constructor( + public readonly quoteId: string, + public readonly expiredMs: number + ) { + const staleMs = Date.now() - expiredMs; + super(`Quote ${quoteId} expired ${staleMs} ms ago`); + this.name = "QuoteExpiredError"; + } +} + +export class QuoteNotFoundError extends Error { + constructor(public readonly quoteId: string) { + super(`Quote ${quoteId} not found or already evicted`); + this.name = "QuoteNotFoundError"; + } } /** @@ -16,23 +39,55 @@ export interface PriceQuote { * (no-API-key) endpoint; if the call fails we surface a `null` price * instead of a fabricated number, so callers can decide to render * "price unavailable" rather than misleading data. + * + * Every response carries a `quoteId` that resolvers (and the order + * announce endpoint) can reference. `assertFresh(quoteId)` rejects + * fills that reference stale quotes before any chain action is + * attempted, satisfying the quote-freshness enforcement requirement. */ export class QuoteService { - private readonly cache = new Map(); + /** In-flight / recently-issued quotes, keyed by quoteId. */ + private readonly quotes = new Map(); + /** Cached CoinGecko response, keyed by pair name. */ + private readonly priceCache = new Map(); private readonly cacheTtlMs = 30_000; - constructor(private readonly log: Logger) {} + constructor( + private readonly log: Logger, + /** Injected for testing — defaults to Date.now(). */ + private readonly now: () => number = Date.now + ) {} + + // ---------------------------------------------------------------- + // Public API + // ---------------------------------------------------------------- - async quoteEthXlm(): Promise<{ ethUsd: string | null; xlmUsd: string | null; source: PriceQuote["source"]; fetchedAt: number }> { - const cached = this.cache.get("ETH-XLM"); - if (cached && Date.now() - cached.fetchedAt < this.cacheTtlMs) { - return { - ethUsd: cached.srcUsd, - xlmUsd: cached.dstUsd, + /** + * Fetch (or return a cached) ETH/XLM price quote. + * The returned object always has a unique `quoteId` so callers + * can reference it when announcing an order. + */ + async quoteEthXlm(): Promise { + const cached = this.priceCache.get("ETH-XLM"); + if (cached && this.now() < cached.expiresAt) { + // Re-issue a *new* quoteId that shares the same price data. + // This ensures each API response has a distinct, trackable id + // while still benefiting from the price cache. + const reissued: PriceQuote = { + ...cached, + quoteId: this.newQuoteId(), source: "cache", - fetchedAt: cached.fetchedAt + issuedAt: this.now() }; + this.quotes.set(reissued.quoteId, reissued); + this.log.debug({ quoteId: reissued.quoteId, pair: "ETH-XLM" }, "quote reissued from cache"); + return reissued; } + + let ethUsd: string | null = null; + let xlmUsd: string | null = null; + let source: PriceQuote["source"] = "unknown"; + try { const res = await fetch( "https://api.coingecko.com/api/v3/simple/price?ids=ethereum,stellar&vs_currencies=usd", @@ -40,20 +95,91 @@ export class QuoteService { ); if (!res.ok) throw new Error(`coingecko ${res.status}`); const body = (await res.json()) as Record; - const ethUsd = body.ethereum?.usd?.toString() ?? null; - const xlmUsd = body.stellar?.usd?.toString() ?? null; - const quote: PriceQuote = { - pair: "ETH-XLM", - srcUsd: ethUsd, - dstUsd: xlmUsd, - source: "coingecko", - fetchedAt: Date.now() - }; - this.cache.set("ETH-XLM", quote); - return { ethUsd, xlmUsd, source: "coingecko", fetchedAt: quote.fetchedAt }; + ethUsd = body.ethereum?.usd?.toString() ?? null; + xlmUsd = body.stellar?.usd?.toString() ?? null; + source = "coingecko"; } catch (err) { - this.log.warn({ err }, "coingecko quote failed"); - return { ethUsd: null, xlmUsd: null, source: "unknown", fetchedAt: Date.now() }; + this.log.warn({ err }, "coingecko quote failed — returning null prices"); } + + const quoteId = this.newQuoteId(); + const issuedAt = this.now(); + const expiresAt = issuedAt + this.cacheTtlMs; + + const quote: PriceQuote = { + quoteId, + pair: "ETH-XLM", + srcUsd: ethUsd, + dstUsd: xlmUsd, + source, + issuedAt, + expiresAt + }; + + this.priceCache.set("ETH-XLM", quote); + this.quotes.set(quoteId, quote); + this.log.debug({ quoteId, source }, "quote issued"); + return quote; + } + + /** + * Look up a previously issued quote by its id. + * Returns `null` when the quote has been evicted (too old) or + * was never known. + */ + getById(quoteId: string): PriceQuote | null { + return this.quotes.get(quoteId) ?? null; + } + + /** + * Assert that a quote exists **and** has not expired. + * + * Throws `QuoteNotFoundError` when the id is unknown. + * Throws `QuoteExpiredError` when `now > expiresAt`. + * + * Resolvers and the order-announce handler call this before + * attempting any on-chain action so fills using stale prices + * are rejected deterministically before gas is spent. + */ + assertFresh(quoteId: string): PriceQuote { + const quote = this.quotes.get(quoteId); + if (!quote) { + throw new QuoteNotFoundError(quoteId); + } + if (this.now() > quote.expiresAt) { + this.log.warn( + { quoteId, expiredMs: quote.expiresAt, nowMs: this.now() }, + "stale quote rejected" + ); + throw new QuoteExpiredError(quoteId, quote.expiresAt); + } + return quote; + } + + /** + * Remove all quotes whose `expiresAt` is in the past. + * Called periodically to prevent unbounded memory growth. + */ + evictExpired(): number { + const now = this.now(); + let count = 0; + for (const [id, q] of this.quotes) { + if (now > q.expiresAt) { + this.quotes.delete(id); + count++; + } + } + if (count > 0) { + this.log.debug({ evicted: count }, "expired quotes evicted"); + } + return count; + } + + // ---------------------------------------------------------------- + // Private helpers + // ---------------------------------------------------------------- + + private newQuoteId(): string { + return randomBytes(16).toString("hex"); } } diff --git a/coordinator/src/services/secret-service.ts b/coordinator/src/services/secret-service.ts index 4150aa7..e507176 100644 --- a/coordinator/src/services/secret-service.ts +++ b/coordinator/src/services/secret-service.ts @@ -11,10 +11,28 @@ function sha256Hex(buf: Buffer): string { return "0x" + createHash("sha256").update(buf).digest("hex"); } +function assertValidSecretFormat(value: unknown, fieldName: string = "secret"): `0x${string}` { + if (typeof value !== "string") { + throw new Error(`${fieldName} must be a string`); + } + if (!value.startsWith("0x")) { + throw new Error(`${fieldName} must start with "0x"`); + } + const hexPart = value.slice(2); + if (hexPart.length !== 64) { + throw new Error(`${fieldName} must be exactly 32 bytes (64 hex characters)`); + } + if (!/^[0-9a-fA-F]+$/.test(hexPart)) { + throw new Error(`${fieldName} contains invalid hex characters`); + } + return value as `0x${string}`; +} + function keccak256Hex(buf: Buffer): string { return keccak256(toHex(buf)) as `0x${string}`; } + /** * Coordinates secret reveal between the two chains. * @@ -36,6 +54,7 @@ export class SecretService { * before storing it, so a malicious caller cannot poison the cache. */ async reveal(publicId: string, preimage: string, txHash: string): Promise<{ ok: true }> { + assertValidSecretFormat(preimage, "preimage"); const order = await this.orders.get(publicId); if (!order) { throw new Error(`unknown order ${publicId}`); diff --git a/coordinator/test/api.test.ts b/coordinator/test/api.test.ts new file mode 100644 index 0000000..39977a2 --- /dev/null +++ b/coordinator/test/api.test.ts @@ -0,0 +1,719 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import pino from "pino"; +import { mkdtempSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { createHash } from "node:crypto"; +import request from "supertest"; +import { createApp } from "../src/server/app.js"; +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"; +import { QuoteService } from "../src/services/quote-service.js"; +import type { Express } from "express"; + +const log = pino({ level: "silent" }); + +const VALID_HASHLOCK = "0x" + "a".repeat(64); +const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; +const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; +const VALID_PREIMAGE = "0x" + "b".repeat(64); + + +describe("Coordinator API Contract Tests", () => { + let app: Express; + let tmpDir: string; + let db: any; + let orders: OrderService; + let secrets: SecretService; + let quotes: QuoteService; + + beforeAll(async () => { + tmpDir = mkdtempSync(resolve(tmpdir(), "oversync-api-test-")); + db = await openDatabase(`file:${tmpDir}/test.db`); + orders = new OrderService(new OrdersRepository(db), log); + secrets = new SecretService(orders, log); + quotes = new QuoteService(log); + + app = createApp({ + log, + corsOrigin: "*", + maxRequestBodyBytes: 65536, + orders, + secrets, + quotes + }); + }); + + afterAll(() => { + db.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe("GET /health", () => { + it("should return 200 with service status and version", async () => { + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("status", "ok"); + expect(res.body).toHaveProperty("service", "oversync-coordinator"); + expect(res.body).toHaveProperty("version"); + expect(typeof res.body.version).toBe("string"); + }); + + it("should include uptimeSeconds field", async () => { + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("uptimeSeconds"); + expect(typeof res.body.uptimeSeconds).toBe("number"); + expect(res.body.uptimeSeconds).toBeGreaterThanOrEqual(0); + }); + + it("should include timestamp in ISO format", async () => { + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("timestamp"); + expect(typeof res.body.timestamp).toBe("string"); + expect(() => new Date(res.body.timestamp)).not.toThrow(); + }); + }); + + describe("GET /metrics", () => { + it("should return 200 with Prometheus content type", async () => { + const res = await request(app).get("/metrics"); + + expect(res.status).toBe(200); + expect(res.type).toContain("text/plain"); + expect(res.headers["content-type"]).toContain("charset=utf-8"); + }); + + it("should return valid Prometheus format", async () => { + const res = await request(app).get("/metrics"); + + expect(res.status).toBe(200); + const text = res.text; + expect(typeof text).toBe("string"); + expect(text.length).toBeGreaterThan(0); + // Prometheus metrics should have # HELP or # TYPE or actual metric lines + expect(text).toMatch(/^(# HELP|# TYPE|[a-zA-Z_])/m); + }); + + it("should include coordinator metric names", async () => { + const res = await request(app).get("/metrics"); + + expect(res.status).toBe(200); + const text = res.text; + // Check for common metric patterns (lines starting with metric name) + expect(text).toMatch(/^[a-z_]+/m); + }); + }); + + describe("POST /api/orders/announce", () => { + it("should announce a valid eth->xlm order", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1000000000000000000", + srcSafetyDeposit: "1000000000000000", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "5000000000" + }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty("id"); + expect(res.body.id).toMatch(/^[a-f0-9]{32}$/); + expect(res.body).toHaveProperty("status", "announced"); + expect(res.body).toHaveProperty("direction", "eth_to_xlm"); + expect(res.body).toHaveProperty("hashlock", VALID_HASHLOCK); + expect(res.body).toHaveProperty("src"); + expect(res.body).toHaveProperty("dst"); + expect(res.body.src.chain).toBe("ethereum"); + expect(res.body.dst.chain).toBe("stellar"); + }); + + it("should announce a valid xlm->eth order", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "xlm_to_eth", + hashlock: "0x" + "b".repeat(64), + srcChain: "stellar", + srcAddress: VALID_STELLAR_ADDR, + srcAsset: "XLM", + srcAmount: "5000000000", + srcSafetyDeposit: "1000000000", + dstChain: "ethereum", + dstAddress: VALID_ETH_ADDR, + dstAsset: "ETH", + dstAmount: "1000000000000000000" + }); + + expect(res.status).toBe(201); + expect(res.body.direction).toBe("xlm_to_eth"); + }); + + it("should reject missing required fields", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK + // Missing all other fields + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "validation_error"); + expect(res.body).toHaveProperty("details"); + }); + + it("should reject invalid hashlock format", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "invalid-hashlock", + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "validation_error"); + }); + + it("should reject invalid Ethereum address", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: "not-an-eth-address", + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "order_validation_error"); + }); + + it("should reject invalid Stellar address", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: "not-a-stellar-address", + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "order_validation_error"); + }); + + it("should reject mismatched direction and chains", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK, + srcChain: "stellar", // Wrong: should be ethereum + srcAddress: VALID_STELLAR_ADDR, + srcAsset: "XLM", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "ethereum", // Wrong: should be stellar + dstAddress: VALID_ETH_ADDR, + dstAsset: "ETH", + dstAmount: "1" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "order_validation_error"); + }); + + it("should reject duplicate hashlocks", async () => { + // First announcement succeeds + const res1 = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "c".repeat(64), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(res1.status).toBe(201); + + // Second with same hashlock fails + const res2 = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "c".repeat(64), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(res2.status).toBe(400); + expect(res2.body).toHaveProperty("error", "order_validation_error"); + expect(res2.body.message).toContain("already exists"); + }); + + it("should reject non-numeric amounts", async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "not-a-number", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "validation_error"); + }); + }); + + describe("GET /api/orders/:id", () => { + let orderId: string; + + beforeAll(async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "d".repeat(64), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1000000000000000000", + srcSafetyDeposit: "1000000000000000", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "5000000000" + }); + + orderId = res.body.id; + }); + + it("should retrieve order by ID", async () => { + const res = await request(app).get(`/api/orders/${orderId}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(orderId); + expect(res.body).toHaveProperty("status", "announced"); + expect(res.body).toHaveProperty("hashlock"); + expect(res.body).toHaveProperty("src"); + expect(res.body).toHaveProperty("dst"); + expect(res.body).toHaveProperty("createdAt"); + expect(res.body).toHaveProperty("updatedAt"); + }); + + it("should return 404 for non-existent order", async () => { + const res = await request(app).get("/api/orders/nonexistent"); + + expect(res.status).toBe(404); + expect(res.body).toHaveProperty("error", "not_found"); + }); + }); + + describe("GET /api/orders/history", () => { + it("should return orders for a given Ethereum address", async () => { + // Create an order + const createRes = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "e".repeat(64), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + expect(createRes.status).toBe(201); + + // Query history + const historyRes = await request(app) + .get("/api/orders/history") + .query({ eth: VALID_ETH_ADDR }); + + expect(historyRes.status).toBe(200); + expect(historyRes.body).toHaveProperty("transactions"); + expect(Array.isArray(historyRes.body.transactions)).toBe(true); + expect(historyRes.body.transactions.length).toBeGreaterThan(0); + expect(historyRes.body).toHaveProperty("pagination"); + }); + + it("should return orders for a given Stellar address", async () => { + // Create an order + const createRes = await request(app) + .post("/api/orders/announce") + .send({ + direction: "xlm_to_eth", + hashlock: "0x" + "f".repeat(64), + srcChain: "stellar", + srcAddress: VALID_STELLAR_ADDR, + srcAsset: "XLM", + srcAmount: "5000000000", + srcSafetyDeposit: "1000000000", + dstChain: "ethereum", + dstAddress: VALID_ETH_ADDR, + dstAsset: "ETH", + dstAmount: "1000000000000000000" + }); + + expect(createRes.status).toBe(201); + + // Query history + const historyRes = await request(app) + .get("/api/orders/history") + .query({ stellar: VALID_STELLAR_ADDR }); + + expect(historyRes.status).toBe(200); + expect(historyRes.body).toHaveProperty("transactions"); + expect(Array.isArray(historyRes.body.transactions)).toBe(true); + }); + + it("should support pagination with limit", async () => { + const res = await request(app) + .get("/api/orders/history") + .query({ address: VALID_ETH_ADDR, limit: 10 }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("pagination"); + expect(res.body.pagination.limit).toBeLessThanOrEqual(10); + }); + + it("should cap limit at 200", async () => { + const res = await request(app) + .get("/api/orders/history") + .query({ address: VALID_ETH_ADDR, limit: 500 }); + + expect(res.status).toBe(200); + expect(res.body.pagination.limit).toBeLessThanOrEqual(200); + }); + + it("should require an address parameter", async () => { + const res = await request(app).get("/api/orders/history"); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "address_required"); + }); + + it("should return empty list for address with no orders", async () => { + const res = await request(app) + .get("/api/orders/history") + .query({ address: "0x2222222222222222222222222222222222222222" }); + + expect(res.status).toBe(200); + expect(res.body.transactions).toHaveLength(0); + expect(res.body.pagination.count).toBe(0); + }); + }); + + describe("POST /api/secrets/reveal", () => { + const revealPreimage = "0x" + "55".repeat(32); + const revealHashlock = "0x" + createHash("sha256").update(Buffer.from("55".repeat(32), "hex")).digest("hex"); + let orderId: string; + + beforeAll(async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: revealHashlock, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + orderId = res.body.id; + + // Transition to src_locked + await request(app) + .post(`/api/orders/${orderId}/src-locked`) + .send({ + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + }); + + it("should reveal a valid secret for an order", async () => { + const res = await request(app) + .post("/api/secrets/reveal") + .send({ + publicId: orderId, + preimage: revealPreimage, + txHash: "0x123456" + }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("ok", true); + }); + + it("should reject missing publicId", async () => { + const res = await request(app) + .post("/api/secrets/reveal") + .send({ + preimage: revealPreimage, + txHash: "0x123456" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "validation_error"); + }); + + it("should reject invalid preimage format", async () => { + const res = await request(app) + .post("/api/secrets/reveal") + .send({ + publicId: orderId, + preimage: "not-hex-format", + txHash: "0x123456" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "validation_error"); + }); + + it("should reject preimage without 0x prefix", async () => { + const res = await request(app) + .post("/api/secrets/reveal") + .send({ + publicId: orderId, + preimage: "b".repeat(64), + txHash: "0x123456" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "validation_error"); + }); + + it("should reject preimage with mismatched hashlock", async () => { + const res = await request(app) + .post("/api/secrets/reveal") + .send({ + publicId: orderId, + preimage: "0x" + "00".repeat(32), + txHash: "0x123456" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "secret_error"); + }); + + it("should reject secret reveal for non-existent order", async () => { + const res = await request(app) + .post("/api/secrets/reveal") + .send({ + publicId: "nonexistent", + preimage: revealPreimage, + txHash: "0x123456" + }); + + expect(res.status).toBe(400); + expect(res.body).toHaveProperty("error", "secret_error"); + }); + }); + + describe("GET /api/secrets/:publicId", () => { + const getPreimage = "0x" + "66".repeat(32); + const getHashlock = "0x" + createHash("sha256").update(Buffer.from("66".repeat(32), "hex")).digest("hex"); + let orderId: string; + + beforeAll(async () => { + const res = await request(app) + .post("/api/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: getHashlock, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "ETH", + srcAmount: "1", + srcSafetyDeposit: "1", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "XLM", + dstAmount: "1" + }); + + orderId = res.body.id; + + // Transition to src_locked + await request(app) + .post(`/api/orders/${orderId}/src-locked`) + .send({ + orderId: "1", + txHash: "0xdead", + blockNumber: 1, + timelock: 0 + }); + + // Reveal the secret + await request(app) + .post("/api/secrets/reveal") + .send({ + publicId: orderId, + preimage: getPreimage, + txHash: "0x123456" + }); + }); + + it("should retrieve a revealed secret", async () => { + const res = await request(app).get(`/api/secrets/${orderId}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("publicId", orderId); + expect(res.body).toHaveProperty("preimage", getPreimage); + }); + + it("should return 404 for unrevealed secret", async () => { + const res = await request(app).get("/api/secrets/unrevealed-order-id"); + + expect(res.status).toBe(404); + expect(res.body).toHaveProperty("error", "not_revealed"); + }); + }); + + describe("GET /api/quotes/eth-xlm", () => { + it("should return a price quote", async () => { + const res = await request(app).get("/api/quotes/eth-xlm"); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("ethUsd"); + expect(res.body).toHaveProperty("xlmUsd"); + expect(res.body).toHaveProperty("source"); + expect(res.body).toHaveProperty("fetchedAt"); + }); + + it("should include valid source", async () => { + const res = await request(app).get("/api/quotes/eth-xlm"); + + expect(res.status).toBe(200); + const validSources = ["coingecko", "oneinch", "cache", "unknown"]; + expect(validSources).toContain(res.body.source); + }); + + it("should include valid timestamp", async () => { + const res = await request(app).get("/api/quotes/eth-xlm"); + + expect(res.status).toBe(200); + expect(typeof res.body.fetchedAt).toBe("number"); + expect(res.body.fetchedAt).toBeGreaterThan(0); + expect(res.body.fetchedAt).toBeLessThanOrEqual(Date.now()); + }); + + it("should cache prices for 30 seconds", async () => { + const res1 = await request(app).get("/api/quotes/eth-xlm"); + expect(res1.status).toBe(200); + const source1 = res1.body.source; + + const res2 = await request(app).get("/api/quotes/eth-xlm"); + expect(res2.status).toBe(200); + const source2 = res2.body.source; + + // Both should be from cache or have been fetched within short time + expect([source1, source2]).toContain("cache"); + }); + + it("should allow null prices gracefully", async () => { + const res = await request(app).get("/api/quotes/eth-xlm"); + + expect(res.status).toBe(200); + // ethUsd and xlmUsd can be null if API call fails + expect(res.body.ethUsd === null || typeof res.body.ethUsd === "string").toBe(true); + expect(res.body.xlmUsd === null || typeof res.body.xlmUsd === "string").toBe(true); + }); + }); + + describe("Error Handling", () => { + it("should handle malformed JSON gracefully", async () => { + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send("{ invalid json }"); + + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it("should handle CORS for all endpoints", async () => { + const res = await request(app) + .get("/health") + .set("Origin", "http://example.com"); + + if (res.status !== 200) { + // eslint-disable-next-line no-console + console.error("CORS test failed! Status:", res.status, "Body:", res.body); + } + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBeDefined(); + }); + }); +}); diff --git a/coordinator/test/health-route.test.ts b/coordinator/test/health-route.test.ts new file mode 100644 index 0000000..d590269 --- /dev/null +++ b/coordinator/test/health-route.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import express from "express"; +import { healthRoutes } from "../src/server/routes/health"; + +function makeApp() { + const app = express(); + app.use(healthRoutes()); + return app; +} + +describe("GET /health", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + + // Explicitly seed base defaults so tests are isolated from host machine configurations + process.env.DATABASE_URL = "file:./oversync.db"; + process.env.SEPOLIA_RPC_URL = "https://sepolia.infura.io/v3/mock-key"; + process.env.SOROBAN_RPC_URL = "https://horizon-testnet.stellar.org"; + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); + }); + + it("includes default metadata and safe dependency status", async () => { + delete process.env.npm_package_version; + delete process.env.NETWORK_MODE; + + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.body).toMatchObject({ + status: "ok", + service: "oversync-coordinator", + uptimeSeconds: expect.any(Number), + timestamp: expect.any(String), + version: "0.1.0", + }); + + expect(res.body).toHaveProperty("build"); + expect(res.body.build).toMatchObject({ + commit: null, + env: "testnet", + }); + + expect(res.body).toHaveProperty("dependencies"); + expect(res.body.dependencies).toMatchObject({ + database: { + mode: "sqlite", + }, + ethereum: { + rpcUrlConfigured: true, + }, + soroban: { + rpcUrlConfigured: true, + }, + }); + + // Ensure we never leak system credential string representations + const json = JSON.stringify(res.body); + expect(json).not.toContain("INFURA_API_KEY"); + expect(json).not.toContain("SEPOLIA_RPC_URL"); + expect(json).not.toContain("MAINNET_RPC_URL"); + expect(json).not.toContain("ETHEREUM_RPC_URL"); + }); + + it("uses env override for build env", async () => { + process.env.NETWORK_MODE = "mainnet"; + + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.body.build.env).toBe("mainnet"); + }); + + it("redacts RPC credentials from dependency metadata", async () => { + process.env.ETHEREUM_RPC_URL = + "https://USER:SECRET@rpc.example.com/private-rpc"; + process.env.SOROBAN_RPC_URL = "https://example.com/horizon/SECRETKEY"; + + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.body.dependencies.ethereum.rpcUrl).toBe( + "https://rpc.example.com", + ); + expect(res.body.dependencies.soroban.rpcUrl).toBe("https://example.com"); + + const json = JSON.stringify(res.body); + expect(json).not.toContain("SECRET"); + expect(json).not.toContain("USER"); + expect(json).not.toContain("SECRETKEY"); + }); +}); diff --git a/coordinator/test/http-routes.test.ts b/coordinator/test/http-routes.test.ts new file mode 100644 index 0000000..4f65444 --- /dev/null +++ b/coordinator/test/http-routes.test.ts @@ -0,0 +1,303 @@ +/** + * HTTP integration tests for coordinator route size limits. + * + * Tests mount a real Express app with mocked services and a deliberately small + * `maxRequestBodyBytes` (200 bytes) so oversized payloads are cheap to construct. + * No database is needed — OrderService / SecretService / QuoteService are fully mocked. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import request from "supertest"; +import pino from "pino"; +import { createApp } from "../src/server/app.js"; +import type { OrderService } from "../src/services/order-service.js"; +import type { SecretService } from "../src/services/secret-service.js"; +import type { QuoteService } from "../src/services/quote-service.js"; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +const SMALL_LIMIT = 200; // bytes — easy to exceed in tests + +const log = pino({ level: "silent" }); + +const VALID_HASHLOCK = "0x" + "a".repeat(64); +const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; +const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; + +/** Builds a payload string that exceeds SMALL_LIMIT bytes. */ +function oversizedBody(): string { + return JSON.stringify({ padding: "x".repeat(SMALL_LIMIT + 50) }); +} + +/** Creates a fresh test app with mocked services and the given body limit. */ +function buildApp(maxRequestBodyBytes = SMALL_LIMIT) { + const orders = { + announce: vi.fn(), + get: vi.fn(), + history: vi.fn(), + recordSrcLock: vi.fn(), + recordDstLock: vi.fn() + } as unknown as OrderService; + + const secrets = { + reveal: vi.fn(), + get: vi.fn() + } as unknown as SecretService; + + const quotes = { + quoteEthXlm: vi.fn().mockResolvedValue({ rate: 1 }) + } as unknown as QuoteService; + + const app = createApp({ log, corsOrigin: "*", maxRequestBodyBytes, orders, secrets, quotes }); + + return { app, orders, secrets }; +} + +// ─── Valid announce payload ───────────────────────────────────────────────── + +const VALID_ANNOUNCE = { + direction: "eth_to_xlm", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1000", + srcSafetyDeposit: "10", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "100" +}; + +// ─── POST /api/orders/announce ───────────────────────────────────────────── + +describe("POST /api/orders/announce", () => { + it("returns 201 for a valid small payload", async () => { + const { app, orders } = buildApp(65_536); // use generous limit for valid-payload test + (orders.announce as ReturnType).mockResolvedValueOnce({ + publicId: "abc123", + direction: "eth_to_xlm", + status: "announced", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1000", + srcSafetyDeposit: "10", + srcOrderId: null, + srcLockTx: null, + srcLockBlock: null, + srcTimelock: null, + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "100", + dstOrderId: null, + dstLockTx: null, + dstLockBlock: null, + dstTimelock: null, + preimage: null, + secretRevealedTx: null, + resolverAddress: null, + createdAt: 0, + updatedAt: 0 + }); + + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send(JSON.stringify(VALID_ANNOUNCE)); + + expect(res.status).toBe(201); + expect(res.body.id).toBe("abc123"); + }); + + it("returns 413 for an oversized payload", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send(oversizedBody()); + + expect(res.status).toBe(413); + expect(res.body.error).toBe("payload_too_large"); + expect(res.body.message).toContain(`${SMALL_LIMIT}`); + }); + + it("returns 400 for malformed but small JSON", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send("{not valid json}"); + + // Express json() parser sends a 400 for syntax errors + expect(res.status).toBe(400); + }); + + it("returns 400 for valid JSON that fails schema validation", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send(JSON.stringify({ direction: "eth_to_xlm" /* missing required fields */ })); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("validation_error"); + }); +}); + +// ─── POST /api/secrets/reveal ───────────────────────────────────────────── + +describe("POST /api/secrets/reveal", () => { + it("returns 200 for a valid small payload", async () => { + const { app, secrets } = buildApp(65_536); + (secrets.reveal as ReturnType).mockResolvedValueOnce(undefined); + + const res = await request(app) + .post("/api/secrets/reveal") + .set("Content-Type", "application/json") + .send( + JSON.stringify({ + publicId: "abc123", + preimage: "0x" + "b".repeat(64), + txHash: "0x" + "c".repeat(64) + }) + ); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); + + it("returns 413 for an oversized payload", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/secrets/reveal") + .set("Content-Type", "application/json") + .send(oversizedBody()); + + expect(res.status).toBe(413); + expect(res.body.error).toBe("payload_too_large"); + }); + + it("returns 400 for malformed but small JSON", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/secrets/reveal") + .set("Content-Type", "application/json") + .send("{bad json}"); + + expect(res.status).toBe(400); + }); + + it("returns 400 for valid JSON that fails schema validation", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/secrets/reveal") + .set("Content-Type", "application/json") + .send(JSON.stringify({ publicId: "x" /* missing preimage, txHash */ })); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("validation_error"); + }); +}); + +// ─── POST /api/orders/:id/src-locked ───────────────────────────────────── + +describe("POST /api/orders/:id/src-locked", () => { + it("returns 413 for an oversized payload", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/orders/order-1/src-locked") + .set("Content-Type", "application/json") + .send(oversizedBody()); + + expect(res.status).toBe(413); + expect(res.body.error).toBe("payload_too_large"); + }); +}); + +// ─── POST /api/orders/:id/dst-locked ───────────────────────────────────── + +describe("POST /api/orders/:id/dst-locked", () => { + it("returns 413 for an oversized payload", async () => { + const { app } = buildApp(); + + const res = await request(app) + .post("/api/orders/order-1/dst-locked") + .set("Content-Type", "application/json") + .send(oversizedBody()); + + expect(res.status).toBe(413); + expect(res.body.error).toBe("payload_too_large"); + }); +}); + +// ─── Configurable limit override ──────────────────────────────────────────── + +describe("Configurable limit override", () => { + it("accepts a payload just within a custom limit", async () => { + const customLimit = 500; + const { app, orders } = buildApp(customLimit); + (orders.announce as ReturnType).mockResolvedValueOnce({ + publicId: "xyz", + direction: "eth_to_xlm", + status: "announced", + hashlock: VALID_HASHLOCK, + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1", + srcSafetyDeposit: "1", + srcOrderId: null, + srcLockTx: null, + srcLockBlock: null, + srcTimelock: null, + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "1", + dstOrderId: null, + dstLockTx: null, + dstLockBlock: null, + dstTimelock: null, + preimage: null, + secretRevealedTx: null, + resolverAddress: null, + createdAt: 0, + updatedAt: 0 + }); + + const payload = JSON.stringify(VALID_ANNOUNCE); + // payload must be smaller than customLimit + expect(Buffer.byteLength(payload)).toBeLessThan(customLimit); + + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send(payload); + + expect(res.status).toBe(201); + }); + + it("rejects a payload that exceeds a custom limit but would fit the default", async () => { + const customLimit = 50; // tiny + const { app } = buildApp(customLimit); + + // VALID_ANNOUNCE serialised is ~300 bytes — fits default (65536) but not 50 + const res = await request(app) + .post("/api/orders/announce") + .set("Content-Type", "application/json") + .send(JSON.stringify(VALID_ANNOUNCE)); + + expect(res.status).toBe(413); + expect(res.body.error).toBe("payload_too_large"); + expect(res.body.message).toContain("50"); + }); +}); diff --git a/coordinator/test/order-metrics.test.ts b/coordinator/test/order-metrics.test.ts new file mode 100644 index 0000000..e409326 --- /dev/null +++ b/coordinator/test/order-metrics.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import express from "express"; +import pino from "pino"; +import { fileURLToPath } from "node:url"; +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 { orderMetricsRoutes } from "../src/server/routes/metrics.js"; + +const log = pino({ level: "silent" }); + +const VALID_HASHLOCK_BASE = "0x" + "a".repeat(64); +const VALID_ETH_ADDR = "0x1111111111111111111111111111111111111111"; +const VALID_STELLAR_ADDR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; + +function makeHashlock(seed: string): string { + return "0x" + seed.padStart(64, "0").slice(0, 64); +} + +async function seedOrders(orders: OrderService) { + const announced = await orders.announce({ + direction: "eth_to_xlm", + hashlock: makeHashlock("aa"), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "100", + srcSafetyDeposit: "10", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "100000" + }); + + const srcLocked = await orders.announce({ + direction: "xlm_to_eth", + hashlock: makeHashlock("bb"), + srcChain: "stellar", + srcAddress: VALID_STELLAR_ADDR, + srcAsset: "native", + srcAmount: "200", + srcSafetyDeposit: "20", + dstChain: "ethereum", + dstAddress: VALID_ETH_ADDR, + dstAsset: "native", + dstAmount: "200000" + }); + await orders.recordSrcLock({ + publicId: srcLocked.publicId, + orderId: "src-1", + txHash: "0xaaa", + blockNumber: 1, + timelock: 1000 + }); + + const dstLocked = await orders.announce({ + direction: "eth_to_xlm", + hashlock: makeHashlock("cc"), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "300", + srcSafetyDeposit: "30", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "300000" + }); + await orders.recordSrcLock({ + publicId: dstLocked.publicId, + orderId: "src-2", + txHash: "0xbbb", + blockNumber: 2, + timelock: 2000 + }); + await orders.recordDstLock({ + publicId: dstLocked.publicId, + orderId: "dst-1", + txHash: "0xccc", + blockNumber: 3, + timelock: 3000, + resolver: null + }); + + const completed = await orders.announce({ + direction: "eth_to_xlm", + hashlock: makeHashlock("dd"), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "400", + srcSafetyDeposit: "40", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "400000" + }); + await orders.recordSrcLock({ + publicId: completed.publicId, + orderId: "src-3", + txHash: "0xddd", + blockNumber: 4, + timelock: 4000 + }); + await orders.recordDstLock({ + publicId: completed.publicId, + orderId: "dst-2", + txHash: "0xeee", + blockNumber: 5, + timelock: 5000, + resolver: null + }); + await orders.recordSecret(completed.publicId, "0xdeadbeef", "0xfff"); + await orders.markStatus(completed.publicId, "completed"); + + const refunded = await orders.announce({ + direction: "eth_to_xlm", + hashlock: makeHashlock("ee"), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "500", + srcSafetyDeposit: "50", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "500000" + }); + await orders.recordSrcLock({ + publicId: refunded.publicId, + orderId: "src-4", + txHash: "0xggg", + blockNumber: 6, + timelock: 6000 + }); + await orders.markStatus(refunded.publicId, "refunded"); + + const expired = await orders.announce({ + direction: "eth_to_xlm", + hashlock: makeHashlock("ff"), + srcChain: "ethereum", + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "600", + srcSafetyDeposit: "60", + dstChain: "stellar", + dstAddress: VALID_STELLAR_ADDR, + dstAsset: "native", + dstAmount: "600000" + }); + await orders.markStatus(expired.publicId, "expired"); +} + +describe("GET /api/metrics", () => { + async function makeApp() { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-metrics-test-")); + const db = await openDatabase(`file:${dir}/test.db`); + const repo = new OrdersRepository(db); + const orders = new OrderService(repo, log); + await seedOrders(orders); + + const app = express(); + app.use("/api", orderMetricsRoutes(orders)); + return app; + } + + it("returns aggregate metrics with counts by status", async () => { + const app = await makeApp(); + const res = await request(app).get("/api/metrics").expect(200); + + expect(res.body).toHaveProperty("totalOrders"); + expect(res.body).toHaveProperty("byStatus"); + expect(res.body).toHaveProperty("completedOrders"); + expect(res.body).toHaveProperty("refundedOrders"); + expect(res.body).toHaveProperty("staleExpiredOrders"); + expect(res.body).toHaveProperty("lastUpdatedTimestamp"); + + expect(res.body.totalOrders).toBe(6); + + expect(res.body.byStatus).toMatchObject({ + announced: 1, + src_locked: 1, + dst_locked: 1, + completed: 1, + refunded: 1, + expired: 1 + }); + + expect(res.body.completedOrders).toBe(1); + expect(res.body.refundedOrders).toBe(1); + expect(res.body.staleExpiredOrders).toBe(1); // expired only (no failed) + + expect(res.body.lastUpdatedTimestamp).toEqual(expect.any(Number)); + }); + + it("returns zero metrics when no orders exist", async () => { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-metrics-empty-")); + const db = await openDatabase(`file:${dir}/test.db`); + const repo = new OrdersRepository(db); + const orders = new OrderService(repo, log); + + const app = express(); + app.use("/api", orderMetricsRoutes(orders)); + + const res = await request(app).get("/api/metrics").expect(200); + + expect(res.body).toEqual({ + totalOrders: 0, + byStatus: {}, + completedOrders: 0, + refundedOrders: 0, + staleExpiredOrders: 0, + lastUpdatedTimestamp: null + }); + }); + + it("does not expose raw secrets, preimages, or user identifiers", async () => { + const app = await makeApp(); + const res = await request(app).get("/api/metrics").expect(200); + const json = JSON.stringify(res.body); + + expect(json).not.toContain("hashlock"); + expect(json).not.toContain("preimage"); + expect(json).not.toContain(VALID_ETH_ADDR); + expect(json).not.toContain(VALID_STELLAR_ADDR); + expect(json).not.toContain("0xdeadbeef"); + }); +}); diff --git a/coordinator/test/public-endpoint-redaction.test.ts b/coordinator/test/public-endpoint-redaction.test.ts new file mode 100644 index 0000000..24d2f62 --- /dev/null +++ b/coordinator/test/public-endpoint-redaction.test.ts @@ -0,0 +1,316 @@ +/** + * Public endpoint redaction tests. + * + * Asserts that the public /health and /metrics endpoints never leak: + * - private keys + * - preimages / secrets + * - bearer tokens + * - private RPC URLs (must be redacted to protocol://host) + * - raw env var names or values + * - full database rows (aggregated data only) + * + * Fixtures include secret-like values to prove redaction / non-emission. + * Public endpoints still return useful, non-sensitive data. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import request from "supertest"; +import express from "express"; +import { healthRoutes } from "../src/server/routes/health.js"; +import { metricsRoutes } from "../src/server/routes/metrics.js"; + +// ── Secret-like fixtures ───────────────────────────────────────────────────── + +const SECRETS = { + /** Raw password embedded in DATABASE_URL */ + dbPass: "S3cr3tDbP4ssw0rd", + /** Full database connection string with credentials */ + dbUrl: + "postgresql://admin:S3cr3tDbP4ssw0rd@db.internal.example.com:5432/oversync?sslmode=require", + + /** Infura API key — must never appear in any response */ + infuraKey: "deadbeef_infura_key_999", + + /** Raw credential portion of an authenticated RPC URL */ + rpcUser: "admin", + rpcPass: "RpcT0kenz!", + /** Ethereum RPC URL with embedded Basic Auth credentials */ + ethRpcUrl: "https://admin:RpcT0kenz!@eth-mainnet.internal.example/v3/private", + + /** Soroban RPC URL path component that looks like a credential */ + sorobanPathKey: "secret-api-key", + /** Soroban RPC URL with embedded path credentials */ + sorobanRpcUrl: + "https://horizon.internal.example/horizon/secret-api-key", + + /** An Ethereum private key (64 hex chars) */ + privateKey: + "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318", + + /** Raw preimage for an HTLC secret */ + preimage: + "super_secret_htlc_preimage_256_bits_of_entropy_do_not_share", + + /** Bearer authorization token */ + bearer: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.SensitivePayload.Signature", +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeApp() { + const app = express(); + app.use(healthRoutes()); + app.use(metricsRoutes()); + return app; +} + +function assertNoSecretLeaks(json: string, label: string) { + // Raw secret values + expect(json, `${label}: DB password leaked`).not.toContain(SECRETS.dbPass); + expect(json, `${label}: Infura key leaked`).not.toContain(SECRETS.infuraKey); + expect(json, `${label}: RPC user leaked`).not.toContain(SECRETS.rpcUser); + expect(json, `${label}: RPC password leaked`).not.toContain(SECRETS.rpcPass); + expect(json, `${label}: Soroban path key leaked`).not.toContain(SECRETS.sorobanPathKey); + expect(json, `${label}: Private key leaked`).not.toContain(SECRETS.privateKey); + expect(json, `${label}: Preimage leaked`).not.toContain(SECRETS.preimage); + expect(json, `${label}: Bearer token leaked`).not.toContain(SECRETS.bearer); + + // Env var names that should never surface as keys or values + const forbiddenEnvNames = [ + "DATABASE_URL", + // INFURA_API_KEY intentionally omitted — the health endpoint legitimately + // returns the redaction marker "[CONFIGURED_VIA_INFURA_API_KEY]". The + // actual key *value* is still checked via SECRETS.infuraKey above. + "ETHEREUM_RPC_URL", + "SEPOLIA_RPC_URL", + "MAINNET_RPC_URL", + "SOROBAN_RPC_URL", + "APP_PRIVATE_KEY", + "SERVICE_BEARER_TOKEN", + "HTLC_PREIMAGE", + "COORDINATOR_PORT", + "RELAYER_PORT", + "STELLAR_HORIZON_URL", + "SOROBAN_HTLC_TESTNET", + "SOROBAN_HTLC_MAINNET", + "ETH_HTLC_ESCROW_TESTNET", + "ETH_HTLC_ESCROW_MAINNET", + "ETH_RESOLVER_REGISTRY_TESTNET", + "ETH_RESOLVER_REGISTRY_MAINNET", + "SOROBAN_RESOLVER_REGISTRY_TESTNET", + "SOROBAN_RESOLVER_REGISTRY_MAINNET", + "COORDINATOR_POLL_INTERVAL_MS", + "CORS_ORIGIN", + "LOG_LEVEL", + ]; + + for (const name of forbiddenEnvNames) { + expect(json, `${label}: env var name "${name}" leaked`).not.toContain(name); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("Public Endpoints — Redaction", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + + // Inject secret-like fixture values into the environment + process.env.DATABASE_URL = SECRETS.dbUrl; + process.env.ETHEREUM_RPC_URL = SECRETS.ethRpcUrl; + process.env.SOROBAN_RPC_URL = SECRETS.sorobanRpcUrl; + process.env.INFURA_API_KEY = SECRETS.infuraKey; + process.env.APP_PRIVATE_KEY = SECRETS.privateKey; + process.env.SERVICE_BEARER_TOKEN = SECRETS.bearer; + process.env.HTLC_PREIMAGE = SECRETS.preimage; + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + // ── /health ─────────────────────────────────────────────────────────────── + + describe("GET /health", () => { + it("returns useful structure without leaking any secrets", async () => { + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + const json = JSON.stringify(res.body); + assertNoSecretLeaks(json, "/health"); + + // Verify useful data is still returned + expect(res.body).toMatchObject({ + status: "ok", + service: "oversync-coordinator", + uptimeSeconds: expect.any(Number), + timestamp: expect.any(String), + }); + + expect(res.body.build).toMatchObject({ + env: expect.any(String), + }); + + expect(res.body.dependencies).toMatchObject({ + database: { + mode: expect.any(String), + }, + ethereum: { + rpcUrlConfigured: expect.any(Boolean), + rpcUrl: expect.any(String), + }, + soroban: { + rpcUrlConfigured: expect.any(Boolean), + rpcUrl: expect.any(String), + }, + }); + }); + + it("redacts RPC URLs to protocol://host only", async () => { + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + const { ethereum, soroban } = res.body.dependencies; + + // RPC URLs must be stripped of credentials and paths + expect(ethereum.rpcUrl).toBe("https://eth-mainnet.internal.example"); + expect(soroban.rpcUrl).toBe("https://horizon.internal.example"); + + // But still report that they are configured + expect(ethereum.rpcUrlConfigured).toBe(true); + expect(soroban.rpcUrlConfigured).toBe(true); + }); + + it("reports database mode without leaking the connection string", async () => { + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.body.dependencies.database.mode).toBe("postgres"); + + const json = JSON.stringify(res.body); + // The full URL patterns must not appear + expect(json).not.toContain("admin"); + expect(json).not.toContain("S3cr3tDbP4ssw0rd"); + expect(json).not.toContain("db.internal.example.com"); + expect(json).not.toContain("postgresql://"); + expect(json).not.toContain("postgres://"); + }); + + it("reports RPCs as configured without leaking the INFURA_API_KEY value", async () => { + // Only INFURA_API_KEY set, no explicit RPC URL + delete process.env.ETHEREUM_RPC_URL; + delete process.env.SOROBAN_RPC_URL; + + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + const { ethereum, soroban } = res.body.dependencies; + + // Infura key shouldn't appear anywhere + const json = JSON.stringify(res.body); + expect(json).not.toContain(SECRETS.infuraKey); + + // Should still report as configured + expect(ethereum.rpcUrlConfigured).toBe(true); + // Soroban was deleted so it should be unconfigured + expect(soroban.rpcUrlConfigured).toBe(false); + }); + + it("redacts SEPOLIA_RPC_URL credential portion", async () => { + delete process.env.ETHEREUM_RPC_URL; + process.env.SEPOLIA_RPC_URL = + "https://user:sep0lia-k3y@sepolia.infura.io/v3/mock-key"; + + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.body.dependencies.ethereum.rpcUrl).toBe( + "https://sepolia.infura.io", + ); + expect(res.body.dependencies.ethereum.rpcUrlConfigured).toBe(true); + + const json = JSON.stringify(res.body); + expect(json).not.toContain("sep0lia-k3y"); + expect(json).not.toContain("user"); + }); + + it("redacts MAINNET_RPC_URL credential portion", async () => { + delete process.env.ETHEREUM_RPC_URL; + process.env.MAINNET_RPC_URL = + "https://api:mainn3t-k3y@mainnet.infura.io/v3/mock-key"; + + const app = makeApp(); + const res = await request(app).get("/health").expect(200); + + expect(res.body.dependencies.ethereum.rpcUrl).toBe( + "https://mainnet.infura.io", + ); + expect(res.body.dependencies.ethereum.rpcUrlConfigured).toBe(true); + + const json = JSON.stringify(res.body); + expect(json).not.toContain("mainn3t-k3y"); + expect(json).not.toContain("api"); + }); + }); + + // ── /metrics ────────────────────────────────────────────────────────────── + + describe("GET /metrics", () => { + it("returns Prometheus text output without leaking any secrets", async () => { + const app = makeApp(); + const res = await request(app).get("/metrics").expect(200); + + const metricsText = res.text; + assertNoSecretLeaks(metricsText, "/metrics"); + + // Also check the full database URL components don't appear + expect(metricsText).not.toContain("postgresql://"); + expect(metricsText).not.toContain("postgres://"); + expect(metricsText).not.toContain("db.internal.example.com"); + expect(metricsText).not.toContain("horizon.internal.example"); + expect(metricsText).not.toContain("eth-mainnet.internal.example"); + }); + + it("returns Prometheus text output with useful metric data", async () => { + const app = makeApp(); + const res = await request(app).get("/metrics").expect(200); + + const metricsText = res.text; + + // Must contain standard Prometheus exposition format pragmas + expect(metricsText).toContain("# HELP"); + expect(metricsText).toContain("# TYPE"); + + // Must contain the application-level custom metrics + expect(metricsText).toContain("coordinator_orders_total"); + expect(metricsText).toContain("coordinator_listener_last_block"); + expect(metricsText).toContain( + "coordinator_http_request_duration_seconds", + ); + + // Must contain default Node.js process metrics + expect(metricsText).toContain("process_"); + expect(metricsText).toContain("nodejs_"); + }); + + it("never exposes secret values through metric labels or help text", async () => { + const app = makeApp(); + const res = await request(app).get("/metrics").expect(200); + + const metricsText = res.text; + const lines = metricsText.split("\n"); + + for (const line of lines) { + // Every metric/data line + if (line.startsWith("#") || line.trim() === "") continue; + + // Check that no secret values appear in label keys or values + for (const [name, value] of Object.entries(SECRETS)) { + expect(line, `Metric line leaks ${name}`).not.toContain(value); + } + } + }); + }); +}); diff --git a/coordinator/test/quote-expiry.test.ts b/coordinator/test/quote-expiry.test.ts new file mode 100644 index 0000000..296152c --- /dev/null +++ b/coordinator/test/quote-expiry.test.ts @@ -0,0 +1,322 @@ +/** + * Quote freshness and expiry enforcement tests. + * + * Covers: + * - Fresh quote is accepted by QuoteService.assertFresh + * - Expired quote is rejected by QuoteService.assertFresh + * - Boundary: quote at exactly expiresAt is NOT expired (strict >); + * one ms later IS expired + * - Missing / unknown quoteId throws QuoteNotFoundError + * - OrderService.announce rejects an expired quoteId before persisting + * - OrderService.announce accepts a fresh quoteId + * - OrderService.announce proceeds normally when no quoteId is provided + * - QuoteService.evictExpired removes stale entries and leaves fresh ones + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import pino from "pino"; +import { 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, OrderValidationError } from "../src/services/order-service.js"; +import { + QuoteService, + QuoteExpiredError, + QuoteNotFoundError +} from "../src/services/quote-service.js"; + +// ── Shared fixtures ────────────────────────────────────────────────────────── + +const log = pino({ level: "silent" }); + +const VALID_HASHLOCK = "0x" + "b".repeat(64); +const VALID_ETH_ADDR = "0x2222222222222222222222222222222222222222"; +const VALID_STELLAR = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422"; + +const BASE_ANNOUNCE = { + direction: "eth_to_xlm" as const, + hashlock: VALID_HASHLOCK, + srcChain: "ethereum" as const, + srcAddress: VALID_ETH_ADDR, + srcAsset: "native", + srcAmount: "1000000000000000000", + srcSafetyDeposit: "1000000000000000", + dstChain: "stellar" as const, + dstAddress: VALID_STELLAR, + dstAsset: "native", + dstAmount: "100000000", +}; + +/** Returns a silent pino mock of CoinGecko that succeeds. */ +function mockCoingecko() { + vi.stubGlobal("fetch", async () => ({ + ok: true, + json: async () => ({ ethereum: { usd: 2000 }, stellar: { usd: 0.1 } }), + })); +} + +async function freshDb() { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-quote-test-")); + return openDatabase(`file:${dir}/test.db`); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// ── QuoteService unit tests ────────────────────────────────────────────────── + +describe("QuoteService — quote lifecycle", () => { + it("returns a PriceQuote with quoteId, issuedAt, and expiresAt = issuedAt + 30 s", async () => { + const now = vi.fn(() => 1_000_000); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + + expect(q.quoteId).toMatch(/^[a-f0-9]{32}$/); + expect(q.issuedAt).toBe(1_000_000); + expect(q.expiresAt).toBe(1_030_000); + expect(q.srcUsd).toBe("2000"); + expect(q.dstUsd).toBe("0.1"); + expect(q.source).toBe("coingecko"); + }); + + it("getById returns the quote by its id", async () => { + const svc = new QuoteService(log); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + expect(svc.getById(q.quoteId)).toMatchObject({ quoteId: q.quoteId }); + }); + + it("getById returns null for an unknown id", () => { + const svc = new QuoteService(log); + expect(svc.getById("unknown")).toBeNull(); + }); +}); + +// ── assertFresh — fresh quote ──────────────────────────────────────────────── + +describe("QuoteService.assertFresh — fresh quote", () => { + it("does not throw when now < expiresAt", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + + nowMs = 1_010_000; // +10 s, well within 30 s TTL + const result = svc.assertFresh(q.quoteId); + expect(result.quoteId).toBe(q.quoteId); + }); +}); + +// ── assertFresh — expired quote ────────────────────────────────────────────── + +describe("QuoteService.assertFresh — expired quote", () => { + it("throws QuoteExpiredError when now > expiresAt", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + + nowMs = q.expiresAt + 1; // one ms past the deadline + expect(() => svc.assertFresh(q.quoteId)).toThrowError(QuoteExpiredError); + }); + + it("QuoteExpiredError carries the original expiry timestamp", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + nowMs = q.expiresAt + 5_000; + + let caught: QuoteExpiredError | undefined; + try { + svc.assertFresh(q.quoteId); + } catch (e) { + caught = e as QuoteExpiredError; + } + expect(caught).toBeInstanceOf(QuoteExpiredError); + expect(caught!.expiredMs).toBe(q.expiresAt); + expect(caught!.quoteId).toBe(q.quoteId); + }); +}); + +// ── assertFresh — boundary timestamp ──────────────────────────────────────── + +describe("QuoteService.assertFresh — boundary timestamp", () => { + it("is still fresh when now === expiresAt (condition is strictly >)", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + + // Exact boundary — NOT expired + nowMs = q.expiresAt; + expect(() => svc.assertFresh(q.quoteId)).not.toThrow(); + + // One ms past — expired + nowMs = q.expiresAt + 1; + expect(() => svc.assertFresh(q.quoteId)).toThrowError(QuoteExpiredError); + }); +}); + +// ── assertFresh — missing quoteId ──────────────────────────────────────────── + +describe("QuoteService.assertFresh — missing quoteId", () => { + it("throws QuoteNotFoundError for an id that was never issued", () => { + const svc = new QuoteService(log); + expect(() => svc.assertFresh("never-issued")).toThrowError(QuoteNotFoundError); + }); + + it("throws QuoteNotFoundError for an empty string", () => { + const svc = new QuoteService(log); + expect(() => svc.assertFresh("")).toThrowError(QuoteNotFoundError); + }); +}); + +// ── evictExpired ───────────────────────────────────────────────────────────── + +describe("QuoteService.evictExpired", () => { + it("removes expired quotes and returns the count", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q1 = await svc.quoteEthXlm(); + + // Advance past the price-cache TTL so quoteEthXlm fetches again + nowMs = q1.expiresAt + 1; + const q2 = await svc.quoteEthXlm(); + + // Advance past q2's expiry too + nowMs = q2.expiresAt + 1; + + const evicted = svc.evictExpired(); + expect(evicted).toBeGreaterThanOrEqual(2); + expect(svc.getById(q1.quoteId)).toBeNull(); + expect(svc.getById(q2.quoteId)).toBeNull(); + }); + + it("leaves unexpired quotes intact", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + const svc = new QuoteService(log, now); + mockCoingecko(); + + const q = await svc.quoteEthXlm(); + + nowMs = 1_005_000; // +5 s, inside 30 s TTL + svc.evictExpired(); + + expect(svc.getById(q.quoteId)).not.toBeNull(); + }); + + it("returns 0 when nothing is expired", () => { + const svc = new QuoteService(log); + expect(svc.evictExpired()).toBe(0); + }); +}); + +// ── OrderService integration ───────────────────────────────────────────────── + +describe("OrderService.announce — quote freshness gate", () => { + it("accepts an order when quoteId is omitted", async () => { + const db = await freshDb(); + const orders = new OrderService(new OrdersRepository(db), log); + + const order = await orders.announce(BASE_ANNOUNCE); + expect(order.status).toBe("announced"); + }); + + it("accepts an order when quoteId references a fresh quote", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + mockCoingecko(); + + const db = await freshDb(); + const quoteSvc = new QuoteService(log, now); + const orders = new OrderService(new OrdersRepository(db), log, quoteSvc); + + const q = await quoteSvc.quoteEthXlm(); + + nowMs = 1_010_000; // still fresh + const order = await orders.announce({ ...BASE_ANNOUNCE, quoteId: q.quoteId }); + expect(order.status).toBe("announced"); + }); + + it("rejects an order when quoteId references an expired quote", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + mockCoingecko(); + + const db = await freshDb(); + const quoteSvc = new QuoteService(log, now); + const orders = new OrderService(new OrdersRepository(db), log, quoteSvc); + + const q = await quoteSvc.quoteEthXlm(); + + nowMs = q.expiresAt + 1; // past expiry + await expect( + orders.announce({ ...BASE_ANNOUNCE, quoteId: q.quoteId }) + ).rejects.toThrowError(OrderValidationError); + }); + + it("rejects an order when quoteId is unknown (never issued)", async () => { + const db = await freshDb(); + const quoteSvc = new QuoteService(log); + const orders = new OrderService(new OrdersRepository(db), log, quoteSvc); + + await expect( + orders.announce({ ...BASE_ANNOUNCE, quoteId: "this-id-was-never-issued" }) + ).rejects.toThrowError(OrderValidationError); + }); + + it("does NOT persist the order row when the quote is expired", async () => { + let nowMs = 1_000_000; + const now = vi.fn(() => nowMs); + mockCoingecko(); + + const db = await freshDb(); + const quoteSvc = new QuoteService(log, now); + const repo = new OrdersRepository(db); + const orders = new OrderService(repo, log, quoteSvc); + + const q = await quoteSvc.quoteEthXlm(); + nowMs = q.expiresAt + 1; + + await expect( + orders.announce({ ...BASE_ANNOUNCE, quoteId: q.quoteId }) + ).rejects.toThrowError(OrderValidationError); + + // The hashlock must not have been written to the DB + const persisted = await repo.findByHashlock(VALID_HASHLOCK); + expect(persisted).toBeNull(); + }); + + it("does NOT persist the order row when the quoteId is unknown", async () => { + const db = await freshDb(); + const quoteSvc = new QuoteService(log); + const repo = new OrdersRepository(db); + const orders = new OrderService(repo, log, quoteSvc); + + await expect( + orders.announce({ ...BASE_ANNOUNCE, quoteId: "ghost-id" }) + ).rejects.toThrowError(OrderValidationError); + + const persisted = await repo.findByHashlock(VALID_HASHLOCK); + expect(persisted).toBeNull(); + }); +}); diff --git a/coordinator/test/replay.test.ts b/coordinator/test/replay.test.ts new file mode 100644 index 0000000..f148c46 --- /dev/null +++ b/coordinator/test/replay.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { ReplayArgsSchema } from "../src/replay.js"; + +describe("replay argument parsing", () => { + it("should parse valid ethereum args", () => { + const args = ReplayArgsSchema.parse({ + chain: "ethereum", + from: "1000", + to: "2000" + }); + expect(args).toEqual({ + chain: "ethereum", + from: 1000, + to: 2000 + }); + }); + + it("should parse valid soroban args", () => { + const args = ReplayArgsSchema.parse({ + chain: "soroban", + from: 5000, + to: 6000 + }); + expect(args).toEqual({ + chain: "soroban", + from: 5000, + to: 6000 + }); + }); + + it("should fail on invalid chain", () => { + expect(() => { + ReplayArgsSchema.parse({ + chain: "bitcoin", + from: 100, + to: 200 + }); + }).toThrow(); + }); + + it("should fail on negative blocks", () => { + expect(() => { + ReplayArgsSchema.parse({ + chain: "ethereum", + from: -5, + to: 100 + }); + }).toThrow(); + }); +}); From d1513f0744c49cfae2bc4e4a521dfe0a8a2645db Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 1 Jul 2026 11:55:29 +0100 Subject: [PATCH 2/2] fix(coordinator): resolve ReferenceError: cors is not defined, restore getCompletedOrderSnapshots repository function, and fix order transitions route --- coordinator/src/index.ts | 2 +- coordinator/src/persistence/orders-repo.ts | 52 ++++++++++++++++++++++ coordinator/src/server/app.ts | 1 + coordinator/src/server/routes/orders.ts | 17 +++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/coordinator/src/index.ts b/coordinator/src/index.ts index 9a06644..4405bd3 100644 --- a/coordinator/src/index.ts +++ b/coordinator/src/index.ts @@ -23,7 +23,7 @@ async function main(): Promise { const app = createApp({ log, - corsOrigin: cfg.corsOrigin, + corsOrigin: cfg.corsOrigins, maxRequestBodyBytes: cfg.maxRequestBodyBytes, orders, secrets, diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index 30a8016..c36571d 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -180,6 +180,7 @@ export class OrdersRepository { private readonly updateSrcLock: Statement; private readonly updateDstLock: Statement; private readonly updateSecret: Statement; + private readonly completedOrderRows: Statement; private readonly metricsByStatus: Statement; private readonly metricsTotal: Statement; private readonly metricsLastUpdated: Statement; @@ -251,6 +252,11 @@ export class OrdersRepository { updated_at = CAST(strftime('%s','now') AS INTEGER) WHERE public_id = :publicId `); + this.completedOrderRows = db.prepare(` + SELECT * FROM orders + WHERE status IN ('completed', 'refunded', 'failed', 'expired') + ORDER BY updated_at DESC + `); this.metricsByStatus = db.prepare( "SELECT status, COUNT(*) as count FROM orders GROUP BY status" ); @@ -434,4 +440,50 @@ export class OrdersRepository { lastUpdatedTimestamp: lastUpdatedRow?.ts != null ? Number(lastUpdatedRow.ts) : null }; } + + async getCompletedOrderSnapshots(): Promise { + const rows = await this.all(this.completedOrderRows); + return rows.map(rowToOrder).map(buildSnapshot); + } +} + +export function buildSnapshot(order: OrderRow): OrderSnapshot { + const transitions = deriveTransitions(order.status); + const publicTxHashes = [ + order.srcLockTx, + order.dstLockTx, + order.secretRevealedTx + ].filter((tx): tx is string => tx !== null); + const outcomeSummary = order.status === "completed" ? "Order completed successfully" : + order.status === "refunded" ? "Order refunded" : + order.status === "failed" ? "Order failed" : + "Order expired"; + + return { + orderId: order.publicId, + currentState: order.status, + transitions, + publicTxHashes, + timestamps: { + createdAt: order.createdAt, + updatedAt: order.updatedAt + }, + direction: order.direction, + outcomeSummary + }; +} + +function deriveTransitions(status: OrderStatus): string[] { + switch (status) { + case "completed": + return ["announced", "src_locked", "dst_locked", "secret_revealed", "completed"]; + case "refunded": + return ["announced", "src_locked", "dst_locked", "secret_revealed", "refunded"]; + case "failed": + return ["announced", "failed"]; + case "expired": + return ["announced", "expired"]; + default: + return [status]; + } } diff --git a/coordinator/src/server/app.ts b/coordinator/src/server/app.ts index 2386733..a79c302 100644 --- a/coordinator/src/server/app.ts +++ b/coordinator/src/server/app.ts @@ -1,4 +1,5 @@ import express, { type Express } from "express"; +import cors from "cors"; import pinoHttp from "pino-http"; import type { Logger } from "pino"; import { healthRoutes } from "./routes/health.js"; diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 627da84..46ea429 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -129,6 +129,23 @@ 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) { + res.status(404).json({ error: "not_found" }); + return; + } + } + res.json({ transitions }); + } catch (err) { + next(err); + } + }); + const lockSchema = z.object({ orderId: z.string().min(1),