From 9413b3d6f7d1c7b0c04eaae1c85e844e946788fc Mon Sep 17 00:00:00 2001
From: Zainab Wahab
Date: Sat, 26 Sep 2026 19:02:32 +0100
Subject: [PATCH] feat: add atomic swap proposal and acceptance UI
---
src/components/common/AtomicSwapHistory.tsx | 194 ++++++++
.../common/AtomicSwapProposalForm.tsx | 462 ++++++++++++++++++
.../common/AtomicSwapProposalReview.tsx | 287 +++++++++++
src/hooks/useAtomicSwap.ts | 82 ++++
src/lib/queryKeys.ts | 7 +
src/pages/AtomicSwapCreatePage.tsx | 198 ++++++++
src/pages/AtomicSwapProposalPage.tsx | 14 +
src/pages/LandingPage.tsx | 13 +-
src/pages/ProfilePage.tsx | 32 +-
src/routes.tsx | 10 +
src/services/atomicSwap.service.ts | 319 ++++++++++++
src/types/atomicSwap.ts | 75 +++
12 files changed, 1690 insertions(+), 3 deletions(-)
create mode 100644 src/components/common/AtomicSwapHistory.tsx
create mode 100644 src/components/common/AtomicSwapProposalForm.tsx
create mode 100644 src/components/common/AtomicSwapProposalReview.tsx
create mode 100644 src/hooks/useAtomicSwap.ts
create mode 100644 src/pages/AtomicSwapCreatePage.tsx
create mode 100644 src/pages/AtomicSwapProposalPage.tsx
create mode 100644 src/services/atomicSwap.service.ts
create mode 100644 src/types/atomicSwap.ts
diff --git a/src/components/common/AtomicSwapHistory.tsx b/src/components/common/AtomicSwapHistory.tsx
new file mode 100644
index 00000000..0887f44b
--- /dev/null
+++ b/src/components/common/AtomicSwapHistory.tsx
@@ -0,0 +1,194 @@
+import React, { useMemo } from 'react';
+import { ArrowLeftRight, ClockIcon } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import EmptyState from '@/components/common/EmptyState';
+import { cn } from '@/lib/utils';
+import { formatRelativeTime } from '@/utils/time.utils';
+import { formatNumber } from '@/utils/numberFormat.utils';
+import type { SwapHistoryEntry } from '@/types/atomicSwap';
+import { useAtomicSwapHistory } from '@/hooks/useAtomicSwap';
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+function SwapTypePill() {
+ return (
+
+
+ Atomic Swap
+
+ );
+}
+
+// ─── skeleton loader ───────────────────────────────────────────────────────────
+
+function SkeletonRow() {
+ return (
+
+ );
+}
+
+// ─── single row ───────────────────────────────────────────────────────────────
+
+interface SwapRowProps {
+ swap: SwapHistoryEntry;
+}
+
+function SwapRow({ swap }: SwapRowProps) {
+ return (
+
+ {/* Keys exchanged */}
+
+
+
+
+ {swap.offeredKeyName} ↔ {swap.receivedKeyName}
+
+
+
+ Atomic swap
+
+
+
+ {/* Numeric columns */}
+
+
+ You gave
+ {formatNumber(swap.offeredAmount)} {swap.offeredKeyName}
+
+
+
+ You received
+ {formatNumber(swap.receivedAmount)} {swap.receivedKeyName}
+
+
+
+ When
+
+ {formatRelativeTime(swap.timestamp)}
+
+
+
+
+ Status
+
+ {swap.status}
+
+
+
+
+ {/* Expanded details */}
+
+
+ Details
+ ▼
+
+
+
Proposer: {swap.proposerAddress.slice(0, 8)}…{swap.proposerAddress.slice(-6)}
+
Counterparty: {swap.counterpartyAddress.slice(0, 8)}…{swap.counterpartyAddress.slice(-6)}
+
Tx hash: {swap.transactionHash}
+
+
+
+ );
+}
+
+// ─── main component ────────────────────────────────────────────────────────────
+
+interface AtomicSwapHistoryProps {
+ walletAddress: string;
+}
+
+const AtomicSwapHistory: React.FC = ({ walletAddress }) => {
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError } = useAtomicSwapHistory(walletAddress);
+
+ const swaps = useMemo(() => {
+ const seen = new Set();
+ const result: SwapHistoryEntry[] = [];
+ for (const page of data?.pages ?? []) {
+ for (const swap of page.swaps) {
+ if (seen.has(swap.id)) continue;
+ seen.add(swap.id);
+ result.push(swap);
+ }
+ }
+ return result;
+ }, [data]);
+
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => )}
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ );
+ }
+
+ if (swaps.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Column header row */}
+
+
Exchange
+
+ You gave
+ You received
+
+
+ When
+
+ Status
+
+
+
+
+ {swaps.map(swap => )}
+
+
+ {hasNextPage && (
+
+ void fetchNextPage()}
+ disabled={isFetchingNextPage}
+ data-testid="atomic-swap-history-load-more"
+ className="rounded-xl border-white/15 bg-white/5 px-8 text-white/80 hover:bg-white/10 hover:text-white"
+ >
+ {isFetchingNextPage ? 'Loading…' : 'Load More'}
+
+
+ )}
+
+ );
+};
+
+export default AtomicSwapHistory;
\ No newline at end of file
diff --git a/src/components/common/AtomicSwapProposalForm.tsx b/src/components/common/AtomicSwapProposalForm.tsx
new file mode 100644
index 00000000..28430286
--- /dev/null
+++ b/src/components/common/AtomicSwapProposalForm.tsx
@@ -0,0 +1,462 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { StableButtonContent } from '@/components/ui/stable-button-content';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { cn } from '@/lib/utils';
+import { formatNumber } from '@/utils/numberFormat.utils';
+import { useWalletHoldings } from '@/hooks/useWallet';
+import { copyTextToClipboard } from '@/utils/clipboard.utils';
+import showToast from '@/utils/toast.util';
+import { useCreateAtomicSwapProposal } from '@/hooks/useAtomicSwap';
+import type { Course } from '@/services/course.service';
+
+interface AtomicSwapProposalFormProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ creators: Course[];
+ walletAddress: string;
+ isSubmitting?: boolean;
+}
+
+const MAX_EXPIRY_HOURS = 168; // 1 week
+
+export const AtomicSwapProposalForm: React.FC = ({
+ open,
+ onOpenChange,
+ creators,
+ walletAddress,
+ isSubmitting = false,
+}) => {
+ const { data: holdings = [] } = useWalletHoldings(walletAddress);
+ const createProposalMutation = useCreateAtomicSwapProposal(walletAddress);
+
+ const [offeredKeyId, setOfferedKeyId] = useState('');
+ const [offeredAmount, setOfferedAmount] = useState('1');
+ const [desiredKeyId, setDesiredKeyId] = useState('');
+ const [desiredAmount, setDesiredAmount] = useState('1');
+ const [expiryHours, setExpiryHours] = useState(24);
+ const [touched, setTouched] = useState>({});
+ const [generatedLink, setGeneratedLink] = useState('');
+ const [showLinkDialog, setShowLinkDialog] = useState(false);
+
+ const offeredKey = useMemo(() => creators.find(c => c.id === offeredKeyId), [creators, offeredKeyId]);
+ const desiredKey = useMemo(() => creators.find(c => c.id === desiredKeyId), [creators, desiredKeyId]);
+ const offeredHolding = useMemo(
+ () => holdings.find(h => h.creatorId === offeredKeyId)?.quantity ?? 0,
+ [holdings, offeredKeyId]
+ );
+
+ const parsedOfferedAmount = useMemo(() => Number(offeredAmount), [offeredAmount]);
+ const parsedDesiredAmount = useMemo(() => Number(desiredAmount), [desiredAmount]);
+
+ const validationErrors = useMemo((): Record => {
+ const errors: Record = {};
+
+ if (touched.offeredKey && !offeredKeyId) errors.offeredKey = 'Please select a key to offer';
+ if (touched.desiredKey && !desiredKeyId) errors.desiredKey = 'Please select a key you want';
+ if (touched.offeredAmount && (!offeredAmount.trim() || !Number.isFinite(parsedOfferedAmount))) {
+ errors.offeredAmount = 'Please enter a valid amount';
+ }
+ if (touched.desiredAmount && (!desiredAmount.trim() || !Number.isFinite(parsedDesiredAmount))) {
+ errors.desiredAmount = 'Please enter a valid amount';
+ }
+ if (touched.offeredAmount && parsedOfferedAmount <= 0) errors.offeredAmount = 'Amount must be greater than zero';
+ if (touched.desiredAmount && parsedDesiredAmount <= 0) errors.desiredAmount = 'Amount must be greater than zero';
+ if (touched.offeredAmount && parsedOfferedAmount > offeredHolding) {
+ errors.offeredAmount = `You only have ${formatNumber(offeredHolding)} ${offeredKey?.title ?? 'this key'}`;
+ }
+ if (touched.offeredKey && touched.desiredKey && offeredKeyId && desiredKeyId && offeredKeyId === desiredKeyId) {
+ errors.desiredKey = 'Cannot swap the same key with itself';
+ }
+ if (touched.expiryHours && (!expiryHours || expiryHours < 1 || expiryHours > MAX_EXPIRY_HOURS)) {
+ errors.expiryHours = `Expiry must be between 1 and ${MAX_EXPIRY_HOURS} hours`;
+ }
+
+ return errors;
+ }, [touched, offeredKeyId, desiredKeyId, offeredAmount, desiredAmount, parsedOfferedAmount, parsedDesiredAmount, offeredHolding, offeredKey?.title, expiryHours]);
+
+ const isFormValid = offeredKeyId && desiredKeyId && offeredAmount.trim() && desiredAmount.trim() &&
+ Number.isFinite(parsedOfferedAmount) && Number.isFinite(parsedDesiredAmount) &&
+ parsedOfferedAmount > 0 && parsedDesiredAmount > 0 &&
+ parsedOfferedAmount <= offeredHolding &&
+ offeredKeyId !== desiredKeyId &&
+ expiryHours >= 1 && expiryHours <= MAX_EXPIRY_HOURS;
+
+ const handleBlur = (field: string) => {
+ setTouched(prev => ({ ...prev, [field]: true }));
+ };
+
+ const handleSubmit = async () => {
+ setTouched({
+ offeredKey: true,
+ desiredKey: true,
+ offeredAmount: true,
+ desiredAmount: true,
+ expiryHours: true,
+ });
+
+ if (!isFormValid) return;
+
+ const proposal = await createProposalMutation.mutateAsync({
+ proposerAddress: walletAddress,
+ offeredKeyId,
+ offeredKeyName: offeredKey?.title ?? 'Unknown',
+ offeredAmount: parsedOfferedAmount,
+ desiredKeyId,
+ desiredKeyName: desiredKey?.title ?? 'Unknown',
+ desiredAmount: parsedDesiredAmount,
+ expiryHours,
+ });
+
+ if (proposal) {
+ const proposalUrl = `${window.location.origin}/swap/${proposal.id}`;
+ setGeneratedLink(proposalUrl);
+ setShowLinkDialog(true);
+ setOfferedKeyId('');
+ setOfferedAmount('1');
+ setDesiredKeyId('');
+ setDesiredAmount('1');
+ setExpiryHours(24);
+ setTouched({});
+ }
+ };
+
+ const handleCopyLink = async () => {
+ try {
+ await copyTextToClipboard(generatedLink);
+ showToast.success('Link copied');
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Clipboard API is unavailable')) {
+ showToast.error('Could not copy');
+ } else {
+ showToast.error('Copy failed');
+ }
+ }
+ };
+
+ const handleCloseLinkDialog = () => {
+ setShowLinkDialog(false);
+ setGeneratedLink('');
+ onOpenChange(false);
+ };
+
+ useEffect(() => {
+ if (!open) {
+ setOfferedKeyId('');
+ setOfferedAmount('1');
+ setDesiredKeyId('');
+ setDesiredAmount('1');
+ setExpiryHours(24);
+ setTouched({});
+ setGeneratedLink('');
+ setShowLinkDialog(false);
+ }
+ }, [open]);
+
+ const heldKeysWithQuantity = useMemo(() => {
+ return creators.filter(creator =>
+ holdings.some(h => h.creatorId === creator.id && (h.quantity ?? 0) > 0)
+ );
+ }, [creators, holdings]);
+
+ return (
+ <>
+ !isSubmitting && !createProposalMutation.isPending && onOpenChange(next)}>
+
+
+ Create Atomic Swap Proposal
+
+ Offer your creator keys in exchange for another creator's keys. The swap executes atomically —
+ both sides transfer simultaneously or neither does.
+
+
+
+
+ {/* Offered Side */}
+
+ You offer
+
+
+
+ Key to offer
+
+
setOfferedKeyId(e.target.value)}
+ onBlur={() => handleBlur('offeredKey')}
+ disabled={isSubmitting || createProposalMutation.isPending}
+ className={cn(
+ 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
+ validationErrors.offeredKey ? 'border-red-500/60' : 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15'
+ )}
+ aria-invalid={!!validationErrors.offeredKey}
+ aria-describedby={validationErrors.offeredKey ? 'offered-key-error' : undefined}
+ >
+ Select a key you own
+ {heldKeysWithQuantity.map(creator => {
+ const holding = holdings.find(h => h.creatorId === creator.id);
+ return (
+
+ {creator.title} ({formatNumber(holding?.quantity ?? 0)} held)
+
+ );
+ })}
+
+ {validationErrors.offeredKey && (
+
+ {validationErrors.offeredKey}
+
+ )}
+
+
+
+
+ Amount to offer
+
+
setOfferedAmount(e.target.value)}
+ onBlur={() => handleBlur('offeredAmount')}
+ disabled={isSubmitting || createProposalMutation.isPending || !offeredKeyId}
+ className={cn(
+ 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
+ validationErrors.offeredAmount ? 'border-red-500/60' : 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15'
+ )}
+ aria-invalid={!!validationErrors.offeredAmount}
+ aria-describedby={validationErrors.offeredAmount ? 'offered-amount-error' : undefined}
+ placeholder={offeredKeyId ? 'Enter amount' : 'Select a key first'}
+ />
+ {validationErrors.offeredAmount && (
+
+ {validationErrors.offeredAmount}
+
+ )}
+ {offeredKeyId && (
+
+ Available: {formatNumber(offeredHolding)} {offeredKey?.title ?? 'keys'}
+
+ )}
+
+
+
+
+ {/* Desired Side */}
+
+ You want
+
+
+
+ Key you want
+
+
setDesiredKeyId(e.target.value)}
+ onBlur={() => handleBlur('desiredKey')}
+ disabled={isSubmitting || createProposalMutation.isPending}
+ className={cn(
+ 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
+ validationErrors.desiredKey ? 'border-red-500/60' : 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15'
+ )}
+ aria-invalid={!!validationErrors.desiredKey}
+ aria-describedby={validationErrors.desiredKey ? 'desired-key-error' : undefined}
+ >
+ Select a key you want
+ {creators
+ .filter(c => c.id !== offeredKeyId)
+ .map(creator => (
+
+ {creator.title} ({formatNumber(creator.creatorShareSupply ?? 0)} supply)
+
+ ))}
+
+ {validationErrors.desiredKey && (
+
+ {validationErrors.desiredKey}
+
+ )}
+
+
+
+
+ Amount wanted
+
+
setDesiredAmount(e.target.value)}
+ onBlur={() => handleBlur('desiredAmount')}
+ disabled={isSubmitting || createProposalMutation.isPending || !desiredKeyId}
+ className={cn(
+ 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
+ validationErrors.desiredAmount ? 'border-red-500/60' : 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15'
+ )}
+ aria-invalid={!!validationErrors.desiredAmount}
+ aria-describedby={validationErrors.desiredAmount ? 'desired-amount-error' : undefined}
+ placeholder={desiredKeyId ? 'Enter amount' : 'Select a key first'}
+ />
+ {validationErrors.desiredAmount && (
+
+ {validationErrors.desiredAmount}
+
+ )}
+
+
+
+
+ {/* Expiry */}
+
+
+ Proposal expires in (hours)
+
+
setExpiryHours(Number(e.target.value) || 1)}
+ onBlur={() => handleBlur('expiryHours')}
+ disabled={isSubmitting || createProposalMutation.isPending}
+ className={cn(
+ 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
+ validationErrors.expiryHours ? 'border-red-500/60' : 'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15'
+ )}
+ aria-invalid={!!validationErrors.expiryHours}
+ aria-describedby={validationErrors.expiryHours ? 'expiry-hours-error' : undefined}
+ />
+ {validationErrors.expiryHours && (
+
+ {validationErrors.expiryHours}
+
+ )}
+
+ After expiry, the proposal can no longer be accepted. Max {MAX_EXPIRY_HOURS} hours (7 days).
+
+
+
+ {/* Summary Preview */}
+ {offeredKey && desiredKey && offeredAmount && desiredAmount && (
+
+
Proposal summary
+
+
+
{offeredKey.title}
+
{formatNumber(parsedOfferedAmount)} keys
+
+
↔
+
+
{desiredKey.title}
+
{formatNumber(parsedDesiredAmount)} keys
+
+
+
+ Expires in {expiryHours} hour{expiryHours !== 1 ? 's' : ''}
+
+
+ )}
+
+
+
+ onOpenChange(false)}
+ disabled={isSubmitting || createProposalMutation.isPending}
+ >
+ Cancel
+
+
+
+ Create Proposal
+
+
+
+
+
+
+ {/* Generated Link Dialog */}
+
+
+
+ Proposal Created
+
+ Share this link with your counterparty. They can review and accept the swap.
+
+
+
+
+
Shareable link
+
+
+
+ Copy
+
+
+
+
+ This proposal expires in {expiryHours} hour{expiryHours !== 1 ? 's' : ''}.
+
+
+
+ {
+ navigator.clipboard.writeText(generatedLink).catch(() => {});
+ handleCopyLink();
+ }}
+ >
+ Copy Again
+
+
+ Done
+
+
+
+
+ >
+ );
+};
+
+export default AtomicSwapProposalForm;
\ No newline at end of file
diff --git a/src/components/common/AtomicSwapProposalReview.tsx b/src/components/common/AtomicSwapProposalReview.tsx
new file mode 100644
index 00000000..55b7d62d
--- /dev/null
+++ b/src/components/common/AtomicSwapProposalReview.tsx
@@ -0,0 +1,287 @@
+import { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { StableButtonContent } from '@/components/ui/stable-button-content';
+import { AlertCircle, CheckCircle, Clock, XCircle } from 'lucide-react';
+import { useParams, useNavigate } from 'react-router';
+import { useAccount } from 'wagmi';
+import { useAtomicSwapProposal, useAcceptAtomicSwapProposal } from '@/hooks/useAtomicSwap';
+import { copyTextToClipboard } from '@/utils/clipboard.utils';
+import showToast from '@/utils/toast.util';
+import { formatRelativeTime } from '@/utils/time.utils';
+import { formatNumber } from '@/utils/numberFormat.utils';
+import { isProposalExpired, canAcceptProposal } from '@/types/atomicSwap';
+import { cn } from '@/lib/utils';
+
+export const AtomicSwapProposalReview: React.FC = () => {
+ const { proposalId } = useParams<{ proposalId: string }>();
+ const { address: connectedAddress } = useAccount();
+ const navigate = useNavigate();
+ const [copied, setCopied] = useState(false);
+
+ const { data: proposal, isLoading, isError } = useAtomicSwapProposal(proposalId ?? '');
+ const acceptMutation = useAcceptAtomicSwapProposal(connectedAddress ?? '');
+
+ const handleAccept = async () => {
+ if (!proposal || !connectedAddress) return;
+ if (!canAcceptProposal(proposal, connectedAddress)) {
+ showToast.error('Cannot accept');
+ return;
+ }
+ await acceptMutation.mutateAsync({ proposalId: proposal.id, counterpartyAddress: connectedAddress });
+ };
+
+ const handleCopyLink = async () => {
+ if (!proposal) return;
+ try {
+ await copyTextToClipboard(window.location.href);
+ setCopied(true);
+ showToast.success('Link copied');
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('Clipboard API is unavailable')) {
+ showToast.error('Could not copy');
+ } else {
+ showToast.error('Copy failed');
+ }
+ }
+ };
+
+ const isProposer = connectedAddress && proposal?.proposerAddress === connectedAddress;
+ const isCounterparty = connectedAddress && proposal?.counterpartyAddress === connectedAddress;
+ const isExpired = proposal ? isProposalExpired(proposal) : false;
+ const isExecuted = proposal?.status === 'executed';
+ const isAccepted = proposal?.status === 'accepted';
+ const isPending = proposal?.status === 'pending';
+ const canAct = isPending && !isExpired && !isProposer && connectedAddress && canAcceptProposal(proposal, connectedAddress);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
Loading proposal…
+
+ );
+ }
+
+ if (isError || !proposal) {
+ return (
+
+
+
Proposal not found
+
+ This proposal doesn't exist or has been removed. It may have expired or been cancelled.
+
+
navigate('/')}>Back to marketplace
+
+ );
+ }
+
+ const statusConfig = {
+ pending: { icon: Clock, color: 'text-amber-400', bg: 'bg-amber-400/10 border-amber-400/20', label: 'Pending' },
+ accepted: { icon: CheckCircle, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'Accepted' },
+ executed: { icon: CheckCircle, color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', label: 'Executed' },
+ expired: { icon: Clock, color: 'text-rose-400', bg: 'bg-rose-500/10 border-rose-500/20', label: 'Expired' },
+ cancelled: { icon: XCircle, color: 'text-rose-400', bg: 'bg-rose-500/10 border-rose-500/20', label: 'Cancelled' },
+ } as const;
+
+ const status = statusConfig[proposal.status];
+
+ return (
+
+
+
+
Atomic Swap Proposal
+
Review the exchange details below
+
+
+
+
+ {status.label}
+
+
+ {copied ? 'Copied!' : 'Copy link'}
+
+
+
+
+ {/* Exchange Details */}
+
+
+ {/* You Receive */}
+
+
+ You receive
+
+
+
+
+
+
+
+
{proposal.desiredKeyName}
+
Desired key
+
+
+
+
Amount
+
{formatNumber(proposal.desiredAmount)} keys
+
+
+
+
+ {/* You Give */}
+
+
+ You give
+
+
+
+
+
+
{proposal.offeredKeyName}
+
Offered by proposer
+
+
+
+
Amount
+
{formatNumber(proposal.offeredAmount)} keys
+
+
+
+
+
+
+ {/* Proposer Info */}
+
+
Proposal details
+
+
+
Proposer
+ {proposal.proposerAddress.slice(0, 8)}…{proposal.proposerAddress.slice(-6)}
+
+
+
Created
+ {formatRelativeTime(proposal.createdAt)}
+
+
+
Expires
+ {formatRelativeTime(proposal.expiresAt)}
+
+
+
Proposal ID
+ {proposal.id}
+
+ {proposal.executedAt && (
+
+
Executed
+ {formatRelativeTime(proposal.executedAt)}
+
+ )}
+ {proposal.transactionHash && (
+
+
Transaction
+ {proposal.transactionHash}
+
+ )}
+
+
+
+ {/* Action / Status */}
+
+ {isExpired && (
+
+
+
+
This proposal has expired
+
+ It expired {formatRelativeTime(proposal.expiresAt)} and can no longer be accepted.
+
+
+
+ )}
+
+ {isExecuted && (
+
+
+
+
Swap executed
+
+ This atomic swap was completed {formatRelativeTime(proposal.executedAt!)}.
+ {proposal.transactionHash && (
+ Tx: {proposal.transactionHash.slice(0, 10)}…
+ )}
+
+
+
+ )}
+
+ {isAccepted && !isExecuted && (
+
+
+
+
Proposal accepted
+
Waiting for on-chain confirmation…
+
+
+ )}
+
+ {canAct && (
+
+
+ You're connected as {connectedAddress!.slice(0, 8)}…{connectedAddress!.slice(-6)} .
+ Accept this proposal to execute the atomic swap.
+
+
+
+ Accept & Execute Swap
+
+
+
+ This will submit an atomic transaction — both transfers succeed or neither does.
+
+
+ )}
+
+ {isProposer && isPending && !isExpired && (
+
+
You created this proposal. Share the link above with your counterparty.
+
Expires {formatRelativeTime(proposal.expiresAt)}
+
+ )}
+
+ {isProposer && (isExpired || isExecuted) && (
+
navigate('/')} className="w-full rounded-xl">
+ Back to marketplace
+
+ )}
+
+ {isCounterparty && isExecuted && (
+
navigate('/')} className="w-full rounded-xl">
+ Back to marketplace
+
+ )}
+
+ {!connectedAddress && isPending && !isExpired && !isProposer && (
+
+
Connect your wallet to accept this proposal.
+
+ )}
+
+
+ );
+};
+
+export default AtomicSwapProposalReview;
\ No newline at end of file
diff --git a/src/hooks/useAtomicSwap.ts b/src/hooks/useAtomicSwap.ts
new file mode 100644
index 00000000..6555229c
--- /dev/null
+++ b/src/hooks/useAtomicSwap.ts
@@ -0,0 +1,82 @@
+import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import showToast from '@/utils/toast.util';
+import type { AtomicSwapProposal } from '@/types/atomicSwap';
+import {
+ createAtomicSwapProposal,
+ fetchAtomicSwapProposal,
+ acceptAtomicSwapProposal,
+ fetchUserAtomicSwapProposals,
+ fetchAtomicSwapHistory,
+} from '@/services/atomicSwap.service';
+
+export function useCreateAtomicSwapProposal(address: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['atomic-swap', 'create', address],
+ mutationFn: async (params: Parameters[0]) => {
+ return createAtomicSwapProposal(params);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) });
+ queryClient.invalidateQueries({ queryKey: ['atomic-swap', 'proposals', address] });
+ showToast.success('Proposal created');
+ },
+ onError: (error) => {
+ showToast.error(error instanceof Error ? error.message : 'Failed to create proposal');
+ },
+ });
+}
+
+export function useAtomicSwapProposal(proposalId: string, enabled = true) {
+ return useQuery({
+ queryKey: ['atomic-swap', 'proposal', proposalId],
+ queryFn: () => fetchAtomicSwapProposal({ proposalId }),
+ enabled: enabled && !!proposalId,
+ staleTime: 15_000,
+ refetchInterval: 30_000,
+ });
+}
+
+export function useAcceptAtomicSwapProposal(address: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationKey: ['atomic-swap', 'accept', address],
+ mutationFn: async (params: Parameters[0]) => {
+ return acceptAtomicSwapProposal(params);
+ },
+ onSuccess: (data, variables) => {
+ queryClient.setQueryData(['atomic-swap', 'proposal', variables.proposalId], data.proposal);
+ queryClient.invalidateQueries({ queryKey: ['atomic-swap', 'proposals', address] });
+ queryClient.invalidateQueries({ queryKey: ['atomic-swap', 'history', address] });
+ queryClient.invalidateQueries({ queryKey: queryKeys.wallet.holdings(address) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.creators.all });
+ showToast.transactionSuccess('Swap completed', 'Atomic swap executed successfully');
+ },
+ onError: (error) => {
+ showToast.error(error instanceof Error ? error.message : 'Failed to accept proposal');
+ },
+ });
+}
+
+export function useUserAtomicSwapProposals(address: string, status?: AtomicSwapProposal['status']) {
+ return useQuery({
+ queryKey: ['atomic-swap', 'proposals', address, status ?? 'all'],
+ queryFn: () => fetchUserAtomicSwapProposals({ address, status }),
+ enabled: !!address,
+ staleTime: 30_000,
+ });
+}
+
+export function useAtomicSwapHistory(address: string) {
+ return useInfiniteQuery({
+ queryKey: ['atomic-swap', 'history', address],
+ queryFn: ({ pageParam }) => fetchAtomicSwapHistory({ address, cursor: pageParam as string | null | undefined }),
+ initialPageParam: null as string | null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ enabled: !!address,
+ staleTime: 30_000,
+ });
+}
\ No newline at end of file
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index fa3def57..9eae2df5 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -43,4 +43,11 @@ export const queryKeys = {
admin: {
oracleCallers: () => ['admin', 'oracle', 'callers'] as const,
},
+ atomicSwap: {
+ all: () => ['atomic-swap'] as const,
+ proposal: (proposalId: string) => ['atomic-swap', 'proposal', proposalId] as const,
+ proposals: (address: string, status?: string) =>
+ ['atomic-swap', 'proposals', address, status ?? 'all'] as const,
+ history: (address: string) => ['atomic-swap', 'history', address] as const,
+ },
} as const;
diff --git a/src/pages/AtomicSwapCreatePage.tsx b/src/pages/AtomicSwapCreatePage.tsx
new file mode 100644
index 00000000..cc80aa0a
--- /dev/null
+++ b/src/pages/AtomicSwapCreatePage.tsx
@@ -0,0 +1,198 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAccount } from 'wagmi';
+import { courseService, type Course } from '@/services/course.service';
+import { useWalletHoldings } from '@/hooks/useWallet';
+import { AtomicSwapProposalForm } from '@/components/common/AtomicSwapProposalForm';
+import { Button } from '@/components/ui/button';
+import { ArrowLeft } from 'lucide-react';
+import showToast from '@/utils/toast.util';
+import SectionDivider from '@/components/common/SectionDivider';
+import MarketplaceSection from '@/components/common/MarketplaceSection';
+import SectionHeading from '@/components/common/SectionHeading';
+import { calculatePortfolioValue, sortHoldingsByTotalValue } from '@/utils/portfolioValue.utils';
+import { formatPortfolioValueDisplay, getPortfolioValueHelperText } from '@/utils/portfolioValue.utils';
+import PortfolioHoldingRow from '@/components/common/PortfolioHoldingRow';
+import HoldingsEmptyState from '@/components/common/HoldingsEmptyState';
+import CreatorSkeleton from '@/components/common/CreatorSkeleton';
+import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
+
+const DEMO_WALLET_ADDRESS = 'demo-wallet-address';
+
+export default function AtomicSwapCreatePage() {
+ const { address: connectedAddress } = useAccount();
+ const activeWalletAddress = connectedAddress || DEMO_WALLET_ADDRESS;
+ const { isMismatch: isNetworkMismatch } = useNetworkMismatch();
+
+ const [creators, setCreators] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [formOpen, setFormOpen] = useState(false);
+
+ const { data: cachedHoldings = [] } = useWalletHoldings(activeWalletAddress);
+
+ useEffect(() => {
+ const fetchCreators = async () => {
+ setIsLoading(true);
+ try {
+ const data = await courseService.getCourses();
+ if (data && data.length > 0) {
+ setCreators(data);
+ }
+ } catch {
+ showToast.error('Unable to load creators. Check your connection and try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchCreators();
+ }, []);
+
+ const heldKeyPositions = useMemo(
+ () =>
+ sortHoldingsByTotalValue(
+ creators.map(creator => {
+ const cached = cachedHoldings.find(h => h.creatorId === creator.id);
+ return {
+ creatorId: creator.id,
+ quantity: cached?.quantity ?? 0,
+ priceStroops: creator.priceStroops,
+ price: creator.price,
+ pending: cached?.pending ?? false,
+ unclaimedDividend: cached?.unclaimedDividend ?? 0,
+ };
+ })
+ ),
+ [creators, cachedHoldings]
+ );
+
+ const portfolioValue = useMemo(() => calculatePortfolioValue(heldKeyPositions), [heldKeyPositions]);
+ const portfolioValueDisplay = formatPortfolioValueDisplay(portfolioValue);
+ const portfolioValueHelperText = getPortfolioValueHelperText(portfolioValue);
+
+ const hasHoldings = heldKeyPositions.some(p => p.quantity && p.quantity > 0);
+
+ const handleOpenForm = useCallback(() => {
+ if (!hasHoldings) {
+ showToast.error('No holdings');
+ return;
+ }
+ setFormOpen(true);
+ }, [hasHoldings]);
+
+ return (
+
+
+
+
+
+
+
+
window.history.back()} className="rounded-xl">
+
+ Back
+
+
+
+ Direct Key Exchange
+
+ Atomic Swap Proposals
+
+
+ Create trustless, atomic key exchange proposals. Both sides of the swap execute simultaneously —
+ or neither does. Share a link with your counterparty to complete the exchange.
+
+
+
+
+
+
+
+
+
Portfolio value
+
+ {portfolioValueDisplay}
+
+
{portfolioValueHelperText}
+
+
+
+ {isLoading ? (
+
+ ) : !hasHoldings ? (
+
+ ) : (
+
+ {heldKeyPositions
+ .filter(position => position.quantity && position.quantity > 0)
+ .map(position => {
+ const creator = creators.find(item => item.id === position.creatorId);
+ return (
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
+ {!hasHoldings ? (
+
+
+
No keys to swap
+
You need to own at least one creator key before you can create an atomic swap proposal.
+
+ ) : (
+
+
+
+
+
+
+ Create Swap Proposal
+
+
+ The counterparty will need to connect their wallet and accept the proposal to execute the atomic swap.
+
+
+ )}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/AtomicSwapProposalPage.tsx b/src/pages/AtomicSwapProposalPage.tsx
new file mode 100644
index 00000000..dcb8d648
--- /dev/null
+++ b/src/pages/AtomicSwapProposalPage.tsx
@@ -0,0 +1,14 @@
+import { AtomicSwapProposalReview } from '@/components/common/AtomicSwapProposalReview';
+
+export default function AtomicSwapProposalPage() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index 9712237d..db5628ba 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -72,7 +72,7 @@ import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition';
import { creatorListKey } from '@/utils/creatorListKey.utils';
-import { Check, ChevronDown, Copy, RefreshCw } from 'lucide-react';
+import { Check, ChevronDown, Copy, RefreshCw, ArrowLeftRight } from 'lucide-react';
import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState';
import CreatorListPagination from '@/components/common/CreatorListPagination';
import CreatorListGroupSeparator from '@/components/common/CreatorListGroupSeparator';
@@ -1467,6 +1467,17 @@ function LandingPage() {
+
+
window.location.href = '/swap/create'}
+ disabled={heldKeyPositions.filter(p => p.quantity && p.quantity > 0).length === 0}
+ className="rounded-xl border-white/15 bg-white/5 text-white/80 hover:bg-white/10 hover:text-white"
+ >
+
+ Create Atomic Swap
+
+
{isLoading ? (
) : heldKeyPositions.filter(
diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx
index f54e39aa..9d7d6628 100644
--- a/src/pages/ProfilePage.tsx
+++ b/src/pages/ProfilePage.tsx
@@ -1,13 +1,16 @@
import { useState } from 'react';
-import { BarChart2, Clock } from 'lucide-react';
+import { BarChart2, Clock, ArrowLeftRight } from 'lucide-react';
import ReferralLinkPanel from '@/components/common/ReferralLinkPanel';
import TradeHistoryTable from '@/components/common/TradeHistoryTable';
+import AtomicSwapHistory from '@/components/common/AtomicSwapHistory';
import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill';
import { useProfileStore } from '@/hooks/useProfileStore';
+import { useAccount } from 'wagmi';
const TABS = [
{ label: 'Holdings', value: 'holdings', icon: },
{ label: 'Trade History', value: 'trade-history', icon: },
+ { label: 'Atomic Swaps', value: 'atomic-swaps', icon: },
];
// Mock wallet address – in a real app this would come from the wallet provider.
@@ -23,6 +26,8 @@ const keys = [
export default function ProfilePage() {
const profile = useProfileStore(state => state.profile);
+ const { address: connectedAddress } = useAccount();
+ const walletAddress = connectedAddress || DEMO_WALLET;
const [activeTab, setActiveTab] = useState('holdings');
return (
@@ -73,7 +78,30 @@ export default function ProfilePage() {
-
+
+
+
+ )}
+
+ {/* Atomic swap history panel */}
+ {activeTab === 'atomic-swaps' && (
+
+
+
+
+ Atomic Swap History
+
+
+ Completed direct key exchanges with counterparties
+
+
+
+
)}
diff --git a/src/routes.tsx b/src/routes.tsx
index 395c43df..fe856169 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -7,6 +7,8 @@ import NotificationsPage from './pages/NotificationsPage';
import LeaderboardPage from './pages/LeaderboardPage';
import ProfilePage from './pages/ProfilePage';
import FollowingPage from './pages/FollowingPage';
+import AtomicSwapCreatePage from './pages/AtomicSwapCreatePage';
+import AtomicSwapProposalPage from './pages/AtomicSwapProposalPage';
export const routes = [
{
@@ -53,6 +55,14 @@ export const routes = [
path: '/admin/dashboard',
element: ,
},
+ {
+ path: '/swap/create',
+ element: ,
+ },
+ {
+ path: '/swap/:proposalId',
+ element: ,
+ },
{
path: '*',
element: ,
diff --git a/src/services/atomicSwap.service.ts b/src/services/atomicSwap.service.ts
new file mode 100644
index 00000000..57ccdd1c
--- /dev/null
+++ b/src/services/atomicSwap.service.ts
@@ -0,0 +1,319 @@
+import { BaseApiService, type APIResponse } from './api.service';
+import { cacheManager } from '@/utils/cache.utils';
+import type {
+ AtomicSwapProposal,
+ CreateProposalInput,
+ ProposalValidationResult,
+ SwapHistoryEntry,
+} from '@/types/atomicSwap';
+import { generateProposalId, calculateExpiryTimestamp, isProposalExpired } from '@/types/atomicSwap';
+
+const PROPOSAL_CACHE_PREFIX = 'atomic_swap_proposal_';
+const PROPOSALS_LIST_CACHE_PREFIX = 'atomic_swap_proposals_';
+const PROPOSAL_CACHE_TTL_MS = 30_000;
+
+export interface CreateProposalParams {
+ proposerAddress: string;
+ offeredKeyId: string;
+ offeredKeyName: string;
+ offeredAmount: number;
+ desiredKeyId: string;
+ desiredKeyName: string;
+ desiredAmount: number;
+ expiryHours?: number;
+}
+
+export interface GetProposalParams {
+ proposalId: string;
+}
+
+export interface AcceptProposalParams {
+ proposalId: string;
+ counterpartyAddress: string;
+}
+
+export interface GetUserProposalsParams {
+ address: string;
+ status?: AtomicSwapProposal['status'];
+}
+
+export interface GetSwapHistoryParams {
+ address: string;
+ cursor?: string | null;
+ limit?: number;
+}
+
+export interface SwapHistoryPage {
+ swaps: SwapHistoryEntry[];
+ nextCursor: string | null;
+}
+
+class AtomicSwapService extends BaseApiService {
+ async createProposal(params: CreateProposalParams): Promise {
+ const proposal: AtomicSwapProposal = {
+ id: generateProposalId(),
+ proposer: 'Current User',
+ proposerAddress: params.proposerAddress,
+ offeredKeyId: params.offeredKeyId,
+ offeredKeyName: params.offeredKeyName,
+ offeredAmount: params.offeredAmount,
+ desiredKeyId: params.desiredKeyId,
+ desiredKeyName: params.desiredKeyName,
+ desiredAmount: params.desiredAmount,
+ status: 'pending',
+ createdAt: Date.now(),
+ expiresAt: calculateExpiryTimestamp(params.expiryHours),
+ };
+
+ try {
+ const response = await this.api.post>(
+ '/atomic-swaps/proposals',
+ proposal
+ );
+ return response.data.data;
+ } catch {
+ // Mock implementation for development
+ // In production, this would call the actual backend
+ this.cacheProposal(proposal);
+ return proposal;
+ }
+ }
+
+ async getProposal(params: GetProposalParams): Promise {
+ const cacheKey = `${PROPOSAL_CACHE_PREFIX}${params.proposalId}`;
+ const cached = cacheManager.get(cacheKey);
+ if (cached) return cached;
+
+ try {
+ const response = await this.api.get>(
+ `/atomic-swaps/proposals/${params.proposalId}`
+ );
+ const proposal = response.data.data;
+ this.cacheProposal(proposal);
+ return proposal;
+ } catch {
+ // Mock implementation - check local storage for demo
+ const stored = this.getStoredProposal(params.proposalId);
+ return stored;
+ }
+ }
+
+ async acceptProposal(params: AcceptProposalParams): Promise<{ transactionHash: string; proposal: AtomicSwapProposal }> {
+ try {
+ const response = await this.api.post>(
+ `/atomic-swaps/proposals/${params.proposalId}/accept`,
+ { counterpartyAddress: params.counterpartyAddress }
+ );
+ return response.data.data;
+ } catch {
+ // Mock implementation
+ const proposal = await this.getProposal({ proposalId: params.proposalId });
+ if (!proposal) throw new Error('Proposal not found');
+ if (isProposalExpired(proposal)) throw new Error('Proposal has expired');
+ if (proposal.status !== 'pending') throw new Error('Proposal is no longer actionable');
+
+ const updatedProposal: AtomicSwapProposal = {
+ ...proposal,
+ status: 'executed',
+ counterpartyAddress: params.counterpartyAddress,
+ counterparty: 'Counterparty',
+ executedAt: Date.now(),
+ transactionHash: `0x${Math.random().toString(16).slice(2, 66)}`,
+ };
+ this.cacheProposal(updatedProposal);
+ this.addToSwapHistory(updatedProposal);
+ return { transactionHash: updatedProposal.transactionHash!, proposal: updatedProposal };
+ }
+ }
+
+ async getUserProposals(params: GetUserProposalsParams): Promise {
+ const cacheKey = `${PROPOSALS_LIST_CACHE_PREFIX}${params.address}_${params.status ?? 'all'}`;
+ const cached = cacheManager.get(cacheKey);
+ if (cached) return cached;
+
+ try {
+ const response = await this.api.get>(
+ `/atomic-swaps/proposals`,
+ {
+ params: {
+ address: params.address,
+ ...(params.status ? { status: params.status } : {}),
+ },
+ }
+ );
+ const proposals = response.data.data;
+ cacheManager.set(cacheKey, proposals, PROPOSAL_CACHE_TTL_MS);
+ return proposals;
+ } catch {
+ // Mock implementation
+ return this.getStoredUserProposals(params.address, params.status);
+ }
+ }
+
+ async getSwapHistory(params: GetSwapHistoryParams): Promise {
+ try {
+ const response = await this.api.get>(
+ `/atomic-swaps/history/${params.address}`,
+ {
+ params: {
+ ...(params.cursor ? { cursor: params.cursor } : {}),
+ ...(params.limit ? { limit: params.limit } : {}),
+ },
+ }
+ );
+ return response.data.data;
+ } catch {
+ // Mock implementation
+ return this.getStoredSwapHistory(params.address, params.cursor, params.limit);
+ }
+ }
+
+ private cacheProposal(proposal: AtomicSwapProposal): void {
+ const cacheKey = `${PROPOSAL_CACHE_PREFIX}${proposal.id}`;
+ cacheManager.set(cacheKey, proposal, PROPOSAL_CACHE_TTL_MS);
+ }
+
+ private getStoredProposal(proposalId: string): AtomicSwapProposal | null {
+ if (typeof window === 'undefined') return null;
+ try {
+ const stored = localStorage.getItem(`atomic_swap_proposal_${proposalId}`);
+ return stored ? JSON.parse(stored) : null;
+ } catch {
+ return null;
+ }
+ }
+
+ private getStoredUserProposals(address: string, status?: AtomicSwapProposal['status']): AtomicSwapProposal[] {
+ if (typeof window === 'undefined') return [];
+ try {
+ const allKeys = Object.keys(localStorage);
+ const proposalKeys = allKeys.filter(k => k.startsWith('atomic_swap_proposal_'));
+ const proposals: AtomicSwapProposal[] = [];
+ for (const key of proposalKeys) {
+ const stored = localStorage.getItem(key);
+ if (stored) {
+ const proposal = JSON.parse(stored) as AtomicSwapProposal;
+ if (
+ (proposal.proposerAddress === address || proposal.counterpartyAddress === address) &&
+ (!status || proposal.status === status)
+ ) {
+ proposals.push(proposal);
+ }
+ }
+ }
+ return proposals.sort((a, b) => b.createdAt - a.createdAt);
+ } catch {
+ return [];
+ }
+ }
+
+ private addToSwapHistory(proposal: AtomicSwapProposal): void {
+ if (typeof window === 'undefined') return;
+ try {
+ const historyKey = `atomic_swap_history_${proposal.proposerAddress}`;
+ const existing = localStorage.getItem(historyKey);
+ const history: SwapHistoryEntry[] = existing ? JSON.parse(existing) : [];
+
+ const entry: SwapHistoryEntry = {
+ id: `swap_hist_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
+ type: 'atomic-swap',
+ proposer: proposal.proposer,
+ proposerAddress: proposal.proposerAddress,
+ counterparty: proposal.counterparty ?? 'Unknown',
+ counterpartyAddress: proposal.counterpartyAddress ?? '',
+ offeredKeyId: proposal.offeredKeyId,
+ offeredKeyName: proposal.offeredKeyName,
+ offeredAmount: proposal.offeredAmount,
+ receivedKeyId: proposal.desiredKeyId,
+ receivedKeyName: proposal.desiredKeyName,
+ receivedAmount: proposal.desiredAmount,
+ timestamp: proposal.executedAt ?? Date.now(),
+ transactionHash: proposal.transactionHash ?? '',
+ status: 'completed',
+ };
+
+ history.unshift(entry);
+ localStorage.setItem(historyKey, JSON.stringify(history.slice(0, 100)));
+ } catch {
+ // Ignore storage errors
+ }
+ }
+
+ private getStoredSwapHistory(
+ address: string,
+ cursor: string | null | undefined,
+ limit = 20
+ ): SwapHistoryPage {
+ if (typeof window === 'undefined') return { swaps: [], nextCursor: null };
+ try {
+ const historyKey = `atomic_swap_history_${address}`;
+ const stored = localStorage.getItem(historyKey);
+ const allSwaps: SwapHistoryEntry[] = stored ? JSON.parse(stored) : [];
+
+ let startIndex = 0;
+ if (cursor) {
+ const cursorIndex = allSwaps.findIndex(s => s.id === cursor);
+ if (cursorIndex !== -1) startIndex = cursorIndex + 1;
+ }
+
+ const pageSwaps = allSwaps.slice(startIndex, startIndex + limit);
+ const nextCursor = startIndex + limit < allSwaps.length ? pageSwaps[pageSwaps.length - 1].id : null;
+
+ return { swaps: pageSwaps, nextCursor };
+ } catch {
+ return { swaps: [], nextCursor: null };
+ }
+ }
+
+ validateProposal(input: CreateProposalInput, userHoldings: Record): ProposalValidationResult {
+ const errors: string[] = [];
+
+ if (!input.offeredKeyId) errors.push('Offered key is required');
+ if (!input.desiredKeyId) errors.push('Desired key is required');
+ if (!Number.isFinite(input.offeredAmount) || input.offeredAmount <= 0) {
+ errors.push('Offered amount must be greater than zero');
+ }
+ if (!Number.isFinite(input.desiredAmount) || input.desiredAmount <= 0) {
+ errors.push('Desired amount must be greater than zero');
+ }
+ if (input.offeredKeyId === input.desiredKeyId) {
+ errors.push('Cannot create a swap with the same key on both sides');
+ }
+
+ const availableHolding = userHoldings[input.offeredKeyId] ?? 0;
+ if (input.offeredAmount > availableHolding) {
+ errors.push(`Insufficient holdings: you have ${availableHolding} ${input.offeredKeyName} keys`);
+ }
+
+ return { valid: errors.length === 0, errors };
+ }
+}
+
+export const atomicSwapService = new AtomicSwapService();
+
+export async function createAtomicSwapProposal(params: CreateProposalParams): Promise {
+ return atomicSwapService.createProposal(params);
+}
+
+export async function fetchAtomicSwapProposal(params: GetProposalParams): Promise {
+ return atomicSwapService.getProposal(params);
+}
+
+export async function acceptAtomicSwapProposal(params: AcceptProposalParams): Promise<{ transactionHash: string; proposal: AtomicSwapProposal }> {
+ return atomicSwapService.acceptProposal(params);
+}
+
+export async function fetchUserAtomicSwapProposals(params: GetUserProposalsParams): Promise {
+ return atomicSwapService.getUserProposals(params);
+}
+
+export async function fetchAtomicSwapHistory(params: GetSwapHistoryParams): Promise {
+ return atomicSwapService.getSwapHistory(params);
+}
+
+export function validateAtomicSwapProposal(
+ input: CreateProposalInput,
+ userHoldings: Record
+): ProposalValidationResult {
+ return atomicSwapService.validateProposal(input, userHoldings);
+}
\ No newline at end of file
diff --git a/src/types/atomicSwap.ts b/src/types/atomicSwap.ts
new file mode 100644
index 00000000..c41e0d27
--- /dev/null
+++ b/src/types/atomicSwap.ts
@@ -0,0 +1,75 @@
+export type ProposalStatus = 'pending' | 'accepted' | 'expired' | 'executed' | 'cancelled';
+
+export interface AtomicSwapProposal {
+ id: string;
+ proposer: string;
+ proposerAddress: string;
+ offeredKeyId: string;
+ offeredKeyName: string;
+ offeredAmount: number;
+ desiredKeyId: string;
+ desiredKeyName: string;
+ desiredAmount: number;
+ status: ProposalStatus;
+ createdAt: number;
+ expiresAt: number;
+ executedAt?: number;
+ counterparty?: string;
+ counterpartyAddress?: string;
+ transactionHash?: string;
+}
+
+export interface CreateProposalInput {
+ offeredKeyId: string;
+ offeredKeyName: string;
+ offeredAmount: number;
+ desiredKeyId: string;
+ desiredKeyName: string;
+ desiredAmount: number;
+ expiryHours?: number;
+}
+
+export interface ProposalValidationResult {
+ valid: boolean;
+ errors: string[];
+}
+
+export interface SwapHistoryEntry {
+ id: string;
+ type: 'atomic-swap';
+ proposer: string;
+ proposerAddress: string;
+ counterparty: string;
+ counterpartyAddress: string;
+ offeredKeyId: string;
+ offeredKeyName: string;
+ offeredAmount: number;
+ receivedKeyId: string;
+ receivedKeyName: string;
+ receivedAmount: number;
+ timestamp: number;
+ transactionHash: string;
+ status: 'completed' | 'failed';
+}
+
+export const PROPOSAL_EXPIRY_HOURS = 24;
+export const PROPOSAL_ID_PREFIX = 'swap_';
+
+export function generateProposalId(): string {
+ return `${PROPOSAL_ID_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
+}
+
+export function calculateExpiryTimestamp(hours: number = PROPOSAL_EXPIRY_HOURS): number {
+ return Date.now() + hours * 60 * 60 * 1000;
+}
+
+export function isProposalExpired(proposal: AtomicSwapProposal): boolean {
+ return Date.now() > proposal.expiresAt;
+}
+
+export function canAcceptProposal(proposal: AtomicSwapProposal, userAddress: string): boolean {
+ if (proposal.status !== 'pending') return false;
+ if (isProposalExpired(proposal)) return false;
+ if (proposal.proposerAddress === userAddress) return false;
+ return true;
+}
\ No newline at end of file