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 d04c195..0000000 Binary files a/coordinator/oversync.db and /dev/null differ diff --git a/coordinator/package.json b/coordinator/package.json index 4198d1b..9c8328d 100644 --- a/coordinator/package.json +++ b/coordinator/package.json @@ -18,6 +18,7 @@ "fixtures:remove": "tsx src/remove-fixtures.ts" }, "dependencies": { + "@oversync/sdk": "workspace:*", "@stellar/stellar-sdk": "^13.0.0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index f9c2606..c34df97 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -41,6 +41,7 @@ export interface OrderRow { publicId: string; direction: Direction; status: OrderStatus; + failureCode: string | null; hashlock: string; srcChain: Chain; srcAddress: string; @@ -110,6 +111,7 @@ interface OrderDbRow { public_id: string; direction: Direction; status: OrderStatus; + failure_code: string | null; hashlock: string; src_chain: Chain; src_address: string; @@ -142,6 +144,7 @@ function rowToOrder(r: OrderDbRow): OrderRow { publicId: r.public_id, direction: r.direction, status: r.status, + failureCode: r.failure_code, hashlock: r.hashlock, srcChain: r.src_chain, srcAddress: r.src_address, @@ -227,7 +230,7 @@ export class OrdersRepository { `); this.updateStatus = db.prepare(` UPDATE orders - SET status = :status, updated_at = CAST(strftime('%s','now') AS INTEGER) + SET status = :status, failure_code = :failureCode, updated_at = CAST(strftime('%s','now') AS INTEGER) WHERE public_id = :publicId `); this.updateSrcLock = db.prepare(` @@ -383,10 +386,10 @@ export class OrdersRepository { }); } - async setStatus(publicId: string, status: OrderStatus): Promise { + async setStatus(publicId: string, status: OrderStatus, failureCode: string | null = null): Promise { const order = await this.findByPublicId(publicId); if (!order) throw new Error("Unknown order"); - await this.run(this.updateStatus, { publicId, status }); + await this.run(this.updateStatus, { publicId, status, failureCode }); await this.recordTransition(order.id, order.status, status, null, status); } diff --git a/coordinator/src/persistence/schema.sql b/coordinator/src/persistence/schema.sql index 808076a..90168e8 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 30509a7..fd873bb 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, OrderSnapshot } from "../../persistence/orders-repo.js"; import { announceSchema, OrderService, OrderValidationError } from "../../services/order-service.js"; +import { FAILURE_CODE_CATALOG, type FailureCode } from "@oversync/sdk"; import { encodeCursor, decodeCursor } from "./cursor-utils.js"; function serialiseOrder(order: OrderRow | null) { @@ -10,6 +11,7 @@ function serialiseOrder(order: OrderRow | null) { id: order.publicId, direction: order.direction, status: order.status, + failureCode: order.failureCode, hashlock: order.hashlock, src: { chain: order.srcChain, @@ -43,6 +45,14 @@ function serialiseOrder(order: OrderRow | null) { }; } +export function sendError(res: any, code: FailureCode, status: number = 400) { + const detail = FAILURE_CODE_CATALOG[code]; + res.status(status).json({ + error: code, + message: detail.message + }); +} + export function ordersRoutes(orders: OrderService): Router { const router = Router(); @@ -53,11 +63,11 @@ export function ordersRoutes(orders: OrderService): Router { res.status(201).json(serialiseOrder(order)); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof OrderValidationError) { - res.status(400).json({ error: "order_validation_error", message: err.message }); + sendError(res, err.code); return; } next(err); @@ -68,7 +78,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; } @@ -76,7 +86,7 @@ export function ordersRoutes(orders: OrderService): Router { const limitStr = req.query.limit as string | undefined; const limit = limitStr ? Number(limitStr) : 50; if (!Number.isInteger(limit) || limit < 1 || limit > 200) { - res.status(400).json({ error: "invalid_limit", message: "limit must be an integer between 1 and 200" }); + sendError(res, "VALIDATION_FAILED"); return; } @@ -86,7 +96,7 @@ export function ordersRoutes(orders: OrderService): Router { if (cursorStr) { const decoded = decodeCursor(cursorStr); if (!decoded) { - res.status(400).json({ error: "invalid_cursor", message: "cursor is malformed or expired" }); + sendError(res, "VALIDATION_FAILED"); return; } offset = decoded.offset; @@ -136,7 +146,7 @@ export function ordersRoutes(orders: OrderService): Router { try { const order = await orders.get(id); if (!order) { - res.status(404).json({ error: "not_found" }); + sendError(res, "ORDER_NOT_FOUND", 404); return; } res.json(serialiseOrder(order)); @@ -145,6 +155,22 @@ export function ordersRoutes(orders: OrderService): Router { } }); + router.get("/orders/:id/transitions", async (req, res, next) => { + const id = req.params.id; + try { + const transitions = await orders.getTransitions(id); + if (!transitions.length) { + const order = await orders.get(id); + if (!order) { + sendError(res, "ORDER_NOT_FOUND", 404); + return; + } + } + res.json({ transitions }); + } catch (err) { + next(err); + } + }); const lockSchema = z.object({ orderId: z.string().min(1), txHash: z.string().min(1), @@ -159,11 +185,11 @@ export function ordersRoutes(orders: OrderService): Router { res.json({ ok: true }); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof OrderValidationError) { - res.status(400).json({ error: "order_validation_error", message: err.message }); + sendError(res, err.code); return; } next(err); @@ -184,11 +210,11 @@ export function ordersRoutes(orders: OrderService): Router { res.json({ ok: true }); } catch (err) { if (err instanceof z.ZodError) { - res.status(400).json({ error: "validation_error", details: err.errors }); + sendError(res, "VALIDATION_FAILED"); return; } if (err instanceof OrderValidationError) { - res.status(400).json({ error: "order_validation_error", message: err.message }); + sendError(res, err.code); return; } next(err); 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/src/services/order-service.ts b/coordinator/src/services/order-service.ts index a3b8e36..fb86344 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -10,6 +10,7 @@ import { type Direction, type Chain } from "../persistence/orders-repo.js"; +import { type FailureCode } from "@oversync/sdk"; import { canTransition } from "../state-machine/order-machine.js"; import { ordersTotal } from "../metrics.js"; import { QuoteService, QuoteExpiredError, QuoteNotFoundError } from "./quote-service.js"; @@ -52,10 +53,10 @@ export class OrderValidationError extends Error { function validateChainAddress(chain: Chain, addr: string): void { if (chain === "ethereum" && !HEX_ADDRESS.test(addr)) { - throw new OrderValidationError(`${addr} is not a valid Ethereum address`); + throw new OrderValidationError(`${addr} is not a valid Ethereum address`, "VALIDATION_FAILED"); } if (chain === "stellar" && !STELLAR_ADDRESS.test(addr)) { - throw new OrderValidationError(`${addr} is not a valid Stellar account`); + throw new OrderValidationError(`${addr} is not a valid Stellar account`, "VALIDATION_FAILED"); } } @@ -67,7 +68,8 @@ function validateDirectionAgainstChains(input: AnnounceInput): void { const want = expected[input.direction]; if (want.src !== input.srcChain || want.dst !== input.dstChain) { throw new OrderValidationError( - `Direction ${input.direction} requires src=${want.src} and dst=${want.dst}` + `Direction ${input.direction} requires src=${want.src} and dst=${want.dst}`, + "VALIDATION_FAILED" ); } } @@ -124,7 +126,8 @@ export class OrderService { const existing = await this.repo.findByHashlock(input.hashlock); if (existing) { throw new OrderValidationError( - `An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})` + `An order with hashlock ${input.hashlock} already exists (publicId=${existing.publicId})`, + "VALIDATION_FAILED" ); } @@ -163,9 +166,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 (!canTransition(order.status, "src_locked") && order.status !== "src_locked") { - throw new OrderValidationError(`cannot record src lock from status ${order.status}`); + if (!order) throw new OrderValidationError(`unknown order ${input.publicId}`, "ORDER_NOT_FOUND"); + if (!canTransition(order.status, "src_locked")) { + throw new OrderValidationError(`cannot record src lock from status ${order.status}`, "VALIDATION_FAILED"); } await this.repo.recordSrcLock(input); this.log.info({ publicId: input.publicId, srcOrderId: input.orderId }, "src lock recorded"); @@ -181,9 +184,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"); } if (order.srcTimelock) { @@ -206,9 +209,9 @@ 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"); @@ -219,14 +222,14 @@ export class OrderService { return this.repo.getMetrics(); } - 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..dfaa8f0 --- /dev/null +++ b/coordinator/test/api-errors.test.ts @@ -0,0 +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/coordinator/test/http-routes.test.ts b/coordinator/test/http-routes.test.ts index 5f56f0e..d2c0685 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/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/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'] } } } 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 e7b3520..a67e3df 100644 --- a/frontend/src/components/TransactionHistory.tsx +++ b/frontend/src/components/TransactionHistory.tsx @@ -9,6 +9,7 @@ import { classifyOrderFreshness } from '../lib/orderFreshness'; import { buildHtlcReceipt } from '../lib/parseHtlcReceipt'; import type { Address } from 'viem'; import HtlcTimeline from './HtlcTimeline'; +import { FAILURE_CODE_CATALOG, type FailureCode } from '@oversync/sdk'; interface Transaction { id: string; @@ -20,6 +21,7 @@ interface Transaction { amount: string; estimatedAmount: string; status: 'pending' | 'completed' | 'cancelled' | 'failed'; + failureCode?: FailureCode; timestamp: number; ethTxHash?: string; stellarTxHash?: string; @@ -484,6 +486,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/frontend/vite.config.ts b/frontend/vite.config.ts index 43bbcd9..249e938 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -62,6 +62,10 @@ 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', ], }, 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/src/types/index.ts b/packages/sdk/src/types/index.ts index 9b0ffe6..6527bc8 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -1,170 +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" + + | "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; } 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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e33d400..016d843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: coordinator: dependencies: + '@oversync/sdk': + specifier: workspace:* + version: link:../packages/sdk '@stellar/stellar-sdk': specifier: ^13.0.0 version: 13.3.0