Skip to content

Commit 4e7e94a

Browse files
r4topunkclaude
andcommitted
refactor(prices): one source of truth for USD prices
The app had four independent price paths: /api/eth-price (Alchemy, 60s), /api/prices (CoinGecko, 4h), services/stake-graph.ts (CoinGecko, 300s) and /api/wallet/tokens. Two of them ran inside services/treasury.ts at once, so the ETH price behind one component could disagree with another's on the same page — measured locally at ~1% apart. New `src/services/prices.ts` owns all of it: Alchemy for ETH, CoinGecko for ERC-20s, 300s, tagged `prices`. **An unknown price is now `null`, never `0`.** This was a live bug, not a tidy-up: /api/eth-price answered `{ usd: 0 }` on all three failure paths and services/treasury.ts multiplied the ETH balance by it, so any Alchemy hiccup rendered a confident "$0.00" for a treasury that is mostly ETH. `UsdPrice = number | null` makes the compiler force every consumer to decide; treasury, hero stats and the OG image now render "—". Server consumers call the service directly instead of the server fetching its own /api/* over HTTP: treasury.ts drops 2 self-requests per load, TokenHoldings 1, and the treasury OG image 4 — that one also stopped re-implementing the whole balance+price computation (-122 lines of duplicated financial logic that could drift from the page it illustrates). Client side gets one shared state, as intended: StakeDialog had its own `["eth-price"]` query with a different staleTime than useEthPrice, so two observers on one key kept undercutting each other's freshness. It now uses the hook. `useEthPrice` also drops `refetchInterval: 60s` — against a 300s cache that re-fetched identical bytes five times per window, across every mounted bounty card and member profile. Verified at runtime: /api/eth-price and /api/prices both 300s with the same ETH value for ETH and WETH (previously 1901.85 vs 1924.17 from two providers); /api/stake-graph still resolves with its stETH row intact; /treasury renders identically to production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ee3be3e commit 4e7e94a

14 files changed

Lines changed: 665 additions & 461 deletions

File tree

docs/architecture/caching-standard.md

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,19 @@ Purging the CDN by tag is possible via `invalidateByTag` + `Vercel-Cache-Tag` fr
4141

4242
All reads in `src/services/*` go through `unstable_cache` with canonical tags. TTL is a _backstop_, not the freshness mechanism.
4343

44-
| Tag | Covers | Backstop TTL |
45-
| ------------------------- | ------------------------------ | -------------------------- |
46-
| `proposals` | proposal lists (all consumers) | 1800 |
47-
| `proposal:<number>` | one proposal detail + votes | 1800 (Active/Pending: 120) |
48-
| `auction` | current auction state | 60 |
49-
| `auctions` | settled auction history | 3600 |
50-
| `feed` | activity feed events | 300 |
51-
| `members` | holders list, overviews | 3600 |
52-
| `treasury` | balances | 900 |
53-
| `propdates` | EAS attestations | 300 |
54-
| `rounds` / `round:<slug>` | rounds listings / one round | 300 / closed: 86400 |
55-
| `stake` | sponsorship graph (orbit) | 1800 |
44+
| Tag | Covers | Backstop TTL |
45+
| ------------------------- | ------------------------------------- | -------------------------- |
46+
| `proposals` | proposal lists (all consumers) | 1800 |
47+
| `proposal:<number>` | one proposal detail + votes | 1800 (Active/Pending: 120) |
48+
| `auction` | current auction state | 60 |
49+
| `auctions` | settled auction history | 3600 |
50+
| `feed` | activity feed events | 300 |
51+
| `members` | holders list, overviews | 3600 |
52+
| `treasury` | balances | 900 |
53+
| `propdates` | EAS attestations | 300 |
54+
| `rounds` / `round:<slug>` | rounds listings / one round | 300 / closed: 86400 |
55+
| `stake` | sponsorship graph (orbit) | 1800 |
56+
| `prices` | all USD prices (`services/prices.ts`) | 300 |
5657

5758
### Rule 3 — mutations invalidate, clients don't poll
5859

@@ -72,6 +73,18 @@ After a write-hook confirms a receipt it must:
7273
| round vote/submit | `round:<slug>`, `rounds` |
7374
| stake deposit/withdraw/claim (`useStakeDeposit`, `useMorpheusStake`) | `stake` |
7475

76+
### Rule 3b — a missing value is `null`, never `0`
77+
78+
Prices, balances and rates that could not be fetched must surface as `null` and
79+
be rendered as unavailable. Defaulting them to `0` produces a well-formed,
80+
confident, wrong number that then gets cached like a good one — the treasury
81+
rendered "$0.00" on any price-feed hiccup, and the stake graph silently dropped
82+
most of its TVL. `services/prices.ts` is the reference: `UsdPrice = number | null`.
83+
84+
Server-side callers should import the service directly. Fetching the app's own
85+
`/api/*` route from a Server Component is a self-HTTP round trip to read a value
86+
already sitting in the data cache.
87+
7588
### Rule 4 — prefetch discipline
7689

7790
Grids with >20 `<Link>`s (`ProposalCard`, members list, auctions) set `prefetch={false}`. Each card in viewport otherwise prefetches the detail page's segments — ISR writes with no pageview, multiplied by bots.

src/app/[locale]/treasury/opengraph-image.tsx

Lines changed: 15 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { headers } from "next/headers";
21
import { ImageResponse } from "next/og";
3-
import { formatEther } from "viem";
4-
import { DAO_ADDRESSES, TREASURY_TOKEN_ADDRESSES, TREASURY_TOKEN_ALLOWLIST } from "@/lib/config";
2+
import { DAO_ADDRESSES } from "@/lib/config";
53
import { formatEthDisplay, formatUsdDisplay, OG_COLORS, OG_FONTS, OG_SIZE } from "@/lib/og-utils";
4+
import { loadTreasurySnapshot } from "@/services/treasury";
65

76
export const alt = "Gnars DAO Treasury";
87
export const size = OG_SIZE;
@@ -13,133 +12,26 @@ export const contentType = "image/png";
1312
// served from the edge with zero function CPU. Keeps us under Hobby CPU/transfer.
1413
const OG_CACHE_CONTROL = "public, max-age=0, s-maxage=1800, stale-while-revalidate=3600";
1514

16-
type TokenBalance = {
17-
contractAddress?: string;
18-
tokenBalance: string;
19-
decimals?: number;
20-
};
21-
22-
type AlchemyTokenResponse = {
23-
result?: {
24-
tokenBalances?: TokenBalance[];
25-
};
26-
};
27-
28-
type PriceResponse = {
29-
prices?: Record<string, { usd?: number }>;
30-
};
31-
32-
type EthPriceResponse = {
33-
usd: number;
34-
error?: string;
35-
};
36-
37-
async function fetchTreasurySnapshot(): Promise<{ ethBalance: string; usdTotal: number } | null> {
15+
async function fetchTreasurySnapshot(): Promise<{
16+
ethBalance: string;
17+
usdTotal: number | null;
18+
} | null> {
19+
// Delegates to the same service the treasury page uses. This function used to
20+
// re-implement the whole balance+price computation and reach it over HTTP via
21+
// the app's own /api/alchemy, /api/prices and /api/eth-price — two copies of
22+
// financial logic that could disagree, plus four self-requests per render.
3823
try {
39-
const baseUrl = await getBaseUrl();
40-
41-
const [ethRes, tokenRes, priceRes, ethPriceRes] = await Promise.all([
42-
fetchJson<{ result?: string }>(`${baseUrl}/api/alchemy`, {
43-
method: "POST",
44-
body: JSON.stringify({
45-
method: "eth_getBalance",
46-
params: [DAO_ADDRESSES.treasury, "latest"],
47-
}),
48-
}),
49-
fetchJson<AlchemyTokenResponse>(`${baseUrl}/api/alchemy`, {
50-
method: "POST",
51-
body: JSON.stringify({
52-
method: "alchemy_getTokenBalances",
53-
params: [DAO_ADDRESSES.treasury, TREASURY_TOKEN_ADDRESSES.filter(Boolean)],
54-
}),
55-
}),
56-
fetchJson<PriceResponse>(`${baseUrl}/api/prices`, {
57-
method: "POST",
58-
body: JSON.stringify({
59-
addresses: TREASURY_TOKEN_ADDRESSES.map((a) => String(a).toLowerCase()),
60-
}),
61-
}).catch(() => ({ prices: {} })),
62-
fetchJson<EthPriceResponse>(`${baseUrl}/api/eth-price`, {
63-
method: "GET",
64-
}).catch(() => ({ usd: 0 })),
65-
]);
66-
67-
const ethBalanceWei = BigInt(ethRes.result ?? "0x0");
68-
const ethBalance = Number(formatEther(ethBalanceWei));
69-
const ethPrice = ethPriceRes?.usd ?? 0;
70-
71-
const tokenBalances = (tokenRes.result?.tokenBalances ?? []).filter((token) => {
72-
const balance = token.tokenBalance?.toLowerCase();
73-
return balance && balance !== "0" && balance !== "0x0";
74-
});
75-
76-
const prices: Record<string, { usd: number }> = priceRes.prices ?? {};
77-
const wethAddress = String(TREASURY_TOKEN_ALLOWLIST.WETH).toLowerCase();
78-
79-
const priceLookup = Object.fromEntries(
80-
Object.entries(prices).map(([address, value]) => [
81-
address.toLowerCase(),
82-
address.toLowerCase() === wethAddress ? ethPrice : Number(value?.usd ?? 0) || 0,
83-
]),
84-
);
85-
priceLookup[wethAddress] = ethPrice;
86-
87-
const decimals: Record<string, number> = {
88-
[String(TREASURY_TOKEN_ALLOWLIST.USDC).toLowerCase()]: 6,
89-
[String(TREASURY_TOKEN_ALLOWLIST.WETH).toLowerCase()]: 18,
90-
[String(TREASURY_TOKEN_ALLOWLIST.SENDIT).toLowerCase()]: 18,
91-
};
92-
93-
const tokensUsd = tokenBalances.reduce((sum, token) => {
94-
const address = token.contractAddress ? String(token.contractAddress).toLowerCase() : null;
95-
if (!address) return sum;
96-
const tokenDecimals = decimals[address] ?? 18;
97-
const raw = token.tokenBalance ?? "0x0";
98-
const parsed = Number.parseInt(raw, 16);
99-
const balance = Number.isFinite(parsed) ? parsed / Math.pow(10, tokenDecimals) : 0;
100-
const price = priceLookup[address] ?? 0;
101-
return sum + balance * price;
102-
}, 0);
103-
104-
const usdTotal = tokensUsd + ethBalance * ethPrice;
105-
24+
const snapshot = await loadTreasurySnapshot(DAO_ADDRESSES.treasury);
10625
return {
107-
ethBalance: formatEthDisplay(ethBalance),
108-
usdTotal,
26+
ethBalance: formatEthDisplay(snapshot.ethBalance),
27+
usdTotal: snapshot.usdTotal,
10928
};
11029
} catch (error) {
11130
console.error("[treasury OG] error fetching snapshot:", error);
11231
return null;
11332
}
11433
}
11534

116-
async function getBaseUrl() {
117-
const h = await headers();
118-
const protocol = h.get("x-forwarded-proto") ?? "https";
119-
const host = h.get("x-forwarded-host") ?? h.get("host");
120-
if (!host) {
121-
throw new Error("Unable to determine request host");
122-
}
123-
return `${protocol}://${host}`;
124-
}
125-
126-
async function fetchJson<T>(url: string, init: RequestInit): Promise<T> {
127-
const response = await fetch(url, {
128-
...init,
129-
headers: {
130-
"Content-Type": "application/json",
131-
...(init.headers || {}),
132-
},
133-
cache: "no-store",
134-
});
135-
136-
if (!response.ok) {
137-
throw new Error(`Request failed: ${response.status}`);
138-
}
139-
140-
return (await response.json()) as T;
141-
}
142-
14335
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
14436
const { locale } = await params;
14537
const isPt = locale === "pt-br";
@@ -157,7 +49,8 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
15749
}
15850

15951
const ethBalance = treasuryData.ethBalance;
160-
const usdTotal = formatUsdDisplay(treasuryData.usdTotal);
52+
// An unpriced treasury renders as a dash, never as "$0".
53+
const usdTotal = treasuryData.usdTotal == null ? "—" : formatUsdDisplay(treasuryData.usdTotal);
16154
const labels = {
16255
title: isPt ? "TESOURO" : "TREASURY",
16356
subtitle: isPt ? "Visão Geral Financeira da Gnars DAO" : "Gnars DAO Financial Overview",

src/app/api/eth-price/route.ts

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,32 @@
11
import { NextResponse } from "next/server";
2+
import { getEthUsd } from "@/services/prices";
23

3-
export async function GET() {
4-
const apiKey = process.env.ALCHEMY_API_KEY;
5-
6-
if (!apiKey) {
7-
console.error("[eth-price] ALCHEMY_API_KEY not set");
8-
return NextResponse.json({ usd: 0, error: "missing_api_key" });
9-
}
10-
11-
try {
12-
const res = await fetch("https://api.g.alchemy.com/prices/v1/tokens/by-symbol?symbols=ETH", {
13-
headers: {
14-
Authorization: `Bearer ${apiKey}`,
15-
},
16-
cache: "no-store",
17-
});
4+
/**
5+
* Thin wrapper over `services/prices`. Provider choice, TTL and failure
6+
* semantics all live there — this route exists only so client components can
7+
* reach the price without the Alchemy key.
8+
*
9+
* `usd` is `null` when the price is unknown, deliberately NOT `0`: this route
10+
* used to answer `{ usd: 0 }` on every failure path and `services/treasury.ts`
11+
* multiplied the treasury's ETH balance by it, rendering a confident $0.
12+
* Clients that do `?? 0` keep working — `formatEthToUsd` already renders "—"
13+
* for a falsy price.
14+
*/
15+
export const dynamic = "force-dynamic";
1816

19-
if (!res.ok) {
20-
console.error("[eth-price] Alchemy API error:", res.status);
21-
return NextResponse.json({ usd: 0, error: `api_error_${res.status}` });
22-
}
17+
const CDN_TTL_SECONDS = 300;
2318

24-
const data = await res.json();
25-
const ethData = data?.data?.find((d: { symbol: string }) => d.symbol === "ETH");
26-
const usdPrice = ethData?.prices?.find(
27-
(p: { currency: string }) => p.currency.toLowerCase() === "usd",
28-
);
29-
const usd = Number(usdPrice?.value ?? 0) || 0;
30-
31-
console.log("[eth-price] ETH price:", usd);
32-
return NextResponse.json(
33-
{ usd },
34-
{
35-
headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" },
36-
},
37-
);
38-
} catch (error) {
39-
console.error("[eth-price] Error:", error);
40-
return NextResponse.json({ usd: 0, error: "fetch_error" });
41-
}
19+
export async function GET() {
20+
const usd = await getEthUsd();
21+
return NextResponse.json(
22+
{ usd },
23+
{
24+
headers: usd
25+
? {
26+
"Cache-Control": `public, s-maxage=${CDN_TTL_SECONDS}, stale-while-revalidate=${CDN_TTL_SECONDS * 2}`,
27+
}
28+
: // Never pin an outage to the CDN for the full window.
29+
{ "Cache-Control": "no-store" },
30+
},
31+
);
4232
}

0 commit comments

Comments
 (0)