|
3 | 3 | // The DAO's cut of the rider sponsorship vaults. |
4 | 4 | // |
5 | 5 | // The performance fee is minted as vault SHARES to each rider's 0xSplits |
6 | | -// contract (Gnars 50 / athlete 50). The athlete can withdraw theirs whenever; |
7 | | -// the treasury is a Nouns Builder timelock, so its half only moves through a |
8 | | -// governance proposal. This card surfaces what's sitting there and starts that |
9 | | -// proposal from the matching template. |
10 | | - |
11 | | -import { useEffect, useState } from "react"; |
| 6 | +// contract, which splits them Gnars 50 / athlete 50 — verified on-chain against |
| 7 | +// each split's stored `splitHash`, so the "you keep half, rider + treasury take |
| 8 | +// the rest" claim has a source that is not a code comment. |
| 9 | +// |
| 10 | +// WHAT NEEDS GOVERNANCE AND WHAT DOES NOT. This card used to be a single |
| 11 | +// "Start claim proposal" button, which told visitors the whole thing was gated |
| 12 | +// behind a 4-day vote plus timelock. Two of the three remaining steps are not: |
| 13 | +// distribute() on the split — permissionless (split `paused` = false) |
| 14 | +// SplitsWarehouse.withdraw(treasury) — permissionless (the treasury's |
| 15 | +// withdrawConfig is `paused` = false, so |
| 16 | +// a third party can push the balance to |
| 17 | +// the owner without being the owner) |
| 18 | +// VaultV2.redeem(shares → USDC) — treasury only, because ERC-4626 requires |
| 19 | +// msg.sender to own or be approved for the |
| 20 | +// shares. The treasury is a timelock, so |
| 21 | +// this one really is a proposal. |
| 22 | +// After the withdraw the treasury already OWNS the shares and they keep earning. |
| 23 | +// The proposal is only needed to convert them to USDC — so the two halves are |
| 24 | +// presented separately instead of one button implying friction that isn't there. |
| 25 | +// |
| 26 | +// TVL and accrued yield both come from the shared stake graph rather than |
| 27 | +// bespoke RPC reads, so this card and /stake can never disagree about a rider's |
| 28 | +// numbers. |
| 29 | +import { useLocale, useTranslations } from "next-intl"; |
| 30 | +import Image from "next/image"; |
12 | 31 | import Link from "next/link"; |
13 | | -import { PiggyBank, ArrowRight } from "lucide-react"; |
14 | | -import { createPublicClient, http, fallback, formatUnits, type Address } from "viem"; |
15 | | -import { base } from "viem/chains"; |
16 | | -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 32 | +import { ArrowRight, ExternalLink, PiggyBank } from "lucide-react"; |
17 | 33 | import { Button } from "@/components/ui/button"; |
18 | | -import { RIDER_LIST } from "@/lib/gnars-vaults"; |
19 | | - |
20 | | -const client = createPublicClient({ |
21 | | - chain: base, |
22 | | - transport: fallback([ |
23 | | - http("https://mainnet.base.org"), |
24 | | - http("https://base-rpc.publicnode.com"), |
25 | | - http("https://base.drpc.org"), |
26 | | - ]), |
27 | | -}); |
28 | | - |
29 | | -const abi = [ |
30 | | - { type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ type: "address" }], outputs: [{ type: "uint256" }] }, |
31 | | - { type: "function", name: "convertToAssets", stateMutability: "view", inputs: [{ type: "uint256" }], outputs: [{ type: "uint256" }] }, |
32 | | -] as const; |
| 34 | +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 35 | +import { useStakeGraphQuery } from "@/hooks/use-stake-graph"; |
| 36 | +import { RIDER_LIST, type RiderId } from "@/lib/gnars-vaults"; |
33 | 37 |
|
34 | | -type Row = { id: string; handle: string; vault: Address; accrued: number }; |
| 38 | +/** |
| 39 | + * Avatar crop per rider. Duplicated from StakeOrbit's `RIDER_VISUAL` on purpose, |
| 40 | + * same reasoning that file gives: importing it would drag the orbit's whole |
| 41 | + * module graph onto the treasury page for the sake of seven thumbnails. A rider |
| 42 | + * gaining a new cut-out needs it in both. |
| 43 | + */ |
| 44 | +const AVATAR: Record<RiderId, { src: string; size: string; pos: string }> = { |
| 45 | + vlad: { src: "/stake/cutout/vlad.png", size: "420%", pos: "50% 6%" }, |
| 46 | + yan: { src: "/stake/cutout/yan.png", size: "420%", pos: "50% 5%" }, |
| 47 | + r4to: { src: "/stake/cutout/r4to.png", size: "420%", pos: "50% 5%" }, |
| 48 | + pamtech: { src: "/stake/cutout/pamtech.png", size: "420%", pos: "50% 9%" }, |
| 49 | + v2: { src: "/stake/cutout/v2.png", size: "420%", pos: "50% 8%" }, |
| 50 | + zima: { src: "/stake/cutout/zima.png", size: "330%", pos: "50% 3%" }, |
| 51 | + will: { src: "/stake/cutout/will.png", size: "400%", pos: "50% 8%" }, |
| 52 | + ephraim: { src: "/stake/cutout/ephraim.png", size: "400%", pos: "50% 8%" }, |
| 53 | +}; |
35 | 54 |
|
36 | | -const usd = (n: number) => |
37 | | - `$${n.toLocaleString("en-US", { maximumFractionDigits: n > 0 && n < 100 ? 4 : 2 })}`; |
| 55 | +const SPLITS_APP = (split: string) => `https://app.splits.org/accounts/${split}/?chainId=8453`; |
38 | 56 |
|
39 | 57 | export function SponsorshipYield() { |
40 | | - const [rows, setRows] = useState<Row[] | null>(null); |
41 | | - |
42 | | - useEffect(() => { |
43 | | - let cancelled = false; |
44 | | - (async () => { |
45 | | - const live = RIDER_LIST.filter((r) => r.vault && r.split); |
46 | | - try { |
47 | | - const out = await Promise.all( |
48 | | - live.map(async (r) => { |
49 | | - const shares = await client.readContract({ |
50 | | - address: r.vault as Address, abi, functionName: "balanceOf", args: [r.split as Address], |
51 | | - }); |
52 | | - const accrued = |
53 | | - shares === BigInt(0) |
54 | | - ? 0 |
55 | | - : Number( |
56 | | - formatUnits( |
57 | | - await client.readContract({ |
58 | | - address: r.vault as Address, abi, functionName: "convertToAssets", args: [shares], |
59 | | - }), |
60 | | - 6, |
61 | | - ), |
62 | | - ); |
63 | | - return { id: r.id, handle: r.handle, vault: r.vault as Address, accrued }; |
64 | | - }), |
65 | | - ); |
66 | | - if (!cancelled) setRows(out); |
67 | | - } catch { |
68 | | - if (!cancelled) setRows(null); |
69 | | - } |
70 | | - })(); |
71 | | - return () => { cancelled = true; }; |
72 | | - }, []); |
| 58 | + const t = useTranslations("treasury.sponsorship"); |
| 59 | + const tc = useTranslations("stake.characters"); |
| 60 | + const locale = useLocale(); |
| 61 | + const { data: graph } = useStakeGraphQuery(); |
73 | 62 |
|
74 | 63 | // Nothing deployed yet — don't show an empty widget on the treasury page. |
75 | 64 | if (RIDER_LIST.every((r) => !r.vault)) return null; |
76 | 65 |
|
77 | | - // The split is 50/50, so the treasury's share is half of what accrued to it. |
78 | | - const totalAccrued = rows?.reduce((s, r) => s + r.accrued, 0) ?? 0; |
79 | | - const treasuryShare = totalAccrued / 2; |
| 66 | + const usd = (n: number) => |
| 67 | + `$${n.toLocaleString(locale, { maximumFractionDigits: n > 0 && n < 100 ? 4 : 2 })}`; |
| 68 | + |
| 69 | + const byId = new Map((graph?.athletes ?? []).map((a) => [a.id, a])); |
| 70 | + const rows = RIDER_LIST.map((r) => { |
| 71 | + const a = byId.get(r.id); |
| 72 | + return { |
| 73 | + id: r.id, |
| 74 | + split: r.split, |
| 75 | + // The split holds the whole fee; the treasury's half is what this card is about. |
| 76 | + yieldUsd: a ? a.feeAccrued / 2 : null, |
| 77 | + tvl: a ? a.total : null, |
| 78 | + }; |
| 79 | + }); |
| 80 | + |
| 81 | + const treasuryShare = rows.reduce((s, r) => s + (r.yieldUsd ?? 0), 0); |
| 82 | + // One source of truth: `total` is the vault's own `totalAssets()`, the same |
| 83 | + // read the stake orbit sums. No second TVL field to drift out of step. |
| 84 | + const totalTvl = rows.reduce((s, r) => s + (r.tvl ?? 0), 0); |
| 85 | + const claimable = rows.filter((r) => r.split && (r.yieldUsd ?? 0) > 0); |
80 | 86 |
|
81 | 87 | return ( |
82 | 88 | <Card className="gap-2"> |
83 | 89 | <CardHeader> |
84 | 90 | <CardTitle className="flex items-center gap-2 text-sm font-medium"> |
85 | | - <PiggyBank className="h-4 w-4" /> Sponsorship yield |
| 91 | + <PiggyBank className="size-4" /> {t("title")} |
86 | 92 | </CardTitle> |
87 | 93 | </CardHeader> |
| 94 | + |
88 | 95 | <CardContent className="space-y-4"> |
89 | 96 | <div> |
90 | 97 | <p className="font-mono text-2xl font-bold tabular-nums"> |
91 | | - {rows === null ? "—" : usd(treasuryShare)} |
92 | | - </p> |
93 | | - <p className="mt-1 text-xs text-muted-foreground"> |
94 | | - Treasury's share of the yield accrued in the rider vaults. Depositors keep their |
95 | | - principal — only the yield is split. |
| 98 | + {graph ? usd(treasuryShare) : "—"} |
96 | 99 | </p> |
| 100 | + <p className="mt-1 text-xs text-muted-foreground">{t("desc")}</p> |
97 | 101 | </div> |
98 | 102 |
|
99 | | - {rows && rows.length > 0 && ( |
100 | | - <div className="space-y-1.5"> |
| 103 | + <div> |
| 104 | + <div className="grid grid-cols-[minmax(0,1fr)_72px_66px] gap-2.5 border-b border-border pb-2 text-[11px] font-medium text-muted-foreground"> |
| 105 | + <span>{t("colRider")}</span> |
| 106 | + <span className="text-right">{t("colTvl")}</span> |
| 107 | + <span className="text-right">{t("colYield")}</span> |
| 108 | + </div> |
| 109 | + |
| 110 | + <ul className="mt-1"> |
101 | 111 | {rows.map((r) => ( |
102 | | - <div key={r.id} className="flex items-center justify-between text-xs"> |
103 | | - <span className="capitalize text-muted-foreground">{r.id}</span> |
104 | | - <span className="font-mono tabular-nums">{usd(r.accrued / 2)}</span> |
105 | | - </div> |
| 112 | + <li |
| 113 | + key={r.id} |
| 114 | + className="grid grid-cols-[minmax(0,1fr)_72px_66px] items-center gap-2.5 py-1.5" |
| 115 | + > |
| 116 | + <span className="flex min-w-0 items-center gap-2"> |
| 117 | + {/* Cropped to the face the same way the orbit nodes are, so a |
| 118 | + rider reads as the same character across the site. */} |
| 119 | + <span className="relative size-6 shrink-0 overflow-hidden rounded-full bg-muted"> |
| 120 | + <Image |
| 121 | + src={AVATAR[r.id].src} |
| 122 | + alt="" |
| 123 | + fill |
| 124 | + sizes="24px" |
| 125 | + className="object-cover" |
| 126 | + style={{ |
| 127 | + objectPosition: AVATAR[r.id].pos, |
| 128 | + scale: parseFloat(AVATAR[r.id].size) / 100, |
| 129 | + }} |
| 130 | + /> |
| 131 | + </span> |
| 132 | + <span className="truncate text-[13px] font-medium">{tc(`${r.id}.name`)}</span> |
| 133 | + </span> |
| 134 | + |
| 135 | + {/* An em dash is not zero. No vault, or a vault nobody has |
| 136 | + deposited into, is a different fact from "earned nothing" — |
| 137 | + and the yield column right beside it says $0 for exactly |
| 138 | + that case, so the two must not look alike. */} |
| 139 | + <span className="text-right font-mono text-xs tabular-nums text-muted-foreground"> |
| 140 | + {(r.tvl ?? 0) > 0 ? usd(r.tvl as number) : "–"} |
| 141 | + </span> |
| 142 | + <span |
| 143 | + className={`text-right font-mono text-xs font-semibold tabular-nums ${ |
| 144 | + (r.yieldUsd ?? 0) > 0 ? "text-amber-500" : "text-muted-foreground" |
| 145 | + }`} |
| 146 | + > |
| 147 | + {usd(r.yieldUsd ?? 0)} |
| 148 | + </span> |
| 149 | + </li> |
106 | 150 | ))} |
| 151 | + </ul> |
| 152 | + </div> |
| 153 | + |
| 154 | + {/* One source of truth: `total` is each vault's own `totalAssets()`, the |
| 155 | + same read the stake orbit sums — no second TVL field to drift. */} |
| 156 | + <div className="flex items-baseline justify-between gap-2.5 border-t border-border pt-3"> |
| 157 | + <span className="text-xs text-muted-foreground">{t("totalTvl")}</span> |
| 158 | + <span className="font-mono text-sm font-semibold tabular-nums"> |
| 159 | + {graph ? usd(totalTvl) : "—"} |
| 160 | + </span> |
| 161 | + </div> |
| 162 | + |
| 163 | + {claimable.length > 0 ? ( |
| 164 | + <div className="space-y-3"> |
| 165 | + {/* Permissionless first, deliberately: it is the step available RIGHT |
| 166 | + NOW, and burying it under the proposal button is what made the |
| 167 | + whole flow look gated. */} |
| 168 | + <div className="rounded-lg border border-border bg-muted/40 p-3"> |
| 169 | + <p className="text-xs font-semibold">{t("openTitle")}</p> |
| 170 | + <p className="mt-1 text-xs text-muted-foreground">{t("openDesc")}</p> |
| 171 | + <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1"> |
| 172 | + {claimable.map((r) => ( |
| 173 | + <a |
| 174 | + key={r.id} |
| 175 | + href={SPLITS_APP(r.split as string)} |
| 176 | + target="_blank" |
| 177 | + rel="noopener noreferrer" |
| 178 | + className="inline-flex items-center gap-1 text-xs font-medium underline underline-offset-4 hover:text-foreground" |
| 179 | + > |
| 180 | + {tc(`${r.id}.name`)} |
| 181 | + <ExternalLink className="size-3" /> |
| 182 | + </a> |
| 183 | + ))} |
| 184 | + </div> |
| 185 | + </div> |
| 186 | + |
| 187 | + <div> |
| 188 | + <p className="text-xs text-muted-foreground">{t("govDesc")}</p> |
| 189 | + <Button asChild size="sm" variant="outline" className="mt-2 w-full cursor-pointer"> |
| 190 | + <Link href="/propose?template=sponsorship-yield-claim"> |
| 191 | + {t("govCta")} <ArrowRight className="ml-1 size-3.5" /> |
| 192 | + </Link> |
| 193 | + </Button> |
| 194 | + </div> |
107 | 195 | </div> |
108 | | - )} |
109 | | - |
110 | | - {treasuryShare > 0 ? ( |
111 | | - <Button asChild size="sm" variant="outline" className="w-full"> |
112 | | - <Link href="/propose?template=sponsorship-yield-claim"> |
113 | | - Start claim proposal <ArrowRight className="ml-1 h-3.5 w-3.5" /> |
114 | | - </Link> |
115 | | - </Button> |
116 | | - ) : ( |
117 | | - <p className="text-xs text-muted-foreground"> |
118 | | - Nothing to claim yet — the fee accrues as the vaults earn. |
119 | | - </p> |
120 | | - )} |
| 196 | + ) : null} |
121 | 197 | </CardContent> |
122 | 198 | </Card> |
123 | 199 | ); |
|
0 commit comments