From 6cbb0bc142353ca8401813086fb08f1a561914d8 Mon Sep 17 00:00:00 2001 From: Adesanya Fuhad Date: Sun, 28 Jun 2026 13:48:44 +0000 Subject: [PATCH 1/5] Implement shared failure-code catalog for order lifecycle errors --- coordinator/oversync.db | Bin 57344 -> 57344 bytes coordinator/package.json | 1 + coordinator/src/persistence/orders-repo.ts | 9 +- coordinator/src/server/routes/orders.ts | 26 ++-- coordinator/src/services/order-service.ts | 40 +++--- coordinator/test/api-errors.test.ts | 114 ++++++++++++++++++ coordinator/test/order-service.test.ts | 28 +++-- docs/FAILURE_CODES.md | 27 +++++ .../src/components/TransactionHistory.tsx | 12 ++ packages/sdk/src/types/index.ts | 65 ++++++++++ pnpm-lock.yaml | 8 +- resolver/package.json | 1 + 12 files changed, 293 insertions(+), 38 deletions(-) create mode 100644 coordinator/test/api-errors.test.ts create mode 100644 docs/FAILURE_CODES.md diff --git a/coordinator/oversync.db b/coordinator/oversync.db index d04c1955df2cd86d0e1f4c220d592621d5af4b29..32724cc6211a2ea302100f0766582fa8eeeb59d5 100644 GIT binary patch delta 121 zcmZoTz}#?vd4jYcGXnzy9}vR;_e33Ib!G;=v{$@9AzsEd2F5o2%lwV}GJG5P+<2ez z=JI^yp2983`IhU_!ikJr6H*x2#U&*f7bPf1k>ag7Mk S+&q!-8SCWJ{JM(-E&u>bv>(6# delta 111 zcmZoTz}#?vd4jYc69WSS9}vR;*F+s-btVSAE&*Qt9}K*V6B*dc`A@T}vK?RxWqrlk z$tuV)li7l4FXKd}vdw}5+>D#waz10_nh?myE-op_xS)3P1V&Ge$(Q)`IDkfiFvB8& F3jp(S7-0Ya diff --git a/coordinator/package.json b/coordinator/package.json index a0d6d1a..217d5ce 100644 --- a/coordinator/package.json +++ b/coordinator/package.json @@ -15,6 +15,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@oversync/sdk": "workspace:*", "@stellar/stellar-sdk": "^13.0.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index 4d591b1..7fae800 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -28,6 +28,7 @@ export interface OrderRow { publicId: string; direction: Direction; status: OrderStatus; + failureCode: string | null; hashlock: string; srcChain: Chain; srcAddress: string; @@ -72,6 +73,7 @@ interface OrderDbRow { public_id: string; direction: Direction; status: OrderStatus; + failure_code: string | null; hashlock: string; src_chain: Chain; src_address: string; @@ -103,6 +105,7 @@ function rowToOrder(r: OrderDbRow): OrderRow { publicId: r.public_id, direction: r.direction, status: r.status, + failureCode: r.failure_code, hashlock: r.hashlock, srcChain: r.src_chain, srcAddress: r.src_address, @@ -169,7 +172,7 @@ export class OrdersRepository { `); this.updateStatus = db.prepare(` UPDATE orders - SET status = :status, updated_at = CAST(strftime('%s','now') AS INTEGER) + SET status = :status, failure_code = :failureCode, updated_at = CAST(strftime('%s','now') AS INTEGER) WHERE public_id = :publicId `); this.updateSrcLock = db.prepare(` @@ -265,8 +268,8 @@ export class OrdersRepository { return rows.map(rowToOrder); } - async setStatus(publicId: string, status: OrderStatus): Promise { - await this.run(this.updateStatus, { publicId, status }); + async setStatus(publicId: string, status: OrderStatus, failureCode: string | null = null): Promise { + await this.run(this.updateStatus, { publicId, status, failureCode }); } async recordSrcLock(input: { diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 21043e9..5e14ab8 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { z } from "zod"; import type { OrderRow } from "../../persistence/orders-repo.js"; import { announceSchema, OrderService, OrderValidationError } from "../../services/order-service.js"; +import { FAILURE_CODE_CATALOG, type FailureCode } from "@oversync/sdk/types"; function serialiseOrder(order: OrderRow | null) { if (!order) return null; @@ -9,6 +10,7 @@ function serialiseOrder(order: OrderRow | null) { id: order.publicId, direction: order.direction, status: order.status, + failureCode: order.failureCode, hashlock: order.hashlock, src: { chain: order.srcChain, @@ -42,6 +44,14 @@ function serialiseOrder(order: OrderRow | null) { }; } +function sendError(res: any, code: FailureCode, status: number = 400) { + const detail = FAILURE_CODE_CATALOG[code]; + res.status(status).json({ + error: code, + message: detail.message + }); +} + export function ordersRoutes(orders: OrderService): Router { const router = Router(); @@ -52,11 +62,11 @@ export function ordersRoutes(orders: OrderService): Router { res.status(201).json(serialiseOrder(order)); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof OrderValidationError) { - res.status(400).json({ error: "order_validation_error", message: err.message }); + sendError(res, err.code); return; } next(err); @@ -68,7 +78,7 @@ export function ordersRoutes(orders: OrderService): Router { try { const order = await orders.get(id); if (!order) { - res.status(404).json({ error: "not_found" }); + sendError(res, "ORDER_NOT_FOUND", 404); return; } res.json(serialiseOrder(order)); @@ -80,7 +90,7 @@ export function ordersRoutes(orders: OrderService): Router { router.get("/orders/history", async (req, res, next) => { const address = (req.query.address as string | undefined) ?? ""; if (!address) { - res.status(400).json({ error: "address_required" }); + sendError(res, "VALIDATION_FAILED"); return; } const limit = Math.min(Number(req.query.limit ?? 50), 200); @@ -110,11 +120,11 @@ export function ordersRoutes(orders: OrderService): Router { res.json({ ok: true }); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof OrderValidationError) { - res.status(400).json({ error: "order_validation_error", message: err.message }); + sendError(res, err.code); return; } next(err); @@ -135,11 +145,11 @@ export function ordersRoutes(orders: OrderService): Router { res.json({ ok: true }); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof OrderValidationError) { - res.status(400).json({ error: "order_validation_error", message: err.message }); + sendError(res, err.code); return; } next(err); diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 6d99374..e264a7d 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -7,6 +7,7 @@ import { type Direction, type Chain } from "../persistence/orders-repo.js"; +import { type FailureCode } from "@oversync/sdk/types"; import { canTransition } from "../state-machine/order-machine.js"; import { ordersTotal } from "../metrics.js"; @@ -30,14 +31,19 @@ export const announceSchema = z.object({ export type AnnounceInput = z.infer; -export class OrderValidationError extends Error {} +export class OrderValidationError extends Error { + constructor(message: string, public readonly code: FailureCode = "VALIDATION_FAILED") { + super(message); + this.name = "OrderValidationError"; + } +} function validateChainAddress(chain: Chain, addr: string): void { if (chain === "ethereum" && !HEX_ADDRESS.test(addr)) { - throw new OrderValidationError(`${addr} is not a valid Ethereum address`); + throw new OrderValidationError(`${addr} is not a valid Ethereum address`, "VALIDATION_FAILED"); } if (chain === "stellar" && !STELLAR_ADDRESS.test(addr)) { - throw new OrderValidationError(`${addr} is not a valid Stellar account`); + throw new OrderValidationError(`${addr} is not a valid Stellar account`, "VALIDATION_FAILED"); } } @@ -49,7 +55,8 @@ function validateDirectionAgainstChains(input: AnnounceInput): void { const want = expected[input.direction]; if (want.src !== input.srcChain || want.dst !== input.dstChain) { throw new OrderValidationError( - `Direction ${input.direction} requires src=${want.src} and dst=${want.dst}` + `Direction ${input.direction} requires src=${want.src} and dst=${want.dst}`, + "VALIDATION_FAILED" ); } } @@ -74,7 +81,8 @@ export class OrderService { const existing = await this.repo.findByHashlock(input.hashlock); if (existing) { throw new OrderValidationError( - `An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})` + `An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})`, + "VALIDATION_FAILED" ); } @@ -104,9 +112,9 @@ export class OrderService { timelock: number; }): Promise { const order = await this.repo.findByPublicId(input.publicId); - if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`); + if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`, "ORDER_NOT_FOUND"); if (!canTransition(order.status, "src_locked") && order.status !== "src_locked") { - throw new OrderValidationError(`cannot record src lock from status ${order.status}`); + throw new OrderValidationError(`cannot record src lock from status ${order.status}`, "VALIDATION_FAILED"); } await this.repo.recordSrcLock(input); this.log.info({ publicId: input.publicId, srcOrderId: input.orderId }, "src lock recorded"); @@ -122,9 +130,9 @@ export class OrderService { resolver: string | null; }): Promise { const order = await this.repo.findByPublicId(input.publicId); - if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`); + if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`, "ORDER_NOT_FOUND"); if (!canTransition(order.status, "dst_locked") && order.status !== "dst_locked") { - throw new OrderValidationError(`cannot record dst lock from status ${order.status}`); + throw new OrderValidationError(`cannot record dst lock from status ${order.status}`, "VALIDATION_FAILED"); } await this.repo.recordDstLock(input); this.log.info({ publicId: input.publicId, dstOrderId: input.orderId }, "dst lock recorded"); @@ -133,23 +141,23 @@ export class OrderService { async recordSecret(publicId: string, preimage: string, txHash: string): Promise { const order = await this.repo.findByPublicId(publicId); - if (!order) throw new OrderValidationError(`unknown order ${publicId}`); + if (!order) throw new OrderValidationError(`unknown order ${publicId}`, "ORDER_NOT_FOUND"); if (!canTransition(order.status, "secret_revealed") && order.status !== "secret_revealed") { - throw new OrderValidationError(`cannot record secret from status ${order.status}`); + throw new OrderValidationError(`cannot record secret from status ${order.status}`, "VALIDATION_FAILED"); } await this.repo.recordSecretRevealed({ publicId, preimage, txHash }); this.log.info({ publicId }, "secret recorded"); ordersTotal.inc({ status: "secret_revealed" }); } - async markStatus(publicId: string, status: OrderRow["status"]): Promise { + async markStatus(publicId: string, status: OrderRow["status"], failureCode: string | null = null): Promise { const order = await this.repo.findByPublicId(publicId); - if (!order) throw new OrderValidationError(`unknown order ${publicId}`); + if (!order) throw new OrderValidationError(`unknown order ${publicId}`, "ORDER_NOT_FOUND"); if (!canTransition(order.status, status)) { - throw new OrderValidationError(`cannot transition from ${order.status} to ${status}`); + throw new OrderValidationError(`cannot transition from ${order.status} to ${status}`, "VALIDATION_FAILED"); } - await this.repo.setStatus(publicId, status); - this.log.info({ publicId, status }, "status updated"); + await this.repo.setStatus(publicId, status, failureCode); + this.log.info({ publicId, status, failureCode }, "status updated"); ordersTotal.inc({ status }); } } diff --git a/coordinator/test/api-errors.test.ts b/coordinator/test/api-errors.test.ts new file mode 100644 index 0000000..a54ca20 --- /dev/null +++ b/coordinator/test/api-errors.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import { OrdersRepository } from "../src/persistence/orders-repo.js"; +import { OrderService } from "../src/services/order-service.js"; +import { ordersRoutes } from "../src/server/routes/orders.js"; +import { openDatabase } from "../src/persistence/db.js"; +import pino from "pino"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const log = pino({ level: "silent" }); + +async function freshDb() { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-api-test-")); + return openDatabase(`file:${dir}/test.db`); +} + +describe("Coordinator API Error Mapping", () => { + let app: express.Application; + let orderRepo: OrdersRepository; + let orderService: OrderService; + + beforeEach(async () => { + const db = await freshDb(); + orderRepo = new OrdersRepository(db); + orderService = new OrderService(orderRepo, log); + app = express(); + app.use(express.json()); + app.use(ordersRoutes(orderService)); + app.use((err: any, req: any, res: any, next: any) => { + res.status(500).json({ error: "internal_error", message: err.message }); + }); + }); + + it("maps Zod validation errors to VALIDATION_FAILED", async () => { + const res = await request(app) + .post("/orders/announce") + .send({ direction: "invalid_direction" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + expect(res.body.message).toBe("The order failed validation checks."); + }); + + it("maps OrderValidationError to its specific failure code", async () => { + const res = await request(app) + .post("/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "a".repeat(64), + srcChain: "ethereum", + srcAddress: "not-an-address", + srcAsset: "native", + srcAmount: "100", + srcSafetyDeposit: "10", + dstChain: "stellar", + dstAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422", + dstAsset: "native", + dstAmount: "100" + }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + expect(res.body.message).toBe("The order failed validation checks."); + }); + + it("maps missing orders to ORDER_NOT_FOUND", async () => { + const res = await request(app).get("/orders/non-existent"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("ORDER_NOT_FOUND"); + expect(res.body.message).toBe("The requested order could not be found."); + }); + + it("maps missing address in history to VALIDATION_FAILED", async () => { + const res = await request(app).get("/orders/history"); + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + expect(res.body.message).toBe("The order failed validation checks."); + }); + + it("maps OrderValidationError in src-locked to its failure code", async () => { + // First announce an order + const announceRes = await request(app) + .post("/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "b".repeat(64), + srcChain: "ethereum", + srcAddress: "0x1111111111111111111111111111111111111111", + srcAsset: "native", + srcAmount: "100", + srcSafetyDeposit: "10", + dstChain: "stellar", + dstAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422", + dstAsset: "native", + dstAmount: "100" + }); + const publicId = announceRes.body.id; + + // Attempt to record src lock on an order that can't transition (already src_locked) + await request(app) + .post(`/orders/${publicId}/src-locked`) + .send({ orderId: "1", txHash: "0x1", blockNumber: 1, timelock: 1 }); + + const res = await request(app) + .post(`/orders/${publicId}/src-locked`) + .send({ orderId: "2", txHash: "0x2", blockNumber: 2, timelock: 2 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + }); +}); diff --git a/coordinator/test/order-service.test.ts b/coordinator/test/order-service.test.ts index 6a34d3d..e79bf3c 100644 --- a/coordinator/test/order-service.test.ts +++ b/coordinator/test/order-service.test.ts @@ -49,7 +49,7 @@ describe("OrderService", () => { expect(list).toHaveLength(1); }); - it("rejects duplicate hashlocks", async () => { + it("rejects duplicate hashlocks with VALIDATION_FAILED code", async () => { const db = await freshDb(); const orders = new OrderService(new OrdersRepository(db), log); await orders.announce({ @@ -66,8 +66,8 @@ describe("OrderService", () => { dstAmount: "1" }); - await expect( - orders.announce({ + try { + await orders.announce({ direction: "eth_to_xlm", hashlock: VALID_HASHLOCK, srcChain: "ethereum", @@ -79,15 +79,19 @@ describe("OrderService", () => { dstAddress: VALID_STELLAR_ADDR, dstAsset: "native", dstAmount: "1" - }) - ).rejects.toThrowError(OrderValidationError); + }); + throw new Error("Should have failed"); + } catch (err: any) { + expect(err).toBeInstanceOf(OrderValidationError); + expect(err.code).toBe("VALIDATION_FAILED"); + } }); - it("rejects mismatched direction / chains", async () => { + it("rejects mismatched direction / chains with VALIDATION_FAILED code", async () => { const db = await freshDb(); const orders = new OrderService(new OrdersRepository(db), log); - await expect( - orders.announce({ + try { + await orders.announce({ direction: "eth_to_xlm", hashlock: VALID_HASHLOCK, srcChain: "stellar", @@ -99,8 +103,12 @@ describe("OrderService", () => { dstAddress: VALID_ETH_ADDR, dstAsset: "native", dstAmount: "1" - }) - ).rejects.toThrowError(OrderValidationError); + }); + throw new Error("Should have failed"); + } catch (err: any) { + expect(err).toBeInstanceOf(OrderValidationError); + expect(err.code).toBe("VALIDATION_FAILED"); + } }); }); diff --git a/docs/FAILURE_CODES.md b/docs/FAILURE_CODES.md new file mode 100644 index 0000000..f3f0bf1 --- /dev/null +++ b/docs/FAILURE_CODES.md @@ -0,0 +1,27 @@ +# Failure Code Catalog + +This document defines the stable, machine-readable failure codes used across the OverSync system (Coordinator, Resolver, and Frontend) for order lifecycle errors. + +## Guidelines + +All lifecycle failures must be assigned one of the codes in this catalog. Do not use ad-hoc strings for API error responses. + +## Catalog + +| Code | Meaning | Emitted By | Condition | Default User Message | Category | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `ORDER_EXPIRED` | Order exceeded deadline | Coordinator | `currentTime > order.deadline` | The order has expired and can now be refunded. | user-actionable | +| `INSUFFICIENT_LIQUIDITY` | Not enough funds to fill | Resolver | Resolver cannot provide requested amount | Insufficient liquidity to complete the order at this time. | system-transient | +| `RESOLVER_TIMEOUT` | Resolver failed to act | Coordinator | Resolver did not settle in time | The resolver failed to respond within the expected time. | system-transient | +| `SETTLEMENT_REJECTED` | Chain rejected settlement | Resolver/Coordinator | On-chain transaction reverted | The settlement transaction was rejected by the network. | permanent | +| `INVALID_SIGNATURE` | Signature verification failed | Coordinator | Invalid user/resolver signature | The provided signature is invalid. | user-actionable | +| `CHAIN_RPC_UNAVAILABLE` | RPC node unresponsive | Coordinator/Resolver | Connectivity issues with chain node | The blockchain network is currently unreachable. | system-transient | +| `VALIDATION_FAILED` | Request failed validation | Coordinator | Invalid address, mismatched chains, etc. | The order failed validation checks. | user-actionable | +| `ORDER_NOT_FOUND` | Order does not exist | Coordinator | Public ID not found in DB | The requested order could not be found. | permanent | +| `INTERNAL_ERROR` | Unexpected system failure | Coordinator | Uncaught exception / 500 error | An unexpected internal error occurred. | permanent | + +## Categories + +- **user-actionable**: The user can take a specific action to resolve the issue (e.g., fix an address, wait for expiry). +- **system-transient**: The error is likely temporary and may resolve itself upon retry. +- **permanent**: The error is fatal for this specific order; no further action will change the outcome. diff --git a/frontend/src/components/TransactionHistory.tsx b/frontend/src/components/TransactionHistory.tsx index 7170653..be99ccd 100644 --- a/frontend/src/components/TransactionHistory.tsx +++ b/frontend/src/components/TransactionHistory.tsx @@ -3,6 +3,7 @@ import { Clock, CheckCircle, XCircle, ArrowRight, ExternalLink, RefreshCw, Undo2 import { isTestnet } from '../config/networks'; import RefundDialog from '../features/refund/RefundDialog'; import type { Address } from 'viem'; +import { FAILURE_CODE_CATALOG, type FailureCode } from '@oversync/sdk/types'; interface Transaction { id: string; @@ -14,6 +15,7 @@ interface Transaction { amount: string; estimatedAmount: string; status: 'pending' | 'completed' | 'cancelled' | 'failed'; + failureCode?: FailureCode; timestamp: number; ethTxHash?: string; stellarTxHash?: string; @@ -377,6 +379,16 @@ export default function TransactionHistory({ ethAddress, stellarAddress }: Trans {formatTime(tx.timestamp)} + {tx.status === 'failed' && ( +
+ + + {tx.failureCode && FAILURE_CODE_CATALOG[tx.failureCode] + ? FAILURE_CODE_CATALOG[tx.failureCode].message + : 'Something went wrong with this transaction.'} + +
+ )}
{tx.ethTxHash && isRealHash(tx.ethTxHash) && ( diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index de76d73..9f862fc 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -44,6 +44,71 @@ export type OrderStatus = | "failed" | "expired"; +export type FailureCode = + | "ORDER_EXPIRED" + | "INSUFFICIENT_LIQUIDITY" + | "RESOLVER_TIMEOUT" + | "SETTLEMENT_REJECTED" + | "INVALID_SIGNATURE" + | "CHAIN_RPC_UNAVAILABLE" + | "VALIDATION_FAILED" + | "ORDER_NOT_FOUND" + | "INTERNAL_ERROR"; + +export interface FailureCodeDetail { + code: FailureCode; + message: string; + category: "user-actionable" | "system-transient" | "permanent"; +} + +export const FAILURE_CODE_CATALOG: Record = { + ORDER_EXPIRED: { + code: "ORDER_EXPIRED", + message: "The order has expired and can now be refunded.", + category: "user-actionable", + }, + INSUFFICIENT_LIQUIDITY: { + code: "INSUFFICIENT_LIQUIDITY", + message: "Insufficient liquidity to complete the order at this time.", + category: "system-transient", + }, + RESOLVER_TIMEOUT: { + code: "RESOLVER_TIMEOUT", + message: "The resolver failed to respond within the expected time.", + category: "system-transient", + }, + SETTLEMENT_REJECTED: { + code: "SETTLEMENT_REJECTED", + message: "The settlement transaction was rejected by the network.", + category: "permanent", + }, + INVALID_SIGNATURE: { + code: "INVALID_SIGNATURE", + message: "The provided signature is invalid.", + category: "user-actionable", + }, + CHAIN_RPC_UNAVAILABLE: { + code: "CHAIN_RPC_UNAVAILABLE", + message: "The blockchain network is currently unreachable.", + category: "system-transient", + }, + VALIDATION_FAILED: { + code: "VALIDATION_FAILED", + message: "The order failed validation checks.", + category: "user-actionable", + }, + ORDER_NOT_FOUND: { + code: "ORDER_NOT_FOUND", + message: "The requested order could not be found.", + category: "permanent", + }, + INTERNAL_ERROR: { + code: "INTERNAL_ERROR", + message: "An unexpected internal error occurred.", + category: "permanent", + }, +}; + /** Cross-chain swap order as visible to clients of the SDK. */ export interface Order { publicId: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c25d3d..da9eda3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -70,6 +70,9 @@ importers: coordinator: dependencies: + '@oversync/sdk': + specifier: workspace:* + version: link:../packages/sdk '@stellar/stellar-sdk': specifier: ^13.0.0 version: 13.3.0 @@ -333,6 +336,9 @@ importers: resolver: dependencies: + '@oversync/sdk': + specifier: workspace:* + version: link:../packages/sdk '@stellar/stellar-sdk': specifier: ^13.0.0 version: 13.3.0 @@ -3857,7 +3863,7 @@ packages: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.17 - vite: 5.4.19(@types/node@20.19.7) + vite: 5.4.19(@types/node@22.19.19) dev: true /@vitest/pretty-format@2.1.9: diff --git a/resolver/package.json b/resolver/package.json index 7235f81..92ee887 100644 --- a/resolver/package.json +++ b/resolver/package.json @@ -16,6 +16,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@oversync/sdk": "workspace:*", "@stellar/stellar-sdk": "^13.0.0", "commander": "^12.1.0", "dotenv": "^16.4.5", From 48e839d680c3569669b6f4e4d0ceb5bcd29dddcc Mon Sep 17 00:00:00 2001 From: Adesanya Fuhad Date: Mon, 29 Jun 2026 11:45:41 +0000 Subject: [PATCH 2/5] fix: strip trailing whitespace and EOF blank lines --- coordinator/src/persistence/schema.sql | 1 + coordinator/src/server/routes/orders.ts | 28 +-- coordinator/src/services/order-service.ts | 2 +- coordinator/test/api-errors.test.ts | 121 ++++++++++- packages/sdk/src/types/index.ts | 251 +++++++++++++++++++++- 5 files changed, 375 insertions(+), 28 deletions(-) diff --git a/coordinator/src/persistence/schema.sql b/coordinator/src/persistence/schema.sql index 567cc28..7d85201 100644 --- a/coordinator/src/persistence/schema.sql +++ b/coordinator/src/persistence/schema.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS orders ( direction TEXT NOT NULL CHECK (direction IN ('eth_to_xlm', 'xlm_to_eth')), status TEXT NOT NULL CHECK (status IN ('announced', 'src_locked', 'dst_locked', 'secret_revealed', 'completed', 'refunded', 'failed', 'expired')), + failure_code TEXT, -- Cross-chain link. hashlock TEXT NOT NULL, -- 0x-prefixed 32-byte hex. diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 5e14ab8..d5738be 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -73,20 +73,6 @@ 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) { - sendError(res, "ORDER_NOT_FOUND", 404); - return; - } - res.json(serialiseOrder(order)); - } catch (err) { - next(err); - } - }); - router.get("/orders/history", async (req, res, next) => { const address = (req.query.address as string | undefined) ?? ""; if (!address) { @@ -106,6 +92,20 @@ 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) { + sendError(res, "ORDER_NOT_FOUND", 404); + 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/services/order-service.ts b/coordinator/src/services/order-service.ts index e264a7d..410669b 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -113,7 +113,7 @@ export class OrderService { }): Promise { const order = await this.repo.findByPublicId(input.publicId); if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`, "ORDER_NOT_FOUND"); - if (!canTransition(order.status, "src_locked") && order.status !== "src_locked") { + if (!canTransition(order.status, "src_locked")) { throw new OrderValidationError(`cannot record src lock from status ${order.status}`, "VALIDATION_FAILED"); } await this.repo.recordSrcLock(input); diff --git a/coordinator/test/api-errors.test.ts b/coordinator/test/api-errors.test.ts index a54ca20..dfaa8f0 100644 --- a/coordinator/test/api-errors.test.ts +++ b/coordinator/test/api-errors.test.ts @@ -1,114 +1,227 @@ import { describe, it, expect, beforeEach } from "vitest"; + import express from "express"; + import request from "supertest"; + import { OrdersRepository } from "../src/persistence/orders-repo.js"; + import { OrderService } from "../src/services/order-service.js"; + import { ordersRoutes } from "../src/server/routes/orders.js"; + import { openDatabase } from "../src/persistence/db.js"; + import pino from "pino"; + import { mkdtempSync } from "node:fs"; + import { tmpdir } from "node:os"; + import { resolve } from "node:path"; + + const log = pino({ level: "silent" }); + + async function freshDb() { + const dir = mkdtempSync(resolve(tmpdir(), "oversync-api-test-")); + return openDatabase(`file:${dir}/test.db`); + } + + describe("Coordinator API Error Mapping", () => { + let app: express.Application; + let orderRepo: OrdersRepository; + let orderService: OrderService; + + beforeEach(async () => { + const db = await freshDb(); + orderRepo = new OrdersRepository(db); + orderService = new OrderService(orderRepo, log); + app = express(); + app.use(express.json()); + app.use(ordersRoutes(orderService)); + app.use((err: any, req: any, res: any, next: any) => { + res.status(500).json({ error: "internal_error", message: err.message }); + }); + }); + + it("maps Zod validation errors to VALIDATION_FAILED", async () => { + const res = await request(app) + .post("/orders/announce") + .send({ direction: "invalid_direction" }); - + + + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + expect(res.body.message).toBe("The order failed validation checks."); + }); + + it("maps OrderValidationError to its specific failure code", async () => { + const res = await request(app) + .post("/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "a".repeat(64), + srcChain: "ethereum", + srcAddress: "not-an-address", + srcAsset: "native", + srcAmount: "100", + srcSafetyDeposit: "10", + dstChain: "stellar", + dstAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422", + dstAsset: "native", + dstAmount: "100" + }); - + + + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + expect(res.body.message).toBe("The order failed validation checks."); + }); + + it("maps missing orders to ORDER_NOT_FOUND", async () => { + const res = await request(app).get("/orders/non-existent"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("ORDER_NOT_FOUND"); + expect(res.body.message).toBe("The requested order could not be found."); + }); + + it("maps missing address in history to VALIDATION_FAILED", async () => { + const res = await request(app).get("/orders/history"); + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + expect(res.body.message).toBe("The order failed validation checks."); + }); + + it("maps OrderValidationError in src-locked to its failure code", async () => { + // First announce an order + const announceRes = await request(app) + .post("/orders/announce") + .send({ + direction: "eth_to_xlm", + hashlock: "0x" + "b".repeat(64), + srcChain: "ethereum", + srcAddress: "0x1111111111111111111111111111111111111111", + srcAsset: "native", + srcAmount: "100", + srcSafetyDeposit: "10", + dstChain: "stellar", + dstAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422", + dstAsset: "native", + dstAmount: "100" + }); + const publicId = announceRes.body.id; + + // Attempt to record src lock on an order that can't transition (already src_locked) + await request(app) + .post(`/orders/${publicId}/src-locked`) + .send({ orderId: "1", txHash: "0x1", blockNumber: 1, timelock: 1 }); - + + + const res = await request(app) + .post(`/orders/${publicId}/src-locked`) + .send({ orderId: "2", txHash: "0x2", blockNumber: 2, timelock: 2 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("VALIDATION_FAILED"); + }); -}); + +}); \ No newline at end of file diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 9f862fc..69e1ecc 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -1,234 +1,467 @@ export type Chain = "ethereum" | "stellar"; + export type Direction = "eth_to_xlm" | "xlm_to_eth"; + + // --------------------------------------------------------------- + // Soroban HTLC order types + // --------------------------------------------------------------- + + /** Lifecycle status returned by the Soroban HTLC contract. */ + export type SorobanOrderStatus = "Funded" | "Claimed" | "Refunded"; + + /** + * Typed representation of a Soroban HTLC order, normalised from the + * raw `scValToNative` response. Field names use camelCase to match the + * Ethereum `OrderData` ergonomics. + */ + export interface SorobanOrderData { + id: bigint; + sender: string; + beneficiary: string; + refundAddress: string; + /** Stellar asset contract address. */ + asset: string; + /** Amount in the asset's smallest unit (stroops for XLM). */ + amount: bigint; + safetyDeposit: bigint; + /** sha256 hashlock as a 0x-prefixed hex string. */ + hashlock: `0x${string}`; + /** Absolute unix-second timestamp after which refund is valid. */ + timelock: bigint; + status: SorobanOrderStatus; + /** Revealed preimage as 0x-prefixed hex, empty string when not yet claimed. */ + preimage: `0x${string}` | ""; + createdAt: bigint; + finalisedAt: bigint; + } + + export type OrderStatus = + | "announced" + | "src_locked" + | "dst_locked" + | "secret_revealed" + | "completed" + | "refunded" + | "failed" + | "expired"; -export type FailureCode = - | "ORDER_EXPIRED" - | "INSUFFICIENT_LIQUIDITY" - | "RESOLVER_TIMEOUT" - | "SETTLEMENT_REJECTED" - | "INVALID_SIGNATURE" - | "CHAIN_RPC_UNAVAILABLE" - | "VALIDATION_FAILED" + + +export type FailureCode = + + | "ORDER_EXPIRED" + + | "INSUFFICIENT_LIQUIDITY" + + | "RESOLVER_TIMEOUT" + + | "SETTLEMENT_REJECTED" + + | "INVALID_SIGNATURE" + + | "CHAIN_RPC_UNAVAILABLE" + + | "VALIDATION_FAILED" + | "ORDER_NOT_FOUND" + | "INTERNAL_ERROR"; + + export interface FailureCodeDetail { + code: FailureCode; + message: string; + category: "user-actionable" | "system-transient" | "permanent"; + } + + export const FAILURE_CODE_CATALOG: Record = { + ORDER_EXPIRED: { + code: "ORDER_EXPIRED", + message: "The order has expired and can now be refunded.", + category: "user-actionable", + }, + INSUFFICIENT_LIQUIDITY: { + code: "INSUFFICIENT_LIQUIDITY", + message: "Insufficient liquidity to complete the order at this time.", + category: "system-transient", + }, + RESOLVER_TIMEOUT: { + code: "RESOLVER_TIMEOUT", + message: "The resolver failed to respond within the expected time.", + category: "system-transient", + }, + SETTLEMENT_REJECTED: { + code: "SETTLEMENT_REJECTED", + message: "The settlement transaction was rejected by the network.", + category: "permanent", + }, + INVALID_SIGNATURE: { + code: "INVALID_SIGNATURE", + message: "The provided signature is invalid.", + category: "user-actionable", + }, + CHAIN_RPC_UNAVAILABLE: { + code: "CHAIN_RPC_UNAVAILABLE", + message: "The blockchain network is currently unreachable.", + category: "system-transient", + }, + VALIDATION_FAILED: { + code: "VALIDATION_FAILED", + message: "The order failed validation checks.", + category: "user-actionable", + }, + ORDER_NOT_FOUND: { + code: "ORDER_NOT_FOUND", + message: "The requested order could not be found.", + category: "permanent", + }, + INTERNAL_ERROR: { + code: "INTERNAL_ERROR", + message: "An unexpected internal error occurred.", + category: "permanent", + }, + }; + + /** Cross-chain swap order as visible to clients of the SDK. */ + export interface Order { + publicId: string; + direction: Direction; + status: OrderStatus; + hashlock: `0x${string}`; + src: ChainLeg; + dst: ChainLeg; + preimage: `0x${string}` | null; + } + + export interface ChainLeg { + chain: Chain; + address: string; + asset: string; + /** Atomic units, decimal string. */ + amount: string; + /** Atomic units, decimal string. */ + safetyDeposit?: string; + /** On-chain order id once the leg is locked. */ + orderId?: string | null; + /** Tx hash that created the on-chain lock. */ + lockTx?: string | null; + /** Absolute timelock as unix seconds. */ + timelock?: number | null; + } + + /** Resolver listing entry returned by the coordinator. */ + export interface ResolverInfo { + address: string; + chain: Chain; + stake: string; + active: boolean; + registeredAt: number; + } + + // --------------------------------------------------------------- + // External bridge route composability (v2.0 interface, v2.1 fillout) + // --------------------------------------------------------------- + // + // OverSync's atomic HTLC swap is one of several ways to move value + // between Ethereum and Stellar. CCTP v2 (USDC burn-and-mint) and + // Axelar ITS (validator-set wrapped tokens) handle different asset + // classes with different trust models. For some swaps, routing a leg + // through one of those external bridges is strictly better for the + // user (e.g. native USDC via CCTP v2 instead of an OverSync USDC + // hop). + // + // We expose the route abstraction in v2.0 even though no provider is + // wired up yet, so that v2.1 implementations can ship as additive + // adapters without breaking SDK consumers. A frontend or coordinator + // can iterate `getAvailableRoutes(...)` and present the user with + // "OverSync HTLC" vs "CCTP v2 fast path" choices, and the SDK + // orchestrates whichever the user picks. + // + // v2.0 ships a single built-in route (`oversync-htlc`). Adapters for + // `cctp-v2` and `axelar-its` arrive during the Q1 2027 mainnet + // tranche (see ROADMAP.md). + + export type ExternalBridgeKind = "oversync-htlc" | "cctp-v2" | "axelar-its"; + + /** + * A candidate route for moving value between two chains. Routes are + * additive and may be composed: a swap can use OverSync HTLC for the + * native-asset leg and CCTP v2 for the USDC leg in the same + * cross-chain operation. + */ + export interface ExternalBridgeRoute { + /** Stable identifier for the routing engine. */ + kind: ExternalBridgeKind; + + /** Human-readable label for UI presentation. */ + label: string; + + /** Source chain leg as seen by the user. */ + src: ChainLeg; + + /** Destination chain leg as seen by the user. */ + dst: ChainLeg; + + /** + * Trust assumptions disclosed alongside the route. Surfacing this + * forces every adapter author to be explicit about what the user + * is opting into. + */ + trust: { + /** Set of off-chain actors whose compromise would let funds be stolen. */ + trustedParties: string[]; + /** True if the locked funds are recoverable without any off-chain actor. */ + permissionlessRefund: boolean; + }; + + /** + * Expected settlement window in seconds. For OverSync HTLC this is + * dominated by the destination-side timelock and the user's claim + * latency; for attestation-style bridges it is dominated by the + * attester latency. + */ + estimatedSettlementSeconds: number; + + /** + * Adapter-specific extension blob. Adapters are expected to + * extend this interface with their own typed payload via + * declaration merging. + */ + extra?: Record; + } + + /** + * Optional adapter contract implemented by external-bridge plugins. + * v2.0 does not ship any third-party implementations; we define the + * shape now so that v2.1 plugins are non-breaking. + */ + export interface ExternalBridgeAdapter { + kind: ExternalBridgeKind; + /** Return zero or more routes this adapter can serve for the given pair. */ + quote(params: { + direction: Direction; + srcAsset: string; + dstAsset: string; + amount: string; + }): Promise; -} + +} \ No newline at end of file From eaa5de52c2b573bb33d293a0ba802902031864c5 Mon Sep 17 00:00:00 2001 From: Adesanya Fuhad Date: Mon, 29 Jun 2026 15:53:47 +0000 Subject: [PATCH 3/5] fix(sdk): point exports map at TS sources so @oversync/sdk/types resolves without a build step The SDK exports map (and main/types fields) pointed every entry at ./dist//index.{js,d.ts}. When dist/ was missing, stale, or empty, consumers silently got undefined for FAILURE_CODE_CATALOG at runtime and TS2305 'no exported member' from tsc -- a single shared root cause behind the coordinator API error mapper TypeError and the frontend TransactionHistory TS2305 errors. Switch every exports entry plus main/types to ./src/.ts. With moduleResolution: bundler in coordinator and frontend tsconfigs, tsc + vite + vitest + tsx consume the TS source directly, so no precompiled dist artifact is required for dev or test. Also: - Add 'sideEffects': false on the SDK package for tree-shaking. - Add a 'default' condition on every exports entry so future require() callers don't hit ERR_PACKAGE_PATH_NOT_EXPORTED. - Pre-bundle @oversync/sdk and @oversync/sdk/types in frontend vite.optimizeDeps.include to absorb the new on-the-fly esbuild scan. - Add packages/sdk/test/exports.test.ts which imports the catalog via the package subpath, the package main entry, and the source shortcut, with toBeDefined() guards and an exhaustive switch terminating in const _: never = code so any future FailureCode drift fails tsc at compile time and fails vitest loudly if exports silently resolve to undefined again. Verified: SDK 31/31 vitest passes, all three (SDK, coordinator, frontend) tsc --noEmit pass, frontend full 'tsc && vite build' passes. --- frontend/vite.config.ts | 5 ++ packages/sdk/package.json | 35 +++++---- packages/sdk/test/exports.test.ts | 126 ++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 packages/sdk/test/exports.test.ts diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 43bbcd9..003f597 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -62,6 +62,11 @@ export default defineConfig(({ mode }) => { 'ethers', '@rainbow-me/rainbowkit', 'wagmi', + // Pre-bundle the workspace SDK so the Vite dev server and + // Vitest don't cold-scan src/**/*.ts on first launch now that + // the package exports point directly at TS source. + '@oversync/sdk', + '@oversync/sdk/types', ], }, test: { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f06bf2d..f6efe85 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,32 +3,39 @@ "version": "0.1.0", "description": "TypeScript SDK for the OverSync cross-chain bridge: Ethereum + Soroban HTLC clients, secret utilities, and a shared order state machine.", "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", "exports": { ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "types": "./src/index.ts", + "import": "./src/index.ts", + "default": "./src/index.ts" }, "./ethereum": { - "import": "./dist/ethereum/index.js", - "types": "./dist/ethereum/index.d.ts" + "types": "./src/ethereum/index.ts", + "import": "./src/ethereum/index.ts", + "default": "./src/ethereum/index.ts" }, "./soroban": { - "import": "./dist/soroban/index.js", - "types": "./dist/soroban/index.d.ts" + "types": "./src/soroban/index.ts", + "import": "./src/soroban/index.ts", + "default": "./src/soroban/index.ts" }, "./secrets": { - "import": "./dist/secrets/index.js", - "types": "./dist/secrets/index.d.ts" + "types": "./src/secrets/index.ts", + "import": "./src/secrets/index.ts", + "default": "./src/secrets/index.ts" }, "./state-machine": { - "import": "./dist/state-machine/index.js", - "types": "./dist/state-machine/index.d.ts" + "types": "./src/state-machine/index.ts", + "import": "./src/state-machine/index.ts", + "default": "./src/state-machine/index.ts" }, "./types": { - "import": "./dist/types/index.js", - "types": "./dist/types/index.d.ts" + "types": "./src/types/index.ts", + "import": "./src/types/index.ts", + "default": "./src/types/index.ts" } }, "scripts": { diff --git a/packages/sdk/test/exports.test.ts b/packages/sdk/test/exports.test.ts new file mode 100644 index 0000000..dffb9fb --- /dev/null +++ b/packages/sdk/test/exports.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; + +import { + FAILURE_CODE_CATALOG, + type FailureCode +} from "../src/types/index.js"; + +// These imports go through the package.json `exports` map — not the +// `../src/...` shortcut used by the rest of the SDK's own tests. +// If the `exports."./types"` entry (or the `.` entry) silently resolves +// to a missing/empty file (e.g. a stale `dist/`), vitest returns +// `undefined` for the runtime value rather than throwing, which is +// exactly the failure mode seen when the coordinator's API error mapper +// and the frontend's TransactionHistory both broke against the +// "@oversync/sdk/types" subpath. Re-importing from both the package +// subpath and the main entry ensures every surface stays wired up. +import { FAILURE_CODE_CATALOG as SUBPATH_CATALOG } from "@oversync/sdk/types"; +import { + FAILURE_CODE_CATALOG as MAIN_CATALOG, + type FailureCode as MainFailureCode +} from "@oversync/sdk"; + +// Snapshot of every entry the SDK ships today. A future change that +// adds or removes a code MUST update this list; that's the point — +// the test will then flag the regression explicitly instead of +// silently dropping the new code from consumer code paths. +const EXPECTED_CODES: FailureCode[] = [ + "ORDER_EXPIRED", + "INSUFFICIENT_LIQUIDITY", + "RESOLVER_TIMEOUT", + "SETTLEMENT_REJECTED", + "INVALID_SIGNATURE", + "CHAIN_RPC_UNAVAILABLE", + "VALIDATION_FAILED", + "ORDER_NOT_FOUND", + "INTERNAL_ERROR" +]; + +// Exhaustiveness sentinel: if `FailureCode` ever gains or loses a +// member, this `switch` forces tsc to flag the file at compile time, +// no runtime assertion required. +function assertFailureCodeExhaustive(code: FailureCode): string { + switch (code) { + case "ORDER_EXPIRED": + case "INSUFFICIENT_LIQUIDITY": + case "RESOLVER_TIMEOUT": + case "SETTLEMENT_REJECTED": + case "INVALID_SIGNATURE": + case "CHAIN_RPC_UNAVAILABLE": + case "VALIDATION_FAILED": + case "ORDER_NOT_FOUND": + case "INTERNAL_ERROR": + return code; + default: { + // `never` here proves all union members are covered; adding a + // new FailureCode will produce a tsc error at this line. + const _exhaustive: never = code; + return _exhaustive; + } + } +} + +describe("@oversync/sdk exports surface", () => { + it("exposes FAILURE_CODE_CATALOG with the expected set of codes", () => { + // Guard before `Object.keys(...)`: if the package's `exports` map + // ever silently resolves to `undefined` again (the original bug), + // these `expect`s report a clean failure instead of throwing + // `TypeError: Cannot convert undefined or null to object`. + expect(SUBPATH_CATALOG).toBeDefined(); + expect(MAIN_CATALOG).toBeDefined(); + expect(FAILURE_CODE_CATALOG).toBeDefined(); + + // Snapshot assertion (not just identity) so a future change that + // accidentally drops or renames a code fails this test by value, + // not by reference. Both the source import and the subpath import + // must list the exact same set of keys. + expect(Object.keys(SUBPATH_CATALOG).sort()).toEqual([...EXPECTED_CODES].sort()); + expect(Object.keys(MAIN_CATALOG).sort()).toEqual([...EXPECTED_CODES].sort()); + for (const code of EXPECTED_CODES) { + expect(FAILURE_CODE_CATALOG[code]).toBeDefined(); + expect(FAILURE_CODE_CATALOG[code].code).toBe(code); + expect(typeof FAILURE_CODE_CATALOG[code].message).toBe("string"); + expect(["user-actionable", "system-transient", "permanent"]).toContain( + FAILURE_CODE_CATALOG[code].category + ); + } + }); + + it("re-exposes the catalog at @oversync/sdk/types via the package exports map", () => { + // Regression guard: previously the SDK's `exports."./types"` entry + // pointed at `dist/types/index.js`, which silently resolved to + // `undefined` when `dist/` had not been built (or was stale), + // taking down both the coordinator's API error mapper and the + // frontend's TransactionHistory component with a single shared + // root cause. + expect(SUBPATH_CATALOG).toBeDefined(); + expect(SUBPATH_CATALOG).toBe(FAILURE_CODE_CATALOG); + expect(SUBPATH_CATALOG.VALIDATION_FAILED.message).toBe( + "The order failed validation checks." + ); + expect(SUBPATH_CATALOG.ORDER_NOT_FOUND.message).toBe( + "The requested order could not be found." + ); + }); + + it("re-exposes the catalog through the main @oversync/sdk entry as well", () => { + expect(MAIN_CATALOG).toBeDefined(); + expect(MAIN_CATALOG).toBe(FAILURE_CODE_CATALOG); + }); + + it("keeps the FailureCode type reachable and exhaustive through both surface paths", () => { + // The value of `assertFailureCodeExhaustive` is purely *compile-time*: + // its `const _exhaustive: never = code` line is what forces tsc to + // error if `FailureCode` ever gains or loses a member. We still call + // the helper at runtime so any future regression that loses its + // exhaustiveness (e.g. someone replaces it with `as FailureCode`) + // surfaces as a runtime no-op too, without needing a separate test. + for (const code of EXPECTED_CODES) { + assertFailureCodeExhaustive(code); + } + // Same check, but imported from the main entry — proving both + // surface paths expose the same type, not just the same value. + const fromMain: MainFailureCode = "VALIDATION_FAILED"; + assertFailureCodeExhaustive(fromMain); + }); +}); From 3c6dad93e21e4bfdc2c0424aa978ad74c3c7de3c Mon Sep 17 00:00:00 2001 From: Adesanya Fuhad Date: Tue, 30 Jun 2026 10:28:52 +0000 Subject: [PATCH 4/5] fix: resolve @oversync/sdk/types import for coordinator tests - Add @oversync/sdk to vitest deps.inline so Vite resolves the workspace package's TS source files for subpath exports - Export sendError from orders.ts and use it in secrets.ts for consistent VALIDATION_FAILED responses - Update http-routes.test.ts expectations from validation_error to VALIDATION_FAILED --- coordinator/src/server/routes/orders.ts | 2 +- coordinator/src/server/routes/secrets.ts | 3 ++- coordinator/test/http-routes.test.ts | 4 ++-- coordinator/vitest.config.ts | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index d5738be..5db280f 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -44,7 +44,7 @@ function serialiseOrder(order: OrderRow | null) { }; } -function sendError(res: any, code: FailureCode, status: number = 400) { +export function sendError(res: any, code: FailureCode, status: number = 400) { const detail = FAILURE_CODE_CATALOG[code]; res.status(status).json({ error: code, diff --git a/coordinator/src/server/routes/secrets.ts b/coordinator/src/server/routes/secrets.ts index 397774b..0af5d2f 100644 --- a/coordinator/src/server/routes/secrets.ts +++ b/coordinator/src/server/routes/secrets.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { z } from "zod"; +import { sendError } from "./orders.js"; import type { SecretService } from "../../services/secret-service.js"; export function secretsRoutes(secrets: SecretService): Router { @@ -18,7 +19,7 @@ export function secretsRoutes(secrets: SecretService): Router { res.json({ ok: true }); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof Error) { diff --git a/coordinator/test/http-routes.test.ts b/coordinator/test/http-routes.test.ts index 4f65444..f02c580 100644 --- a/coordinator/test/http-routes.test.ts +++ b/coordinator/test/http-routes.test.ts @@ -145,7 +145,7 @@ describe("POST /api/orders/announce", () => { .send(JSON.stringify({ direction: "eth_to_xlm" /* missing required fields */ })); expect(res.status).toBe(400); - expect(res.body.error).toBe("validation_error"); + expect(res.body.error).toBe("VALIDATION_FAILED"); }); }); @@ -203,7 +203,7 @@ describe("POST /api/secrets/reveal", () => { .send(JSON.stringify({ publicId: "x" /* missing preimage, txHash */ })); expect(res.status).toBe(400); - expect(res.body.error).toBe("validation_error"); + expect(res.body.error).toBe("VALIDATION_FAILED"); }); }); diff --git a/coordinator/vitest.config.ts b/coordinator/vitest.config.ts index 3cde7db..040afe9 100644 --- a/coordinator/vitest.config.ts +++ b/coordinator/vitest.config.ts @@ -7,7 +7,8 @@ export default defineConfig({ pool: "forks", server: { deps: { - external: [/^node:/] + external: [/^node:/], + inline: ['@oversync/sdk'] } } } From 81244b451af0ec874897cc8a4bd123a0c1651d53 Mon Sep 17 00:00:00 2001 From: Adesanya Fuhad Date: Wed, 1 Jul 2026 15:15:31 +0000 Subject: [PATCH 5/5] fix: import failure codes from @oversync/sdk main entry, remove tracked DB - Switch all @oversync/sdk/types imports to @oversync/sdk (main entry already re-exports types/index.ts). Avoids Vitest subpath resolution issues with workspace-linked packages. - Remove coordinator/oversync.db from git tracking and add *.db to .gitignore so generated SQLite files aren't committed. - Remove @oversync/sdk/types from frontend optimizeDeps.include since that subpath is no longer imported directly. --- .gitignore | 3 +++ coordinator/oversync.db | Bin 57344 -> 0 bytes coordinator/src/server/routes/orders.ts | 2 +- coordinator/src/services/order-service.ts | 2 +- frontend/src/components/TransactionHistory.tsx | 2 +- frontend/vite.config.ts | 1 - 6 files changed, 6 insertions(+), 4 deletions(-) delete mode 100644 coordinator/oversync.db diff --git a/.gitignore b/.gitignore index 289a53f..4bbf021 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,9 @@ e2e/load-test/reports/*.json e2e/load-test/reports/*.md # Fallback if LOAD_TEST_OUTPUT_DIR is set to an absolute path outside the tree +# Local SQLite databases (generated at runtime) +*.db + # Miscellaneous .cache/ .parcel-cache/ diff --git a/coordinator/oversync.db b/coordinator/oversync.db deleted file mode 100644 index 32724cc6211a2ea302100f0766582fa8eeeb59d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57344 zcmeI5&u<$=6vyqPja#Qlxml4o3`LQ>suL(VAr4@YZHbz?ZQ_XL!fL%cu9vKL-JNl2 zpHM2)GY9?w{sjIN_#3z&apcaMnO%GB*iAWbF<(ouy}NJToA-G?W=5{mljlc)uHwN?D(W9ha-9w$Cegn3`tKeih! zo3$EWAKA>@W3@7)SKzDn+1FvSWk0fy*|X!rCynDX_SimSjnmfC!zO8ZVmDiLJ$Pq_ zJqqKt8-^n`h=NxGuJjEA_gQZ?1--0CxI>>W;%pSg6JiC z$s?Y)(hQbe&b*kW8bq#)6Gn;UMo~P7JkDg$)s&rj`oScgnKfGqbaAajM@Lj-ha$$6Y0Yyt5vz|Ix=>KVXsy#?)gfyNIWNuy>qUwd?9mJf_n*jn`EgXr8q?Yg3?Y)?Be>q1>UrUdwOxYH+# z!I0v%_t{Q+M4fsF8DnvJ4)zCCax!`N^+`tl+{zZ7R|}$UeI$xmexy+3ZoZzs`8}B(GzZm%aP;r zek=m5`27@4rldQ>R8=Vr+M~SB6x=vU(?<_c)gP-0HK{vMsIDD3%njBGbslD^?zHns zmv*4Es3>%*=<@=MWg9y1%V$we^(r{CiRXu zDv`x;9LMKGH#nwB<(w9VT}BOt68|Nq%y&n^^mMA3Af))1CTLP^EH`0D-*Txo-8s{Y zG|#n`~QLNa-FI5 zV~}vii^*Z-jCI+*Q>yIk72lleZN}K`E_V~zCKtt=TN_gwkU0YBZX{m=%36K{Vp>7I z{ItXvT)H9{fom%+bSW1F=V}))8-ucCZ>&|$_KJmo*3qGO71H+BaR)Ni*R=Acp`Q5c zJeYHHbF^Nne7;@0{78=r{GnqCjFOi6i@C_(xszf#Y#!JzsF}`_XHT1E2j>_qtdBv!^t1BIH`pk40nZ7#;W7l`S7iw2GUuMEa z>A5nee_3{A>*98)^2Jv1a(gRDfA{rkl;UFvDO5}b}>v=^JXypb9*N5PqgwaD%9I*qjMudG$R z+*;HC&77SF=5tk@&FRpO7YKj=2!H?xfB*=900@8p2!H?xfWQ(HC@bG^{$Jt1sY z{$KtW!$S}N0T2KI5C8!X009sH0T2KI5Wx8#IRFA600JNY0w4eaAOHd&00JNY0?SVT z=l|u8F+2nT5C8!X009sH0T2KI5C8!X00Er;kpmzA0w4eaAOHd&00JNY0w4eaAh7%d z@c;iUe~jTF2!H?xfB*=900@8p2!H?xfB*>K_kZL72!H?xfB*=900@8p2!H?xfB*1u60m$NFg#Z8m diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 5db280f..6b2efdb 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { z } from "zod"; import type { OrderRow } from "../../persistence/orders-repo.js"; import { announceSchema, OrderService, OrderValidationError } from "../../services/order-service.js"; -import { FAILURE_CODE_CATALOG, type FailureCode } from "@oversync/sdk/types"; +import { FAILURE_CODE_CATALOG, type FailureCode } from "@oversync/sdk"; function serialiseOrder(order: OrderRow | null) { if (!order) return null; diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 18d4e89..20ea278 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -8,7 +8,7 @@ import { type Direction, type Chain } from "../persistence/orders-repo.js"; -import { type FailureCode } from "@oversync/sdk/types"; +import { type FailureCode } from "@oversync/sdk"; import { canTransition } from "../state-machine/order-machine.js"; import { ordersTotal } from "../metrics.js"; import { QuoteService, QuoteExpiredError, QuoteNotFoundError } from "./quote-service.js"; diff --git a/frontend/src/components/TransactionHistory.tsx b/frontend/src/components/TransactionHistory.tsx index 1255857..e5b2b9d 100644 --- a/frontend/src/components/TransactionHistory.tsx +++ b/frontend/src/components/TransactionHistory.tsx @@ -7,7 +7,7 @@ import OrderStaleBanner from './OrderStaleBanner'; import { classifyOrderFreshness } from '../lib/orderFreshness'; import type { Address } from 'viem'; import HtlcTimeline from './HtlcTimeline'; -import { FAILURE_CODE_CATALOG, type FailureCode } from '@oversync/sdk/types'; +import { FAILURE_CODE_CATALOG, type FailureCode } from '@oversync/sdk'; interface Transaction { id: string; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 003f597..249e938 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -66,7 +66,6 @@ export default defineConfig(({ mode }) => { // Vitest don't cold-scan src/**/*.ts on first launch now that // the package exports point directly at TS source. '@oversync/sdk', - '@oversync/sdk/types', ], }, test: {