Skip to content

Commit 9424864

Browse files
authored
Merge pull request #196 from r4topunk/perf/orbit-cached-multicall
perf(stake): cache + multicall the orbit graph; fix stale dialog copy
2 parents f9f6161 + ba0d628 commit 9424864

5 files changed

Lines changed: 289 additions & 246 deletions

File tree

messages/en/stake.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"selectedRider": "{name} is ready to drop in.",
88
"stakeCta": "Stake with {name}",
99
"stakeToast": "Staked with {name}",
10-
"dialogIntro": "Stake ETH as {name}. Your ETH keeps earning staking yield — the rewards stream to you, your rider, and the Gnars treasury.",
10+
"dialogIntro": "Back {name}. Your deposit stays yours and keeps earning — only the yield is shared with your rider and the Gnars treasury.",
1111
"amountLabel": "Amount to stake",
1212
"aprNote": "at {rate}% {rateType} via {source}",
1313
"flowTitle": "How rewards flow",
@@ -18,7 +18,7 @@
1818
"treasuryLabel": "Gnars Treasury",
1919
"confirm": "Confirm stake",
2020
"cancel": "Cancel",
21-
"disclaimer": "Preview only — staking isn't wired to a contract yet, and this isn't financial advice.",
21+
"disclaimer": "Your deposit stays yours and is withdrawable anytime. Rates vary; this isn't financial advice.",
2222
"prev": "Previous rider",
2323
"next": "Next rider",
2424
"statusTitle": "Status",

messages/pt-br/stake.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"selectedRider": "{name} está pronto para descer.",
88
"stakeCta": "Fazer stake com {name}",
99
"stakeToast": "Stake feito com {name}",
10-
"dialogIntro": "Faça stake de ETH como {name}. Seu ETH continua rendendo staking — as recompensas fluem para você, seu rider e o tesouro da Gnars.",
10+
"dialogIntro": "Apoie {name}. Seu depósito continua seu e rendendo — só o rendimento é dividido com o rider e o tesouro da Gnars.",
1111
"amountLabel": "Valor para stake",
1212
"aprNote": "a {rate}% {rateType} via {source}",
1313
"flowTitle": "Como as recompensas fluem",
@@ -18,7 +18,7 @@
1818
"treasuryLabel": "Tesouro Gnars",
1919
"confirm": "Confirmar stake",
2020
"cancel": "Cancelar",
21-
"disclaimer": "Apenas prévia — o stake ainda não está ligado a um contrato, e isto não é aconselhamento financeiro.",
21+
"disclaimer": "Seu depósito continua seu e pode ser sacado quando quiser. As taxas variam; isto não é aconselhamento financeiro.",
2222
"prev": "Rider anterior",
2323
"next": "Próximo rider",
2424
"statusTitle": "Status",

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { NextResponse } from "next/server";
2+
import { getStakeGraph } from "@/services/stake-graph";
3+
4+
// Recompute at most once a minute; serve stale for 5 more while revalidating.
5+
export const revalidate = 60;
6+
7+
export async function GET() {
8+
try {
9+
const graph = await getStakeGraph();
10+
return NextResponse.json(graph, {
11+
headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300" },
12+
});
13+
} catch {
14+
return NextResponse.json({ error: "stake_graph_failed" }, { status: 500 });
15+
}
16+
}

src/hooks/use-stake-graph.ts

Lines changed: 18 additions & 242 deletions
Original file line numberDiff line numberDiff line change
@@ -1,251 +1,27 @@
11
"use client";
22

3-
// The whole sponsorship graph in one read: every rider vault's total, and who
4-
// backs it for how much. Feeds the orbital view, where the per-rider supporters
5-
// list can't show a backer's positions across riders at once.
6-
//
7-
// Depositors are found from each vault's share-token transfers (a deposit mints
8-
// shares, from 0x0), then every balance is read from the contract — the index
9-
// only says WHO to ask about, never how much.
3+
// Thin client hook over the cached /api/stake-graph route. The heavy on-chain
4+
// work (multicalled, parallelized) now happens server-side and is cached +
5+
// shared across users, so the orbit paints from JSON instead of doing dozens of
6+
// RPC round-trips on every mount. react-query keeps it warm between navigations.
107

11-
import { useEffect, useState } from "react";
12-
import { createPublicClient, http, fallback, formatUnits, getAddress, type Address } from "viem";
13-
import { base, mainnet } from "viem/chains";
14-
import { RIDER_LIST, type RiderId } from "@/lib/gnars-vaults";
15-
import { MORPHEUS_POOLS, MOR_REWARD_POOL_INDEX, depositPoolAbi } from "@/lib/morpheus";
8+
import { useQuery } from "@tanstack/react-query";
9+
import type { StakeGraph } from "@/services/stake-graph";
1610

17-
const client = createPublicClient({
18-
chain: base,
19-
transport: fallback([
20-
http("https://mainnet.base.org"),
21-
http("https://base-rpc.publicnode.com"),
22-
http("https://base.drpc.org"),
23-
]),
24-
});
11+
export type { OrbitBacker, OrbitAthlete, StakeGraph } from "@/services/stake-graph";
2512

26-
// Morpheus deposits live on Ethereum mainnet; a separate client reads them.
27-
const ethClient = createPublicClient({
28-
chain: mainnet,
29-
transport: fallback([
30-
http("https://ethereum.publicnode.com"),
31-
http("https://eth.llamarpc.com"),
32-
http("https://rpc.ankr.com/eth"),
33-
]),
34-
});
35-
36-
// The referrer is indexed, so we can pull exactly the Morpheus stakes that named
37-
// one of our riders — cheaply, without scanning every staker on the pool.
38-
const userReferredEvent = {
39-
type: "event", name: "UserReferred",
40-
inputs: [
41-
{ name: "rewardPoolIndex", type: "uint256", indexed: true },
42-
{ name: "user", type: "address", indexed: true },
43-
{ name: "referrer", type: "address", indexed: true },
44-
{ name: "amount", type: "uint256", indexed: false },
45-
],
46-
} as const;
47-
48-
const abi = [
49-
{ type: "function", name: "totalAssets", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] },
50-
{ type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] },
51-
{ type: "function", name: "convertToAssets", stateMutability: "view", inputs: [{ type: "uint256" }], outputs: [{ type: "uint256" }] },
52-
] as const;
53-
54-
export type OrbitBacker = {
55-
address: Address;
56-
amount: number;
57-
/** "vault" = Morpho USDC sponsorship vault (Base); "mor" = Morpheus stake (mainnet). */
58-
kind: "vault" | "mor";
59-
/** For MOR backers, which asset they staked. */
60-
asset?: "steth" | "usdc";
61-
};
62-
export type OrbitAthlete = {
63-
id: RiderId;
64-
handle: string;
65-
vault: Address;
66-
split?: Address;
67-
total: number;
68-
/** Fee accrued to the split for this vault (the shares minted to it), in USDC. */
69-
feeAccrued: number;
70-
backers: OrbitBacker[];
71-
};
72-
export type StakeGraph = {
73-
athletes: OrbitAthlete[];
74-
total: number;
75-
/** Distinct backer addresses across all riders. */
76-
backerCount: number;
77-
/** The Gnars treasury's earned share so far = half of the accrued fee. */
78-
gnarsAccrued: number;
79-
};
80-
81-
const ZERO = "0x0000000000000000000000000000000000000000";
82-
83-
async function backerAddresses(vault: Address, feeRecipient?: Address): Promise<Set<Address>> {
84-
const out = new Set<Address>();
85-
try {
86-
const res = await fetch(`https://base.blockscout.com/api/v2/tokens/${vault}/transfers`, {
87-
headers: { Accept: "application/json" },
88-
signal: AbortSignal.timeout(9000),
89-
});
90-
if (res.ok) {
91-
const json = (await res.json()) as { items?: { to?: { hash?: string }; from?: { hash?: string } }[] };
92-
for (const t of json.items ?? []) {
93-
for (const raw of [t.to?.hash, t.from?.hash]) {
94-
if (raw && raw.toLowerCase() !== ZERO) {
95-
try { out.add(getAddress(raw)); } catch { /* skip */ }
96-
}
97-
}
98-
}
99-
}
100-
} catch { /* fall through */ }
101-
// The fee recipient (the split) holds shares too — it's not a backer, drop it.
102-
if (feeRecipient) out.delete(getAddress(feeRecipient));
103-
return out;
104-
}
105-
106-
async function fetchEthUsd(): Promise<number> {
107-
try {
108-
const res = await fetch("/api/eth-price");
109-
if (!res.ok) return 0;
110-
const json = (await res.json()) as { usd?: number };
111-
return json.usd ?? 0;
112-
} catch {
113-
return 0;
114-
}
115-
}
116-
117-
/**
118-
* Morpheus (MOR) backers per rider — the mainnet stakes that named a rider as
119-
* referrer. Kept separate from the vault backers, so a wallet that staked on
120-
* BOTH shows up twice (a green MOR stream and a Morpho one). Best-effort: on any
121-
* RPC hiccup the orbit just falls back to the vault backers.
122-
*/
123-
async function morBackersByRider(ethUsd: number): Promise<Record<string, OrbitBacker[]>> {
124-
const walletToId = new Map<string, RiderId>();
125-
for (const r of RIDER_LIST) if (r.wallet) walletToId.set(r.wallet.toLowerCase(), r.id);
126-
const referrers = [...walletToId.keys()].map((a) => getAddress(a));
127-
const out: Record<string, OrbitBacker[]> = {};
128-
if (referrers.length === 0) return out;
129-
130-
let latest: bigint;
131-
try { latest = await ethClient.getBlockNumber(); } catch { return out; }
132-
// Gnars referrals only started recently; a ~2-week window (chunked to stay
133-
// under public-RPC getLogs limits) covers them without a full-history scan.
134-
const WINDOW = BigInt(90_000);
135-
const CHUNK = BigInt(45_000);
136-
const from0 = latest > WINDOW ? latest - WINDOW : BigInt(0);
137-
138-
const pools: Array<{ asset: "steth" | "usdc"; pool: Address; decimals: number }> = [
139-
{ asset: "steth", pool: MORPHEUS_POOLS.stEth.pool, decimals: 18 },
140-
{ asset: "usdc", pool: MORPHEUS_POOLS.usdc.pool, decimals: 6 },
141-
];
142-
143-
for (const { asset, pool, decimals } of pools) {
144-
const userToRef = new Map<string, string>(); // user (lc) -> referrer (lc)
145-
for (let start = from0; start <= latest; start += CHUNK + BigInt(1)) {
146-
const end = start + CHUNK > latest ? latest : start + CHUNK;
147-
try {
148-
const logs = await ethClient.getLogs({
149-
address: pool, event: userReferredEvent,
150-
args: { rewardPoolIndex: MOR_REWARD_POOL_INDEX, referrer: referrers },
151-
fromBlock: start, toBlock: end,
152-
});
153-
for (const l of logs) {
154-
const user = l.args.user as Address | undefined;
155-
const ref = l.args.referrer as Address | undefined;
156-
if (user && ref) userToRef.set(user.toLowerCase(), ref.toLowerCase());
157-
}
158-
} catch { /* skip this chunk */ }
159-
}
160-
161-
for (const [userLc, refLc] of userToRef) {
162-
const id = walletToId.get(refLc);
163-
if (!id) continue;
164-
try {
165-
const ud = (await ethClient.readContract({
166-
address: pool, abi: depositPoolAbi, functionName: "usersData",
167-
args: [getAddress(userLc), MOR_REWARD_POOL_INDEX],
168-
})) as unknown as readonly bigint[];
169-
const deposited = ud[1] ?? BigInt(0); // struct field: deposited
170-
if (deposited <= BigInt(0)) continue;
171-
const tokens = Number(formatUnits(deposited, decimals));
172-
const amount = asset === "steth" ? tokens * ethUsd : tokens; // stETH≈ETH, USDC=$1
173-
if (amount <= 0) continue;
174-
(out[id] ||= []).push({ address: getAddress(userLc), amount, kind: "mor", asset });
175-
} catch { /* skip this user */ }
176-
}
177-
}
178-
return out;
13+
async function fetchStakeGraph(): Promise<StakeGraph> {
14+
const res = await fetch("/api/stake-graph");
15+
if (!res.ok) throw new Error("stake-graph");
16+
return res.json();
17917
}
18018

18119
export function useStakeGraph(nonce = 0): StakeGraph | null {
182-
const [graph, setGraph] = useState<StakeGraph | null>(null);
183-
184-
useEffect(() => {
185-
const live = RIDER_LIST.filter((r) => r.vault);
186-
if (live.length === 0) { setGraph({ athletes: [], total: 0, backerCount: 0, gnarsAccrued: 0 }); return; }
187-
let cancelled = false;
188-
189-
(async () => {
190-
try {
191-
const ethUsd = await fetchEthUsd();
192-
const morPromise = morBackersByRider(ethUsd);
193-
const athletes = await Promise.all(
194-
live.map(async (r): Promise<OrbitAthlete> => {
195-
const vault = r.vault as Address;
196-
const totalRaw = await client.readContract({ address: vault, abi, functionName: "totalAssets" });
197-
const candidates = await backerAddresses(vault, r.split);
198-
const backers: OrbitBacker[] = [];
199-
for (const address of candidates) {
200-
const shares = await client.readContract({ address: vault, abi, functionName: "balanceOf", args: [address] });
201-
if (shares <= BigInt(0)) continue;
202-
const assets = await client.readContract({ address: vault, abi, functionName: "convertToAssets", args: [shares] });
203-
backers.push({ address, amount: Number(formatUnits(assets, 6)), kind: "vault" });
204-
}
205-
backers.sort((a, b) => b.amount - a.amount);
206-
207-
// The performance fee is minted to the split as vault shares — its
208-
// position is the fee accrued so far.
209-
let feeAccrued = 0;
210-
if (r.split) {
211-
const sShares = await client.readContract({ address: vault, abi, functionName: "balanceOf", args: [r.split as Address] });
212-
if (sShares > BigInt(0)) {
213-
const sAssets = await client.readContract({ address: vault, abi, functionName: "convertToAssets", args: [sShares] });
214-
feeAccrued = Number(formatUnits(sAssets, 6));
215-
}
216-
}
217-
return {
218-
id: r.id, handle: r.handle, vault, split: r.split,
219-
total: Number(formatUnits(totalRaw, 6)), feeAccrued, backers,
220-
};
221-
}),
222-
);
223-
const mor = await morPromise;
224-
if (cancelled) return;
225-
// Attach MOR backers (kept distinct from vault backers) and re-rank.
226-
for (const a of athletes) {
227-
const m = mor[a.id];
228-
if (m && m.length) {
229-
a.backers.push(...m);
230-
a.backers.sort((x, y) => y.amount - x.amount);
231-
}
232-
}
233-
const distinct = new Set<string>();
234-
athletes.forEach((a) => a.backers.forEach((b) => distinct.add(b.address.toLowerCase())));
235-
setGraph({
236-
athletes,
237-
total: athletes.reduce((s, a) => s + a.total, 0),
238-
backerCount: distinct.size,
239-
// Split is Gnars 50 / athlete 50, so the treasury's share is half.
240-
gnarsAccrued: athletes.reduce((s, a) => s + a.feeAccrued, 0) / 2,
241-
});
242-
} catch {
243-
if (!cancelled) setGraph(null);
244-
}
245-
})();
246-
247-
return () => { cancelled = true; };
248-
}, [nonce]);
249-
250-
return graph;
20+
const { data } = useQuery({
21+
queryKey: ["stake-graph", nonce],
22+
queryFn: fetchStakeGraph,
23+
staleTime: 60_000,
24+
refetchOnWindowFocus: false,
25+
});
26+
return data ?? null;
25127
}

0 commit comments

Comments
 (0)