Skip to content

Commit bc40a37

Browse files
authored
feat(treasury): allocation bar, source tiles, arrows, honest claim (#264)
* feat(treasury): allocation bar, source tiles, named senders, paged inflows Layout from the design reference, data from the chain — never the mock's numbers (it shows $1,017 where the real vault holds $17.04). - Allocation bar: share of the treasury by asset, built from the same snapshot and token reads the page already does. Drops the ETH slice rather than estimate it when any token is unpriced. - Inflows paginate with prev/next arrows instead of "show more", so the column stops growing and unbalancing the row. Short last pages reserve their height. - Source tiles: one per source, real totals per asset and real counts. Assets are NOT blended into one figure — there is no price feed here and a conversion would be invented. - Known senders render as names (Auction house, Splits Warehouse, rider vault/split) with the hex as fallback. - Sponsorship card: rider avatars, Vault TVL and Yield columns, total in the footer. TVL comes from the stake graph, so this card and /stake cannot disagree. - The claim button stops implying the whole flow is gated: distribute and the warehouse withdraw are permissionless (verified on-chain), only the ERC-4626 redeem needs the timelock. The proposal template drops the warehouse step accordingly. - The card is now translated; it was hardcoded English. * fix(treasury): uniform row heights and a mobile layout for the inflows Found on the first actual render of the paged list: - Named-sender rows were text-height while hex rows carry a 32px avatar, so the card's height depended on which senders a page happened to hold. Named rows and the height-reserving fillers now share the avatar's footprint. - At 390px a hex sender + badge + amount did not fit one line and the overflow painted the amount OVER the badge. Rows now wrap: the amount group drops to its own right-aligned line on mobile, desktop identical. - With only three sources active, the fourth summary-grid cell rendered as a solid block of border colour; card-coloured fillers close the row on both breakpoints.
1 parent 1d02ff8 commit bc40a37

9 files changed

Lines changed: 619 additions & 207 deletions

File tree

messages/en/treasury.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,38 @@
6969
},
7070
"inflows": {
7171
"title": "Latest inflows",
72-
"showMore": "Show {count} more",
7372
"empty": "No recent inflows.",
7473
"auction": "Auction",
7574
"auctionHint": "Arrived from the auction house as an internal transfer",
7675
"viewTx": "View transaction on Basescan",
7776
"subnet": "Morpheus Subnet",
7877
"splits": "Splits",
79-
"transfer": "Transfer"
78+
"transfer": "Transfer",
79+
"subtitle": "Every credit to the treasury, tagged by where it came from",
80+
"fullLedger": "Full ledger",
81+
"prevPage": "Previous page",
82+
"nextPage": "Next page",
83+
"range": "{from}–{to} of {total}",
84+
"entryCount": "{count, plural, one {# entry} other {# entries}}",
85+
"nameAuction": "Auction house",
86+
"nameWarehouse": "Splits Warehouse",
87+
"nameVault": "Rider vault",
88+
"nameSplit": "Rider split"
89+
},
90+
"allocation": {
91+
"title": "Allocation",
92+
"subtitle": "{count, plural, one {# asset} other {# assets}} · marked to market"
93+
},
94+
"sponsorship": {
95+
"title": "Sponsorship yield",
96+
"desc": "Treasury's share of the yield accrued in the rider vaults. Depositors keep their principal — only the yield is split.",
97+
"colRider": "Rider",
98+
"colTvl": "Vault TVL",
99+
"colYield": "Yield",
100+
"totalTvl": "Total vault TVL",
101+
"openTitle": "Anyone can do this now",
102+
"openDesc": "Distributing the split and pushing the balance to the treasury are both permissionless — no vote needed. Any wallet can trigger them.",
103+
"govDesc": "Only converting those shares to USDC needs a proposal: the treasury is a timelock, and ERC-4626 lets nobody else redeem on its behalf.",
104+
"govCta": "Start redeem proposal"
80105
}
81106
}

messages/pt-br/treasury.json

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,38 @@
6969
},
7070
"inflows": {
7171
"title": "Últimas entradas",
72-
"showMore": "Ver mais {count}",
7372
"empty": "Nenhuma entrada recente.",
7473
"auction": "Leilão",
7574
"auctionHint": "Chegou do contrato de leilão como transferência interna",
7675
"viewTx": "Ver transação no Basescan",
7776
"subnet": "Morpheus Subnet",
7877
"splits": "Splits",
79-
"transfer": "Transferência"
78+
"transfer": "Transferência",
79+
"subtitle": "Cada crédito no tesouro, marcado pela origem",
80+
"fullLedger": "Extrato completo",
81+
"prevPage": "Página anterior",
82+
"nextPage": "Próxima página",
83+
"range": "{from}–{to} de {total}",
84+
"entryCount": "{count, plural, one {# entrada} other {# entradas}}",
85+
"nameAuction": "Casa de leilão",
86+
"nameWarehouse": "Splits Warehouse",
87+
"nameVault": "Vault do rider",
88+
"nameSplit": "Split do rider"
89+
},
90+
"allocation": {
91+
"title": "Alocação",
92+
"subtitle": "{count, plural, one {# ativo} other {# ativos}} · a preço de mercado"
93+
},
94+
"sponsorship": {
95+
"title": "Rendimento de patrocínio",
96+
"desc": "Parte do tesouro no rendimento acumulado nos vaults dos riders. Quem deposita mantém o principal — só o rendimento é dividido.",
97+
"colRider": "Rider",
98+
"colTvl": "TVL do vault",
99+
"colYield": "Rendimento",
100+
"totalTvl": "TVL total dos vaults",
101+
"openTitle": "Qualquer um pode fazer agora",
102+
"openDesc": "Distribuir o split e empurrar o saldo pro tesouro são os dois permissionless — não precisa de votação. Qualquer carteira dispara.",
103+
"govDesc": "Só converter essas shares em USDC exige proposta: o tesouro é um timelock, e o ERC-4626 não deixa mais ninguém resgatar no lugar dele.",
104+
"govCta": "Abrir proposta de resgate"
80105
}
81106
}

src/app/[locale]/treasury/page.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { NftHoldings } from "@/components/treasury/NftHoldings";
1111
import { SponsorshipYield } from "@/components/treasury/SponsorshipYield";
1212
import { TokenHoldings } from "@/components/treasury/TokenHoldings";
13+
import { TreasuryAllocation } from "@/components/treasury/TreasuryAllocation";
1314
import { TreasuryBalance } from "@/components/treasury/TreasuryBalance";
1415
import { TreasuryInflows } from "@/components/treasury/TreasuryInflows";
1516
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -116,9 +117,17 @@ export default async function TreasuryPage({ params }: { params: Promise<{ local
116117
</Card>
117118
</div>
118119

120+
{/* What the treasury is MADE OF, before the income breakdown below.
121+
Every figure is already on this page — the point is the share, which
122+
neither the KPI row nor the token table can answer on its own. */}
123+
<Suspense fallback={null}>
124+
<TreasuryAllocation />
125+
</Suspense>
126+
119127
{/* Income, paired. Left: what actually arrived and where from. Right:
120-
what has accrued in the rider vaults but has NOT arrived yet — it
121-
needs a claim proposal. Side by side because they are the two halves
128+
what has accrued in the rider vaults but has NOT arrived yet. Only
129+
the final USDC redemption needs a proposal; the two steps before it
130+
are permissionless. Side by side because they are the two halves
122131
of the same question, and reading one without the other overstates
123132
or understates what the DAO has earned. */}
124133
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.85fr)_minmax(0,1fr)]">

src/components/treasury/SponsorshipYield.tsx

Lines changed: 165 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -3,121 +3,197 @@
33
// The DAO's cut of the rider sponsorship vaults.
44
//
55
// 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";
1231
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";
1733
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";
3337

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+
};
3554

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`;
3856

3957
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();
7362

7463
// Nothing deployed yet — don't show an empty widget on the treasury page.
7564
if (RIDER_LIST.every((r) => !r.vault)) return null;
7665

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);
8086

8187
return (
8288
<Card className="gap-2">
8389
<CardHeader>
8490
<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")}
8692
</CardTitle>
8793
</CardHeader>
94+
8895
<CardContent className="space-y-4">
8996
<div>
9097
<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&apos;s share of the yield accrued in the rider vaults. Depositors keep their
95-
principal — only the yield is split.
98+
{graph ? usd(treasuryShare) : "—"}
9699
</p>
100+
<p className="mt-1 text-xs text-muted-foreground">{t("desc")}</p>
97101
</div>
98102

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">
101111
{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>
106150
))}
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>
107195
</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}
121197
</CardContent>
122198
</Card>
123199
);

0 commit comments

Comments
 (0)