|
1 | 1 | "use client"; |
2 | 2 |
|
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. |
10 | 7 |
|
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"; |
16 | 10 |
|
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"; |
25 | 12 |
|
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(); |
179 | 17 | } |
180 | 18 |
|
181 | 19 | 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; |
251 | 27 | } |
0 commit comments