diff --git a/src/components/DutchAuctionCard.tsx b/src/components/DutchAuctionCard.tsx index 243e968..8058570 100644 --- a/src/components/DutchAuctionCard.tsx +++ b/src/components/DutchAuctionCard.tsx @@ -1,61 +1,50 @@ - -import { useState, useEffect } from 'react'; +import React from 'react'; import type { DutchAuction } from '../types/dutchAuction'; import { COLOR, fmt } from '../utils/tokens'; import { PendingButton } from './PendingButton'; +import { useLedgerTime } from '../hooks/useLedgerTime'; interface DutchAuctionCardProps { auction: DutchAuction; onPurchase?: (auctionId: string, price: number) => void; } -const calculateCurrentPrice = (auction: DutchAuction): number => { - const now = Date.now(); +const calculateCurrentPrice = (auction: DutchAuction, now: number): number => { const start = new Date(auction.startTime).getTime(); const end = new Date(auction.endTime).getTime(); - + if (now < start) return auction.startPrice; if (now > end) return auction.floorPrice; - + const elapsed = now - start; const total = end - start; const progress = elapsed / total; - + return auction.startPrice - progress * (auction.startPrice - auction.floorPrice); }; -const formatTimeLeft = (endTime: string): string => { - const now = Date.now(); +const formatTimeLeft = (endTime: string, now: number): string => { const end = new Date(endTime).getTime(); const diff = end - now; - + if (diff <= 0) return 'Ended'; - + const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); - + const h = hours; const m = minutes % 60; const s = seconds % 60; - + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; export const DutchAuctionCard: React.FC = ({ auction, onPurchase }) => { - const [currentPrice, setCurrentPrice] = useState(calculateCurrentPrice(auction)); - const [timeLeft, setTimeLeft] = useState(formatTimeLeft(auction.endTime)); - - useEffect(() => { - if (auction.status !== 'Active') return; + const { now, isSynced } = useLedgerTime(); - const interval = setInterval(() => { - setCurrentPrice(calculateCurrentPrice(auction)); - setTimeLeft(formatTimeLeft(auction.endTime)); - }, 1000); - - return () => clearInterval(interval); - }, [auction]); + const currentPrice = calculateCurrentPrice(auction, now); + const timeLeft = formatTimeLeft(auction.endTime, now); return (
@@ -69,6 +58,8 @@ export const DutchAuctionCard: React.FC = ({ auction, onP borderRadius: '8px', objectFit: 'cover', border: `1px solid ${COLOR.border}`, + opacity: isSynced ? 1 : 0.7, + transition: 'opacity 0.3s ease' }} />
@@ -87,12 +78,12 @@ export const DutchAuctionCard: React.FC = ({ auction, onP borderRadius: '4px', fontSize: '0.75rem', fontWeight: 500, - background: auction.status === 'Active' - ? 'rgba(63,185,80,0.16)' + background: auction.status === 'Active' + ? 'rgba(63,185,80,0.16)' : auction.status === 'Completed' ? 'rgba(88,166,255,0.16)' : 'rgba(248,81,73,0.16)', - color: auction.status === 'Active' + color: auction.status === 'Active' ? '#8ee99d' : auction.status === 'Completed' ? '#58a6ff' @@ -127,7 +118,7 @@ export const DutchAuctionCard: React.FC = ({ auction, onP {auction.status === 'Active' && (

- Time Left + {isSynced ? 'Time Left' : 'Time Left (Syncing...)'}

{timeLeft} @@ -161,4 +152,3 @@ export const DutchAuctionCard: React.FC = ({ auction, onP

); }; - diff --git a/src/hooks/useLedgerTime.test.ts b/src/hooks/useLedgerTime.test.ts new file mode 100644 index 0000000..f6f02f5 --- /dev/null +++ b/src/hooks/useLedgerTime.test.ts @@ -0,0 +1,59 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useLedgerTime } from './useLedgerTime'; +import { useWallet } from '../context/WalletContext'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +vi.mock('../context/WalletContext', () => ({ + useWallet: vi.fn(), +})); + +global.fetch = vi.fn(); + +describe('useLedgerTime', () => { + beforeEach(() => { + vi.resetAllMocks(); + (useWallet as any).mockReturnValue({ wallet: { network: 'public' } }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('syncs with ledger time and calculates offset', async () => { + const mockLedgerTime = new Date('2026-08-30T10:00:00Z').getTime(); + const localLaggingTime = mockLedgerTime - 300000; // 5 minutes behind + + vi.spyOn(Date, 'now').mockReturnValue(localLaggingTime); + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ history_latest_ledger_closed_at: '2026-08-30T10:00:00Z' }), + }); + + const { result } = renderHook(() => useLedgerTime()); + + expect(result.current.isSynced).toBe(false); + + await waitFor(() => { + expect(result.current.isSynced).toBe(true); + }); + + // The hook's `now` output should exactly equal the network ledger time, + // entirely ignoring the lagging local clock. + expect(result.current.now).toBe(mockLedgerTime); + }); + + it('degrades gracefully to local time if fetch fails', async () => { + const localTime = 1700000000000; + vi.spyOn(Date, 'now').mockReturnValue(localTime); + (global.fetch as any).mockRejectedValue(new Error('Horizon offline')); + + const { result } = renderHook(() => useLedgerTime()); + + // We wait a microtask tick for the failed promise to resolve and catch + await new Promise(process.nextTick); + + expect(result.current.isSynced).toBe(false); + expect(result.current.now).toBe(localTime); + }); +}); diff --git a/src/hooks/useLedgerTime.ts b/src/hooks/useLedgerTime.ts new file mode 100644 index 0000000..ad1e407 --- /dev/null +++ b/src/hooks/useLedgerTime.ts @@ -0,0 +1,58 @@ +import { useState, useEffect } from 'react'; +import { useWallet } from '../context/WalletContext'; + +export const useLedgerTime = () => { + const { wallet } = useWallet(); + const [offset, setOffset] = useState(0); + const [isSynced, setIsSynced] = useState(false); + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + let mounted = true; + let timeoutId: ReturnType; + + const syncWithLedger = async () => { + try { + const networkUrl = wallet?.network === 'testnet' + ? 'https://horizon-testnet.stellar.org' + : 'https://horizon.stellar.org'; + + const response = await fetch(networkUrl); + if (!response.ok) throw new Error('Horizon fetch failed'); + + const data = await response.json(); + const ledgerTime = new Date(data.history_latest_ledger_closed_at).getTime(); + + if (mounted) { + setOffset(ledgerTime - Date.now()); + setIsSynced(true); + } + } catch (err) { + console.warn('Failed to sync ledger time, falling back to local clock.'); + } + + if (mounted) { + timeoutId = setTimeout(syncWithLedger, 30000); + } + }; + + syncWithLedger(); + + return () => { + mounted = false; + clearTimeout(timeoutId); + }; + }, [wallet?.network]); + + useEffect(() => { + // Instantly apply the offset the moment it changes + setNow(Date.now() + offset); + + const ticker = setInterval(() => { + setNow(Date.now() + offset); + }, 1000); + return () => clearInterval(ticker); + }, [offset]); + + return { now, isSynced }; +};