diff --git a/frontend/src/components/RefundSimulator.test.tsx b/frontend/src/components/RefundSimulator.test.tsx new file mode 100644 index 0000000..e5600fe --- /dev/null +++ b/frontend/src/components/RefundSimulator.test.tsx @@ -0,0 +1,184 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RefundSimulator } from './RefundSimulator'; + +// Stub Date.now() so timelock comparisons are deterministic +const NOW = 1_700_000_000; + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useEffect: (fn: () => void | (() => void)) => { fn(); }, + }; +}); + +describe('RefundSimulator', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW * 1000)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the simulation header', () => { + render( + + ); + expect(screen.getByText(/refund state simulator/i)).toBeInTheDocument(); + }); + + it('shows read-only simulation disclaimer', () => { + render( + + ); + expect(screen.getByText(/read-only simulation/i)).toBeInTheDocument(); + expect(screen.getByText(/without submitting any transactions/i)).toBeInTheDocument(); + }); + + it('displays eth_to_xlm direction', () => { + render( + + ); + expect(screen.getByText(/ETH → XLM/)).toBeInTheDocument(); + }); + + it('displays xlm_to_eth direction', () => { + render( + + ); + expect(screen.getByText(/XLM → ETH/)).toBeInTheDocument(); + }); + + // ── Phase: claimable ─────────────────────────────────────────────────── + + it('shows claimable state when both timelocks are in the future', () => { + render( + + ); + expect(screen.getByText(/Claimable/)).toBeInTheDocument(); + expect(screen.getByText(/beneficiary can claim/i)).toBeInTheDocument(); + }); + + // ── Phase: waiting ───────────────────────────────────────────────────── + + it('shows waiting state when only source timelock has expired', () => { + render( + + ); + expect(screen.getByText(/Waiting/)).toBeInTheDocument(); + expect(screen.getByText(/partial refund available/i)).toBeInTheDocument(); + }); + + it('shows waiting state when only destination timelock has expired', () => { + render( + + ); + expect(screen.getByText(/Waiting/)).toBeInTheDocument(); + }); + + // ── Phase: refundable ────────────────────────────────────────────────── + + it('shows refundable state when both timelocks have expired', () => { + render( + + ); + expect(screen.getByText(/Refundable/)).toBeInTheDocument(); + expect(screen.getByText(/both legs/i)).toBeInTheDocument(); + }); + + // ── Leg cards ────────────────────────────────────────────────────────── + + it('renders source and destination leg cards', () => { + render( + + ); + expect(screen.getByText(/Source/)).toBeInTheDocument(); + expect(screen.getByText(/Destination/)).toBeInTheDocument(); + }); + + it('shows chain names in leg cards for eth_to_xlm', () => { + render( + + ); + expect(screen.getByText(/Ethereum/)).toBeInTheDocument(); + expect(screen.getByText(/Stellar/)).toBeInTheDocument(); + }); + + it('shows chain names in leg cards for xlm_to_eth', () => { + render( + + ); + expect(screen.getByText(/Stellar/)).toBeInTheDocument(); + expect(screen.getByText(/Ethereum/)).toBeInTheDocument(); + }); + + it('shows claim and refund parties', () => { + render( + + ); + expect(screen.getByText(/Claim before expiry/)).toBeInTheDocument(); + expect(screen.getByText(/Refund after expiry/)).toBeInTheDocument(); + }); + + it('renders expandable summary section', () => { + render( + + ); + expect(screen.getByText(/Full simulation summary/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/RefundSimulator.tsx b/frontend/src/components/RefundSimulator.tsx new file mode 100644 index 0000000..dad2bc1 --- /dev/null +++ b/frontend/src/components/RefundSimulator.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState, useEffect } from "react"; +import { Clock, ShieldCheck, AlertTriangle, Info } from "lucide-react"; +import { simulateRefund } from "@oversync/sdk"; +import type { Direction, SimulatedPhase, LegSimulation } from "@oversync/sdk"; + +export interface RefundSimulatorProps { + direction: Direction; + srcTimelockUnixSeconds: number; + dstTimelockUnixSeconds: number; +} + +function LegCard({ leg, label }: { leg: LegSimulation; label: string }) { + return ( +
+
+ + {label} — {leg.chain} + + + {leg.expired ? "expired" : "active"} + +
+
+
+ Timelock + + {new Date(leg.timelockUnix * 1000).toISOString()} + +
+
+ Claim before expiry + {leg.claimParty} +
+
+ Refund after expiry + {leg.refundParty} +
+
+
+ ); +} + +function PhaseBadge({ phase }: { phase: SimulatedPhase }) { + if (phase === "refundable") { + return ( +
+ +
+

Refundable

+

+ Both timelocks have expired. Refund is available on both legs. +

+
+
+ ); + } + if (phase === "waiting") { + return ( +
+ +
+

Waiting

+

+ One timelock has expired while the other is still active. Partial refund available. +

+
+
+ ); + } + return ( +
+ +
+

Claimable

+

+ Both timelocks are active. The beneficiary can claim on either chain. +

+
+
+ ); +} + +export function RefundSimulator({ + direction, + srcTimelockUnixSeconds, + dstTimelockUnixSeconds, +}: RefundSimulatorProps) { + const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); + + useEffect(() => { + const id = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), 10_000); + return () => window.clearInterval(id); + }, []); + + const sim = useMemo( + () => + simulateRefund({ + direction, + srcTimelockUnixSeconds, + dstTimelockUnixSeconds, + nowUnixSeconds: now, + }), + [direction, srcTimelockUnixSeconds, dstTimelockUnixSeconds, now] + ); + + return ( +
+
+ +

Refund state simulator

+
+ +

+ This is a read-only simulation. It explains the refund state of the cross-chain + swap without submitting any transactions. All information is for educational + purposes only. +

+ +
+ Direction + + {direction === "eth_to_xlm" ? "ETH → XLM" : "XLM → ETH"} + +
+ + + +
+ + +
+ +
+ + Full simulation summary + +

+ {sim.summary} +

+
+
+ ); +} + +export default RefundSimulator; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7f46ee0..efd3b78 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,6 +1,8 @@ export * from "./types/index.js"; export * from "./secrets/index.js"; export * from "./state-machine/index.js"; +export type { SimulatedPhase, LegSimulation, RefundSimulation } from "./state-machine/refund-simulator.js"; +export { simulateRefund } from "./state-machine/refund-simulator.js"; export * from "./assets/index.js"; export { EthereumHTLCClient, diff --git a/packages/sdk/src/state-machine/index.ts b/packages/sdk/src/state-machine/index.ts index 7ac03f7..49e8f4c 100644 --- a/packages/sdk/src/state-machine/index.ts +++ b/packages/sdk/src/state-machine/index.ts @@ -34,3 +34,10 @@ export function isTerminal(status: OrderStatus): boolean { export function nextStatesOf(status: OrderStatus): OrderStatus[] { return [...TRANSITIONS[status]]; } + +export { simulateRefund } from "./refund-simulator.js"; +export type { + SimulatedPhase, + LegSimulation, + RefundSimulation, +} from "./refund-simulator.js"; diff --git a/packages/sdk/src/state-machine/refund-simulator.ts b/packages/sdk/src/state-machine/refund-simulator.ts new file mode 100644 index 0000000..3c79260 --- /dev/null +++ b/packages/sdk/src/state-machine/refund-simulator.ts @@ -0,0 +1,146 @@ +import type { Direction } from "../types/index.js"; + +export type SimulatedPhase = "claimable" | "waiting" | "refundable"; + +export interface LegSimulation { + chain: string; + timelockUnix: number; + expired: boolean; + claimParty: string; + refundParty: string; +} + +export interface RefundSimulation { + direction: Direction; + src: LegSimulation; + dst: LegSimulation; + phase: SimulatedPhase; + summary: string; + readonly: true; +} + +interface DirectionInfo { + src: { + chain: string; + claimParty: string; + refundParty: string; + }; + dst: { + chain: string; + claimParty: string; + refundParty: string; + }; +} + +const DIRECTION_INFO: Record = { + eth_to_xlm: { + src: { + chain: "Ethereum", + claimParty: "Recipient (Stellar address)", + refundParty: "Sender (refundAddress)", + }, + dst: { + chain: "Stellar", + claimParty: "Recipient (you)", + refundParty: "Resolver", + }, + }, + xlm_to_eth: { + src: { + chain: "Stellar", + claimParty: "Recipient (Ethereum address)", + refundParty: "Sender (refundAddress)", + }, + dst: { + chain: "Ethereum", + claimParty: "Recipient (you)", + refundParty: "Resolver", + }, + }, +}; + +export function simulateRefund(options: { + direction: Direction; + srcTimelockUnixSeconds: number; + dstTimelockUnixSeconds: number; + nowUnixSeconds?: number; +}): RefundSimulation { + const now = options.nowUnixSeconds ?? Math.floor(Date.now() / 1000); + const dir = DIRECTION_INFO[options.direction]; + + const srcExpired = now >= options.srcTimelockUnixSeconds; + const dstExpired = now >= options.dstTimelockUnixSeconds; + + let phase: SimulatedPhase; + if (!srcExpired && !dstExpired) { + phase = "claimable"; + } else if (srcExpired && dstExpired) { + phase = "refundable"; + } else { + phase = "waiting"; + } + + const descriptions: string[] = []; + descriptions.push( + `[read-only simulation] ${options.direction === "eth_to_xlm" ? "ETH → XLM" : "XLM → ETH"}` + ); + + if (phase === "claimable") { + descriptions.push( + `Both timelocks are still in the future. The swap is in progress.` + ); + descriptions.push( + `Before expiry: ${dir.src.claimParty} can claim on ${dir.src.chain}, ${dir.dst.claimParty} can claim on ${dir.dst.chain}.` + ); + } else if (phase === "refundable") { + descriptions.push( + `Both timelocks have expired. Refund is available on both legs.` + ); + descriptions.push( + `${dir.src.refundParty} can refund on ${dir.src.chain}, ${dir.dst.refundParty} can refund on ${dir.dst.chain}.` + ); + } else { + descriptions.push( + `One timelock has expired while the other is still active.` + ); + if (srcExpired) { + descriptions.push( + `${dir.src.chain} timelock has expired — ${dir.src.refundParty} can refund on ${dir.src.chain}.` + ); + } else { + descriptions.push( + `${dir.src.chain} timelock is still active — ${dir.src.claimParty} can still claim.` + ); + } + if (dstExpired) { + descriptions.push( + `${dir.dst.chain} timelock has expired — ${dir.dst.refundParty} can refund on ${dir.dst.chain}.` + ); + } else { + descriptions.push( + `${dir.dst.chain} timelock is still active — ${dir.dst.claimParty} can still claim.` + ); + } + } + + return { + direction: options.direction, + src: { + chain: dir.src.chain, + timelockUnix: options.srcTimelockUnixSeconds, + expired: srcExpired, + claimParty: dir.src.claimParty, + refundParty: dir.src.refundParty, + }, + dst: { + chain: dir.dst.chain, + timelockUnix: options.dstTimelockUnixSeconds, + expired: dstExpired, + claimParty: dir.dst.claimParty, + refundParty: dir.dst.refundParty, + }, + phase, + summary: descriptions.join(" "), + readonly: true, + }; +} diff --git a/packages/sdk/test/refund-simulator.test.ts b/packages/sdk/test/refund-simulator.test.ts new file mode 100644 index 0000000..8304c39 --- /dev/null +++ b/packages/sdk/test/refund-simulator.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { simulateRefund } from "../src/state-machine/refund-simulator.js"; +import type { RefundSimulation, SimulatedPhase } from "../src/state-machine/refund-simulator.js"; + +describe("refund simulator", () => { + const FAR_FUTURE = 4_000_000_000; + const FAR_PAST = 1_000_000_000; + const NOW = 2_000_000_000; + + // ── Phase: claimable (both timelocks active) ──────────────────────────── + + it("returns claimable when both timelocks are in the future", () => { + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.phase).toBe("claimable"); + expect(result.src.expired).toBe(false); + expect(result.dst.expired).toBe(false); + }); + + // ── Phase: waiting (one expired, one active) ──────────────────────────── + + it("returns waiting when only source timelock has expired", () => { + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: FAR_PAST, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.phase).toBe("waiting"); + expect(result.src.expired).toBe(true); + expect(result.dst.expired).toBe(false); + expect(result.summary).toContain("One timelock has expired"); + }); + + it("returns waiting when only destination timelock has expired", () => { + const result = simulateRefund({ + direction: "xlm_to_eth", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_PAST, + nowUnixSeconds: NOW, + }); + expect(result.phase).toBe("waiting"); + expect(result.src.expired).toBe(false); + expect(result.dst.expired).toBe(true); + expect(result.summary).toContain("One timelock has expired"); + }); + + // ── Phase: refundable (both expired) ──────────────────────────────────── + + it("returns refundable when both timelocks have expired", () => { + const result = simulateRefund({ + direction: "xlm_to_eth", + srcTimelockUnixSeconds: FAR_PAST, + dstTimelockUnixSeconds: FAR_PAST, + nowUnixSeconds: NOW, + }); + expect(result.phase).toBe("refundable"); + expect(result.src.expired).toBe(true); + expect(result.dst.expired).toBe(true); + expect(result.summary).toContain("Both timelocks have expired"); + }); + + // ── Direction handling ───────────────────────────────────────────────── + + it("handles eth_to_xlm direction correctly", () => { + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.src.chain).toBe("Ethereum"); + expect(result.dst.chain).toBe("Stellar"); + expect(result.summary).toContain("ETH → XLM"); + }); + + it("handles xlm_to_eth direction correctly", () => { + const result = simulateRefund({ + direction: "xlm_to_eth", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.src.chain).toBe("Stellar"); + expect(result.dst.chain).toBe("Ethereum"); + expect(result.summary).toContain("XLM → ETH"); + }); + + it("uses current time when nowUnixSeconds is not provided", () => { + const past = Math.floor(Date.now() / 1000) - 100; + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: past, + dstTimelockUnixSeconds: past, + }); + expect(result.phase).toBe("refundable"); + }); + + // ── Per-leg metadata ─────────────────────────────────────────────────── + + it("reports who can claim and refund per leg for eth_to_xlm", () => { + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.src.claimParty).toBe("Recipient (Stellar address)"); + expect(result.src.refundParty).toBe("Sender (refundAddress)"); + expect(result.dst.claimParty).toBe("Recipient (you)"); + expect(result.dst.refundParty).toBe("Resolver"); + }); + + it("reports who can claim and refund per leg for xlm_to_eth", () => { + const result = simulateRefund({ + direction: "xlm_to_eth", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.src.claimParty).toBe("Recipient (Ethereum address)"); + expect(result.src.refundParty).toBe("Sender (refundAddress)"); + expect(result.dst.claimParty).toBe("Recipient (you)"); + expect(result.dst.refundParty).toBe("Resolver"); + }); + + // ── Read-only marker ─────────────────────────────────────────────────── + + it("returns readonly marker", () => { + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.readonly).toBe(true); + }); + + it("generates summary that mentions simulation", () => { + const result = simulateRefund({ + direction: "eth_to_xlm", + srcTimelockUnixSeconds: FAR_FUTURE, + dstTimelockUnixSeconds: FAR_FUTURE, + nowUnixSeconds: NOW, + }); + expect(result.summary).toMatch(/read-only\s*simulation/i); + }); +});