Skip to content

Commit 861b390

Browse files
sktbrdclaude
andcommitted
fix(stake,treasury): one definition per number across both pages
Three places where the same question got two answers. Orbit nodes printed vault-only TVL while the headline above them added Morpheus stakes on top. A rider backed purely through Morpheus therefore rendered unlit with no figure — Will had $31 behind him and an empty node — the per-node values could not be summed to the headline, and zooming into that rider listed the backers the overview had just denied. The mobile BackerList dropped him entirely on the same `total > 0` test. `OrbitAthlete` now carries `vaultTvl` and `morUsd`, with `total` their sum. The treasury's Sponsorship yield re-derived the DAO's take as the vault fee alone, so it read $0.0024 against /stake's $0.0112 with nothing marking one as a subset. It now prints the graph's own `treasuryUsd` — the identical field /stake shows — and names both halves under it. The per-rider table stays vault-only, since the fee is the only part attributable per rider. Warehouse credits were classified from the withdrawal's own receipt, and a bare withdraw() of balance credited earlier carries no split event, so four Morpheus payouts sat under the generic "Splits" tag. The credit that funded them does name its split, and the amounts match exactly, so an amount-matched scan of the treasury's ledger credits recovers all four. The Subnet Earnings KPI now runs the same classifier as the tile below it rather than a second copy of the old rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c71fde6 commit 861b390

6 files changed

Lines changed: 343 additions & 43 deletions

File tree

messages/en/treasury.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,17 @@
103103
},
104104
"sponsorship": {
105105
"title": "Sponsorship yield",
106-
"desc": "Treasury's share of the yield accrued in the rider vaults. Depositors keep their principal — only the yield is split.",
106+
"desc": "Everything the riders earn the treasury: the vaults’ performance fee plus MOR from the Morpheus stake. Depositors keep their principal — only the yield is split.",
107107
"colRider": "Rider",
108108
"colTvl": "Vault TVL",
109-
"colYield": "Yield",
109+
"colYield": "Vault yield",
110110
"totalTvl": "Total vault TVL",
111111
"openTitle": "Anyone can do this now",
112112
"openDesc": "Distributing the split and pushing the balance to the treasury are both permissionless — no vote needed. Any wallet can trigger them.",
113113
"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.",
114114
"govCta": "Start redeem proposal",
115-
"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."
115+
"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.",
116+
"fromVaults": "Vault fee",
117+
"fromMor": "MOR rewards"
116118
}
117119
}

messages/pt-br/treasury.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,17 @@
103103
},
104104
"sponsorship": {
105105
"title": "Rendimento de patrocínio",
106-
"desc": "Parte do tesouro no rendimento acumulado nos vaults dos riders. Quem deposita mantém o principal — só o rendimento é dividido.",
106+
"desc": "Tudo que os riders rendem pro tesouro: a taxa de performance dos vaults mais o MOR do stake na Morpheus. Quem deposita mantém o principal — só o rendimento é dividido.",
107107
"colRider": "Rider",
108108
"colTvl": "TVL do vault",
109-
"colYield": "Rendimento",
109+
"colYield": "Rendimento do vault",
110110
"totalTvl": "TVL total dos vaults",
111111
"openTitle": "Qualquer um pode fazer agora",
112112
"openDesc": "Distribuir o split e empurrar o saldo pro tesouro são os dois permissionless — não precisa de votação. Qualquer carteira dispara.",
113113
"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.",
114114
"govCta": "Abrir proposta de resgate",
115-
"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."
115+
"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.",
116+
"fromVaults": "Taxa dos vaults",
117+
"fromMor": "Recompensas MOR"
116118
}
117119
}

src/components/treasury/SponsorshipYield.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
// TVL and accrued yield both come from the shared stake graph rather than
2727
// bespoke RPC reads, so this card and /stake can never disagree about a rider's
2828
// numbers.
29+
//
30+
// The headline is the graph's `treasuryUsd` — the SAME field /stake prints as
31+
// "earned for the treasury" — because these two pages answering "what did the
32+
// riders earn the DAO" with different numbers is precisely the drift this card
33+
// was supposed to have ruled out. What it earns splits two ways, so both are
34+
// named under it: the Morpho vaults' performance fee, and MOR from the Morpheus
35+
// stake. The per-rider table below stays vault-only, since the fee is the only
36+
// part attributable per rider.
2937
import { useLocale, useTranslations } from "next-intl";
3038
import Image from "next/image";
3139
import Link from "next/link";
@@ -87,15 +95,25 @@ export function SponsorshipYield({ brlRate = null }: { brlRate?: number | null }
8795
return {
8896
id: r.id,
8997
split: r.split,
90-
// The split holds the whole fee; the treasury's half is what this card is about.
98+
// The split holds the whole fee; the treasury's half is what this column is about.
9199
yieldUsd: a ? a.feeAccrued / 2 : null,
92-
tvl: a ? a.total : null,
100+
// `vaultTvl`, NOT `total`: the performance fee accrues on the Morpho
101+
// balance alone, so pairing the yield with a MOR-inclusive figure would
102+
// invite reading a return rate off two unrelated numbers.
103+
tvl: a ? a.vaultTvl : null,
93104
};
94105
});
95106

96-
const treasuryShare = rows.reduce((s, r) => s + (r.yieldUsd ?? 0), 0);
97-
// One source of truth: `total` is the vault's own `totalAssets()`, the same
98-
// read the stake orbit sums. No second TVL field to drift out of step.
107+
// The headline is the graph's own `treasuryUsd` — the identical field /stake
108+
// prints as "earned for the treasury". It used to be re-derived here as the
109+
// vault fee alone, so the same question got two different answers on two
110+
// pages ($0.0024 here against $0.0112 there) with nothing saying one was a
111+
// subset of the other. The components are named underneath it instead.
112+
const treasuryTotal = graph?.treasuryUsd ?? 0;
113+
const vaultShare = graph?.gnarsAccrued ?? 0;
114+
const morShare = graph?.gnarsMorUsd ?? 0;
115+
// One source of truth: `vaultTvl` is each vault's own `totalAssets()`,
116+
// straight off the shared graph — no second TVL field to drift.
99117
const totalTvl = rows.reduce((s, r) => s + (r.tvl ?? 0), 0);
100118
const claimable = rows.filter((r) => r.split && (r.yieldUsd ?? 0) > 0);
101119

@@ -110,9 +128,22 @@ export function SponsorshipYield({ brlRate = null }: { brlRate?: number | null }
110128
<CardContent className="space-y-4">
111129
<div>
112130
<p className="font-mono text-2xl font-bold tabular-nums">
113-
{graph ? usd(treasuryShare) : "—"}
131+
{graph ? usd(treasuryTotal) : "—"}
114132
</p>
115-
<p className="mt-1 text-xs text-muted-foreground">{t("desc")}</p>
133+
{/* Both halves, always — including a zero one. "Vault fee $0" is what
134+
tells a reader the headline is not the vault fee, which is the
135+
confusion this card started from. */}
136+
<dl className="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-[11px] text-muted-foreground">
137+
<div className="flex items-baseline gap-1.5">
138+
<dt>{t("fromVaults")}</dt>
139+
<dd className="font-mono tabular-nums">{graph ? usd(vaultShare) : "—"}</dd>
140+
</div>
141+
<div className="flex items-baseline gap-1.5">
142+
<dt>{t("fromMor")}</dt>
143+
<dd className="font-mono tabular-nums">{graph ? usd(morShare) : "—"}</dd>
144+
</div>
145+
</dl>
146+
<p className="mt-1.5 text-xs text-muted-foreground">{t("desc")}</p>
116147
{/* The accrual mechanism, stated because the raw numbers look wrong
117148
without it: Morpho VaultV2 mints the fee on vault INTERACTIONS,
118149
not continuously. Yan's figure once jumped 66x in a day (a
@@ -175,8 +206,8 @@ export function SponsorshipYield({ brlRate = null }: { brlRate?: number | null }
175206
</ul>
176207
</div>
177208

178-
{/* One source of truth: `total` is each vault's own `totalAssets()`, the
179-
same read the stake orbit sums — no second TVL field to drift. */}
209+
{/* Deliberately NOT the orbit node's figure, which also carries the
210+
rider's MOR stake and would not belong under a fee-earning column. */}
180211
<div className="flex items-baseline justify-between gap-2.5 border-t border-border pt-3">
181212
<span className="text-xs text-muted-foreground">{t("totalTvl")}</span>
182213
<span className="font-mono text-sm font-semibold tabular-nums">

src/services/stake-graph.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ import {
4444
MOR_TOKEN,
4545
MORPHEUS_POOLS,
4646
} from "@/lib/morpheus";
47-
import { getEthUsd, getTokenPriceUsd, type UsdPrice } from "@/services/prices";
4847
import { blockscoutGet } from "@/services/blockscout";
48+
import { getEthUsd, getTokenPriceUsd, type UsdPrice } from "@/services/prices";
4949

5050
// Prefer Alchemy (reliable, handles large getLogs) when the key is set, since
5151
// the public RPCs frequently fail/timeout on the MOR log scan — and a swallowed
@@ -136,6 +136,25 @@ export type OrbitAthlete = {
136136
handle: string;
137137
vault: Address;
138138
split?: Address;
139+
/**
140+
* Morpho sponsorship vault TVL (`totalAssets()`, USDC≈USD). This is the
141+
* balance the performance fee accrues on, so it — not `total` — is what a
142+
* yield figure must be read against.
143+
*/
144+
vaultTvl: number;
145+
/** Morpheus stake behind this rider, in USD. Earns MOR, not the vault fee. */
146+
morUsd: number;
147+
/**
148+
* Everything backing this rider = `vaultTvl` + `morUsd`.
149+
*
150+
* It has to be the sum, because this is the number the orbit node prints and
151+
* `StakeGraph.total` is the headline above it. While `total` meant "vault
152+
* only", a rider backed purely through Morpheus rendered UNLIT with no figure
153+
* at all — Will had $31 of MOR behind him and an empty node — and the
154+
* per-node values could not be added up to the headline, since only the
155+
* headline knew about MOR. Zooming into that same rider then listed the
156+
* backers the overview had just denied.
157+
*/
139158
total: number;
140159
feeAccrued: number;
141160
backers: OrbitBacker[];
@@ -797,6 +816,9 @@ async function fetchStakeGraphUncached(): Promise<StakeGraph> {
797816
handle: r.handle,
798817
vault,
799818
split: r.split,
819+
vaultTvl: Number(formatUnits(totalAssets, 6)),
820+
// Both filled in below, once the MOR backers have been merged in.
821+
morUsd: 0,
800822
total: Number(formatUnits(totalAssets, 6)),
801823
feeAccrued: Number(formatUnits(toAssets(feeShares), 6)),
802824
backers,
@@ -817,21 +839,24 @@ async function fetchStakeGraphUncached(): Promise<StakeGraph> {
817839
a.backers.push(...m);
818840
a.backers.sort((x, y) => y.amount - x.amount);
819841
}
842+
// Derived from the merged backer list rather than tracked separately, so
843+
// "what the node prints" and "what zooming into that node lists" cannot
844+
// disagree — they are now the same numbers read twice.
845+
a.morUsd = a.backers.reduce((s, b) => s + (b.kind === "mor" ? b.amount : 0), 0);
846+
a.total = a.vaultTvl + a.morUsd;
820847
}
821848

822849
const distinct = new Set<string>();
823850
athletes.forEach((a) => a.backers.forEach((b) => distinct.add(b.address.toLowerCase())));
824851

825852
const gnarsAccrued = athletes.reduce((s, a) => s + a.feeAccrued, 0) / 2; // vault fee, USDC≈USD
826853
const gm = await gnarsMorEarned(mor);
827-
// Total staked backing riders = Morpho vault TVL + Morpheus (MOR) deposits, USD.
828-
const morTvl = athletes.reduce(
829-
(s, a) => s + a.backers.reduce((x, b) => x + (b.kind === "mor" ? b.amount : 0), 0),
830-
0,
831-
);
832854
const graph: StakeGraph = {
833855
athletes,
834-
total: athletes.reduce((s, a) => s + a.total, 0) + morTvl,
856+
// Plain sum now that `a.total` carries each rider's MOR. The headline used
857+
// to add `morTvl` on top of vault-only per-rider totals, which is exactly
858+
// what let it disagree with the nodes printed underneath it.
859+
total: athletes.reduce((s, a) => s + a.total, 0),
835860
backerCount: distinct.size,
836861
backersResolved,
837862
gnarsAccrued,
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "vitest";
2+
import { matchLedgerCredit, SUBNET_FINAL_SPLIT, SWAP_FEE_SPLIT } from "./treasury-inflows";
3+
4+
/**
5+
* Real shape, from the credit that funded the 2026-08-19 withdrawal: warehouse
6+
* ERC-6909 `Transfer`, topic1 = the split that paid, data = abi.encode(caller,
7+
* amount). That withdrawal spent weeks tagged generic "Splits" because the
8+
* classifier only ever read the withdrawal's own receipt, which names no split.
9+
*/
10+
const topic = (a: string) => `0x${a.replace(/^0x/, "").padStart(64, "0")}`;
11+
const credit = (split: string, raw: bigint) => ({
12+
topics: [
13+
"0x1b3d7edb2e9c0b0e7c525b20aaaef0f5940d2ed71663c7d39266ecafac728859",
14+
topic(split),
15+
topic("0x72ad986ebac0246d2b3c565ab2a1ce3a14ce6f88"),
16+
topic("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"),
17+
],
18+
data: `0x${topic(split).slice(2)}${raw.toString(16).padStart(64, "0")}`,
19+
});
20+
21+
// 7.843441 USDC — the exact amount credited by the subnet split at block
22+
// 50182875 and withdrawn ten blocks later.
23+
const SUBNET_AMOUNT = 7_843_441n;
24+
25+
describe("matchLedgerCredit", () => {
26+
it("attributes a bare withdraw to the split that credited the same amount", () => {
27+
expect(matchLedgerCredit([credit(SUBNET_FINAL_SPLIT, SUBNET_AMOUNT)], SUBNET_AMOUNT)).toBe(
28+
"subnet",
29+
);
30+
});
31+
32+
it("picks the matching credit out of a window holding several", () => {
33+
const logs = [
34+
credit(SWAP_FEE_SPLIT, 86_904n),
35+
credit(SUBNET_FINAL_SPLIT, SUBNET_AMOUNT),
36+
credit(SUBNET_FINAL_SPLIT, 18_268_694n),
37+
];
38+
expect(matchLedgerCredit(logs, 86_904n)).toBe("swap");
39+
expect(matchLedgerCredit(logs, SUBNET_AMOUNT)).toBe("subnet");
40+
});
41+
42+
it("asserts nothing when no credit matches the amount", () => {
43+
expect(matchLedgerCredit([credit(SUBNET_FINAL_SPLIT, 1n)], SUBNET_AMOUNT)).toBeNull();
44+
});
45+
46+
it("asserts nothing when two products credited the identical amount", () => {
47+
const logs = [credit(SUBNET_FINAL_SPLIT, SUBNET_AMOUNT), credit(SWAP_FEE_SPLIT, SUBNET_AMOUNT)];
48+
expect(matchLedgerCredit(logs, SUBNET_AMOUNT)).toBeNull();
49+
});
50+
51+
it("ignores an unmapped split even on an exact amount match", () => {
52+
const logs = [
53+
credit("0xd9b2000000000000000000000000000000000ad6b".slice(0, 42), SUBNET_AMOUNT),
54+
];
55+
expect(matchLedgerCredit(logs, SUBNET_AMOUNT)).toBeNull();
56+
});
57+
58+
it("survives a truncated or malformed log without throwing", () => {
59+
expect(matchLedgerCredit([{ topics: [], data: "0x" }], SUBNET_AMOUNT)).toBeNull();
60+
});
61+
62+
it("treats the same amount from two credits of ONE product as that product", () => {
63+
const logs = [
64+
credit(SUBNET_FINAL_SPLIT, SUBNET_AMOUNT),
65+
credit(SUBNET_FINAL_SPLIT, SUBNET_AMOUNT),
66+
];
67+
expect(matchLedgerCredit(logs, SUBNET_AMOUNT)).toBe("subnet");
68+
});
69+
});

0 commit comments

Comments
 (0)