Skip to content
Merged
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
1 change: 1 addition & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ function App() {
unreadCount={unreadCount}
onMarkNotificationsRead={markAllRead}
notificationsLoading={notificationsLoading}
apiReachable={healthy}
/>

<main id="main-content" className="flex-1">
Expand Down
21 changes: 2 additions & 19 deletions frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useState, useEffect } from 'react';
import CopyButton from './ui/copy-button';
import NotificationBell from './NotificationBell';
import { BANNER_HEIGHT_CLASS } from './OfflineBanner';
import { useTheme } from '../context/ThemeContext';
import { useOnlineStatus } from '../hooks/useOnlineStatus';
import { NETWORK_NAME, STACKS_API_BASE } from '../config/contracts';
import { NETWORK_NAME } from '../config/contracts';
import { formatAddress } from '../lib/utils';
import { getMainnetAddress } from '../utils/stacks';
import { Sun, Moon } from 'lucide-react';
Expand All @@ -25,25 +24,9 @@ import { Sun, Moon } from 'lucide-react';
* @param {Function} props.onMarkNotificationsRead - Callback to mark all read.
* @param {boolean} props.notificationsLoading - Whether notifications are loading.
*/
export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading }) {
export default function Header({ userData, onAuth, authLoading, notifications, unreadCount, onMarkNotificationsRead, notificationsLoading, apiReachable = null }) {
const { theme, toggleTheme } = useTheme();
const isOnline = useOnlineStatus();
const [apiReachable, setApiReachable] = useState(null);

useEffect(() => {
let cancelled = false;
const checkApi = async () => {
try {
const res = await fetch(`${STACKS_API_BASE}/v2/info`, { signal: AbortSignal.timeout(5000) });
if (!cancelled) setApiReachable(res.ok);
} catch {
if (!cancelled) setApiReachable(false);
}
};
checkApi();
const interval = setInterval(checkApi, 30000);
return () => { cancelled = true; clearInterval(interval); };
}, []);

const networkLabel = NETWORK_NAME.charAt(0).toUpperCase() + NETWORK_NAME.slice(1);

Expand Down
64 changes: 59 additions & 5 deletions frontend/src/hooks/useStxPrice.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,90 @@ import { useState, useEffect, useCallback, useRef } from "react";

const COINGECKO_URL =
"https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd";
const REFRESH_INTERVAL = 60_000;
const REFRESH_INTERVAL = 120_000;
const RATE_LIMIT_RETRY_MS = 300_000;
const CACHE_KEY = "tipstream:stx-price";
const CACHE_TTL_MS = 120_000;

function readCachedPrice() {
try {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (typeof parsed?.price !== "number" || typeof parsed?.cachedAt !== "number") {
return null;
}
return parsed;
} catch {
return null;
}
}

function writeCachedPrice(nextPrice) {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ price: nextPrice, cachedAt: Date.now() })
);
} catch {
// Ignore storage errors.
}
}

export function useStxPrice() {
const [price, setPrice] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const intervalRef = useRef(null);

const fetchPrice = useCallback(async () => {
const fetchPrice = useCallback(async (forceNetwork = false) => {
try {
const res = await fetch(COINGECKO_URL);
if (!forceNetwork) {
const cached = readCachedPrice();
if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
setPrice(cached.price);
setError(null);
setLoading(false);
return;
}
}

const demoKey = import.meta.env?.VITE_COINGECKO_DEMO_API_KEY;
const headers = demoKey ? { "x-cg-demo-api-key": demoKey } : undefined;
const res = await fetch(COINGECKO_URL, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const usd = data?.stacks?.usd;
if (typeof usd !== "number") throw new Error("Invalid price data");
setPrice(usd);
writeCachedPrice(usd);
setError(null);
} catch (err) {
setError(err.message);
if (err.message === "HTTP 429") {
console.warn("CoinGecko rate limit reached. Retrying with backoff.");
}
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchPrice();
intervalRef.current = setInterval(fetchPrice, REFRESH_INTERVAL);
intervalRef.current = setInterval(() => {
fetchPrice(true);
}, REFRESH_INTERVAL);
return () => clearInterval(intervalRef.current);
}, [fetchPrice]);

useEffect(() => {
if (error !== "HTTP 429") return;
const timeoutId = setTimeout(() => {
fetchPrice(true);
}, RATE_LIMIT_RETRY_MS);
return () => clearTimeout(timeoutId);
}, [error, fetchPrice]);

const toUsd = useCallback(
(stxAmount) => {
if (price === null || stxAmount === null || stxAmount === undefined) return null;
Expand All @@ -40,5 +94,5 @@ export function useStxPrice() {
[price]
);

return { price, loading, error, toUsd, refetch: fetchPrice };
return { price, loading, error, toUsd, refetch: () => fetchPrice(true) };
}
2 changes: 1 addition & 1 deletion frontend/src/test/Header.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ vi.mock('../context/ThemeContext', () => ({

vi.mock('../config/contracts', () => ({
NETWORK_NAME: 'mainnet',
STACKS_API_BASE: 'https://api.hiro.so',
}));

vi.mock('../utils/stacks', () => ({
Expand All @@ -38,6 +37,7 @@ const defaultProps = {
unreadCount: 0,
onMarkNotificationsRead: vi.fn(),
notificationsLoading: false,
apiReachable: null,
};

function setOnline(value) {
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/test/useStxPrice.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useStxPrice } from '../hooks/useStxPrice';
describe('useStxPrice', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
});

afterEach(() => {
Expand Down Expand Up @@ -229,7 +230,7 @@ describe('useStxPrice', () => {
expect(result.current.toUsd(100)).toBe('0.00');
});

it('polls for updated price after 60 seconds', async () => {
it('polls for updated price after 120 seconds', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ stacks: { usd: 1.0 } }),
Expand All @@ -249,7 +250,7 @@ describe('useStxPrice', () => {
});

await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(120_000);
});

expect(result.current.price).toBe(1.5);
Expand Down Expand Up @@ -294,7 +295,7 @@ describe('useStxPrice', () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });

await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await vi.advanceTimersByTimeAsync(120_000);
});

expect(result.current.price).toBe(1.25);
Expand All @@ -315,6 +316,7 @@ describe('useStxPrice', () => {

expect(global.fetch).toHaveBeenCalledWith(
'https://api.coingecko.com/api/v3/simple/price?ids=stacks&vs_currencies=usd',
expect.any(Object),
);
});

Expand Down
Loading