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
96 changes: 88 additions & 8 deletions frontend/src/components/FiatEquivalent.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 (
Expand All @@ -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 ? (
<span
data-testid="stale-price-warning"
title={staleTooltip}
aria-label={staleTooltip}
aria-describedby="fiat-stale-price-tooltip"
tabIndex={0}
className="group relative inline-flex items-center gap-1 rounded-full bg-amber-500/10 border border-amber-500/30 px-2 py-0.5 text-xs font-medium text-amber-400 cursor-help focus:outline-none focus:ring-2 focus:ring-amber-400/50"
>
<svg
className="h-3.5 w-3.5 shrink-0 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span
id="fiat-stale-price-tooltip"
role="tooltip"
className="sr-only group-hover:not-sr-only group-focus-visible:not-sr-only group-hover:absolute group-hover:bottom-full group-hover:left-1/2 group-hover:-translate-x-1/2 group-hover:mb-1.5 group-hover:whitespace-nowrap group-hover:rounded-md group-hover:bg-slate-900 group-hover:px-2.5 group-hover:py-1 group-hover:text-xs group-hover:text-amber-200 group-hover:shadow-lg group-hover:border group-hover:border-amber-500/30 z-20"
>
{staleTooltip}
</span>
</span>
) : null;

return (
<div className={`space-y-2 ${className}`}>
Expand All @@ -65,12 +143,13 @@ export function FiatEquivalent({
currency
)} {currency})
</span>
{staleWarningIndicator}
</div>
)}

{/* Rate display */}
{(showRate || showDaily || showMonthly) && ratePerSecond && (
<div className="flex flex-wrap gap-3 text-sm">
<div className="flex flex-wrap items-center gap-3 text-sm">
{showRate && (
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-slate-800/50 text-slate-300">
<span className="font-mono">
Expand Down Expand Up @@ -123,6 +202,7 @@ export function FiatEquivalent({
</span>
</span>
)}
{!amount && staleWarningIndicator}
</div>
)}
</div>
Expand Down Expand Up @@ -165,4 +245,4 @@ export function FiatBadge({
</span>
</span>
);
}
}
105 changes: 105 additions & 0 deletions frontend/src/components/__tests__/FiatEquivalent.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/hooks/useTokenPrice")>(
"@/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(<FiatEquivalent tokenSymbol="XLM" amount="1000" />);

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(<FiatEquivalent tokenSymbol="XLM" amount="1000" />);

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(
<FiatEquivalent tokenSymbol="XLM" amount="1000" />,
);
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(<FiatEquivalent tokenSymbol="USDC" amount="500" />);
expect(screen.getByText(/500\.00 USDC/)).toBeInTheDocument();
expect(screen.queryByTestId("stale-price-warning")).not.toBeInTheDocument();
});
});
18 changes: 15 additions & 3 deletions frontend/src/hooks/useTokenPrice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const TOKEN_MAPPING: Record<string, { coingeckoId: string; decimals: number }> =

const CACHE_TTL_MS = 60 * 1000; // 60 seconds

interface TokenPrice {
export interface TokenPrice {
tokenSymbol: string;
priceUSD: number;
priceEUR: number;
Expand All @@ -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<TokenPrice>({
const query = useQuery<TokenPrice>({
queryKey: ["tokenPrice", tokenSymbol],
queryFn: async () => {
const mapping = TOKEN_MAPPING[tokenSymbol.toUpperCase()];
Expand Down Expand Up @@ -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,
};
}

/**
Expand Down Expand Up @@ -159,4 +171,4 @@ export function formatRatePerPeriod(

const fiatRate = ratePerSecond * tokenPrice * multipliers[period];
return formatFiatAmount(fiatRate, currency);
}
}
Loading