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);
+ });
+});