Skip to content

Commit 98cf177

Browse files
sktbrdclaude
andcommitted
feat(treasury): In DeFi — net worth by right, not token-in-wallet
Stock next to the flow: a new card reads every protocol position the treasury owns directly from chain state — ERC-4626 vault shares held by the treasury (convertToAssets of ITS balance, never totalAssets, which is everyone's money), its SplitsWarehouse balances, its verified share of undistributed non-vault split balances, and MOR staked in the Gnars Builders subnet. The vault-fee-in-splits row REUSES the stake graph's gnarsAccrued — one truth shared with the Sponsorship card. Spec rules wired in: a failed read renders as a failure (amber), never as 0; a legitimate zero renders as $0 with liquidity context; the vault-shares zero carries its explanation (the treasury never deposits — supporters do) so nobody 'fixes' it into rendering depositor money. The Total KPI becomes wallet + DeFi with the split named beneath it, and an incomplete DeFi read keeps the total as a labeled floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 980adf0 commit 98cf177

7 files changed

Lines changed: 504 additions & 6 deletions

File tree

messages/en/treasury.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"subnetEarnings": "Subnet Earnings",
1111
"acrossAssets": "across {count} assets",
1212
"auctionsSettled": "{count} auctions settled",
13-
"morpheusClaims": "Morpheus · {count} claims"
13+
"morpheusClaims": "Morpheus · {count} claims",
14+
"walletVsDefi": "In wallet {wallet} · In DeFi {defi}",
15+
"defiUnavailable": "DeFi positions unreadable — the total shows the wallet only"
1416
},
1517
"nftSection": {
1618
"title": "NFT Holdings",
@@ -116,5 +118,25 @@
116118
"accrualNote": "Fees materialize when someone interacts with a vault, not continuously — the figures can sit still for weeks, then jump all at once. An older small vault can carry more accrued fee than a big recent one.",
117119
"fromVaults": "Vault fee",
118120
"fromMor": "MOR rewards"
121+
},
122+
"defi": {
123+
"title": "In DeFi",
124+
"description": "Protocol positions the treasury owns by right",
125+
"positions": {
126+
"vaultShares": "Rider vault shares (Morpho)",
127+
"feeInSplits": "Vault fee accrued in rider splits",
128+
"warehouse": "Splits Warehouse balance",
129+
"splitPending": "Undistributed split balances",
130+
"subnetStake": "MOR staked in the Gnars subnet",
131+
"vaultSharesZeroNote": "the treasury never deposits here — its right is the performance fee accruing in the splits"
132+
},
133+
"liquidity": {
134+
"instant": "redeemable now — permissionless",
135+
"proposal": "redeem via governance proposal",
136+
"locked": "locked until {date}"
137+
},
138+
"readFailed": "read failed",
139+
"total": "Total in DeFi",
140+
"incomplete": "* one or more positions could not be read — the total is a floor, not the whole truth"
119141
}
120142
}

messages/pt-br/treasury.json

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"subnetEarnings": "Ganhos da Subnet",
1111
"acrossAssets": "em {count} ativos",
1212
"auctionsSettled": "{count} leilões concluídos",
13-
"morpheusClaims": "Morpheus · {count} resgates"
13+
"morpheusClaims": "Morpheus · {count} resgates",
14+
"walletVsDefi": "Em carteira {wallet} · Em DeFi {defi}",
15+
"defiUnavailable": "Posições DeFi ilegíveis — o total mostra só a carteira"
1416
},
1517
"nftSection": {
1618
"title": "Holdings de NFT",
@@ -116,5 +118,25 @@
116118
"accrualNote": "O rendimento materializa quando alguém interage com o vault, não de forma contínua — os números podem ficar parados por semanas e pular de uma vez. Um vault pequeno e antigo pode acumular mais que um grande e recente.",
117119
"fromVaults": "Taxa dos vaults",
118120
"fromMor": "Recompensas MOR"
121+
},
122+
"defi": {
123+
"title": "Em DeFi",
124+
"description": "Posições em protocolo que o tesouro detém por direito",
125+
"positions": {
126+
"vaultShares": "Shares dos vaults de rider (Morpho)",
127+
"feeInSplits": "Fee de vault acumulada nos splits dos riders",
128+
"warehouse": "Saldo no Splits Warehouse",
129+
"splitPending": "Saldos não distribuídos nos splits",
130+
"subnetStake": "MOR em stake no subnet da Gnars",
131+
"vaultSharesZeroNote": "o tesouro não deposita nos vaults — o direito dele é a taxa de performance que acumula nos splits"
132+
},
133+
"liquidity": {
134+
"instant": "resgatável já — permissionless",
135+
"proposal": "resgate via proposta de governança",
136+
"locked": "em lock até {date}"
137+
},
138+
"readFailed": "falha de leitura",
139+
"total": "Total em DeFi",
140+
"incomplete": "* uma ou mais posições não puderam ser lidas — o total é um piso, não a verdade inteira"
119141
}
120142
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { SponsorshipYield } from "@/components/treasury/SponsorshipYield";
1212
import { SyncedBadge } from "@/components/treasury/SyncedBadge";
1313
import { TokenHoldings } from "@/components/treasury/TokenHoldings";
1414
import { TreasuryAllocation } from "@/components/treasury/TreasuryAllocation";
15+
import { TreasuryDefi } from "@/components/treasury/TreasuryDefi";
1516
import { TreasuryInflows } from "@/components/treasury/TreasuryInflows";
1617
import { TreasuryKpiRow } from "@/components/treasury/TreasuryKpiRow";
1718
import { DAO_ADDRESSES } from "@/lib/config";
@@ -135,6 +136,12 @@ export default async function TreasuryPage({ params }: { params: Promise<{ local
135136
<Suspense fallback={<TableSkeleton />}>
136137
<TokenHoldings treasuryAddress={DAO_ADDRESSES.treasury} />
137138
</Suspense>
139+
{/* Stock, not flow: what the treasury owns by right in protocol
140+
positions. Sits under Token Holdings ("in wallet") so the two
141+
halves of the Total KPI are adjacent and reconcilable. */}
142+
<Suspense fallback={<TableSkeleton />}>
143+
<TreasuryDefi />
144+
</Suspense>
138145
</div>
139146
</div>
140147

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { getLocale, getTranslations } from "next-intl/server";
2+
import { Layers } from "lucide-react";
3+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4+
import { formatFiatUsd } from "@/lib/i18n/fiat";
5+
import { toIntlLocale } from "@/lib/i18n/format";
6+
import { getBrlRateForRequest } from "@/services/exchange-rate";
7+
import { loadTreasuryDefi, type DefiLiquidity } from "@/services/treasury-defi";
8+
9+
/**
10+
* "In DeFi" — what the treasury owns by right in protocol positions, stock not
11+
* flow. Every row is its own on-chain read; a failed read renders as a failure
12+
* (amber), a legitimate zero renders as $0 — the two must never look alike.
13+
* The vault-fee row is the stake graph's own `gnarsAccrued`, so this card and
14+
* the Sponsorship card can never disagree about that number.
15+
*/
16+
export async function TreasuryDefi() {
17+
const t = await getTranslations("treasury.defi");
18+
const locale = await getLocale();
19+
const [defi, brlRate] = await Promise.all([loadTreasuryDefi(), getBrlRateForRequest()]);
20+
21+
const fiat = (usd: number) => formatFiatUsd(usd, locale, brlRate);
22+
const lockLabel = (liq: DefiLiquidity): string => {
23+
if (liq.kind === "proposal") return t("liquidity.proposal");
24+
if (liq.kind === "locked")
25+
return t("liquidity.locked", {
26+
date: new Date(liq.unlockAt * 1000).toLocaleDateString(toIntlLocale(locale), {
27+
day: "2-digit",
28+
month: "2-digit",
29+
}),
30+
});
31+
return t("liquidity.instant");
32+
};
33+
34+
return (
35+
<Card className="gap-2">
36+
<CardHeader>
37+
<CardTitle className="flex items-center gap-2 text-sm font-medium">
38+
<Layers className="size-4" /> {t("title")}
39+
</CardTitle>
40+
<CardDescription>{t("description")}</CardDescription>
41+
</CardHeader>
42+
<CardContent className="space-y-1">
43+
<ul className="divide-y">
44+
{defi.positions.map((p) => (
45+
<li key={p.labelKey} className="flex items-baseline justify-between gap-3 py-2.5">
46+
<div className="min-w-0">
47+
<div className="text-[13px] font-medium">{t(`positions.${p.labelKey}`)}</div>
48+
{/* Zero-by-design gets its explanation, not a redeem hint: the
49+
treasury never deposits into the vaults — supporters do —
50+
so a mute $0 here reads as a bug and invites the exact
51+
wrong "fix" (rendering totalAssets, i.e. everyone's money,
52+
as Gnars net worth). */}
53+
<div className="text-[11px] text-muted-foreground">
54+
{p.labelKey === "vaultShares" && p.ok && p.usd === 0
55+
? t("positions.vaultSharesZeroNote")
56+
: lockLabel(p.liquidity)}
57+
</div>
58+
</div>
59+
<div className="shrink-0 text-right">
60+
{p.ok ? (
61+
<>
62+
<span
63+
className={`font-mono text-sm tabular-nums ${p.usd === 0 ? "text-muted-foreground" : "font-semibold"}`}
64+
>
65+
{fiat(p.usd)}
66+
</span>
67+
{p.amount && (
68+
<div className="text-[11px] text-muted-foreground tabular-nums">
69+
{p.amount}
70+
</div>
71+
)}
72+
</>
73+
) : (
74+
<span className="text-xs font-medium text-amber-600 dark:text-amber-500">
75+
{t("readFailed")}
76+
</span>
77+
)}
78+
</div>
79+
</li>
80+
))}
81+
</ul>
82+
83+
<div className="flex items-baseline justify-between gap-2.5 border-t pt-3">
84+
<span className="text-xs text-muted-foreground">{t("total")}</span>
85+
<span className="font-mono text-sm font-semibold tabular-nums">
86+
{fiat(defi.knownUsd)}
87+
{!defi.complete && <span className="text-amber-600 dark:text-amber-500"> *</span>}
88+
</span>
89+
</div>
90+
{!defi.complete && (
91+
<p className="text-[11px] text-amber-600 dark:text-amber-500">{t("incomplete")}</p>
92+
)}
93+
</CardContent>
94+
</Card>
95+
);
96+
}

src/components/treasury/TreasuryKpiRow.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { formatFiatUsd } from "@/lib/i18n/fiat";
66
import { fetchSettledAuctionCount } from "@/services/dao";
77
import { getBrlRateForRequest } from "@/services/exchange-rate";
88
import { loadTreasurySnapshot } from "@/services/treasury";
9+
import { loadTreasuryDefi } from "@/services/treasury-defi";
910
import { loadSubnetEarnings } from "@/services/treasury-inflows";
1011
import { FiatFallbackNote } from "./FiatFallbackNote";
1112
import { KpiValue } from "./KpiValue";
@@ -56,13 +57,22 @@ function UsdcMark() {
5657
export async function TreasuryKpiRow() {
5758
const t = await getTranslations("treasury.page.kpis");
5859
const locale = await getLocale();
59-
const [snapshot, subnet, auctionCount, brlRate] = await Promise.all([
60+
const [snapshot, subnet, auctionCount, brlRate, defi] = await Promise.all([
6061
loadTreasurySnapshot(DAO_ADDRESSES.treasury).catch(() => null),
6162
loadSubnetEarnings(),
6263
fetchSettledAuctionCount(),
6364
getBrlRateForRequest(),
65+
loadTreasuryDefi().catch(() => null),
6466
]);
6567

68+
// Total = wallet + DeFi (net worth by right, the shared three-surface spec).
69+
// A failed DeFi read keeps the wallet total but says so out loud — the sum
70+
// of the KNOWN positions still counts (it is a floor, never an estimate).
71+
const walletUsd = snapshot?.usdTotal ?? null;
72+
const defiKnown = defi?.knownUsd ?? 0;
73+
const totalUsd = walletUsd == null ? null : walletUsd + defiKnown;
74+
const defiHealthy = defi != null && defi.complete;
75+
6676
const cards: Array<{
6777
key: string;
6878
label: string;
@@ -76,9 +86,24 @@ export async function TreasuryKpiRow() {
7686
key: "total",
7787
label: t("totalValue"),
7888
accent: "--chart-5",
79-
value: <KpiValue value={snapshot?.usdTotal ?? null} decimals={2} fiat brlRate={brlRate} />,
80-
note: snapshot ? t("acrossAssets", { count: snapshot.assetCount }) : null,
81-
extra: <FiatFallbackNote brlRate={brlRate} className="relative mt-1" />,
89+
value: <KpiValue value={totalUsd} decimals={2} fiat brlRate={brlRate} />,
90+
note:
91+
walletUsd != null
92+
? t("walletVsDefi", {
93+
wallet: formatFiatUsd(walletUsd, locale, brlRate),
94+
defi: formatFiatUsd(defiKnown, locale, brlRate),
95+
})
96+
: null,
97+
extra: (
98+
<>
99+
{!defiHealthy && (
100+
<p className="relative mt-1 text-xs text-amber-600 dark:text-amber-500">
101+
{t("defiUnavailable")}
102+
</p>
103+
)}
104+
<FiatFallbackNote brlRate={brlRate} className="relative mt-1" />
105+
</>
106+
),
82107
mark: (
83108
<Image
84109
src="/red_noggles.png"

src/services/treasury-defi.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { summarizeDefi, type DefiPosition } from "./treasury-defi";
3+
4+
const ok = (usd: number): DefiPosition => ({
5+
labelKey: "x",
6+
liquidity: { kind: "instant" },
7+
ok: true,
8+
usd,
9+
});
10+
const failed: DefiPosition = { labelKey: "x", liquidity: { kind: "instant" }, ok: false };
11+
12+
describe("summarizeDefi", () => {
13+
it("sums known positions and reports complete when all reads succeeded", () => {
14+
const s = summarizeDefi([ok(10), ok(0), ok(2.5)]);
15+
expect(s.knownUsd).toBeCloseTo(12.5);
16+
expect(s.complete).toBe(true);
17+
});
18+
19+
it("a failed read never counts as zero silently — complete goes false", () => {
20+
const s = summarizeDefi([ok(10), failed]);
21+
expect(s.knownUsd).toBe(10);
22+
expect(s.complete).toBe(false);
23+
});
24+
25+
it("legitimate zeros keep complete true — zero is a fact, not a failure", () => {
26+
const s = summarizeDefi([ok(0), ok(0)]);
27+
expect(s.knownUsd).toBe(0);
28+
expect(s.complete).toBe(true);
29+
});
30+
31+
it("all-failed yields zero known and incomplete", () => {
32+
const s = summarizeDefi([failed, failed]);
33+
expect(s.knownUsd).toBe(0);
34+
expect(s.complete).toBe(false);
35+
});
36+
});

0 commit comments

Comments
 (0)