Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions coordinator/src/persistence/orders-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,4 @@ function deriveTransitions(status: OrderStatus): string[] {
return [status];
}
}

29 changes: 20 additions & 9 deletions coordinator/src/server/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } {
if (err.code === "TIMELOCKS_REVERSED" || err.code === "GAP_TOO_SMALL") {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
17 changes: 16 additions & 1 deletion coordinator/src/services/order-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -186,6 +188,19 @@ export class OrderService {
return this.repo.getTransitions(publicId);
}

async getRefundEligibility(publicId: string, nowUnixSeconds?: number): Promise<RefundEligibilityResult> {
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<OrderRow[]> {
return this.repo.findByAddress(address, limit, offset);
}
Expand Down Expand Up @@ -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")) {
Expand Down
124 changes: 124 additions & 0 deletions coordinator/test/refund-eligibility-route.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
7 changes: 7 additions & 0 deletions frontend/src/lib/orderRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { isTestnet } from '../config/networks';
import { computeRefundEligibility, type RefundEligibilityResult } from '@oversync/sdk';

export interface Transaction {
id: string;
Expand Down Expand Up @@ -50,6 +51,7 @@ export interface Transaction {
autoRefundFailed?: boolean;
autoRefundError?: string;
networkMode?: 'mainnet' | 'testnet';
refundEligibility?: RefundEligibilityResult;
}

export interface RecoveryAddresses {
Expand Down Expand Up @@ -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,
}),
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/sdk/src/state-machine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

85 changes: 85 additions & 0 deletions packages/sdk/src/state-machine/refund-eligibility.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading