Skip to content

Commit 1d02ff8

Browse files
authored
fix(stake): two holder sources, and never cache an unknown backer list (#262)
* fix(stake): two holder sources, and never cache an unknown backer list The orbit rendered riders with real deposits as having no backers. The holder lookup had a single source (Blockscout) and a bare `catch {}`, so an indexer hiccup became an empty list that looked exactly like "nobody has staked" — and `unstable_cache` then stored it as if it were true. - Second source: Alchemy's transfer index, paged to the end. Raw eth_getLogs was measured and rejected — every Base RPC we use caps the range at 10k blocks (Alchemy free: ten), and the vaults already span ~1.08M blocks, i.e. ~109 chunked calls per vault per cache miss. - Completeness check: sum the shares found against totalSupply. A source serving one page of transfer history is otherwise indistinguishable from a vault with fewer backers. - A vault with zero totalSupply needs no indexer call at all, and can never be degraded by one being down. - Degraded graphs never persist: the service throws to stay out of the data cache, and the route sends no-store to stay out of the CDN. - Failing loud never means an error page. Order is complete graph, then the last complete one this instance saw, then a flagged partial with exact TVL and a line saying the backers did not load. * docs(stake): scope backersResolved to the vault half The MOR path still empties silently on a failed scan, so the flag must not be read as a promise about Morpheus stakers.
1 parent 55eca28 commit 1d02ff8

6 files changed

Lines changed: 371 additions & 111 deletions

File tree

messages/en/stake.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"legendVault": "Morpho vault",
7979
"legendMor": "Morpheus · MOR",
8080
"backToAll": "All riders",
81+
"backersUnavailable": "Couldn't load who's backing each rider. The amounts staked are live and correct.",
8182
"error": "Couldn't load the flow.",
8283
"retry": "Try again"
8384
},
@@ -354,6 +355,7 @@
354355
"backers": {
355356
"loading": "loading the flow…",
356357
"empty": "No backers yet.",
358+
"unavailable": "Couldn't load the backers. The totals below are live and correct.",
357359
"error": "Couldn't load the flow.",
358360
"retry": "Try again"
359361
}

messages/pt-br/stake.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"legendVault": "Vault Morpho",
7979
"legendMor": "Morpheus · MOR",
8080
"backToAll": "Todos os riders",
81+
"backersUnavailable": "Não deu pra carregar quem apoia cada rider. Os valores em stake estão ao vivo e corretos.",
8182
"error": "Não deu pra carregar o fluxo.",
8283
"retry": "Tentar de novo"
8384
},
@@ -354,6 +355,7 @@
354355
"backers": {
355356
"loading": "carregando o fluxo…",
356357
"empty": "Nenhum apoiador ainda.",
358+
"unavailable": "Não deu pra carregar os apoiadores. Os totais abaixo estão ao vivo e corretos.",
357359
"error": "Não deu pra carregar o fluxo.",
358360
"retry": "Tentar de novo"
359361
}

src/app/api/stake-graph/route.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextResponse } from "next/server";
2-
import { getStakeGraph } from "@/services/stake-graph";
2+
import { loadStakeGraph } from "@/services/stake-graph";
33

44
/**
55
* Two independent cache windows, and they must NOT share a constant:
@@ -31,10 +31,17 @@ const CDN_TTL_SECONDS = 300;
3131

3232
export async function GET() {
3333
try {
34-
const graph = await getStakeGraph();
34+
const { graph, degraded } = await loadStakeGraph();
3535
return NextResponse.json(graph, {
3636
headers: {
37-
"Cache-Control": `public, s-maxage=${CDN_TTL_SECONDS}, stale-while-revalidate=${CDN_TTL_SECONDS * 2}`,
37+
// A degraded graph must not enter EITHER cache. The service keeps it out
38+
// of the data cache by throwing; `no-store` is what keeps it out of the
39+
// CDN — the cache `revalidateTag` cannot reach, and therefore the one
40+
// that would pin an empty orbit for the full s-maxage window even after
41+
// the indexer recovered.
42+
"Cache-Control": degraded
43+
? "no-store"
44+
: `public, s-maxage=${CDN_TTL_SECONDS}, stale-while-revalidate=${CDN_TTL_SECONDS * 2}`,
3845
},
3946
});
4047
} catch {

src/components/stake/BackerList.tsx

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -63,49 +63,62 @@ export function BackerList({ data }: { data?: StakeGraph } = {}) {
6363

6464
const ranked = [...graph.athletes].filter((a) => a.total > 0).sort((a, b) => b.total - a.total);
6565

66-
if (ranked.length === 0) return <p className={cn("text-sm", MUTED)}>{t("empty")}</p>;
66+
// "No backers yet" is a claim about the world, and it may be false: the
67+
// holder index can be down, in which case we simply do not know. Say the
68+
// thing we actually know instead. (`=== false`, not falsy — a react-query
69+
// payload from before this field shipped is `undefined`, not a failure.)
70+
const backersUnknown = graph.backersResolved === false;
71+
72+
if (ranked.length === 0)
73+
return <p className={cn("text-sm", MUTED)}>{t(backersUnknown ? "unavailable" : "empty")}</p>;
6774

6875
return (
69-
// No sub-cards: one hairline between riders. No padding of its own — the
70-
// section card's padding is what keeps the rows off the frame.
71-
<ul className={ROW_LIST}>
72-
{ranked.map((a) => (
73-
<li key={a.id} className={ROW_PAD}>
74-
<div className="flex items-baseline justify-between gap-3">
75-
<span className="truncate text-sm font-semibold">{tc(`${a.id}.name`)}</span>
76-
<span className="font-mono text-sm tabular-nums">{usd(a.total, locale)}</span>
77-
</div>
78-
<ul className="mt-1.5 space-y-0.5">
79-
{a.backers.map((b) => (
80-
<li
81-
// `asset` belongs in the key for the same reason it does in
82-
// StakeOrbit's: one wallet can stake MOR in BOTH Morpheus pools
83-
// (stETH and USDC), which are two rows with the same address and
84-
// kind. Without it React saw duplicate keys and dropped one —
85-
// this fires on the LIVE data today (vlad's wallet), silently
86-
// hiding a real stake from the list.
87-
key={`${b.address}-${b.kind}-${b.asset ?? "na"}`}
88-
className={cn("flex justify-between gap-3 text-xs", MICRO)}
89-
>
90-
{/* A name the graph already carries wins over the address. This
76+
<>
77+
{/* The rider totals below are exact even now — they come from the vault's
78+
own `totalAssets()`, not from the backer list — so the list degrades
79+
without taking the amounts down with it. */}
80+
{backersUnknown && <p className={cn("mb-2 text-xs", MUTED)}>{t("unavailable")}</p>}
81+
{/* No sub-cards: one hairline between riders. No padding of its own — the
82+
section card's padding is what keeps the rows off the frame. */}
83+
<ul className={ROW_LIST}>
84+
{ranked.map((a) => (
85+
<li key={a.id} className={ROW_PAD}>
86+
<div className="flex items-baseline justify-between gap-3">
87+
<span className="truncate text-sm font-semibold">{tc(`${a.id}.name`)}</span>
88+
<span className="font-mono text-sm tabular-nums">{usd(a.total, locale)}</span>
89+
</div>
90+
<ul className="mt-1.5 space-y-0.5">
91+
{a.backers.map((b) => (
92+
<li
93+
// `asset` belongs in the key for the same reason it does in
94+
// StakeOrbit's: one wallet can stake MOR in BOTH Morpheus pools
95+
// (stETH and USDC), which are two rows with the same address and
96+
// kind. Without it React saw duplicate keys and dropped one —
97+
// this fires on the LIVE data today (vlad's wallet), silently
98+
// hiding a real stake from the list.
99+
key={`${b.address}-${b.kind}-${b.asset ?? "na"}`}
100+
className={cn("flex justify-between gap-3 text-xs", MICRO)}
101+
>
102+
{/* A name the graph already carries wins over the address. This
91103
list does no ENS lookup of its own, so without it the fixture
92104
rows would all read as near-identical 0x-shorts.
93105
94106
Every identity on the site links to the internal profile, never
95107
to an explorer — and below `md` this list IS the social proof,
96108
so an unlinked backer is a dead end on the primary surface. */}
97-
<Link
98-
href={`/members/${b.address}`}
99-
className="truncate font-mono hover:text-foreground hover:underline"
100-
>
101-
{b.ens ?? short(b.address)}
102-
</Link>
103-
<span className="shrink-0 font-mono tabular-nums">{usd(b.amount, locale)}</span>
104-
</li>
105-
))}
106-
</ul>
107-
</li>
108-
))}
109-
</ul>
109+
<Link
110+
href={`/members/${b.address}`}
111+
className="truncate font-mono hover:text-foreground hover:underline"
112+
>
113+
{b.ens ?? short(b.address)}
114+
</Link>
115+
<span className="shrink-0 font-mono tabular-nums">{usd(b.amount, locale)}</span>
116+
</li>
117+
))}
118+
</ul>
119+
</li>
120+
))}
121+
</ul>
122+
</>
110123
);
111124
}

src/components/stake/StakeOrbit.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,18 @@ export function StakeOrbit({
550550

551551
return (
552552
<div>
553+
{/* An empty orbit used to be unreadable: it meant "nobody has staked" and
554+
"we couldn't find out who staked" equally well, and the second one is
555+
what was actually happening in production. The graph now says which,
556+
so the page can too. `=== false` on purpose — a payload cached by
557+
react-query from before this field existed is `undefined`, and that is
558+
not a claim of failure. */}
559+
{graph.backersResolved === false && (
560+
<p className="mb-3 rounded-lg border border-white/15 bg-black/30 px-3 py-2 text-xs text-white/70">
561+
{t("orbit.backersUnavailable")}
562+
</p>
563+
)}
564+
553565
{focused && (
554566
<button
555567
type="button"

0 commit comments

Comments
 (0)