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
184 changes: 184 additions & 0 deletions frontend/src/components/RefundSimulator.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/refund state simulator/i)).toBeInTheDocument();
});

it('shows read-only simulation disclaimer', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/read-only simulation/i)).toBeInTheDocument();
expect(screen.getByText(/without submitting any transactions/i)).toBeInTheDocument();
});

it('displays eth_to_xlm direction', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/ETH → XLM/)).toBeInTheDocument();
});

it('displays xlm_to_eth direction', () => {
render(
<RefundSimulator
direction="xlm_to_eth"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/XLM → ETH/)).toBeInTheDocument();
});

// ── Phase: claimable ───────────────────────────────────────────────────

it('shows claimable state when both timelocks are in the future', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
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(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW - 100}
dstTimelockUnixSeconds={NOW + 3600}
/>
);
expect(screen.getByText(/Waiting/)).toBeInTheDocument();
expect(screen.getByText(/partial refund available/i)).toBeInTheDocument();
});

it('shows waiting state when only destination timelock has expired', () => {
render(
<RefundSimulator
direction="xlm_to_eth"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW - 100}
/>
);
expect(screen.getByText(/Waiting/)).toBeInTheDocument();
});

// ── Phase: refundable ──────────────────────────────────────────────────

it('shows refundable state when both timelocks have expired', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW - 200}
dstTimelockUnixSeconds={NOW - 100}
/>
);
expect(screen.getByText(/Refundable/)).toBeInTheDocument();
expect(screen.getByText(/both legs/i)).toBeInTheDocument();
});

// ── Leg cards ──────────────────────────────────────────────────────────

it('renders source and destination leg cards', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/Source/)).toBeInTheDocument();
expect(screen.getByText(/Destination/)).toBeInTheDocument();
});

it('shows chain names in leg cards for eth_to_xlm', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/Ethereum/)).toBeInTheDocument();
expect(screen.getByText(/Stellar/)).toBeInTheDocument();
});

it('shows chain names in leg cards for xlm_to_eth', () => {
render(
<RefundSimulator
direction="xlm_to_eth"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/Stellar/)).toBeInTheDocument();
expect(screen.getByText(/Ethereum/)).toBeInTheDocument();
});

it('shows claim and refund parties', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/Claim before expiry/)).toBeInTheDocument();
expect(screen.getByText(/Refund after expiry/)).toBeInTheDocument();
});

it('renders expandable summary section', () => {
render(
<RefundSimulator
direction="eth_to_xlm"
srcTimelockUnixSeconds={NOW + 3600}
dstTimelockUnixSeconds={NOW + 7200}
/>
);
expect(screen.getByText(/Full simulation summary/)).toBeInTheDocument();
});
});
151 changes: 151 additions & 0 deletions frontend/src/components/RefundSimulator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border border-cyan-200/20 bg-[#0d1225]/80 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-gray-400">
{label} — {leg.chain}
</span>
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
leg.expired
? "bg-red-500/15 text-red-300"
: "bg-emerald-500/15 text-emerald-300"
}`}
>
{leg.expired ? "expired" : "active"}
</span>
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-400">Timelock</span>
<span className="text-white font-mono">
{new Date(leg.timelockUnix * 1000).toISOString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Claim before expiry</span>
<span className="text-white">{leg.claimParty}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Refund after expiry</span>
<span className="text-white">{leg.refundParty}</span>
</div>
</div>
</div>
);
}

function PhaseBadge({ phase }: { phase: SimulatedPhase }) {
if (phase === "refundable") {
return (
<div className="flex items-center gap-2 rounded-lg bg-red-500/10 border border-red-500/30 p-3">
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0" />
<div className="text-sm">
<p className="text-red-300 font-medium">Refundable</p>
<p className="text-gray-400 text-xs">
Both timelocks have expired. Refund is available on both legs.
</p>
</div>
</div>
);
}
if (phase === "waiting") {
return (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 p-3">
<Clock className="h-5 w-5 text-amber-400 shrink-0" />
<div className="text-sm">
<p className="text-amber-300 font-medium">Waiting</p>
<p className="text-gray-400 text-xs">
One timelock has expired while the other is still active. Partial refund available.
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/30 p-3">
<ShieldCheck className="h-5 w-5 text-emerald-400 shrink-0" />
<div className="text-sm">
<p className="text-emerald-300 font-medium">Claimable</p>
<p className="text-gray-400 text-xs">
Both timelocks are active. The beneficiary can claim on either chain.
</p>
</div>
</div>
);
}

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 (
<div className="max-w-lg rounded-2xl border border-cyan-200/20 bg-[#070b1c]/95 p-5 shadow-2xl shadow-black/55 backdrop-blur-2xl w-full space-y-4">
<div className="flex items-center gap-2 mb-1">
<Info className="h-4 w-4 text-cyan-400" />
<h2 className="text-base font-bold text-white">Refund state simulator</h2>
</div>

<p className="text-xs text-gray-500 italic leading-relaxed border-l-2 border-cyan-400/30 pl-3">
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.
</p>

<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">Direction</span>
<span className="rounded-full bg-cyan-500/15 px-3 py-0.5 text-sm font-semibold text-cyan-300">
{direction === "eth_to_xlm" ? "ETH → XLM" : "XLM → ETH"}
</span>
</div>

<PhaseBadge phase={sim.phase} />

<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<LegCard leg={sim.src} label="Source" />
<LegCard leg={sim.dst} label="Destination" />
</div>

<details className="group">
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-300 transition-colors select-none">
Full simulation summary
</summary>
<p className="mt-2 text-xs text-gray-400 leading-relaxed whitespace-pre-wrap">
{sim.summary}
</p>
</details>
</div>
);
}

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