Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/__tests__/components/CancelDonationBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { render, screen, act } from "@testing-library/react";
import { CancelDonationBanner } from "@/components/CancelDonationBanner";
import { resetServerTimeCache, type PendingDonation } from "@/hooks/useDonationGracePeriod";

// Client clock is 5 minutes BEHIND the server clock.
const CLIENT_NOW = Date.parse("2026-08-01T12:00:00.000Z");
const SERVER_NOW = Date.parse("2026-08-01T12:05:00.000Z");

const originalFetch = globalThis.fetch;

function makeDonation(overrides: Partial<PendingDonation> = {}): PendingDonation {
return {
id: "pending_1",
campaignId: 1,
campaignTitle: "Test Campaign",
amount: 10,
currency: "XLM",
timestamp: SERVER_NOW,
expiresAt: SERVER_NOW + 60_000,
...overrides,
};
}

function mockServerTime(serverMs: number) {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ timestamp: new Date(serverMs).toISOString() }),
}) as unknown as typeof fetch;
}

function mockUnreachableServer() {
globalThis.fetch = jest.fn().mockRejectedValue(new Error("offline")) as unknown as typeof fetch;
}

async function flushAsync() {
await act(async () => {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
});
}

describe("CancelDonationBanner", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(CLIENT_NOW);
resetServerTimeCache();
});

afterEach(() => {
jest.useRealTimers();
globalThis.fetch = originalFetch;
jest.restoreAllMocks();
});

it("shows the countdown based on server time when the client clock is skewed", async () => {
// expiresAt is 60s after the SERVER-confirmed deadline. With a client clock
// 5 minutes behind, a raw Date.now() would show ~360s; the corrected
// countdown must show 60s.
mockServerTime(SERVER_NOW);
render(<CancelDonationBanner pendingDonations={[makeDonation()]} onCancel={jest.fn()} />);
await flushAsync();

expect(screen.getByText("60s")).toBeInTheDocument();
});

it("shows 0s once the server-confirmed window has actually closed", async () => {
mockServerTime(SERVER_NOW);
render(<CancelDonationBanner pendingDonations={[makeDonation()]} onCancel={jest.fn()} />);
await flushAsync();
expect(screen.getByText("60s")).toBeInTheDocument();

act(() => {
jest.advanceTimersByTime(61_000);
});

expect(screen.getByText("0s")).toBeInTheDocument();
});

it("falls back to the client clock when the server is unreachable", async () => {
mockUnreachableServer();
render(
<CancelDonationBanner
pendingDonations={[makeDonation({ timestamp: CLIENT_NOW, expiresAt: CLIENT_NOW + 60_000 })]}
onCancel={jest.fn()}
/>,
);
await flushAsync();

expect(screen.getByText("60s")).toBeInTheDocument();
});

it("clamps remaining time at 0 instead of showing negative values", async () => {
mockServerTime(SERVER_NOW);
render(
<CancelDonationBanner
pendingDonations={[makeDonation({ expiresAt: SERVER_NOW - 10_000 })]}
onCancel={jest.fn()}
/>,
);
await flushAsync();

expect(screen.getByText("0s")).toBeInTheDocument();
});
});
120 changes: 120 additions & 0 deletions src/__tests__/hooks/useDonationGracePeriod.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { renderHook, act } from "@testing-library/react";
import { useDonationGracePeriod, resetServerTimeCache } from "@/hooks/useDonationGracePeriod";

// Client clock is 5 minutes BEHIND the server clock.
const CLIENT_NOW = Date.parse("2026-08-01T12:00:00.000Z");
const SERVER_NOW = Date.parse("2026-08-01T12:05:00.000Z");

const originalFetch = globalThis.fetch;

function mockServerTime(serverMs: number | null) {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ timestamp: serverMs === null ? "not-a-date" : new Date(serverMs).toISOString() }),
}) as unknown as typeof fetch;
}

function mockUnreachableServer() {
globalThis.fetch = jest.fn().mockRejectedValue(new Error("offline")) as unknown as typeof fetch;
}

async function flushAsync() {
await act(async () => {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
}
});
}

describe("useDonationGracePeriod", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(CLIENT_NOW);
resetServerTimeCache();
});

afterEach(() => {
jest.useRealTimers();
globalThis.fetch = originalFetch;
jest.restoreAllMocks();
});

it("starts the grace period on server-confirmed time when the client clock is skewed", async () => {
mockServerTime(SERVER_NOW);
const { result } = renderHook(() => useDonationGracePeriod());
await flushAsync();

act(() => {
result.current.startGracePeriod({
campaignId: 1,
campaignTitle: "Test Campaign",
amount: 10,
currency: "XLM",
});
});

const donation = result.current.pendingDonations[0];
expect(donation.timestamp).toBe(SERVER_NOW);
expect(donation.expiresAt).toBe(SERVER_NOW + 60_000);
});

it("purges expired donations using server-confirmed time", async () => {
mockServerTime(SERVER_NOW);
const { result } = renderHook(() => useDonationGracePeriod());
await flushAsync();

act(() => {
result.current.startGracePeriod({
campaignId: 1,
campaignTitle: "Test Campaign",
amount: 10,
currency: "XLM",
});
});
expect(result.current.pendingDonations).toHaveLength(1);

// Server window is only 60s; client clock is 5 minutes behind, so a
// client-based purge would keep it around for 5 extra minutes.
act(() => {
jest.advanceTimersByTime(61_000);
});

expect(result.current.pendingDonations).toHaveLength(0);
});

it("falls back to the client clock when the server is unreachable", async () => {
mockUnreachableServer();
const { result } = renderHook(() => useDonationGracePeriod());
await flushAsync();

act(() => {
result.current.startGracePeriod({
campaignId: 1,
campaignTitle: "Test Campaign",
amount: 10,
currency: "XLM",
});
});

const donation = result.current.pendingDonations[0];
expect(donation.timestamp).toBe(CLIENT_NOW);
expect(donation.expiresAt).toBe(CLIENT_NOW + 60_000);
});

it("ignores an invalid server timestamp and falls back to the client clock", async () => {
mockServerTime(null);
const { result } = renderHook(() => useDonationGracePeriod());
await flushAsync();

act(() => {
result.current.startGracePeriod({
campaignId: 1,
campaignTitle: "Test Campaign",
amount: 10,
currency: "XLM",
});
});

expect(result.current.pendingDonations[0].expiresAt).toBe(CLIENT_NOW + 60_000);
});
});
12 changes: 7 additions & 5 deletions src/components/CancelDonationBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use client';

import React, { useState, useEffect } from 'react';
import { PendingDonation } from '../hooks/useDonationGracePeriod';
import {
PendingDonation,
useServerTimeOffset,
} from '../hooks/useDonationGracePeriod';

interface CancelDonationBannerProps {
pendingDonations: PendingDonation[];
Expand All @@ -15,6 +18,7 @@ export function CancelDonationBanner({
onFinalize,
}: CancelDonationBannerProps) {
const [, setNow] = useState(Date.now());
const { offsetMs } = useServerTimeOffset();

useEffect(() => {
if (pendingDonations.length === 0) return;
Expand All @@ -31,10 +35,8 @@ export function CancelDonationBanner({
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-md w-full px-4"
>
{pendingDonations.map((donation) => {
const remainingSeconds = Math.max(
0,
Math.ceil((donation.expiresAt - Date.now()) / 1000)
);
const serverNow = Date.now() + offsetMs;
const remainingSeconds = Math.max(0, Math.ceil((donation.expiresAt - serverNow) / 1000));

return (
<div
Expand Down
94 changes: 89 additions & 5 deletions src/hooks/useDonationGracePeriod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,106 @@ export interface PendingDonation {

const DEFAULT_GRACE_PERIOD_MS = 60_000; // 60 seconds

// Server time sync ------------------------------------------------------------
//
// The grace-period deadline is enforced on-chain, so the countdown must be
// anchored to a server-confirmed timestamp rather than the client's local
// clock. We derive a client<->server clock offset from the app's health
// endpoint (which reports the server's own `Date.now()`), then apply that
// offset everywhere the hook reasons about "now". When the server cannot be
// reached we degrade gracefully to the client clock (offset 0).

const SERVER_TIME_URL = '/api/health';
const SYNC_TIMEOUT_MS = 4000;
const MAX_CLOCK_SKEW_MS = 24 * 60 * 60 * 1000; // beyond this, treat as invalid

/**
* Resolves to the server-confirmed epoch (ms), or `null` when it cannot be
* obtained (network failure, unparseable/invalid response).
*/
export async function fetchServerTimeMs(): Promise<number | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SYNC_TIMEOUT_MS);
try {
const response = await fetch(SERVER_TIME_URL, {
signal: controller.signal,
cache: 'no-store',
});
const body: unknown = await response.json();
const timestamp = (body as { timestamp?: unknown })?.timestamp;
const serverMs = typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN;
return Number.isFinite(serverMs) ? serverMs : null;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}

let serverTimeCache: Promise<number | null> | null = null;

/** Shared server-time request so all consumers perform a single round-trip. */
export function getServerTimeMs(): Promise<number | null> {
serverTimeCache ??= fetchServerTimeMs();
return serverTimeCache;
}

/** Clears the shared server-time cache (mainly for tests / re-sync). */
export function resetServerTimeCache(): void {
serverTimeCache = null;
}

export interface ServerTimeOffset {
/** `serverNow - clientNow` in ms. `0` until synced or when sync failed. */
offsetMs: number;
/** Whether the offset has been confirmed against the server. */
isSynced: boolean;
}

/**
* Tracks the client<->server clock offset. Falling back to `{ offsetMs: 0 }`
* keeps the app usable when the server is unreachable.
*/
export function useServerTimeOffset(): ServerTimeOffset {
const [state, setState] = useState<ServerTimeOffset>({ offsetMs: 0, isSynced: false });

useEffect(() => {
let cancelled = false;
const clientAtRequest = Date.now();
getServerTimeMs().then((serverMs) => {
if (cancelled || serverMs === null) return;
const clientAtResponse = Date.now();
const estimatedClientNow = (clientAtRequest + clientAtResponse) / 2;
const offsetMs = serverMs - estimatedClientNow;
if (Math.abs(offsetMs) <= MAX_CLOCK_SKEW_MS) {
setState({ offsetMs, isSynced: true });
}
});
return () => {
cancelled = true;
};
}, []);

return state;
}

export function useDonationGracePeriod(gracePeriodMs: number = DEFAULT_GRACE_PERIOD_MS) {
const { offsetMs } = useServerTimeOffset();
const [pendingDonations, setPendingDonations] = useState<PendingDonation[]>([]);

// Periodically purge expired donations and update remaining time
// Periodically purge expired donations using the server-adjusted clock
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
const now = Date.now() + offsetMs;
setPendingDonations((prev) => prev.filter((d) => d.expiresAt > now));
}, 1000);

return () => clearInterval(interval);
}, []);
}, [offsetMs]);

const startGracePeriod = useCallback(
(donation: Omit<PendingDonation, 'id' | 'timestamp' | 'expiresAt'>) => {
const now = Date.now();
const now = Date.now() + offsetMs;
const newDonation: PendingDonation = {
...donation,
id: `pending_${now}_${Math.random().toString(36).substring(2, 7)}`,
Expand All @@ -40,7 +124,7 @@ export function useDonationGracePeriod(gracePeriodMs: number = DEFAULT_GRACE_PER
setPendingDonations((prev) => [newDonation, ...prev]);
return newDonation;
},
[gracePeriodMs]
[gracePeriodMs, offsetMs]
);

const cancelDonation = useCallback((id: string) => {
Expand Down