diff --git a/coordinator/src/persistence/orders-repo.ts b/coordinator/src/persistence/orders-repo.ts index 3db8e3f..64eb99b 100644 --- a/coordinator/src/persistence/orders-repo.ts +++ b/coordinator/src/persistence/orders-repo.ts @@ -603,3 +603,4 @@ function deriveTransitions(status: OrderStatus): string[] { return [status]; } } + diff --git a/coordinator/src/server/routes/orders.ts b/coordinator/src/server/routes/orders.ts index 7aab3de..ada0893 100644 --- a/coordinator/src/server/routes/orders.ts +++ b/coordinator/src/server/routes/orders.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { OrderRow, OrderSnapshot } from "../../persistence/orders-repo.js"; import { announceSchema, OrderService, OrderValidationError } from "../../services/order-service.js"; import { encodeCursor, decodeCursor } from "./cursor-utils.js"; +import { computeRefundEligibility } from "@oversync/sdk"; function orderValidationResponse(err: OrderValidationError): { status: number; body: Record } { if (err.code === "TIMELOCKS_REVERSED" || err.code === "GAP_TOO_SMALL") { @@ -18,6 +19,11 @@ function serialiseOrder(order: OrderRow | null) { direction: order.direction, status: order.status, hashlock: order.hashlock, + refundEligibility: computeRefundEligibility({ + status: order.status, + timelock: order.srcTimelock, + direction: order.direction, + }), src: { chain: order.srcChain, address: order.srcAddress, @@ -153,25 +159,30 @@ export function ordersRoutes(orders: OrderService): Router { } }); - // Parameterized routes come AFTER specific routes - router.get("/orders/:id", async (req, res, next) => { + router.get("/orders/:id/refund-eligibility", async (req, res, next) => { const id = req.params.id; try { - const order = await orders.get(id); - if (!order) { - res.status(404).json({ error: "not_found" }); + const eligibility = await orders.getRefundEligibility(id); + if (eligibility.reasonCode === "unknown_order") { + res.status(404).json({ error: "not_found", refundEligibility: eligibility }); return; } - res.json(serialiseOrder(order)); + res.json({ id, refundEligibility: eligibility }); } catch (err) { next(err); } }); - router.get("/orders/:id/transitions", async (req, res, next) => { + // Parameterized routes come AFTER specific routes + router.get("/orders/:id", async (req, res, next) => { + const id = req.params.id; try { - const transitions = await orders.getTransitions(req.params.id); - res.json({ transitions }); + const order = await orders.get(id); + if (!order) { + res.status(404).json({ error: "not_found" }); + return; + } + res.json(serialiseOrder(order)); } catch (err) { next(err); } diff --git a/coordinator/src/services/order-service.ts b/coordinator/src/services/order-service.ts index 19fd5e8..866db35 100644 --- a/coordinator/src/services/order-service.ts +++ b/coordinator/src/services/order-service.ts @@ -18,6 +18,8 @@ import { validateTimelocksAtCreation, type TimelockValidationError } from "../utils/timelock-validator.js"; +import { computeRefundEligibility, type RefundEligibilityResult } from "@oversync/sdk"; + const HEX32 = /^0x[0-9a-fA-F]{64}$/; const ZERO_HASHLOCK = "0x" + "0".repeat(64); @@ -186,6 +188,19 @@ export class OrderService { return this.repo.getTransitions(publicId); } + async getRefundEligibility(publicId: string, nowUnixSeconds?: number): Promise { + const order = await this.repo.findByPublicId(publicId); + if (!order) { + return computeRefundEligibility({ status: null, nowUnixSeconds }); + } + return computeRefundEligibility({ + status: order.status, + timelock: order.srcTimelock, + direction: order.direction, + nowUnixSeconds, + }); + } + history(address: string, limit?: number, offset?: number): Promise { return this.repo.findByAddress(address, limit, offset); } @@ -266,7 +281,7 @@ export class OrderService { const order = await this.repo.findByPublicId(publicId); if (!order) throw new OrderValidationError(`unknown order ${publicId}`); if (order.status === "secret_revealed") { - if (order.preimage === preimage && order.secretRevealedTx === txHash) return; + if (order.preimage?.toLowerCase() === preimage.toLowerCase()) return; throw new StaleOrderEventError(`conflicting secret event for ${publicId}`); } if (!canTransition(order.status, "secret_revealed")) { diff --git a/coordinator/test/refund-eligibility-route.test.ts b/coordinator/test/refund-eligibility-route.test.ts new file mode 100644 index 0000000..58535b9 --- /dev/null +++ b/coordinator/test/refund-eligibility-route.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from "vitest"; +import request from "supertest"; +import pino from "pino"; +import { createApp } from "../src/server/app.js"; +import type { OrderService } from "../src/services/order-service.js"; +import type { SecretService } from "../src/services/secret-service.js"; +import type { QuoteService } from "../src/services/quote-service.js"; +import type { OrderRow } from "../src/persistence/orders-repo.js"; + +const log = pino({ level: "silent" }); + +const SAMPLE_ORDER: OrderRow = { + id: 1, + publicId: "order-123", + direction: "eth_to_xlm", + status: "src_locked", + hashlock: ("0x" + "a".repeat(64)) as `0x${string}`, + srcChain: "ethereum", + srcAddress: "0x1111111111111111111111111111111111111111", + srcAsset: "native", + srcAmount: "1000000000000000000", + srcSafetyDeposit: "0", + srcOrderId: "1", + srcLockTx: "0xlock", + srcLockBlock: 100, + srcTimelock: Math.floor(Date.now() / 1000) - 500, // Expired + dstChain: "stellar", + dstAddress: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB422", + dstAsset: "native", + dstAmount: "100000000", + dstOrderId: null, + dstLockTx: null, + dstLockBlock: null, + dstTimelock: null, + preimage: null, + secretRevealedTx: null, + resolverAddress: null, + fixture: false, + createdAt: 1700000000, + updatedAt: 1700000100, +}; + +function buildApp() { + const orders = { + announce: vi.fn(), + get: vi.fn().mockImplementation(async (id: string) => { + if (id === "order-123") return SAMPLE_ORDER; + return null; + }), + getRefundEligibility: vi.fn().mockImplementation(async (id: string) => { + if (id === "order-123") { + return { + eligible: true, + reasonCode: "eligible", + reason: "eligible", + timeRemainingSeconds: 0, + }; + } + return { + eligible: false, + reasonCode: "unknown_order", + reason: "unknown order", + timeRemainingSeconds: 0, + }; + }), + history: vi.fn().mockResolvedValue([SAMPLE_ORDER]), + recordSrcLock: vi.fn(), + recordDstLock: vi.fn(), + } as unknown as OrderService; + + const secrets = { reveal: vi.fn(), get: vi.fn() } as unknown as SecretService; + const quotes = {} as unknown as QuoteService; + + const app = createApp({ log, corsOrigins: ["*"], maxRequestBodyBytes: 1024 * 1024, orders, secrets, quotes }); + return { app, orders }; +} + +describe("GET /api/orders/:id/refund-eligibility", () => { + it("returns 200 with refund eligibility details for a valid order", async () => { + const { app } = buildApp(); + + const res = await request(app).get("/api/orders/order-123/refund-eligibility"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + id: "order-123", + refundEligibility: { + eligible: true, + reasonCode: "eligible", + reason: "eligible", + timeRemainingSeconds: 0, + }, + }); + }); + + it("returns 404 with unknown_order reason code for an unknown order", async () => { + const { app } = buildApp(); + + const res = await request(app).get("/api/orders/non-existent/refund-eligibility"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("not_found"); + expect(res.body.refundEligibility).toEqual({ + eligible: false, + reasonCode: "unknown_order", + reason: "unknown order", + timeRemainingSeconds: 0, + }); + }); + + it("includes refundEligibility in GET /api/orders/history response", async () => { + const { app } = buildApp(); + + const res = await request(app).get("/api/orders/history?address=0x1111111111111111111111111111111111111111"); + + expect(res.status).toBe(200); + expect(res.body.transactions).toHaveLength(1); + const tx = res.body.transactions[0]; + expect(tx.id).toBe("order-123"); + expect(tx.refundEligibility).toBeDefined(); + expect(tx.refundEligibility.eligible).toBe(true); + expect(tx.refundEligibility.reasonCode).toBe("eligible"); + }); +}); diff --git a/frontend/src/lib/orderRecovery.ts b/frontend/src/lib/orderRecovery.ts index a6a8d96..93350d8 100644 --- a/frontend/src/lib/orderRecovery.ts +++ b/frontend/src/lib/orderRecovery.ts @@ -17,6 +17,7 @@ */ import { isTestnet } from '../config/networks'; +import { computeRefundEligibility, type RefundEligibilityResult } from '@oversync/sdk'; export interface Transaction { id: string; @@ -50,6 +51,7 @@ export interface Transaction { autoRefundFailed?: boolean; autoRefundError?: string; networkMode?: 'mainnet' | 'testnet'; + refundEligibility?: RefundEligibilityResult; } export interface RecoveryAddresses { @@ -141,6 +143,11 @@ export function mapCoordinatorOrderToTransaction(order: any): Transaction { refundNetwork: isEthToXlm ? 'ethereum' : 'stellar', refundedAt: order.status === 'refunded' ? order.updatedAt * 1000 : undefined, networkMode: isTestnetMode ? 'testnet' : 'mainnet', + refundEligibility: order.refundEligibility ?? computeRefundEligibility({ + status: order.status, + timelock: order.src?.timelock, + direction: order.direction, + }), }; } diff --git a/packages/sdk/src/state-machine/index.ts b/packages/sdk/src/state-machine/index.ts index 051a5dd..1154475 100644 --- a/packages/sdk/src/state-machine/index.ts +++ b/packages/sdk/src/state-machine/index.ts @@ -113,3 +113,11 @@ export { type RefundTimelineInput, type RefundTimelineResult, } from "./refund-timeline.js"; + +export { + computeRefundEligibility, + type RefundEligibilityReasonCode, + type RefundEligibilityInput, + type RefundEligibilityResult, +} from "./refund-eligibility.js"; + diff --git a/packages/sdk/src/state-machine/refund-eligibility.ts b/packages/sdk/src/state-machine/refund-eligibility.ts new file mode 100644 index 0000000..5dfb180 --- /dev/null +++ b/packages/sdk/src/state-machine/refund-eligibility.ts @@ -0,0 +1,85 @@ +import type { Direction, OrderStatus } from "../types/index.js"; + +export type RefundEligibilityReasonCode = + | "not_expired" + | "already_claimed" + | "already_refunded" + | "eligible" + | "unknown_order"; + +export interface RefundEligibilityInput { + status?: OrderStatus | string | null; + timelock?: number | bigint | null; + direction?: Direction | string | null; + nowUnixSeconds?: number; +} + +export interface RefundEligibilityResult { + eligible: boolean; + reasonCode: RefundEligibilityReasonCode; + reason: string; + timeRemainingSeconds: number; +} + +/** + * Computes read-only refund eligibility from an order's status, timelock, + * direction, and current unix timestamp. + */ +export function computeRefundEligibility( + input?: RefundEligibilityInput | null +): RefundEligibilityResult { + const now = input?.nowUnixSeconds ?? Math.floor(Date.now() / 1000); + const status = input?.status; + + // Unknown order + if (!status || status === "unknown" || status === "unknown_order") { + return { + eligible: false, + reasonCode: "unknown_order", + reason: "unknown order", + timeRemainingSeconds: 0, + }; + } + + // Already refunded + if (status === "refunded") { + return { + eligible: false, + reasonCode: "already_refunded", + reason: "already refunded", + timeRemainingSeconds: 0, + }; + } + + // Already claimed + if (status === "completed" || status === "secret_revealed") { + return { + eligible: false, + reasonCode: "already_claimed", + reason: "already claimed", + timeRemainingSeconds: 0, + }; + } + + // Active or failed or expired statuses + const timelock = input?.timelock != null ? Number(input.timelock) : 0; + const isExpired = timelock > 0 && now >= timelock; + + if (isExpired) { + return { + eligible: true, + reasonCode: "eligible", + reason: "eligible", + timeRemainingSeconds: 0, + }; + } + + // Timelock has not expired yet or not yet set on-chain + const timeRemainingSeconds = timelock > 0 ? Math.max(0, timelock - now) : 0; + return { + eligible: false, + reasonCode: "not_expired", + reason: "not expired", + timeRemainingSeconds, + }; +} diff --git a/packages/sdk/test/refund-eligibility.test.ts b/packages/sdk/test/refund-eligibility.test.ts new file mode 100644 index 0000000..de8913b --- /dev/null +++ b/packages/sdk/test/refund-eligibility.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from "vitest"; +import { + computeRefundEligibility, + type RefundEligibilityInput, +} from "../src/state-machine/refund-eligibility.js"; + +const NOW = 1_700_000_000; + +describe("computeRefundEligibility", () => { + describe("Unknown Order", () => { + it("returns unknown_order when input is missing or empty", () => { + const res1 = computeRefundEligibility(); + expect(res1).toEqual({ + eligible: false, + reasonCode: "unknown_order", + reason: "unknown order", + timeRemainingSeconds: 0, + }); + + const res2 = computeRefundEligibility({ status: null }); + expect(res2.reasonCode).toBe("unknown_order"); + + const res3 = computeRefundEligibility({ status: "unknown" }); + expect(res3.reasonCode).toBe("unknown_order"); + }); + }); + + describe("Already Refunded", () => { + it("returns already_refunded for refunded orders regardless of timelock or direction", () => { + const resEth = computeRefundEligibility({ + direction: "eth_to_xlm", + status: "refunded", + timelock: NOW - 100, + nowUnixSeconds: NOW, + }); + expect(resEth).toEqual({ + eligible: false, + reasonCode: "already_refunded", + reason: "already refunded", + timeRemainingSeconds: 0, + }); + + const resXlm = computeRefundEligibility({ + direction: "xlm_to_eth", + status: "refunded", + timelock: NOW - 100, + nowUnixSeconds: NOW, + }); + expect(resXlm).toEqual({ + eligible: false, + reasonCode: "already_refunded", + reason: "already refunded", + timeRemainingSeconds: 0, + }); + }); + }); + + describe("Already Claimed", () => { + it("returns already_claimed when status is completed", () => { + const res = computeRefundEligibility({ + direction: "eth_to_xlm", + status: "completed", + timelock: NOW - 500, + nowUnixSeconds: NOW, + }); + expect(res).toEqual({ + eligible: false, + reasonCode: "already_claimed", + reason: "already claimed", + timeRemainingSeconds: 0, + }); + }); + + it("returns already_claimed when status is secret_revealed", () => { + const res = computeRefundEligibility({ + direction: "xlm_to_eth", + status: "secret_revealed", + timelock: NOW - 500, + nowUnixSeconds: NOW, + }); + expect(res).toEqual({ + eligible: false, + reasonCode: "already_claimed", + reason: "already claimed", + timeRemainingSeconds: 0, + }); + }); + }); + + describe("ETH -> XLM Swap Refund Eligibility", () => { + it("returns not_expired when timelock is in the future for src_locked", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "src_locked", + timelock: NOW + 3600, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(false); + expect(res.reasonCode).toBe("not_expired"); + expect(res.reason).toBe("not expired"); + expect(res.timeRemainingSeconds).toBe(3600); + }); + + it("returns not_expired when timelock is missing/zero for announced", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "announced", + timelock: 0, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(false); + expect(res.reasonCode).toBe("not_expired"); + expect(res.timeRemainingSeconds).toBe(0); + }); + + it("returns eligible when timelock has passed for src_locked", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "src_locked", + timelock: NOW - 100, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + expect(res.reason).toBe("eligible"); + expect(res.timeRemainingSeconds).toBe(0); + }); + + it("returns eligible when timelock has passed for dst_locked", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "dst_locked", + timelock: NOW - 1, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + }); + + it("returns eligible when timelock has passed for failed status", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "failed", + timelock: NOW - 10, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + }); + + it("returns eligible when timelock has passed for expired status", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "expired", + timelock: NOW - 10, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + }); + + it("returns eligible when nowUnixSeconds is exactly equal to timelock", () => { + const input: RefundEligibilityInput = { + direction: "eth_to_xlm", + status: "src_locked", + timelock: NOW, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + }); + }); + + describe("XLM -> ETH Swap Refund Eligibility", () => { + it("returns not_expired when timelock is in the future for src_locked", () => { + const input: RefundEligibilityInput = { + direction: "xlm_to_eth", + status: "src_locked", + timelock: NOW + 1200, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(false); + expect(res.reasonCode).toBe("not_expired"); + expect(res.timeRemainingSeconds).toBe(1200); + }); + + it("returns eligible when timelock has passed for src_locked", () => { + const input: RefundEligibilityInput = { + direction: "xlm_to_eth", + status: "src_locked", + timelock: NOW - 500, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + expect(res.reason).toBe("eligible"); + expect(res.timeRemainingSeconds).toBe(0); + }); + + it("returns eligible when timelock has passed for dst_locked", () => { + const input: RefundEligibilityInput = { + direction: "xlm_to_eth", + status: "dst_locked", + timelock: NOW - 50, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + }); + + it("returns eligible when timelock has passed for failed status", () => { + const input: RefundEligibilityInput = { + direction: "xlm_to_eth", + status: "failed", + timelock: NOW - 100, + nowUnixSeconds: NOW, + }; + const res = computeRefundEligibility(input); + expect(res.eligible).toBe(true); + expect(res.reasonCode).toBe("eligible"); + }); + }); +});