Skip to content

Commit df29078

Browse files
sktbrdclaude
andauthored
feat(treasury): latest ETH and USDC inflows panel (#251)
Adds a card to /treasury listing the most recent value arriving at the DAO treasury — amount, asset, sender, age and a link to the transaction. The category set is the whole trick. Reaching for `external` + `erc20`, which is the obvious pair, reports no ETH income since February 2025 and misses the DAO's primary revenue: auction settlement is a contract-to-contract move from the auction house, so it only appears under `internal`. Those rows are badged as auction income, since "someone sent us money" and "the DAO earned it" are different facts. WETH is included alongside ETH and USDC and labelled as itself — it is the treasury's most frequent recent inflow, and dropping it would leave the panel looking emptier than the treasury actually is. Fetches Alchemy directly on the server rather than routing through our own /api/alchemy proxy: the treasury service already notes that pattern made the server take an HTTP round trip to itself. Failures degrade to an empty panel rather than taking the route down. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4a809f2 commit df29078

5 files changed

Lines changed: 252 additions & 1 deletion

File tree

messages/en/treasury.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,12 @@
6666
"errorDescription": "Unable to load member activity data",
6767
"loadFailed": "Failed to load data"
6868
}
69+
},
70+
"inflows": {
71+
"title": "Latest inflows",
72+
"empty": "No recent inflows.",
73+
"auction": "Auction",
74+
"auctionHint": "Arrived from the auction house as an internal transfer",
75+
"viewTx": "View transaction on Basescan"
6976
}
7077
}

messages/pt-br/treasury.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,12 @@
6666
"errorDescription": "Não foi possível carregar dados de atividade dos membros",
6767
"loadFailed": "Falha ao carregar dados"
6868
}
69+
},
70+
"inflows": {
71+
"title": "Últimas entradas",
72+
"empty": "Nenhuma entrada recente.",
73+
"auction": "Leilão",
74+
"auctionHint": "Chegou do contrato de leilão como transferência interna",
75+
"viewTx": "Ver transação no Basescan"
6976
}
7077
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
TableSkeleton,
99
} from "@/components/skeletons/treasury-skeletons";
1010
import { NftHoldings } from "@/components/treasury/NftHoldings";
11+
import { SponsorshipYield } from "@/components/treasury/SponsorshipYield";
1112
import { TokenHoldings } from "@/components/treasury/TokenHoldings";
1213
import { TreasuryBalance } from "@/components/treasury/TreasuryBalance";
13-
import { SponsorshipYield } from "@/components/treasury/SponsorshipYield";
14+
import { TreasuryInflows } from "@/components/treasury/TreasuryInflows";
1415
import { ZoraCoinHoldings } from "@/components/treasury/ZoraCoinHoldings";
1516
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1617
import { DAO_ADDRESSES } from "@/lib/config";
@@ -129,6 +130,12 @@ export default async function TreasuryPage({ params }: { params: Promise<{ local
129130
<SponsorshipYield />
130131
</div>
131132

133+
{/* What has actually come in lately. Sits directly under the balances:
134+
the KPIs say how much there is, this says where it came from. */}
135+
<Suspense fallback={<TableSkeleton />}>
136+
<TreasuryInflows locale={locale} />
137+
</Suspense>
138+
132139
{/* Charts */}
133140
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
134141
<div className="lg:col-span-1 min-w-0">
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { getTranslations } from "next-intl/server";
2+
import { ArrowDownLeft, ExternalLink, Gavel } from "lucide-react";
3+
import { AddressDisplay } from "@/components/ui/address-display";
4+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5+
import { loadTreasuryInflows, type InflowAsset } from "@/services/treasury-inflows";
6+
7+
/** Per-asset accent. Deliberately not the semantic tokens — these identify a currency. */
8+
const ASSET_TONE: Record<InflowAsset, string> = {
9+
ETH: "text-[#627eea]",
10+
WETH: "text-[#627eea]",
11+
USDC: "text-[#2775ca]",
12+
};
13+
14+
/**
15+
* ETH is worth ~4 decimals; USDC is a dollar figure and reads wrong with more
16+
* than two. `maximumFractionDigits` alone would print `0` for auction dust, so
17+
* very small ETH amounts keep enough significant digits to stay non-zero.
18+
*/
19+
function formatAmount(amount: number, asset: InflowAsset, locale: string): string {
20+
if (asset === "USDC") {
21+
return amount.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
22+
}
23+
if (amount > 0 && amount < 0.0001) {
24+
return amount.toLocaleString(locale, { maximumSignificantDigits: 2 });
25+
}
26+
return amount.toLocaleString(locale, { maximumFractionDigits: 4 });
27+
}
28+
29+
function ageLabel(at: string, now: number): string {
30+
const ms = now - new Date(at).getTime();
31+
if (!Number.isFinite(ms) || ms < 0) return "";
32+
const mins = Math.floor(ms / 60_000);
33+
if (mins < 60) return `${Math.max(1, mins)}m`;
34+
const hours = Math.floor(mins / 60);
35+
if (hours < 24) return `${hours}h`;
36+
const days = Math.floor(hours / 24);
37+
if (days < 30) return `${days}d`;
38+
return `${Math.floor(days / 30)}mo`;
39+
}
40+
41+
/**
42+
* The treasury's most recent income, newest first.
43+
*
44+
* Auction settlements arrive as internal transfers and are marked as such — it
45+
* is the difference between "someone sent us money" and "the DAO earned it".
46+
*/
47+
export async function TreasuryInflows({ locale }: { locale: string }) {
48+
const t = await getTranslations("treasury.inflows");
49+
const inflows = await loadTreasuryInflows(8);
50+
// Rendered once per request on the server; the timestamp IS the snapshot.
51+
// eslint-disable-next-line react-hooks/purity -- server component, render-time clock read for relative ages
52+
const now = Date.now();
53+
54+
return (
55+
<Card className="gap-2">
56+
<CardHeader>
57+
<CardTitle className="flex items-center gap-2 text-sm font-medium">
58+
<ArrowDownLeft className="size-4 text-emerald-500" />
59+
{t("title")}
60+
</CardTitle>
61+
</CardHeader>
62+
<CardContent>
63+
{inflows.length === 0 ? (
64+
<p className="py-6 text-center text-sm text-muted-foreground">{t("empty")}</p>
65+
) : (
66+
<ul className="divide-y divide-border">
67+
{inflows.map((flow) => (
68+
<li key={flow.hash} className="flex items-center gap-3 py-2.5">
69+
<div className="flex min-w-0 flex-1 items-center gap-2">
70+
<AddressDisplay
71+
address={flow.from}
72+
variant="compact"
73+
showCopy={false}
74+
showExplorer={false}
75+
truncateLength={4}
76+
/>
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}
86+
</div>
87+
88+
<span className="shrink-0 whitespace-nowrap font-mono text-sm font-semibold tabular-nums">
89+
+{formatAmount(flow.amount, flow.asset, locale)}{" "}
90+
<span className={ASSET_TONE[flow.asset]}>{flow.asset}</span>
91+
</span>
92+
93+
<span className="w-9 shrink-0 text-right font-mono text-[11px] text-muted-foreground">
94+
{ageLabel(flow.at, now)}
95+
</span>
96+
97+
<a
98+
href={`https://basescan.org/tx/${flow.hash}`}
99+
target="_blank"
100+
rel="noopener noreferrer"
101+
aria-label={t("viewTx")}
102+
className="shrink-0 text-muted-foreground hover:text-foreground"
103+
>
104+
<ExternalLink className="size-3.5" />
105+
</a>
106+
</li>
107+
))}
108+
</ul>
109+
)}
110+
</CardContent>
111+
</Card>
112+
);
113+
}

src/services/treasury-inflows.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { cache } from "react";
2+
import { DAO_ADDRESSES, TREASURY_TOKEN_ALLOWLIST } from "@/lib/config";
3+
4+
/**
5+
* Recent value arriving at the treasury.
6+
*
7+
* Three transfer categories are needed, not the two you would reach for:
8+
*
9+
* - `erc20` — USDC and WETH.
10+
* - `external` — someone sending ETH straight from a wallet. Rare; the last
11+
* one landed in February 2025.
12+
* - `internal` — **where auction proceeds actually arrive.** Settlement is a
13+
* contract-to-contract move from the auction house, which is
14+
* invisible to `external`. Querying only `external` + `erc20`
15+
* reports no ETH income for over a year and misses the DAO's
16+
* primary revenue entirely.
17+
*/
18+
19+
const USDC = TREASURY_TOKEN_ALLOWLIST.USDC.toLowerCase();
20+
const WETH = TREASURY_TOKEN_ALLOWLIST.WETH.toLowerCase();
21+
22+
/** Assets this panel reports. Everything else the treasury receives is noise here. */
23+
const TRACKED = new Set([USDC, WETH]);
24+
25+
export type InflowAsset = "ETH" | "WETH" | "USDC";
26+
27+
export interface TreasuryInflow {
28+
/** Transaction hash — unique per row for keying and for the explorer link. */
29+
hash: string;
30+
asset: InflowAsset;
31+
/** Human units, already scaled by the asset's decimals. */
32+
amount: number;
33+
from: string;
34+
/** ISO timestamp of the containing block. */
35+
at: string;
36+
/** True when the value arrived contract-to-contract (auction settlement). */
37+
internal: boolean;
38+
}
39+
40+
interface AlchemyTransfer {
41+
hash?: string;
42+
from?: string;
43+
value?: number | null;
44+
asset?: string | null;
45+
category?: string;
46+
rawContract?: { address?: string | null };
47+
metadata?: { blockTimestamp?: string };
48+
}
49+
50+
const ALCHEMY_KEY = process.env.ALCHEMY_API_KEY;
51+
52+
/**
53+
* Newest inflows first.
54+
*
55+
* Returns `[]` rather than throwing on any failure: this renders inside the
56+
* treasury page, and a Goldsky/Alchemy hiccup should cost one panel, not the
57+
* whole route.
58+
*/
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+
});

0 commit comments

Comments
 (0)