Skip to content

Commit c31a18d

Browse files
authored
Merge pull request #69 from r4topunk/claude/analyze-test-coverage-4UfDh
test: add Vitest + unit tests for proposal encoding, funding, middleware
2 parents 1d85f72 + 4b48e40 commit c31a18d

7 files changed

Lines changed: 1602 additions & 58 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
lint-format-test:
11+
name: Lint, format, unit tests
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: pnpm/action-setup@v4
17+
with:
18+
version: 10
19+
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
cache: pnpm
24+
25+
- name: Install dependencies
26+
run: pnpm install --frozen-lockfile
27+
28+
- name: Lint
29+
run: pnpm lint
30+
31+
- name: Unit tests
32+
run: pnpm test

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"lint": "eslint",
1010
"format": "prettier --write .",
1111
"format:check": "prettier --check .",
12+
"test": "vitest run",
13+
"test:watch": "vitest",
14+
"test:coverage": "vitest run --coverage",
1215
"proposal:funding:poc": "tsx scripts/proposal-funding-poc.ts"
1316
},
1417
"dependencies": {
@@ -100,6 +103,7 @@
100103
"@types/react": "^19",
101104
"@types/react-dom": "^19.1.9",
102105
"@types/three": "^0.182.0",
106+
"@vitest/coverage-v8": "^4.1.5",
103107
"eslint": "^9",
104108
"eslint-config-next": "15.5.7",
105109
"eslint-config-prettier": "^10.1.8",
@@ -108,6 +112,7 @@
108112
"tailwindcss": "^4",
109113
"tsx": "^4.21.0",
110114
"tw-animate-css": "^1.3.7",
111-
"typescript": "^5"
115+
"typescript": "^5",
116+
"vitest": "^4.1.5"
112117
}
113118
}

pnpm-lock.yaml

Lines changed: 902 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/proposal-funding.test.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { encodeFunctionData, parseEther } from "viem";
2+
import { describe, expect, it } from "vitest";
3+
import { TREASURY_TOKEN_ALLOWLIST } from "./config";
4+
import { getProposalFundingTotals, getProposalRequestedUsdTotal } from "./proposal-funding";
5+
6+
const USDC_BASE = TREASURY_TOKEN_ALLOWLIST.USDC;
7+
const USDC_ETH_MAINNET = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
8+
const SOME_CONTRACT = "0x1234567890123456789012345678901234567890";
9+
const RECIPIENT = "0x000000000000000000000000000000000000dEaD" as const;
10+
11+
const ERC20_TRANSFER_ABI = [
12+
{
13+
type: "function",
14+
name: "transfer",
15+
stateMutability: "nonpayable",
16+
inputs: [
17+
{ name: "to", type: "address" },
18+
{ name: "amount", type: "uint256" },
19+
],
20+
outputs: [{ name: "", type: "bool" }],
21+
},
22+
] as const;
23+
24+
function encodeUsdcTransfer(to: `0x${string}`, amount: bigint): `0x${string}` {
25+
return encodeFunctionData({
26+
abi: ERC20_TRANSFER_ABI,
27+
functionName: "transfer",
28+
args: [to, amount],
29+
});
30+
}
31+
32+
describe("getProposalFundingTotals", () => {
33+
it("returns zeros for an empty proposal", () => {
34+
const totals = getProposalFundingTotals({
35+
targets: [],
36+
values: [],
37+
calldatas: [],
38+
});
39+
40+
expect(totals.totalEthWei).toBe(0n);
41+
expect(totals.totalEth).toBe(0);
42+
expect(totals.totalUsdcRaw).toBe(0n);
43+
expect(totals.totalUsdc).toBe(0);
44+
});
45+
46+
it("sums raw ETH transfers across multiple transactions", () => {
47+
const totals = getProposalFundingTotals({
48+
targets: [RECIPIENT, RECIPIENT],
49+
values: [parseEther("1"), parseEther("2.5")],
50+
calldatas: ["0x", "0x"],
51+
});
52+
53+
expect(totals.totalEthWei).toBe(parseEther("3.5"));
54+
expect(totals.totalEth).toBeCloseTo(3.5, 10);
55+
});
56+
57+
it("accepts numeric string, number, and bigint value inputs", () => {
58+
const totals = getProposalFundingTotals({
59+
targets: [RECIPIENT, RECIPIENT, RECIPIENT],
60+
values: ["1000000000000000000", 2, 3n],
61+
calldatas: ["0x", "0x", "0x"],
62+
});
63+
64+
// 1e18 wei (string) + 2 wei (number) + 3 wei (bigint)
65+
expect(totals.totalEthWei).toBe(parseEther("1") + 5n);
66+
});
67+
68+
it("accepts hex-encoded value strings", () => {
69+
const totals = getProposalFundingTotals({
70+
targets: [RECIPIENT],
71+
values: ["0xde0b6b3a7640000"], // 1 ETH
72+
calldatas: ["0x"],
73+
});
74+
75+
expect(totals.totalEthWei).toBe(parseEther("1"));
76+
});
77+
78+
it("decodes USDC transfers on Base into totalUsdc with 6 decimals", () => {
79+
const calldata = encodeUsdcTransfer(RECIPIENT, 100_000_000n); // 100 USDC
80+
81+
const totals = getProposalFundingTotals({
82+
targets: [USDC_BASE],
83+
values: [0n],
84+
calldatas: [calldata],
85+
});
86+
87+
expect(totals.totalUsdcRaw).toBe(100_000_000n);
88+
expect(totals.totalUsdc).toBeCloseTo(100, 10);
89+
});
90+
91+
it("decodes USDC transfers on Ethereum mainnet too (legacy subgraph)", () => {
92+
const calldata = encodeUsdcTransfer(RECIPIENT, 42_000_000n); // 42 USDC
93+
94+
const totals = getProposalFundingTotals({
95+
targets: [USDC_ETH_MAINNET],
96+
values: [0n],
97+
calldatas: [calldata],
98+
});
99+
100+
expect(totals.totalUsdcRaw).toBe(42_000_000n);
101+
expect(totals.totalUsdc).toBeCloseTo(42, 10);
102+
});
103+
104+
it("matches USDC target regardless of address casing", () => {
105+
const calldata = encodeUsdcTransfer(RECIPIENT, 10_000_000n);
106+
107+
const totals = getProposalFundingTotals({
108+
targets: [USDC_BASE.toUpperCase()],
109+
values: [0n],
110+
calldatas: [calldata],
111+
});
112+
113+
expect(totals.totalUsdcRaw).toBe(10_000_000n);
114+
});
115+
116+
it("reconstructs calldatas that are missing the transfer selector (Nouns subgraph shape)", () => {
117+
// Normal ABI-encoded transfer(to, amount), minus the 4-byte selector.
118+
// First 32 bytes = recipient (left-padded), next 32 bytes = amount.
119+
const recipientPadded = RECIPIENT.slice(2).toLowerCase().padStart(64, "0");
120+
const amountPadded = 50_000_000n.toString(16).padStart(64, "0");
121+
const selectorless = `0x${recipientPadded}${amountPadded}` as const;
122+
123+
const totals = getProposalFundingTotals({
124+
targets: [USDC_BASE],
125+
values: [0n],
126+
calldatas: [selectorless],
127+
});
128+
129+
expect(totals.totalUsdcRaw).toBe(50_000_000n);
130+
});
131+
132+
it("ignores non-USDC ERC20 transfers", () => {
133+
const calldata = encodeUsdcTransfer(RECIPIENT, 999_000_000n);
134+
135+
const totals = getProposalFundingTotals({
136+
targets: [SOME_CONTRACT],
137+
values: [0n],
138+
calldatas: [calldata],
139+
});
140+
141+
expect(totals.totalUsdcRaw).toBe(0n);
142+
});
143+
144+
it("ignores calldata that does not start with the transfer selector", () => {
145+
const totals = getProposalFundingTotals({
146+
targets: [USDC_BASE],
147+
values: [0n],
148+
calldatas: ["0xdeadbeef00000000000000000000000000000000"],
149+
});
150+
151+
expect(totals.totalUsdcRaw).toBe(0n);
152+
});
153+
154+
it("does not throw on malformed calldata; treats it as zero USDC", () => {
155+
const totals = getProposalFundingTotals({
156+
targets: [USDC_BASE],
157+
values: [0n],
158+
calldatas: ["0xa9059cbbdeadbeef"], // selector is right but args are truncated
159+
});
160+
161+
expect(totals.totalUsdcRaw).toBe(0n);
162+
});
163+
164+
it("handles mixed ETH + USDC in a single proposal", () => {
165+
const usdcCalldata = encodeUsdcTransfer(RECIPIENT, 25_000_000n);
166+
167+
const totals = getProposalFundingTotals({
168+
targets: [RECIPIENT, USDC_BASE, RECIPIENT],
169+
values: [parseEther("0.5"), 0n, parseEther("1")],
170+
calldatas: ["0x", usdcCalldata, "0x"],
171+
});
172+
173+
expect(totals.totalEthWei).toBe(parseEther("1.5"));
174+
expect(totals.totalUsdcRaw).toBe(25_000_000n);
175+
expect(totals.totalUsdc).toBeCloseTo(25, 10);
176+
});
177+
});
178+
179+
describe("getProposalRequestedUsdTotal", () => {
180+
const base = {
181+
totalEthWei: parseEther("2"),
182+
totalEth: 2,
183+
totalUsdcRaw: 100_000_000n,
184+
totalUsdc: 100,
185+
};
186+
187+
it("sums USDC plus ETH converted at the given price", () => {
188+
expect(getProposalRequestedUsdTotal(base, 3000)).toBeCloseTo(6100, 6);
189+
});
190+
191+
it("treats a non-positive ETH price as zero ETH contribution", () => {
192+
expect(getProposalRequestedUsdTotal(base, 0)).toBe(100);
193+
expect(getProposalRequestedUsdTotal(base, -500)).toBe(100);
194+
});
195+
196+
it("treats a non-finite ETH price as zero ETH contribution", () => {
197+
expect(getProposalRequestedUsdTotal(base, Number.NaN)).toBe(100);
198+
expect(getProposalRequestedUsdTotal(base, Number.POSITIVE_INFINITY)).toBe(100);
199+
});
200+
});

0 commit comments

Comments
 (0)