diff --git a/frontend/src/components/FiatEquivalent.tsx b/frontend/src/components/FiatEquivalent.tsx
index ad2b8473..ac5b2573 100644
--- a/frontend/src/components/FiatEquivalent.tsx
+++ b/frontend/src/components/FiatEquivalent.tsx
@@ -1,9 +1,33 @@
"use client";
-
import { useTokenPrice, formatFiatAmount, formatRatePerPeriod } from "@/hooks/useTokenPrice";
import { useSettings } from "@/hooks/useSettings";
+export const STALE_PRICE_THRESHOLD_MS = 5 * 60 * 1000;
+
+export function getPriceStaleness(
+ lastUpdatedAt: number | null | undefined,
+ nowMs: number = Date.now(),
+): {
+ isStale: boolean;
+ staleMinutes: number;
+ tooltip: string | null;
+} {
+ if (typeof lastUpdatedAt !== "number" || !Number.isFinite(lastUpdatedAt)) {
+ return { isStale: false, staleMinutes: 0, tooltip: null };
+ }
+ const ageMs = nowMs - lastUpdatedAt;
+ if (ageMs <= STALE_PRICE_THRESHOLD_MS) {
+ return { isStale: false, staleMinutes: 0, tooltip: null };
+ }
+ const staleMinutes = Math.max(1, Math.floor(ageMs / 60_000));
+ return {
+ isStale: true,
+ staleMinutes,
+ tooltip: `Price estimate last updated ${staleMinutes} minutes ago`,
+ };
+}
+
interface FiatEquivalentProps {
tokenSymbol: string;
amount?: string | bigint;
@@ -28,7 +52,11 @@ export function FiatEquivalent({
className = "",
}: FiatEquivalentProps) {
const { displayCurrency } = useSettings();
- const { data: tokenPrice, isLoading } = useTokenPrice({ tokenSymbol });
+ const {
+ data: tokenPrice,
+ isLoading,
+ lastUpdatedAt: hookLastUpdatedAt,
+ } = useTokenPrice({ tokenSymbol });
if (isLoading || !tokenPrice) {
return (
@@ -39,12 +67,62 @@ export function FiatEquivalent({
}
// Determine currency from display setting
- const currency = displayCurrency === "USD" || displayCurrency === "XLM" || displayCurrency === "USDC"
- ? "USD"
- : displayCurrency as "USD" | "EUR" | "GBP";
+ const currency =
+ displayCurrency === "USD" || displayCurrency === "XLM" || displayCurrency === "USDC"
+ ? "USD"
+ : (displayCurrency as "USD" | "EUR" | "GBP");
const priceKey = `price${currency}` as keyof typeof tokenPrice;
- const tokenPriceForCurrency = tokenPrice[priceKey] as number || tokenPrice.priceUSD;
+ const candidatePrice = tokenPrice[priceKey] ?? tokenPrice.priceUSD;
+ const tokenPriceForCurrency =
+ typeof candidatePrice === "number" && Number.isFinite(candidatePrice)
+ ? candidatePrice
+ : 0;
+
+ const resolvedLastUpdatedAt =
+ typeof hookLastUpdatedAt === "number" && Number.isFinite(hookLastUpdatedAt)
+ ? hookLastUpdatedAt
+ : typeof tokenPrice.timestamp === "number" && Number.isFinite(tokenPrice.timestamp)
+ ? tokenPrice.timestamp
+ : null;
+
+ const { isStale, tooltip: staleTooltip } = getPriceStaleness(
+ resolvedLastUpdatedAt,
+ );
+
+ const staleWarningIndicator =
+ isStale && staleTooltip ? (
+
+
+
+ {staleTooltip}
+
+
+ ) : null;
return (
@@ -65,12 +143,13 @@ export function FiatEquivalent({
currency
)} {currency})
+ {staleWarningIndicator}
)}
{/* Rate display */}
{(showRate || showDaily || showMonthly) && ratePerSecond && (
-
+
{showRate && (
@@ -123,6 +202,7 @@ export function FiatEquivalent({
)}
+ {!amount && staleWarningIndicator}
)}
@@ -165,4 +245,4 @@ export function FiatBadge({
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/__tests__/FiatEquivalent.test.tsx b/frontend/src/components/__tests__/FiatEquivalent.test.tsx
new file mode 100644
index 00000000..b42dd688
--- /dev/null
+++ b/frontend/src/components/__tests__/FiatEquivalent.test.tsx
@@ -0,0 +1,105 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+const mockUseTokenPrice = vi.fn();
+
+vi.mock("@/hooks/useTokenPrice", async () => {
+ const actual = await vi.importActual(
+ "@/hooks/useTokenPrice",
+ );
+ return {
+ ...actual,
+ useTokenPrice: (...args: unknown[]) => mockUseTokenPrice(...args),
+ };
+});
+
+vi.mock("@/hooks/useSettings", () => ({
+ useSettings: () => ({ displayCurrency: "USD" }),
+}));
+
+import { FiatEquivalent } from "../FiatEquivalent";
+
+describe("FiatEquivalent stale price warning", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders fresh quotes (< 5 minutes old) without a warning indicator", () => {
+ const freshTimestamp = Date.now() - 2 * 60 * 1000; // 2 minutes ago
+ mockUseTokenPrice.mockReturnValue({
+ data: {
+ tokenSymbol: "XLM",
+ priceUSD: 0.12,
+ priceEUR: 0.11,
+ priceGBP: 0.09,
+ timestamp: freshTimestamp,
+ },
+ isLoading: false,
+ lastUpdatedAt: freshTimestamp,
+ });
+
+ render();
+
+ expect(screen.getByText(/1,000\.00 XLM/)).toBeInTheDocument();
+ expect(screen.getByText(/\(\$120\.00 USD\)|≈ \$120\.00 USD/)).toBeInTheDocument();
+ expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument();
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
+ });
+
+ it("displays an amber info indicator and accessible tooltip when quote is > 5 minutes old", () => {
+ const staleTimestamp = Date.now() - 8 * 60 * 1000; // 8 minutes ago
+ mockUseTokenPrice.mockReturnValue({
+ data: {
+ tokenSymbol: "XLM",
+ priceUSD: 0.12,
+ priceEUR: 0.11,
+ priceGBP: 0.09,
+ timestamp: staleTimestamp,
+ },
+ isLoading: false,
+ lastUpdatedAt: staleTimestamp,
+ });
+
+ render();
+
+ const warning = screen.getByTestId("stale-price-warning");
+ expect(warning).toBeInTheDocument();
+ expect(warning).toHaveAttribute(
+ "title",
+ "Price estimate last updated 8 minutes ago",
+ );
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
+ "Price estimate last updated 8 minutes ago",
+ );
+ });
+
+ it("gracefully handles null or missing price data without crashing", () => {
+ mockUseTokenPrice.mockReturnValue({
+ data: null,
+ isLoading: false,
+ lastUpdatedAt: null,
+ });
+
+ const { container, rerender } = render(
+ ,
+ );
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
+ expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument();
+
+ // Missing timestamp / lastUpdatedAt on returned object should also render safely without warning
+ mockUseTokenPrice.mockReturnValue({
+ data: {
+ tokenSymbol: "USDC",
+ priceUSD: 1.0,
+ priceEUR: 0.92,
+ priceGBP: 0.79,
+ },
+ isLoading: false,
+ lastUpdatedAt: null,
+ });
+
+ rerender();
+ expect(screen.getByText(/500\.00 USDC/)).toBeInTheDocument();
+ expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/hooks/useTokenPrice.ts b/frontend/src/hooks/useTokenPrice.ts
index ae779aa3..967cca02 100644
--- a/frontend/src/hooks/useTokenPrice.ts
+++ b/frontend/src/hooks/useTokenPrice.ts
@@ -20,7 +20,7 @@ const TOKEN_MAPPING: Record =
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
-interface TokenPrice {
+export interface TokenPrice {
tokenSymbol: string;
priceUSD: number;
priceEUR: number;
@@ -38,7 +38,7 @@ interface UseTokenPriceOptions {
* Returns stablecoin parity for USDC/EURC when live data is unavailable.
*/
export function useTokenPrice({ tokenSymbol, enabled = true }: UseTokenPriceOptions) {
- return useQuery({
+ const query = useQuery({
queryKey: ["tokenPrice", tokenSymbol],
queryFn: async () => {
const mapping = TOKEN_MAPPING[tokenSymbol.toUpperCase()];
@@ -102,6 +102,18 @@ export function useTokenPrice({ tokenSymbol, enabled = true }: UseTokenPriceOpti
enabled,
refetchOnWindowFocus: false,
});
+
+ const lastUpdatedAt: number | null =
+ typeof query.data?.timestamp === "number" && Number.isFinite(query.data.timestamp)
+ ? query.data.timestamp
+ : query.dataUpdatedAt > 0
+ ? query.dataUpdatedAt
+ : null;
+
+ return {
+ ...query,
+ lastUpdatedAt,
+ };
}
/**
@@ -159,4 +171,4 @@ export function formatRatePerPeriod(
const fiatRate = ratePerSecond * tokenPrice * multipliers[period];
return formatFiatAmount(fiatRate, currency);
-}
+}
\ No newline at end of file