From 6162a8802430843857fe588a627384ac7113d43f Mon Sep 17 00:00:00 2001 From: Sule Abdulhakeem Date: Sun, 30 Aug 2026 12:00:16 +0100 Subject: [PATCH] fix: derive dispute deadlines from ledger time --- app/api/ledger-time/route.ts | 86 +++ components/disputes/shared/CountdownTimer.tsx | 400 ++++++++--- .../shared/__tests__/CountdownTimer.test.tsx | 624 ++++++++++++++---- components/disputes/shared/useLedgerTime.ts | 188 ++++++ components/disputes/states/OpenState.tsx | 232 +++++-- components/disputes/states/VotingState.tsx | 192 ++++-- 6 files changed, 1421 insertions(+), 301 deletions(-) create mode 100644 app/api/ledger-time/route.ts create mode 100644 components/disputes/shared/useLedgerTime.ts diff --git a/app/api/ledger-time/route.ts b/app/api/ledger-time/route.ts new file mode 100644 index 00000000..7904a221 --- /dev/null +++ b/app/api/ledger-time/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server'; + +const DEFAULT_HORIZON_URL = 'https://horizon-testnet.stellar.org'; + +function getHorizonUrl(): string { + return ( + process.env.STELLAR_HORIZON_URL ?? + process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL ?? + DEFAULT_HORIZON_URL + ).replace(/\/+$/, ''); +} + +export async function GET() { + try { + const response = await fetch( + `${getHorizonUrl()}/ledgers?order=desc&limit=1`, + { + cache: 'no-store', + headers: { + Accept: 'application/json', + }, + } + ); + + if (response.status === 401 || response.status === 403) { + console.error('[ledger-time] Horizon rejected ledger access', { + status: response.status, + }); + + return NextResponse.json( + { error: 'Ledger time access is not permitted.' }, + { status: 403 } + ); + } + + if (!response.ok) { + console.error('[ledger-time] Horizon ledger request failed', { + status: response.status, + }); + + return NextResponse.json( + { error: 'Unable to retrieve ledger time.' }, + { status: 502 } + ); + } + + const payload = await response.json(); + const ledger = payload?._embedded?.records?.[0]; + + const closedAt = ledger?.closed_at; + const sequence = Number(ledger?.sequence); + + if ( + typeof closedAt !== 'string' || + Number.isNaN(new Date(closedAt).getTime()) || + !Number.isSafeInteger(sequence) || + sequence < 0 + ) { + console.error('[ledger-time] Horizon returned an invalid ledger payload'); + + return NextResponse.json( + { error: 'Invalid ledger time response.' }, + { status: 502 } + ); + } + + return NextResponse.json( + { + ledgerTime: closedAt, + ledgerSequence: sequence, + }, + { + headers: { + 'Cache-Control': 'no-store, max-age=0', + }, + } + ); + } catch { + console.error('[ledger-time] Ledger time request failed'); + + return NextResponse.json( + { error: 'Ledger time is temporarily unavailable.' }, + { status: 503 } + ); + } +} \ No newline at end of file diff --git a/components/disputes/shared/CountdownTimer.tsx b/components/disputes/shared/CountdownTimer.tsx index d4f94221..b2825798 100644 --- a/components/disputes/shared/CountdownTimer.tsx +++ b/components/disputes/shared/CountdownTimer.tsx @@ -7,6 +7,15 @@ interface CountdownTimerProps { deadline: Date; label?: string; + /** + * Authoritative time used for deadline calculation. + * + * undefined preserves backwards compatibility for callers that intentionally + * use browser time. + * + * null means authoritative time is still unavailable. + */ + currentTime?: Date | null; } interface TimeLeft { @@ -14,12 +23,39 @@ interface TimeLeft { hours: number; minutes: number; seconds: number; +} + +function isValidDate(date: Date): boolean { + return date instanceof Date && !Number.isNaN(date.getTime()); +} + +function getReferenceTime( + currentTime: Date | null | undefined +): number | null { + if (currentTime === null) { + return null; + } + + if (currentTime === undefined) { + return Date.now(); + } + + if (!isValidDate(currentTime)) { + return null; + } + return currentTime.getTime(); } -function computeTimeLeft(deadline: Date): TimeLeft | null { - const diff = deadline.getTime() - Date.now(); - if (diff <= 0) return null; +function computeTimeLeft( + deadline: Date, + referenceTime: number +): TimeLeft | null { + const diff = deadline.getTime() - referenceTime; + + if (diff <= 0) { + return null; + } const totalSeconds = Math.floor(diff / 1000); const days = Math.floor(totalSeconds / 86400); @@ -27,54 +63,63 @@ function computeTimeLeft(deadline: Date): TimeLeft | null { const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; - return { days, hours, minutes, seconds }; + return { + days, + hours, + minutes, + seconds, + }; } -function isValidDate(date: Date): boolean { - return date instanceof Date && !isNaN(date.getTime()); -} - -/** - * Build a screen-reader-friendly description of the remaining time. - * - * Pluralisation is correct, and zero-valued components are dropped so the - * announcement stays short. We deliberately omit seconds when more than a - * minute remains so the live region does not announce every tick. - */ function buildAnnouncement(t: TimeLeft): string { const parts: string[] = []; - if (t.days > 0) parts.push(`${t.days} day${t.days === 1 ? '' : 's'}`); - if (t.hours > 0) parts.push(`${t.hours} hour${t.hours === 1 ? '' : 's'}`); - // Only include minutes/seconds when the larger unit doesn't dominate. + if (t.days > 0) { + parts.push(`${t.days} day${t.days === 1 ? '' : 's'}`); + } + + if (t.hours > 0) { + parts.push(`${t.hours} hour${t.hours === 1 ? '' : 's'}`); + } + if (t.days === 0) { - if (t.minutes > 0) parts.push(`${t.minutes} minute${t.minutes === 1 ? '' : 's'}`); - // Seconds only when the deadline is under a minute away. - if (t.hours === 0 && t.minutes === 0 && t.seconds > 0) { - parts.push(`${t.seconds} second${t.seconds === 1 ? '' : 's'}`); + if (t.minutes > 0) { + parts.push( + `${t.minutes} minute${t.minutes === 1 ? '' : 's'}` + ); + } + + if ( + t.hours === 0 && + t.minutes === 0 && + t.seconds > 0 + ) { + parts.push( + `${t.seconds} second${t.seconds === 1 ? '' : 's'}` + ); } } - if (parts.length === 0) return 'Less than one second remaining'; - return `${parts.join(', ')} remaining`; + if (parts.length === 0) { + return 'Less than one second remaining'; + } + return `${parts.join(', ')} remaining`; } -/** - * Decide the coarse "announce key" for a given time-left value. The aria-live - * region only re-announces when this key changes, so screen readers are not - * spammed every second. - * - * > 1 day: announce when days changes - * > 1 hour: announce when hours changes - * > 1 min: announce when minutes changes - * < 1 min: announce every 10 seconds - */ function getAnnounceKey(t: TimeLeft): string { - if (t.days > 0) return `d:${t.days}`; - if (t.hours > 0) return `h:${t.hours}`; - if (t.minutes > 0) return `m:${t.minutes}`; - // Bucket seconds in 10s so we announce at most every ~10s in the final minute. + if (t.days > 0) { + return `d:${t.days}`; + } + + if (t.hours > 0) { + return `h:${t.hours}`; + } + + if (t.minutes > 0) { + return `m:${t.minutes}`; + } + return `s:${Math.floor(t.seconds / 10) * 10}`; } @@ -87,66 +132,148 @@ function getPrefersReducedMotion(): boolean { } function usePrefersReducedMotion(): boolean { - const [prefersReducedMotion, setPrefersReducedMotion] = useState( - getPrefersReducedMotion - ); + const [prefersReducedMotion, setPrefersReducedMotion] = + useState(getPrefersReducedMotion); useEffect(() => { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { return; } - const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - const updatePreference = () => setPrefersReducedMotion(mediaQuery.matches); + const mediaQuery = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ); + + const updatePreference = () => { + setPrefersReducedMotion(mediaQuery.matches); + }; updatePreference(); if (typeof mediaQuery.addEventListener === 'function') { mediaQuery.addEventListener('change', updatePreference); - return () => mediaQuery.removeEventListener('change', updatePreference); + + return () => { + mediaQuery.removeEventListener( + 'change', + updatePreference + ); + }; } mediaQuery.addListener(updatePreference); - return () => mediaQuery.removeListener(updatePreference); + + return () => { + mediaQuery.removeListener(updatePreference); + }; }, []); return prefersReducedMotion; } -export function CountdownTimer({ deadline, label }: CountdownTimerProps) { +export function CountdownTimer({ + deadline, + label, + currentTime, +}: CountdownTimerProps) { const prefersReducedMotion = usePrefersReducedMotion(); - const [timeLeft, setTimeLeft] = useState(() => { - if (!isValidDate(deadline)) return null; - return computeTimeLeft(deadline); - }); + + const initialReferenceTime = getReferenceTime(currentTime); + + const [timeLeft, setTimeLeft] = useState( + () => { + if ( + !isValidDate(deadline) || + initialReferenceTime === null + ) { + return null; + } + + return computeTimeLeft( + deadline, + initialReferenceTime + ); + } + ); + const [expired, setExpired] = useState(() => { - if (!isValidDate(deadline)) return false; - return deadline.getTime() <= Date.now(); - - }); + if ( + !isValidDate(deadline) || + initialReferenceTime === null + ) { + return false; + } - // The string the aria-live region currently shows. Updates only at coarse - // intervals so screen readers do not announce every second. - const [announcement, setAnnouncement] = useState(() => { - if (!isValidDate(deadline)) return ''; - if (deadline.getTime() <= Date.now()) return 'Deadline passed'; - const t = computeTimeLeft(deadline); - return t ? buildAnnouncement(t) : ''; + return deadline.getTime() <= initialReferenceTime; }); + + const [announcement, setAnnouncement] = + useState(() => { + if (!isValidDate(deadline)) { + return ''; + } + + if (initialReferenceTime === null) { + return 'Checking ledger time'; + } + + if (deadline.getTime() <= initialReferenceTime) { + return 'Deadline passed'; + } + + const remaining = computeTimeLeft( + deadline, + initialReferenceTime + ); + + return remaining + ? buildAnnouncement(remaining) + : 'Deadline passed'; + }); + const lastAnnounceKey = useRef(''); useEffect(() => { - if (!isValidDate(deadline)) return; + if (!isValidDate(deadline)) { + return; + } const tick = () => { - const remaining = computeTimeLeft(deadline); + const referenceTime = getReferenceTime(currentTime); + + if (referenceTime === null) { + setExpired(false); + setTimeLeft(null); + + if ( + lastAnnounceKey.current !== 'ledger-loading' + ) { + lastAnnounceKey.current = 'ledger-loading'; + setAnnouncement('Checking ledger time'); + } + + return; + } + + const remaining = computeTimeLeft( + deadline, + referenceTime + ); + if (remaining === null) { setExpired(true); setTimeLeft(null); - if (lastAnnounceKey.current !== 'expired') { + + if ( + lastAnnounceKey.current !== 'expired' + ) { lastAnnounceKey.current = 'expired'; setAnnouncement('Deadline passed'); } + return; } @@ -154,29 +281,97 @@ export function CountdownTimer({ deadline, label }: CountdownTimerProps) { setTimeLeft(remaining); const nextKey = getAnnounceKey(remaining); - if (nextKey !== lastAnnounceKey.current) { + + if ( + nextKey !== lastAnnounceKey.current + ) { lastAnnounceKey.current = nextKey; - setAnnouncement(buildAnnouncement(remaining)); + setAnnouncement( + buildAnnouncement(remaining) + ); } }; tick(); - if (prefersReducedMotion) return; - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [deadline, prefersReducedMotion]); + /* + * When authoritative ledger time is supplied, the timer changes only when + * that ledger snapshot changes. This prevents the browser clock from + * independently crossing the authoritative deadline boundary. + */ + if (currentTime !== undefined) { + return; + } + + if (prefersReducedMotion) { + return; + } + + const id = window.setInterval(tick, 1000); + + return () => { + window.clearInterval(id); + }; + }, [ + deadline, + currentTime, + prefersReducedMotion, + ]); if (!isValidDate(deadline)) { - return ; + return ( + + — + + ); + } + + if (currentTime === null) { + const accessibleLabel = label + ? `${label}: Checking ledger time` + : 'Checking ledger time'; + + return ( +
+ {label && ( + + {label} + + )} + + + Checking ledger time… + +
+ ); } if (expired) { return ( -
- {label && {label}} - Deadline passed - +
+ {label && ( + + {label} + + )} + + + Deadline passed + + + {announcement}
@@ -185,41 +380,72 @@ export function CountdownTimer({ deadline, label }: CountdownTimerProps) { if (!timeLeft) { return ( -
- {label && {label}} - Deadline passed +
+ {label && ( + + {label} + + )} + + + Deadline passed +
); } - const totalHoursLeft = timeLeft.days * 24 + timeLeft.hours; + const totalHoursLeft = + timeLeft.days * 24 + timeLeft.hours; + const isUrgent = totalHoursLeft < 24; - const accessibleLabel = label ? `${label}: ${announcement}` : announcement; + const accessibleLabel = label + ? `${label}: ${announcement}` + : announcement; + const visibleLabel = prefersReducedMotion ? announcement : `${timeLeft.days}d ${timeLeft.hours}h ${timeLeft.minutes}m ${timeLeft.seconds}s`; return ( -
- {label && {label}} +
+ {label && ( + + {label} + + )} + - {/* Avoid duplicate text and unnecessary live region updates when reduced motion is preferred */} + {!prefersReducedMotion && ( - + {announcement} )}
); -} +} \ No newline at end of file diff --git a/components/disputes/shared/__tests__/CountdownTimer.test.tsx b/components/disputes/shared/__tests__/CountdownTimer.test.tsx index df770492..b76c74aa 100644 --- a/components/disputes/shared/__tests__/CountdownTimer.test.tsx +++ b/components/disputes/shared/__tests__/CountdownTimer.test.tsx @@ -1,17 +1,29 @@ -import { render, screen, act } from '@testing-library/react'; +import { + render, + screen, + act, +} from '@testing-library/react'; import { CountdownTimer } from '../CountdownTimer'; -const setReducedMotion = (matches: boolean) => { - window.matchMedia = jest.fn().mockImplementation((query: string) => ({ - matches: query === '(prefers-reduced-motion: reduce)' ? matches : false, - media: query, - onchange: null, - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - addListener: jest.fn(), - removeListener: jest.fn(), - dispatchEvent: jest.fn(), - })); +const setReducedMotion = ( + matches: boolean +) => { + window.matchMedia = jest + .fn() + .mockImplementation((query: string) => ({ + matches: + query === + '(prefers-reduced-motion: reduce)' + ? matches + : false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + })); }; describe('CountdownTimer', () => { @@ -26,207 +38,555 @@ describe('CountdownTimer', () => { describe('valid future deadline', () => { it('displays days, hours, minutes, seconds in "Xd Xh Xm Xs" format', () => { - // 2 days, 3 hours, 4 minutes, 5 seconds from now - const deadline = new Date(Date.now() + (2 * 86400 + 3 * 3600 + 4 * 60 + 5) * 1000); - render(); - expect(screen.getByText('2d 3h 4m 5s')).toBeInTheDocument(); + const deadline = new Date( + Date.now() + + (2 * 86400 + + 3 * 3600 + + 4 * 60 + + 5) * + 1000 + ); + + render( + + ); + + expect( + screen.getByText('2d 3h 4m 5s') + ).toBeInTheDocument(); }); - it('renders the label above the countdown when provided', () => { - const deadline = new Date(Date.now() + 48 * 3600 * 1000); - render(); - expect(screen.getByText('Staking deadline')).toBeInTheDocument(); + it('renders the label when provided', () => { + const deadline = new Date( + Date.now() + 48 * 3600 * 1000 + ); + + render( + + ); + + expect( + screen.getByText('Staking deadline') + ).toBeInTheDocument(); }); it('does not render label when not provided', () => { - const deadline = new Date(Date.now() + 48 * 3600 * 1000); - const { container } = render(); - // The component now renders two spans: the visible countdown and a - // visually-hidden aria-live region for accessible announcements. - // The visible (non-sr-only) span is the countdown itself. - const visibleSpans = container.querySelectorAll('span:not(.sr-only)'); + const deadline = new Date( + Date.now() + 48 * 3600 * 1000 + ); + + const { container } = render( + + ); + + const visibleSpans = + container.querySelectorAll( + 'span:not(.sr-only)' + ); + expect(visibleSpans).toHaveLength(1); }); }); - describe('urgency styling (< 24h remaining)', () => { - it('applies text-destructive and animate-pulse when less than 24h remain', () => { - // 23 hours, 59 minutes, 59 seconds from now - const deadline = new Date(Date.now() + (23 * 3600 + 59 * 60 + 59) * 1000); - render(); - const countdownEl = screen.getByText(/\d+d \d+h \d+m \d+s/); - expect(countdownEl).toHaveClass('text-destructive'); - expect(countdownEl).toHaveClass('animate-pulse'); + describe('ledger time', () => { + it('uses ledger time instead of browser time', () => { + const browserNow = Date.now(); + + const ledgerTime = new Date( + browserNow - 120_000 + ); + + const deadline = new Date( + browserNow - 60_000 + ); + + render( + + ); + + expect( + screen.getByText('0d 0h 1m 0s') + ).toBeInTheDocument(); + + expect( + screen.queryByText( + 'Deadline passed' + ) + ).not.toBeInTheDocument(); }); - it('does NOT apply urgency classes when 24h or more remain', () => { - // Exactly 24 hours from now - const deadline = new Date(Date.now() + 24 * 3600 * 1000); - render(); - const countdownEl = screen.getByText(/\d+d \d+h \d+m \d+s/); - expect(countdownEl).not.toHaveClass('text-destructive'); - expect(countdownEl).not.toHaveClass('animate-pulse'); + it('treats exact ledger deadline equality as passed', () => { + const deadline = new Date( + '2026-08-30T12:00:00.000Z' + ); + + const ledgerTime = new Date( + '2026-08-30T12:00:00.000Z' + ); + + render( + + ); + + expect( + screen.getAllByText( + 'Deadline passed' + ).length + ).toBeGreaterThanOrEqual(1); }); - it('applies urgency classes at exactly 1 second remaining', () => { - const deadline = new Date(Date.now() + 1000); - render(); - const countdownEl = screen.getByText(/\d+d \d+h \d+m \d+s/); - expect(countdownEl).toHaveClass('text-destructive'); + it('keeps the deadline open one second before the ledger boundary', () => { + const deadline = new Date( + '2026-08-30T12:00:00.000Z' + ); + + const ledgerTime = new Date( + '2026-08-30T11:59:59.000Z' + ); + + render( + + ); + + expect( + screen.getByText('0d 0h 0m 1s') + ).toBeInTheDocument(); + }); + + it('shows a loading state when ledger time is unavailable', () => { + const deadline = new Date( + Date.now() + 60_000 + ); + + render( + + ); + + expect( + screen.getByText( + 'Checking ledger time…' + ) + ).toBeInTheDocument(); + + expect( + screen.getByRole('timer') + ).toHaveAccessibleName( + 'Voting deadline: Checking ledger time' + ); + }); + }); + + describe('urgency styling', () => { + it('applies urgency styling when less than 24h remain', () => { + const deadline = new Date( + Date.now() + + (23 * 3600 + + 59 * 60 + + 59) * + 1000 + ); + + render( + + ); + + const countdownEl = + screen.getByText( + /\d+d \d+h \d+m \d+s/ + ); + + expect(countdownEl).toHaveClass( + 'text-destructive' + ); + + expect(countdownEl).toHaveClass( + 'animate-pulse' + ); + }); + + it('does not apply urgency styling at exactly 24h', () => { + const deadline = new Date( + Date.now() + 24 * 3600 * 1000 + ); + + render( + + ); + + const countdownEl = + screen.getByText( + /\d+d \d+h \d+m \d+s/ + ); + + expect(countdownEl).not.toHaveClass( + 'text-destructive' + ); + + expect(countdownEl).not.toHaveClass( + 'animate-pulse' + ); + }); + + it('applies urgency styling at exactly one second remaining', () => { + const deadline = new Date( + Date.now() + 1000 + ); + + render( + + ); + + expect( + screen.getByText( + /\d+d \d+h \d+m \d+s/ + ) + ).toHaveClass('text-destructive'); }); }); describe('expired deadline', () => { - it('renders "Deadline passed" when deadline is in the past', () => { - const deadline = new Date(Date.now() - 1000); - render(); - // Both the visible label and the aria-live region say "Deadline passed". - const matches = screen.getAllByText('Deadline passed'); - expect(matches.length).toBeGreaterThanOrEqual(1); + it('renders Deadline passed when deadline is in the past', () => { + const deadline = new Date( + Date.now() - 1000 + ); + + render( + + ); + + expect( + screen.getAllByText( + 'Deadline passed' + ).length + ).toBeGreaterThanOrEqual(1); }); it('does not render a numeric countdown when expired', () => { - const deadline = new Date(Date.now() - 5000); - render(); - expect(screen.queryByText(/\d+d \d+h \d+m \d+s/)).not.toBeInTheDocument(); + const deadline = new Date( + Date.now() - 5000 + ); + + render( + + ); + + expect( + screen.queryByText( + /\d+d \d+h \d+m \d+s/ + ) + ).not.toBeInTheDocument(); }); - it('transitions to "Deadline passed" when the timer runs out', () => { - const deadline = new Date(Date.now() + 1500); - render(); - expect(screen.getByText(/\d+d \d+h \d+m \d+s/)).toBeInTheDocument(); + it('transitions to Deadline passed when browser-time fallback runs out', () => { + const deadline = new Date( + Date.now() + 1500 + ); + + render( + + ); + + expect( + screen.getByText( + /\d+d \d+h \d+m \d+s/ + ) + ).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(2000); }); - const passedMatches = screen.getAllByText('Deadline passed'); - expect(passedMatches.length).toBeGreaterThanOrEqual(1); - expect(screen.queryByText(/\d+d \d+h \d+m \d+s/)).not.toBeInTheDocument(); + expect( + screen.getAllByText( + 'Deadline passed' + ).length + ).toBeGreaterThanOrEqual(1); + + expect( + screen.queryByText( + /\d+d \d+h \d+m \d+s/ + ) + ).not.toBeInTheDocument(); }); }); describe('invalid Date', () => { - it('renders "—" for an invalid Date object', () => { - const invalid = new Date('not-a-date'); - render(); - expect(screen.getByText('—')).toBeInTheDocument(); + it('renders — for an invalid deadline', () => { + render( + + ); + + expect( + screen.getByText('—') + ).toBeInTheDocument(); }); - it('does not crash when given an invalid Date', () => { - const invalid = new Date('garbage'); - expect(() => render()).not.toThrow(); + it('does not crash for an invalid deadline', () => { + expect(() => + render( + + ) + ).not.toThrow(); }); }); describe('timer updates', () => { - it('counts down each second', () => { - const deadline = new Date(Date.now() + 5000); - render(); - expect(screen.getByText('0d 0h 0m 5s')).toBeInTheDocument(); + it('counts down browser-time fallback each second', () => { + const deadline = new Date( + Date.now() + 5000 + ); + + render( + + ); + + expect( + screen.getByText('0d 0h 0m 5s') + ).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(1000); }); - expect(screen.getByText('0d 0h 0m 4s')).toBeInTheDocument(); + + expect( + screen.getByText('0d 0h 0m 4s') + ).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(1000); }); - expect(screen.getByText('0d 0h 0m 3s')).toBeInTheDocument(); + + expect( + screen.getByText('0d 0h 0m 3s') + ).toBeInTheDocument(); }); }); describe('accessibility', () => { - it('exposes the remaining time via role="timer" and an accessible label', () => { - const deadline = new Date(Date.now() + (2 * 86400 + 3 * 3600) * 1000); - render(); - const timer = screen.getByRole('timer'); + it('exposes remaining time using role timer', () => { + const deadline = new Date( + Date.now() + + (2 * 86400 + + 3 * 3600) * + 1000 + ); + + render( + + ); + + const timer = + screen.getByRole('timer'); + expect(timer).toBeInTheDocument(); - expect(timer.getAttribute('aria-label')).toMatch(/2 days/); + + expect( + timer.getAttribute('aria-label') + ).toMatch(/2 days/); }); - it('exposes the label as part of the timer\'s accessible name', () => { - const deadline = new Date(Date.now() + 90 * 60 * 1000); // 1h 30m - render(); - const timer = screen.getByRole('timer'); - expect(timer.getAttribute('aria-label')).toMatch(/Voting closes in/); - expect(timer.getAttribute('aria-label')).toMatch(/1 hour/); + it('includes the supplied label in its accessible name', () => { + const deadline = new Date( + Date.now() + + 90 * 60 * 1000 + ); + + render( + + ); + + const timer = + screen.getByRole('timer'); + + expect( + timer.getAttribute('aria-label') + ).toMatch(/Voting closes in/); + + expect( + timer.getAttribute('aria-label') + ).toMatch(/1 hour/); }); - it('marks the visible numeric countdown as aria-hidden so it is not double-announced', () => { - const deadline = new Date(Date.now() + 5 * 60 * 1000); - render(); - const visibleCountdown = screen.getByText(/\d+d \d+h \d+m \d+s/); - expect(visibleCountdown).toHaveAttribute('aria-hidden', 'true'); + it('marks visible countdown aria-hidden', () => { + const deadline = new Date( + Date.now() + 5 * 60 * 1000 + ); + + render( + + ); + + expect( + screen.getByText( + /\d+d \d+h \d+m \d+s/ + ) + ).toHaveAttribute( + 'aria-hidden', + 'true' + ); }); - it('does not update the aria-live announcement every second when more than a minute remains', () => { - // Start at 5 minutes 30 seconds. aria-live should sit on "5 minutes" and - // not refresh on second-ticks within the same minute bucket. - const deadline = new Date(Date.now() + (5 * 60 + 30) * 1000); - const { container } = render(); - const liveRegion = container.querySelector('[aria-live="polite"]'); - const initial = liveRegion?.textContent ?? ''; - expect(initial).toMatch(/5 minutes/); + it('does not update live announcement every second when more than a minute remains', () => { + const deadline = new Date( + Date.now() + + (5 * 60 + 30) * 1000 + ); + + const { container } = render( + + ); + + const liveRegion = + container.querySelector( + '[aria-live="polite"]' + ); + + const initial = + liveRegion?.textContent ?? ''; + + expect(initial).toMatch( + /5 minutes/ + ); - // Advance 3 seconds (still inside the "5 minutes" bucket). act(() => { jest.advanceTimersByTime(3000); }); - expect(liveRegion?.textContent).toBe(initial); + + expect( + liveRegion?.textContent + ).toBe(initial); }); - it('updates the aria-live announcement when the minute boundary crosses', () => { - const deadline = new Date(Date.now() + (5 * 60 + 1) * 1000); // 5m 1s - const { container } = render(); - const liveRegion = container.querySelector('[aria-live="polite"]'); - expect(liveRegion?.textContent).toMatch(/5 minutes/); + it('updates live announcement at the minute boundary', () => { + const deadline = new Date( + Date.now() + + (5 * 60 + 1) * 1000 + ); + + const { container } = render( + + ); + + const liveRegion = + container.querySelector( + '[aria-live="polite"]' + ); + + expect( + liveRegion?.textContent + ).toMatch(/5 minutes/); - // Advance 2 seconds — we should drop into the "4 minutes" bucket and - // the aria-live region should re-announce. act(() => { jest.advanceTimersByTime(2000); }); - expect(liveRegion?.textContent).toMatch(/4 minutes/); + + expect( + liveRegion?.textContent + ).toMatch(/4 minutes/); }); - it('announces "Deadline passed" once when the deadline elapses', () => { - const deadline = new Date(Date.now() + 1500); - const { container } = render(); + it('announces Deadline passed when deadline elapses', () => { + const deadline = new Date( + Date.now() + 1500 + ); + + const { container } = render( + + ); + act(() => { jest.advanceTimersByTime(2000); }); - const liveRegion = container.querySelector('[aria-live="polite"]'); - expect(liveRegion?.textContent).toBe('Deadline passed'); - }); + const liveRegion = + container.querySelector( + '[aria-live="polite"]' + ); + + expect( + liveRegion?.textContent + ).toBe('Deadline passed'); + }); }); describe('reduced motion', () => { - it('renders a static remaining-time label instead of a ticking numeric countdown', () => { + it('renders static remaining-time text', () => { setReducedMotion(true); - const deadline = new Date(Date.now() + (2 * 86400 + 3 * 3600) * 1000); - - render(); - // Multiple elements may match (visible + sr-only); use getAllByText - const matches = screen.getAllByText('2 days, 3 hours remaining'); - expect(matches.length).toBeGreaterThanOrEqual(1); - expect(screen.queryByText(/\d+d \d+h \d+m \d+s/)).not.toBeInTheDocument(); + const deadline = new Date( + Date.now() + + (2 * 86400 + + 3 * 3600) * + 1000 + ); + + render( + + ); + + expect( + screen.getAllByText( + '2 days, 3 hours remaining' + ).length + ).toBeGreaterThanOrEqual(1); + + expect( + screen.queryByText( + /\d+d \d+h \d+m \d+s/ + ) + ).not.toBeInTheDocument(); }); - it('does not apply pulse animation when reduced motion is preferred', () => { + it('does not pulse when reduced motion is preferred', () => { setReducedMotion(true); - const deadline = new Date(Date.now() + 30 * 1000); - render(); + const deadline = new Date( + Date.now() + 30 * 1000 + ); + + render( + + ); + + const countdownEls = + screen.getAllByText( + /seconds remaining/ + ); + + expect( + countdownEls[0] + ).toHaveClass('text-destructive'); - const countdownEls = screen.getAllByText(/seconds remaining/); - const countdownEl = countdownEls[0]; - expect(countdownEl).toHaveClass('text-destructive'); - expect(countdownEl).not.toHaveClass('animate-pulse'); - + expect( + countdownEls[0] + ).not.toHaveClass('animate-pulse'); }); }); -}); +}); \ No newline at end of file diff --git a/components/disputes/shared/useLedgerTime.ts b/components/disputes/shared/useLedgerTime.ts new file mode 100644 index 00000000..b8a87851 --- /dev/null +++ b/components/disputes/shared/useLedgerTime.ts @@ -0,0 +1,188 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type LedgerTimeStatus = + | 'loading' + | 'ready' + | 'stale' + | 'error' + | 'permission'; + +interface LedgerTimeState { + ledgerTime: Date | null; + ledgerSequence: number | null; + status: LedgerTimeStatus; + error: string | null; +} + +interface LedgerTimePayload { + ledgerTime: string; + ledgerSequence: number; +} + +const POLL_INTERVAL_MS = 5000; + +function parseLedgerPayload(payload: unknown): LedgerTimePayload { + if ( + typeof payload !== 'object' || + payload === null || + !('ledgerTime' in payload) || + !('ledgerSequence' in payload) + ) { + throw new Error('Invalid ledger time response.'); + } + + const ledgerTime = (payload as LedgerTimePayload).ledgerTime; + const ledgerSequence = (payload as LedgerTimePayload).ledgerSequence; + + if ( + typeof ledgerTime !== 'string' || + Number.isNaN(new Date(ledgerTime).getTime()) || + !Number.isSafeInteger(ledgerSequence) || + ledgerSequence < 0 + ) { + throw new Error('Invalid ledger time response.'); + } + + return { + ledgerTime, + ledgerSequence, + }; +} + +/** + * Returns the latest authoritative Stellar ledger close time. + * + * Invariants: + * - Browser wall-clock time is never used to decide dispute deadline status. + * - Older/out-of-order ledger responses never overwrite newer ledger state. + * - A failed refresh keeps the last successful ledger snapshot. + * - Actions can fail closed while ledger state is stale or unavailable. + */ +export function useLedgerTime() { + const [state, setState] = useState({ + ledgerTime: null, + ledgerSequence: null, + status: 'loading', + error: null, + }); + + const requestIdRef = useRef(0); + const abortControllerRef = useRef(null); + const latestSequenceRef = useRef(null); + const latestTimeRef = useRef(null); + + const loadLedgerTime = useCallback(async () => { + const requestId = ++requestIdRef.current; + + abortControllerRef.current?.abort(); + + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + const response = await fetch('/api/ledger-time', { + method: 'GET', + cache: 'no-store', + signal: controller.signal, + headers: { + Accept: 'application/json', + }, + }); + + if (requestId !== requestIdRef.current) { + return; + } + + if (response.status === 401 || response.status === 403) { + setState((previous) => ({ + ...previous, + status: 'permission', + error: 'Unable to verify ledger time because access was denied.', + })); + return; + } + + if (!response.ok) { + throw new Error('Unable to retrieve ledger time.'); + } + + const payload = parseLedgerPayload(await response.json()); + const nextTime = new Date(payload.ledgerTime); + const nextTimeMs = nextTime.getTime(); + + const previousSequence = latestSequenceRef.current; + const previousTime = latestTimeRef.current; + + // Never allow an older response to replace a newer ledger snapshot. + if ( + previousSequence !== null && + payload.ledgerSequence < previousSequence + ) { + return; + } + + // The same ledger sequence must always have the same close time. + if ( + previousSequence !== null && + previousTime !== null && + payload.ledgerSequence === previousSequence && + nextTimeMs !== previousTime + ) { + throw new Error('Inconsistent ledger time response.'); + } + + latestSequenceRef.current = payload.ledgerSequence; + latestTimeRef.current = nextTimeMs; + + setState({ + ledgerTime: nextTime, + ledgerSequence: payload.ledgerSequence, + status: 'ready', + error: null, + }); + } catch (error) { + if ( + error instanceof DOMException && + error.name === 'AbortError' + ) { + return; + } + + if (requestId !== requestIdRef.current) { + return; + } + + setState((previous) => ({ + ...previous, + status: previous.ledgerTime ? 'stale' : 'error', + error: previous.ledgerTime + ? 'Ledger time could not be refreshed. Actions are paused until it is current.' + : 'Ledger time is temporarily unavailable.', + })); + } + }, []); + + const retry = useCallback(() => { + void loadLedgerTime(); + }, [loadLedgerTime]); + + useEffect(() => { + void loadLedgerTime(); + + const intervalId = window.setInterval(() => { + void loadLedgerTime(); + }, POLL_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + abortControllerRef.current?.abort(); + }; + }, [loadLedgerTime]); + + return { + ...state, + retry, + }; +} \ No newline at end of file diff --git a/components/disputes/states/OpenState.tsx b/components/disputes/states/OpenState.tsx index 84576232..c932169f 100644 --- a/components/disputes/states/OpenState.tsx +++ b/components/disputes/states/OpenState.tsx @@ -7,76 +7,193 @@ import { Label } from '@/components/ui/label'; import { WarningBanner } from '@/components/disputes/shared/WarningBanner'; import { CountdownTimer } from '@/components/disputes/shared/CountdownTimer'; import { TallyBar } from '@/components/disputes/shared/TallyBar'; -import type { DisputeData, DisputeState } from '@/types/disputes'; -import { OutcomeIcon, getVariantByIndex } from '@/components/icons/OutcomeIcons'; +import { useLedgerTime } from '@/components/disputes/shared/useLedgerTime'; +import type { + DisputeData, + DisputeState, +} from '@/types/disputes'; +import { + OutcomeIcon, + getVariantByIndex, +} from '@/components/icons/OutcomeIcons'; interface OpenStateProps { data: DisputeData; - onStateChange?: (next: DisputeState, updated: Partial) => void; + onStateChange?: ( + next: DisputeState, + updated: Partial + ) => void; } function formatDeadline(date: Date): string { - return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); + return date.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }); } -export function OpenState({ data, onStateChange }: OpenStateProps) { - const [selectedSide, setSelectedSide] = useState(null); - const [amount, setAmount] = useState(''); +export function OpenState({ + data, + onStateChange, +}: OpenStateProps) { + const [selectedSide, setSelectedSide] = + useState(null); - const leftLabel = data.tally?.[0]?.label ?? 'Yes'; - const rightLabel = data.tally?.[1]?.label ?? 'No'; + const [amount, setAmount] = + useState(''); + + const { + ledgerTime, + status: ledgerStatus, + error: ledgerError, + retry, + } = useLedgerTime(); + + const leftLabel = + data.tally?.[0]?.label ?? 'Yes'; + + const rightLabel = + data.tally?.[1]?.label ?? 'No'; + + const requiresLedgerTime = + data.stakingDeadline != null; + + const ledgerReady = + !requiresLedgerTime || + ledgerStatus === 'ready'; const deadlinePassed = - data.stakingDeadline != null && data.stakingDeadline.getTime() <= Date.now(); + data.stakingDeadline != null && + ledgerTime != null && + data.stakingDeadline.getTime() <= + ledgerTime.getTime(); + + /* + * Fail closed whenever authoritative ledger time cannot be verified. + * This prevents an incorrect client clock or stale ledger response from + * allowing a stake after the on-ledger deadline. + */ + const isDisabled = + data.userHasStaked === true || + deadlinePassed || + !ledgerReady; + + const showWarning = + selectedSide !== null && !isDisabled; - const isDisabled = data.userHasStaked === true || deadlinePassed; - const showWarning = selectedSide !== null && !isDisabled; + const showLedgerProblem = + requiresLedgerTime && + ledgerStatus !== 'ready' && + ledgerStatus !== 'loading'; return (
- {data.reason &&

{data.reason}

} + {data.reason && ( +

+ {data.reason} +

+ )} {data.stakingDeadline && ( - + + )} + + {requiresLedgerTime && + ledgerStatus === 'loading' && ( +

+ Verifying staking deadline from ledger… +

+ )} + + {showLedgerProblem && ( +
+

+ {ledgerError ?? + 'Unable to verify the current ledger time.'} +

+ + +
)} - {data.tally && } + {data.tally && ( + + )}
+
- {[leftLabel, rightLabel].map((sideLabel, index) => ( - - ))} + {[leftLabel, rightLabel].map( + (sideLabel, index) => ( + + ) + )}
- + + setAmount(e.target.value)} + onChange={(event) => + setAmount(event.target.value) + } disabled={isDisabled} />
@@ -86,13 +203,36 @@ export function OpenState({ data, onStateChange }: OpenStateProps) { +
- -
@@ -104,9 +244,13 @@ export function OpenState({ data, onStateChange }: OpenStateProps) { Your stake has been submitted.

)} - {deadlinePassed && !data.userHasStaked && ( -

Staking deadline has passed.

- )} + + {deadlinePassed && + !data.userHasStaked && ( +

+ Staking deadline has passed. +

+ )}
); -} +} \ No newline at end of file diff --git a/components/disputes/states/VotingState.tsx b/components/disputes/states/VotingState.tsx index 90bc5367..d926b8ff 100644 --- a/components/disputes/states/VotingState.tsx +++ b/components/disputes/states/VotingState.tsx @@ -5,58 +5,156 @@ import { Button } from '@/components/ui/button'; import { WarningBanner } from '@/components/disputes/shared/WarningBanner'; import { CountdownTimer } from '@/components/disputes/shared/CountdownTimer'; import { TallyBar } from '@/components/disputes/shared/TallyBar'; -import type { DisputeData, DisputeState } from '@/types/disputes'; -import { OutcomeIcon, getVariantByIndex } from '@/components/icons/OutcomeIcons'; +import { useLedgerTime } from '@/components/disputes/shared/useLedgerTime'; +import type { + DisputeData, + DisputeState, +} from '@/types/disputes'; +import { + OutcomeIcon, + getVariantByIndex, +} from '@/components/icons/OutcomeIcons'; interface VotingStateProps { data: DisputeData; - onStateChange?: (next: DisputeState, updated: Partial) => void; + onStateChange?: ( + next: DisputeState, + updated: Partial + ) => void; } -export function VotingState({ data, onStateChange }: VotingStateProps) { - const [selectedVote, setSelectedVote] = useState(null); +export function VotingState({ + data, + onStateChange, +}: VotingStateProps) { + const [selectedVote, setSelectedVote] = + useState(null); - const leftLabel = data.tally?.[0]?.label ?? 'Yes'; - const rightLabel = data.tally?.[1]?.label ?? 'No'; + const { + ledgerTime, + status: ledgerStatus, + error: ledgerError, + retry, + } = useLedgerTime(); + + const leftLabel = + data.tally?.[0]?.label ?? 'Yes'; + + const rightLabel = + data.tally?.[1]?.label ?? 'No'; + + const requiresLedgerTime = + data.votingDeadline != null; + + const ledgerReady = + !requiresLedgerTime || + ledgerStatus === 'ready'; const deadlinePassed = - data.votingDeadline != null && data.votingDeadline.getTime() <= Date.now(); + data.votingDeadline != null && + ledgerTime != null && + data.votingDeadline.getTime() <= + ledgerTime.getTime(); - const isDisabled = data.userHasVoted === true || deadlinePassed; - const showWarning = selectedVote !== null && !isDisabled; + /* + * Fail closed unless the latest ledger time has been verified. + * This prevents stale or incorrect browser time from enabling a late vote. + */ + const isDisabled = + data.userHasVoted === true || + deadlinePassed || + !ledgerReady; + + const showWarning = + selectedVote !== null && !isDisabled; + + const showLedgerProblem = + requiresLedgerTime && + ledgerStatus !== 'ready' && + ledgerStatus !== 'loading'; return (
- {/* Voting deadline — prominent, above controls */} {data.votingDeadline && (
- +
)} - {data.tally && } + {requiresLedgerTime && + ledgerStatus === 'loading' && ( +

+ Verifying voting deadline from ledger… +

+ )} + + {showLedgerProblem && ( +
+

+ {ledgerError ?? + 'Unable to verify the current ledger time.'} +

+ + +
+ )} + + {data.tally && ( + + )} - {/* Vote option buttons */}
-

Cast your vote

+

+ Cast your vote +

+
- {[leftLabel, rightLabel].map((voteLabel, index) => ( - - ))} + {[leftLabel, rightLabel].map( + (voteLabel, index) => ( + + ) + )}
@@ -67,11 +165,25 @@ export function VotingState({ data, onStateChange }: VotingStateProps) { title="Your vote is final" description={`You are voting "${selectedVote}". This action cannot be changed once confirmed.`} /> +
- -
@@ -83,9 +195,13 @@ export function VotingState({ data, onStateChange }: VotingStateProps) { Your vote has been recorded.

)} - {deadlinePassed && !data.userHasVoted && ( -

Voting has closed.

- )} + + {deadlinePassed && + !data.userHasVoted && ( +

+ Voting has closed. +

+ )}
); -} +} \ No newline at end of file