From 024a9cc5894dd7b666491e9db4574620f84be33e Mon Sep 17 00:00:00 2001 From: Nhat Anh Date: Sat, 11 Jul 2026 22:08:56 +0700 Subject: [PATCH] fix invalid ledger timestamps --- frontend/src/__tests__/events.test.ts | 65 ++++++++++++++++++++++++++ frontend/src/__tests__/helpers.test.ts | 18 +++++++ frontend/src/app/markets/[id]/page.tsx | 4 +- frontend/src/services/events.ts | 18 ++++++- frontend/src/types/index.ts | 1 + frontend/src/utils/helpers.ts | 46 +++++++++++++----- 6 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 frontend/src/__tests__/events.test.ts diff --git a/frontend/src/__tests__/events.test.ts b/frontend/src/__tests__/events.test.ts new file mode 100644 index 0000000..00e645c --- /dev/null +++ b/frontend/src/__tests__/events.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getLatestLedger: vi.fn(), + getEvents: vi.fn(), +})); + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual( + "@stellar/stellar-sdk" + ); + return { ...actual, scValToNative: (value: unknown) => value }; +}); + +vi.mock("@/services/soroban", () => ({ + getSorobanServer: () => ({ + getLatestLedger: mocks.getLatestLedger, + getEvents: mocks.getEvents, + }), +})); + +import { + ledgerClosedAtToUnixSeconds, + pollMarketEvents, +} from "@/services/events"; + +describe("ledgerClosedAtToUnixSeconds", () => { + const expected = Date.UTC(2026, 1, 26, 15, 4) / 1000; + + it.each([ + ["ISO string", "2026-02-26T15:04:00.000Z"], + ["numeric milliseconds", Date.UTC(2026, 1, 26, 15, 4)], + ["Date", new Date("2026-02-26T15:04:00.000Z")], + ])("accepts a valid %s", (_label, value) => { + expect(ledgerClosedAtToUnixSeconds(value)).toBe(expected); + }); + + it.each([ + ["invalid string", "not-a-date"], + ["missing value", undefined], + ["NaN", Number.NaN], + ["positive infinity", Number.POSITIVE_INFINITY], + ["negative infinity", Number.NEGATIVE_INFINITY], + ])("throws RangeError for %s", (_label, value) => { + expect(() => + ledgerClosedAtToUnixSeconds(value as unknown as string) + ).toThrow(new RangeError("Invalid ledger close timestamp")); + }); +}); + +describe("pollMarketEvents", () => { + it("discards an event with malformed ledgerClosedAt", async () => { + const malformedEvent = { + topic: ["market_cancelled", 7], + value: {}, + ledgerClosedAt: "not-a-date", + txHash: "malformed-event", + }; + + mocks.getLatestLedger.mockResolvedValue({ sequence: 100 }); + mocks.getEvents.mockResolvedValue({ events: [malformedEvent] }); + + await expect(pollMarketEvents()).resolves.toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/helpers.test.ts b/frontend/src/__tests__/helpers.test.ts index c039046..3e58199 100644 --- a/frontend/src/__tests__/helpers.test.ts +++ b/frontend/src/__tests__/helpers.test.ts @@ -3,6 +3,8 @@ import { formatXLM, truncateAddress, isValidAmount, + formatDate, + formatTime, timeUntil, calculatePayout, calculateOdds, @@ -10,6 +12,22 @@ import { explorerUrl, } from "@/utils/helpers"; +describe("localized timestamp formatters", () => { + const timestamp = Date.UTC(2026, 2, 1, 1, 30) / 1000; + + it.each([ + ["en-US", "UTC", "Mar 1, 2026, 01:30 AM", "01:30 AM"], + ["en-US", "America/New_York", "Feb 28, 2026, 08:30 PM", "08:30 PM"], + ["en-US", "Asia/Ho_Chi_Minh", "Mar 1, 2026, 08:30 AM", "08:30 AM"], + ["vi-VN", "UTC", "01:30 1 thg 3, 2026", "01:30"], + ["vi-VN", "America/New_York", "20:30 28 thg 2, 2026", "20:30"], + ["vi-VN", "Asia/Ho_Chi_Minh", "08:30 1 thg 3, 2026", "08:30"], + ])("formats %s in %s independently of the test machine", (locale, timeZone, date, time) => { + expect(formatDate(timestamp, locale, timeZone)).toBe(date); + expect(formatTime(timestamp, locale, timeZone)).toBe(time); + }); +}); + // ── formatXLM ───────────────────────────────────────────────────────────────── describe("formatXLM", () => { diff --git a/frontend/src/app/markets/[id]/page.tsx b/frontend/src/app/markets/[id]/page.tsx index 5ae3a63..d641c87 100644 --- a/frontend/src/app/markets/[id]/page.tsx +++ b/frontend/src/app/markets/[id]/page.tsx @@ -7,7 +7,7 @@ import { useWallet } from "@/hooks/useWallet"; import { useToken } from "@/hooks/useToken"; import { pollMarketEvents } from "@/services/events"; import { getXlmBalance } from "@/services/soroban"; -import { displayXLM, formatXLM, calculatePayout, truncateAddress } from "@/utils/helpers"; +import { displayXLM, formatTime, formatXLM, calculatePayout, truncateAddress } from "@/utils/helpers"; import { WIN_POINTS, LOSE_POINTS, @@ -319,7 +319,7 @@ export default function MarketDetailPage({ - {new Date(evt.timestamp * 1000).toLocaleTimeString()} + {formatTime(evt.timestamp)} ))} diff --git a/frontend/src/services/events.ts b/frontend/src/services/events.ts index a30c32d..e36a528 100644 --- a/frontend/src/services/events.ts +++ b/frontend/src/services/events.ts @@ -19,6 +19,22 @@ function isKnownEventType(s: string): s is ContractEventType { return (EVENT_TYPES as readonly string[]).includes(s); } +/** Convert the ledger close time to the app's Unix-seconds timestamp contract. */ +export function ledgerClosedAtToUnixSeconds( + ledgerClosedAt: string | number | Date +): number { + const timestampMs = + ledgerClosedAt instanceof Date + ? ledgerClosedAt.getTime() + : new Date(ledgerClosedAt).getTime(); + + if (!Number.isFinite(timestampMs)) { + throw new RangeError("Invalid ledger close timestamp"); + } + + return Math.floor(timestampMs / 1000); +} + // ── Parse a single event response into MarketEvent ──────────────────────────── function parseEventResponse( @@ -32,7 +48,7 @@ function parseEventResponse( if (!isKnownEventType(eventName)) return null; const data = scValToNative(event.value); - const timestamp = new Date(event.ledgerClosedAt).getTime(); + const timestamp = ledgerClosedAtToUnixSeconds(event.ledgerClosedAt); switch (eventName) { case "bet_placed": diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 670a979..224ac37 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -129,6 +129,7 @@ export interface MarketEvent { user: string; marketId: number; amount?: number; + /** Unix timestamp in seconds. */ timestamp: number; txHash: string; } diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 4688130..28d66e6 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -74,17 +74,41 @@ export function timeUntil(timestamp: number): string { return `${seconds}s`; } -/** - * Format a Unix timestamp to a locale-aware date string. - */ -export function formatDate(timestamp: number): string { - return new Date(timestamp * 1000).toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); +const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", +}; + +const TIME_OPTIONS: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", +}; + +/** Format Unix seconds in the viewer's locale and time zone by default. */ +export function formatDate( + timestamp: number, + locale?: string | string[], + timeZone?: string +): string { + return new Intl.DateTimeFormat(locale, { + ...DATE_TIME_OPTIONS, + ...(timeZone ? { timeZone } : {}), + }).format(new Date(timestamp * 1000)); +} + +/** Format Unix seconds as a localized time of day. */ +export function formatTime( + timestamp: number, + locale?: string | string[], + timeZone?: string +): string { + return new Intl.DateTimeFormat(locale, { + ...TIME_OPTIONS, + ...(timeZone ? { timeZone } : {}), + }).format(new Date(timestamp * 1000)); } /**