Skip to content

Commit 8653e55

Browse files
sktbrdclaude
andcommitted
feat(treasury): tag inflows by where the money came from
The inflows panel could only say "internal = auction". Everything else — three quarters of the rows — arrived unlabelled, so the page showed income without saying what earned it. Adds a source per row (auction / subnet / splits / transfer) plus summary tiles for the sources actually present in the window. The subnet rule is the part worth reading. Splits do not pay recipients directly: `distribute()` credits the 0xSplits SplitsWarehouse singleton and each recipient withdraws from it, so split income reaches the treasury `from` the warehouse and never from the split. Traced and reconciled on-chain: the Gnars Subnet Final Split has distributed $291.02, the treasury is a 20% recipient, and $58.29 of USDC arrived from the warehouse — the nine cents of drift being yield between distribution and withdrawal. Known limit, and it is deliberate rather than overlooked. The warehouse is a shared singleton, so today "warehouse USDC == subnet" holds only because the subnet split is the only 0xSplits paying this treasury. The first time a rider sponsorship split is distributed, its payout leaves the same address and would be reported as Morpheus earnings. `splitsAmbiguous` and the `splits` tag are in place for that day: flipping one flag degrades those rows to an honest "splits" instead of claiming the wrong origin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a8acc79 commit 8653e55

4 files changed

Lines changed: 178 additions & 73 deletions

File tree

messages/en/treasury.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"kpis": {
66
"totalValue": "Total Treasury Value",
77
"ethBalance": "ETH Balance",
8-
"totalAuctions": "Total Auction Sales"
8+
"totalAuctions": "Total Auction Sales",
9+
"subnetEarnings": "Subnet Earnings",
10+
"subnetEarningsNote": "Morpheus · treasury's 20% share"
911
},
1012
"nftSection": {
1113
"title": "NFT Holdings",
@@ -72,6 +74,11 @@
7274
"empty": "No recent inflows.",
7375
"auction": "Auction",
7476
"auctionHint": "Arrived from the auction house as an internal transfer",
75-
"viewTx": "View transaction on Basescan"
77+
"viewTx": "View transaction on Basescan",
78+
"sources": "By source",
79+
"subnet": "Subnet",
80+
"splits": "Splits",
81+
"transfer": "Transfer",
82+
"entries": "entries"
7683
}
7784
}

messages/pt-br/treasury.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"kpis": {
66
"totalValue": "Valor Total do Tesouro",
77
"ethBalance": "Saldo de ETH",
8-
"totalAuctions": "Total de Vendas em Leilão"
8+
"totalAuctions": "Total de Vendas em Leilão",
9+
"subnetEarnings": "Ganhos da subnet",
10+
"subnetEarningsNote": "Morpheus · fatia de 20% do tesouro"
911
},
1012
"nftSection": {
1113
"title": "Holdings de NFT",
@@ -72,6 +74,11 @@
7274
"empty": "Nenhuma entrada recente.",
7375
"auction": "Leilão",
7476
"auctionHint": "Chegou do contrato de leilão como transferência interna",
75-
"viewTx": "Ver transação no Basescan"
77+
"viewTx": "Ver transação no Basescan",
78+
"sources": "Por fonte",
79+
"subnet": "Subnet",
80+
"splits": "Splits",
81+
"transfer": "Transferência",
82+
"entries": "entradas"
7683
}
7784
}

src/components/treasury/TreasuryInflows.tsx

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,23 @@ import { getTranslations } from "next-intl/server";
22
import { ArrowDownLeft, ExternalLink, Gavel } from "lucide-react";
33
import { AddressDisplay } from "@/components/ui/address-display";
44
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5-
import { loadTreasuryInflows, type InflowAsset } from "@/services/treasury-inflows";
5+
import {
6+
loadTreasuryInflows,
7+
type InflowAsset,
8+
type InflowSource,
9+
} from "@/services/treasury-inflows";
10+
11+
/**
12+
* Source accents. Not semantic tokens on purpose — like the asset colours below,
13+
* these identify a kind of income, and the point is telling them apart at a
14+
* glance rather than following the theme's foreground.
15+
*/
16+
const SOURCE_TONE: Record<InflowSource, string> = {
17+
auction: "border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-400",
18+
subnet: "border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
19+
splits: "border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
20+
transfer: "border-border bg-muted text-muted-foreground",
21+
};
622

723
/** Per-asset accent. Deliberately not the semantic tokens — these identify a currency. */
824
const ASSET_TONE: Record<InflowAsset, string> = {
@@ -60,6 +76,30 @@ export async function TreasuryInflows({ locale }: { locale: string }) {
6076
</CardTitle>
6177
</CardHeader>
6278
<CardContent>
79+
{inflows.length > 0 ? (
80+
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
81+
{(["auction", "subnet", "splits", "transfer"] as const)
82+
.map((src) => ({
83+
src,
84+
rows: inflows.filter((f) => f.source === src),
85+
}))
86+
.filter(({ rows }) => rows.length > 0)
87+
.map(({ src, rows }) => (
88+
<div key={src} className="rounded-lg border border-border bg-muted/40 p-2.5">
89+
<div
90+
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-semibold ${SOURCE_TONE[src]}`}
91+
>
92+
{t(src)}
93+
</div>
94+
<div className="mt-1.5 font-mono text-sm font-semibold tabular-nums">
95+
{rows.length}
96+
</div>
97+
<div className="text-[11px] text-muted-foreground">{t("entries")}</div>
98+
</div>
99+
))}
100+
</div>
101+
) : null}
102+
63103
{inflows.length === 0 ? (
64104
<p className="py-6 text-center text-sm text-muted-foreground">{t("empty")}</p>
65105
) : (
@@ -74,15 +114,16 @@ export async function TreasuryInflows({ locale }: { locale: string }) {
74114
showExplorer={false}
75115
truncateLength={4}
76116
/>
77-
{flow.internal ? (
78-
<span
79-
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
80-
title={t("auctionHint")}
81-
>
82-
<Gavel className="size-3" />
83-
{t("auction")}
84-
</span>
85-
) : null}
117+
{/* Every row carries its origin now, not just auctions. The
118+
binary "internal = auction" badge could not say where the
119+
other three quarters of the income came from. */}
120+
<span
121+
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${SOURCE_TONE[flow.source]}`}
122+
title={flow.source === "auction" ? t("auctionHint") : undefined}
123+
>
124+
{flow.source === "auction" ? <Gavel className="size-3" /> : null}
125+
{t(flow.source)}
126+
</span>
86127
</div>
87128

88129
<span className="shrink-0 whitespace-nowrap font-mono text-sm font-semibold tabular-nums">

src/services/treasury-inflows.ts

Lines changed: 109 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ import { DAO_ADDRESSES, TREASURY_TOKEN_ALLOWLIST } from "@/lib/config";
1919
const USDC = TREASURY_TOKEN_ALLOWLIST.USDC.toLowerCase();
2020
const WETH = TREASURY_TOKEN_ALLOWLIST.WETH.toLowerCase();
2121

22+
/**
23+
* 0xSplits V2 SplitsWarehouse on Base — a shared singleton.
24+
*
25+
* Splits do not pay recipients directly: `distribute()` credits the warehouse,
26+
* and each recipient withdraws from it. So treasury income that originated in a
27+
* split arrives `from` THIS address, never from the split itself. Verified: the
28+
* Gnars Subnet Final Split has distributed $291.02, the treasury is a 20%
29+
* recipient, and exactly $58.29 of USDC reached the treasury from here — the
30+
* nine cents of drift being yield between distribution and withdrawal.
31+
*/
32+
const SPLITS_WAREHOUSE = "0x8fb66f38cf86a3d5e8768f8f1754a24a6c661fb8";
33+
34+
/** The split that pays the treasury its 20% of Morpheus subnet earnings. */
35+
export const SUBNET_FINAL_SPLIT = "0xcc7e971fb6828e45c01e168849447e460fdf3a4e";
36+
37+
/**
38+
* Where a credit came from. Kept deliberately coarse — these are the four
39+
* things the DAO actually earns from, not a transaction taxonomy.
40+
*/
41+
export type InflowSource = "auction" | "subnet" | "splits" | "transfer";
42+
2243
/** Assets this panel reports. Everything else the treasury receives is noise here. */
2344
const TRACKED = new Set([USDC, WETH]);
2445

@@ -35,6 +56,8 @@ export interface TreasuryInflow {
3556
at: string;
3657
/** True when the value arrived contract-to-contract (auction settlement). */
3758
internal: boolean;
59+
/** Where it came from, for the source tiles and the tag on each row. */
60+
source: InflowSource;
3861
}
3962

4063
interface AlchemyTransfer {
@@ -56,62 +79,89 @@ const ALCHEMY_KEY = process.env.ALCHEMY_API_KEY;
5679
* treasury page, and a Goldsky/Alchemy hiccup should cost one panel, not the
5780
* whole route.
5881
*/
59-
export const loadTreasuryInflows = cache(async (limit = 8): Promise<TreasuryInflow[]> => {
60-
if (!ALCHEMY_KEY) return [];
61-
62-
try {
63-
const res = await fetch(`https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}`, {
64-
method: "POST",
65-
headers: { "Content-Type": "application/json" },
66-
body: JSON.stringify({
67-
jsonrpc: "2.0",
68-
id: 1,
69-
method: "alchemy_getAssetTransfers",
70-
params: [
71-
{
72-
fromBlock: "0x0",
73-
toBlock: "latest",
74-
toAddress: DAO_ADDRESSES.treasury,
75-
category: ["external", "internal", "erc20"],
76-
withMetadata: true,
77-
excludeZeroValue: true,
78-
// Over-fetch: the window is filtered down to three assets, and a
79-
// burst of one token would otherwise crowd out everything else.
80-
maxCount: "0x32",
81-
order: "desc",
82-
},
83-
],
84-
}),
85-
next: { revalidate: 300 },
86-
});
87-
if (!res.ok) return [];
88-
89-
const json = (await res.json()) as { result?: { transfers?: AlchemyTransfer[] } };
90-
91-
return (json.result?.transfers ?? [])
92-
.flatMap<TreasuryInflow>((t) => {
93-
const amount = typeof t.value === "number" ? t.value : 0;
94-
if (!t.hash || !t.from || amount <= 0) return [];
95-
96-
const contract = t.rawContract?.address?.toLowerCase();
97-
const isNative = t.category === "external" || t.category === "internal";
98-
if (!isNative && (!contract || !TRACKED.has(contract))) return [];
99-
100-
const asset: InflowAsset = isNative ? "ETH" : contract === USDC ? "USDC" : "WETH";
101-
102-
return [
103-
{
104-
hash: t.hash,
105-
asset,
106-
amount,
107-
from: t.from,
108-
at: t.metadata?.blockTimestamp ?? "",
109-
internal: t.category === "internal",
110-
},
111-
];
112-
})
113-
.slice(0, limit);
114-
} catch {
115-
return [];
116-
}
117-
});
82+
export const loadTreasuryInflows = cache(
83+
async (limit = 8, splitsAmbiguous = false): Promise<TreasuryInflow[]> => {
84+
if (!ALCHEMY_KEY) return [];
85+
86+
try {
87+
const res = await fetch(`https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}`, {
88+
method: "POST",
89+
headers: { "Content-Type": "application/json" },
90+
body: JSON.stringify({
91+
jsonrpc: "2.0",
92+
id: 1,
93+
method: "alchemy_getAssetTransfers",
94+
params: [
95+
{
96+
fromBlock: "0x0",
97+
toBlock: "latest",
98+
toAddress: DAO_ADDRESSES.treasury,
99+
category: ["external", "internal", "erc20"],
100+
withMetadata: true,
101+
excludeZeroValue: true,
102+
// Over-fetch: the window is filtered down to three assets, and a
103+
// burst of one token would otherwise crowd out everything else.
104+
maxCount: "0x32",
105+
order: "desc",
106+
},
107+
],
108+
}),
109+
next: { revalidate: 300 },
110+
});
111+
if (!res.ok) return [];
112+
113+
const json = (await res.json()) as { result?: { transfers?: AlchemyTransfer[] } };
114+
115+
return (json.result?.transfers ?? [])
116+
.flatMap<TreasuryInflow>((t) => {
117+
const amount = typeof t.value === "number" ? t.value : 0;
118+
if (!t.hash || !t.from || amount <= 0) return [];
119+
120+
const contract = t.rawContract?.address?.toLowerCase();
121+
const isNative = t.category === "external" || t.category === "internal";
122+
if (!isNative && (!contract || !TRACKED.has(contract))) return [];
123+
124+
const asset: InflowAsset = isNative ? "ETH" : contract === USDC ? "USDC" : "WETH";
125+
const from = t.from.toLowerCase();
126+
127+
// Auction settlement is a contract-to-contract ETH move from the auction
128+
// house — the DAO's primary revenue, and invisible to `external`.
129+
//
130+
// Anything arriving from the warehouse was distributed by a split the
131+
// treasury is a recipient of. Today the only one paying the treasury is
132+
// the subnet split, so warehouse USDC IS subnet income. That stops being
133+
// true the day a rider sponsorship split is first distributed: its
134+
// payout leaves the SAME warehouse address, and tagging it `subnet`
135+
// would report sponsorship yield as Morpheus earnings.
136+
//
137+
// `splitsAmbiguous` is resolved by the caller, which knows whether any
138+
// rider split has distributed yet. Until one has, warehouse credits are
139+
// unambiguously subnet; after that they degrade to the honest `splits`
140+
// rather than silently claiming the wrong origin.
141+
const source: InflowSource =
142+
from === DAO_ADDRESSES.auction.toLowerCase()
143+
? "auction"
144+
: from === SPLITS_WAREHOUSE
145+
? splitsAmbiguous
146+
? "splits"
147+
: "subnet"
148+
: "transfer";
149+
150+
return [
151+
{
152+
hash: t.hash,
153+
asset,
154+
amount,
155+
from: t.from,
156+
at: t.metadata?.blockTimestamp ?? "",
157+
internal: t.category === "internal",
158+
source,
159+
},
160+
];
161+
})
162+
.slice(0, limit);
163+
} catch {
164+
return [];
165+
}
166+
},
167+
);

0 commit comments

Comments
 (0)