Skip to content

Commit a3c0eae

Browse files
committed
Hardening before deploy: 5-point weight cap per epoch, freeze valve fix, two-step exit (leave), draw guards, keeper does the full cycle
1 parent f479cb1 commit a3c0eae

24 files changed

Lines changed: 1862 additions & 107 deletions

‎.github/workflows/contracts.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
name: contracts
22
on: [push, pull_request]
3+
permissions:
4+
contents: read
35
jobs:
46
test:
57
runs-on: ubuntu-latest
68
defaults:
79
run:
810
working-directory: contracts
911
steps:
10-
- uses: actions/checkout@v4
12+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
1113
with:
1214
submodules: recursive
13-
- uses: foundry-rs/foundry-toolchain@v1
15+
- uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1
1416
- run: forge test -vv

‎.github/workflows/keeper.yml‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,30 @@ on:
33
schedule:
44
- cron: "*/15 * * * *"
55
workflow_dispatch:
6+
permissions:
7+
contents: read
8+
# One run at a time: a late cron tick or a manual dispatch waits for the running one instead of racing it on the same
9+
# draw/advance (or nonce).
10+
concurrency:
11+
group: keeper
12+
cancel-in-progress: false
613
jobs:
714
tick:
815
if: ${{ vars.GRAPHFUND_ADDRESS != '' }}
916
runs-on: ubuntu-latest
17+
timeout-minutes: 10
1018
defaults:
1119
run:
1220
working-directory: ops/keeper
1321
steps:
14-
- uses: actions/checkout@v4
15-
- uses: actions/setup-node@v4
22+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
23+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
1624
with:
1725
node-version: 22
18-
- run: npm ci --no-audit --no-fund
26+
- run: npm ci --ignore-scripts --no-audit --no-fund
1927
- run: node keeper.mjs
2028
env:
2129
RPC_URL: ${{ secrets.RPC_URL }}
2230
GRAPHFUND_ADDRESS: ${{ vars.GRAPHFUND_ADDRESS }}
2331
KEEPER_PRIVATE_KEY: ${{ secrets.KEEPER_PRIVATE_KEY }}
32+
MIN_BALANCE_ETH: ${{ vars.MIN_BALANCE_ETH }}

‎README.md‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ small committee drawn at random decides how the money splits. Nobody ever votes
4343
1. **Enlist.** Lock at least 100,000 $GRAPHFUND. Your chance of being drawn is your share of everything locked.
4444
2. **Drawn.** The hash of a future block picks five Keepers for every fork. Nobody knows who in advance.
4545
3. **Vote.** Each Keeper proposes how the money below their fork should split. The **median** wins, so one bad vote
46-
moves nothing. Voting mints a soulbound Keeper badge.
46+
moves nothing, and no share moves more than **5 points per epoch**, so no single committee can hijack a fork.
47+
Voting mints a soulbound Keeper badge.
4748
4. **Flow.** Every six hours the epoch closes and the money climbs down the tree. Keepers who voted on a fork share 2%
4849
of what flowed through it.
4950

@@ -54,7 +55,7 @@ small committee drawn at random decides how the money splits. Nobody ever votes
5455
```mermaid
5556
flowchart LR
5657
D["draw()<br/>five Keepers per fork,<br/>weighted by stake"] --> V["vote()<br/>each Keeper sends a split<br/>and gets a soulbound badge"]
57-
V --> A["advance()<br/>median per fork, money moves down,<br/>2% to the voters, next draw requested"]
58+
V --> A["advance()<br/>median per fork (max 5 pts a move),<br/>money moves down, 2% to the voters,<br/>next draw requested"]
5859
A -- "6 hours later" --> D
5960
6061
classDef step fill:#f3ebdd,stroke:#2b59c3,stroke-width:2px,color:#2b59c3
@@ -83,7 +84,8 @@ escrow **straight into this contract**. Every claim is public on Etherscan. Anyo
8384

8485
<p align="center"><img src="docs/readme/genesis-tree.jpg" alt="The genesis tree: four branches and fourteen recipients" width="100%" /></p>
8586

86-
The starting weights, before any committee has voted. From the first epoch on, the committees decide.
87+
The starting weights, before any committee has voted. From the first epoch on, the committees decide, 5 points per
88+
epoch at most.
8789

8890
<table>
8991
<tr><th align="left">Branch</th><th align="left">Recipient</th><th align="right">Inside the branch</th><th align="right">Of every ETH¹</th></tr>
@@ -111,15 +113,16 @@ The starting weights, before any committee has voted. From the first epoch on, t
111113
> lives in [contracts/script/genesis.json](contracts/script/genesis.json).
112114
113115
**New projects can join.** Anyone can propose a project under a branch with a deposit of 1,000,000 $GRAPHFUND. It
114-
enters at 0% and only gets money if a committee gives it weight; the deposit comes back the first time it does.
116+
enters at 0%, gains at most 5 points per epoch, and only gets money if a committee gives it weight; the deposit comes
117+
back the first time it does.
115118

116119
<br />
117120

118121
## Who can do what
119122

120123
| | Can | Cannot |
121124
|---|---|---|
122-
| **Keepers** | Lock and unlock $GRAPHFUND, vote on the fork they were drawn for | Unlock while sitting on a committee |
125+
| **Keepers** | Lock $GRAPHFUND, vote on the fork they were drawn for, leave the draw and unlock | Unlock while sitting on a committee |
123126
| **Anyone** | Call `draw()` and `advance()`, send ETH, propose a project, trigger `payout` for a recipient | Choose who gets drawn |
124127
| **Owner** *(until renounced)* | Edit the tree and the starting weights, change parameters within fixed bounds, reject spam proposals | Touch anyone's stake, touch money owed to a project, choose who gets drawn |
125128

@@ -132,7 +135,7 @@ The owner renounces after **28 epochs (7 days)** with committees drawn and no em
132135
| Contract | What it does |
133136
|---|---|
134137
| [`GraphFund`](contracts/src/GraphFund.sol) | The tree, the epochs (`draw` → `vote` → `advance`), the payouts (ETH, or WETH to contracts that refuse ETH) and the proposals |
135-
| [`KeeperStaking`](contracts/src/KeeperStaking.sol) | Lock $GRAPHFUND (minimum 100,000) to enter the draw. Stakes freeze for about a minute while a draw is pending (never more than ~1 day), and stay locked while you sit on a committee |
138+
| [`KeeperStaking`](contracts/src/KeeperStaking.sol) | Lock $GRAPHFUND (minimum 100,000) to enter the draw. Stakes can't change while a draw is pending (minutes; if nobody draws, anyone can end it with `draw()`), and stay locked while you sit on a committee. To exit: `leave()` takes you out of every future draw, then `unstake` as soon as your seat ends |
136139
| [`KeeperBadge`](contracts/src/KeeperBadge.sol) | One soulbound token per epoch you voted in |
137140
| $GRAPHFUND | 1,000,000,000 fixed supply, launched on Stockereum |
138141

@@ -143,9 +146,11 @@ Addresses go here, and on the site, the moment they are deployed.
143146
## Honest limits
144147

145148
- ⚖️ **Stake buys seats.** Seats are drawn in proportion to what is locked: whoever locks most of the supply wins most
146-
seats. The median protects a fork's split only while no one holds three of its five seats. Every seat and every vote
147-
is public.
148-
- 🎲 **Randomness is a future blockhash.** The proposer of that block can bias it by withholding the block.
149+
seats. The median protects a fork's split only while no one holds three of its five seats, and even a captured
150+
committee moves each share at most 5 points per epoch. Every seat and every vote is public.
151+
- 🎲 **Randomness is a future blockhash.** Whoever builds that block (a role that can be bought for a single slot) can
152+
grind its hash and pick a committee. The 5-point cap is the answer: capturing one committee pays little, and honest
153+
committees undo it at the same speed. Verifiable randomness (e.g. a VRF) would close it for good.
149154
- 🔑 **The fee claim depends on the creator wallet.** Claims into the contract are public, but someone has to make them.
150155
- ⛽ **One transaction draws every committee.** Parameters are bounded (`committeeSize × maxNodes/2 ≤ 120`) so the worst
151156
case stays well under Ethereum's per-transaction gas cap.

‎contracts/src/GraphFund.sol‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {Median} from "./lib/Median.sol";
1414
/// @title Graph Funding
1515
/// @notice Snowmoon, chapter 17, on Ethereum. Fees flow down a tree of public goods. At every branch a
1616
/// committee drawn at random from $GRAPHFUND stakers sets the weights of the children.
17-
/// @dev Unaudited. Randomness comes from the blockhash of a future block, which that block's proposer can bias.
17+
/// @dev Unaudited. Randomness comes from the blockhash of a future block, which the builder of that block can grind;
18+
/// `maxShiftBps` caps how far one committee can move any weight per epoch.
1819
contract GraphFund is Ownable2Step, ReentrancyGuard {
1920
using SafeERC20 for IERC20;
2021

@@ -42,6 +43,7 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
4243
uint16 maxNodes;
4344
uint8 pruneAfter;
4445
uint256 proposalBond;
46+
uint16 maxShiftBps; // max change of a child's weight per epoch (10_000 = no cap)
4547
}
4648

4749
error BadNode();
@@ -66,6 +68,7 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
6668
error NotFunded();
6769
error BondSettled();
6870
error NotPrunable();
71+
error DrawPending();
6972

7073
event NodeAdded(uint32 indexed id, uint32 indexed parent, address recipient, address proposer, string name, bool pending);
7174
event NodeActivated(uint32 indexed id);
@@ -134,7 +137,8 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
134137
maxDepth: 4,
135138
maxNodes: 48,
136139
pruneAfter: 4,
137-
proposalBond: 1_000_000e18
140+
proposalBond: 1_000_000e18,
141+
maxShiftBps: 500
138142
});
139143
_nodes.push();
140144
Node storage root = _nodes[0];
@@ -154,10 +158,12 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
154158
// ================================================================ owner: tree & params
155159

156160
function addNode(uint32 parent, string calldata name, address recipient) external onlyOwner returns (uint32) {
161+
if (drawPending) revert DrawPending();
157162
return _create(parent, name, recipient, address(0), 0);
158163
}
159164

160165
function removeNode(uint32 id) external onlyOwner {
166+
if (drawPending) revert DrawPending();
161167
if (id == 0 || id >= _nodes.length) revert BadNode();
162168
Node storage n = _nodes[id];
163169
if (!(n.active || n.pending)) revert BadNode();
@@ -176,11 +182,12 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
176182
}
177183

178184
function setParams(Params calldata p) external onlyOwner {
185+
if (drawPending) revert DrawPending();
179186
if (
180187
p.epochLength < 10 minutes || p.epochLength > 7 days || p.committeeSize == 0 || p.committeeSize > 9
181188
|| p.voterPayBps > 1_000 || p.drawDelay == 0 || p.drawDelay > 64 || p.maxChildren < 2
182189
|| p.maxChildren > 16 || p.maxDepth == 0 || p.maxDepth > 6 || p.maxNodes < liveNodes
183-
|| p.maxNodes > 128 || p.pruneAfter == 0
190+
|| p.maxNodes > 128 || p.pruneAfter == 0 || p.maxShiftBps < 100 || p.maxShiftBps > 10_000
184191
) revert BadParams();
185192
if (uint256(p.epochLength) < uint256(p.drawDelay) * 12 + 1 hours) revert BadParams(); // voting window after the seed
186193
if (uint256(p.committeeSize) * (uint256(p.maxNodes) / 2) > 120) revert BadParams(); // keeps draw() far under the 16.78M tx gas cap
@@ -201,7 +208,10 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
201208
function draw() external {
202209
if (!drawPending) revert NoDrawPending();
203210
if (block.number <= seedBlock) revert TooEarly();
204-
if (block.number - seedBlock > 256) {
211+
// A stale seed, or one published after the stake freeze expired (stakes could move once it was known),
212+
// is never consumed: ask for a new one.
213+
if (block.number - seedBlock > 256 || block.number >= staking.drawPendingSince() + staking.FREEZE_MAX()) {
214+
staking.setDrawPending(false); // re-arms the freeze before a new seed exists
205215
_requestDraw();
206216
return;
207217
}
@@ -250,10 +260,12 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
250260
uint256 e = epoch;
251261
if (e == 0) revert NotStarted();
252262
if (block.timestamp < epochStart + _params.epochLength) revert TooEarly();
263+
uint256 raw = address(this).balance; // ETH that arrived without receive() (forced sends)
264+
if (raw > 0) weth.deposit{value: raw}();
253265
(uint32[] memory br, uint256 nb) = _activeBranches();
254266
for (uint256 b; b < nb; b++) _finalize(e, br[b]);
255267
uint256 distributed = _distribute(e);
256-
_updateStreaks(br, nb);
268+
_updateStreaks(e, br, nb);
257269
_activatePending();
258270
epoch = e + 1;
259271
epochStart = block.timestamp;
@@ -281,12 +293,14 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
281293
if (recipient == address(0)) revert ZeroAddress();
282294
uint256 bond = _params.proposalBond;
283295
IERC20 t = _bondToken();
296+
if (parent == 0) revert BadParent(); // proposals go under a branch, never straight under the root
284297
id = _create(parent, name, recipient, msg.sender, bond);
285298
if (bond > 0) t.safeTransferFrom(msg.sender, address(this), bond);
286299
emit Proposed(id, msg.sender, bond);
287300
}
288301

289302
function rejectProposal(uint32 id) external onlyOwner {
303+
if (drawPending) revert DrawPending();
290304
if (id >= _nodes.length) revert BadNode();
291305
Node storage n = _nodes[id];
292306
if (n.proposer == address(0) || !(n.active || n.pending)) revert BadNode();
@@ -318,6 +332,7 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
318332
}
319333

320334
function prune(uint32 id) external {
335+
if (drawPending) revert DrawPending();
321336
if (id >= _nodes.length) revert BadNode();
322337
Node storage n = _nodes[id];
323338
if (n.proposer == address(0) || !n.active || n.zeroStreak < _params.pruneAfter) revert NotPrunable();
@@ -555,14 +570,19 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
555570
if (valid == 0) return;
556571
uint256[] memory med = new uint256[](nk);
557572
uint16[] memory col = new uint16[](valid);
573+
bool any;
558574
for (uint256 c; c < nk; c++) {
559575
uint256 j;
560576
for (uint256 v; v < nv; v++) {
561577
if (vs[v].length == nk) col[j++] = vs[v][c];
562578
}
563579
med[c] = Median.median(col);
580+
if (med[c] > 0) any = true;
564581
}
582+
if (!any) return; // every median is 0: keep the current weights
565583
uint16[] memory w = Median.normalize(med);
584+
uint256 cap = _params.maxShiftBps;
585+
if (cap < 10_000) w = Median.step(_weights[id], w, cap);
566586
_weights[id] = w;
567587
emit WeightsFinalized(e, id, w);
568588
}
@@ -607,15 +627,17 @@ contract GraphFund is Ownable2Step, ReentrancyGuard {
607627
}
608628
}
609629

610-
function _updateStreaks(uint32[] memory br, uint256 nb) internal {
630+
/// @dev A zero-weight epoch only counts toward pruning when the fork had a committee that voted in epoch `e`.
631+
function _updateStreaks(uint256 e, uint32[] memory br, uint256 nb) internal {
611632
for (uint256 b; b < nb; b++) {
612633
uint32[] storage ks = _kids[br[b]];
613634
uint16[] storage ws = _weights[br[b]];
635+
bool voted = _voters[e][br[b]].length > 0;
614636
for (uint256 i; i < ks.length; i++) {
615637
Node storage c = _nodes[ks[i]];
616638
if (c.proposer == address(0)) continue;
617639
if (ws[i] == 0) {
618-
if (c.zeroStreak < type(uint8).max) c.zeroStreak++;
640+
if (voted && c.zeroStreak < type(uint8).max) c.zeroStreak++;
619641
} else {
620642
c.zeroStreak = 0;
621643
c.everFunded = true;

‎contracts/src/KeeperBadge.sol‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ pragma solidity 0.8.26;
33

44
import {ERC1155} from "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
55
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
6+
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
67

78
/// @title Keeper badge
89
/// @notice One soulbound token per epoch in which a keeper voted (id = epoch).
9-
contract KeeperBadge is ERC1155, Ownable {
10+
contract KeeperBadge is ERC1155, Ownable2Step {
1011
error Soulbound();
1112
error NotMinter();
1213
error AlreadySet();

0 commit comments

Comments
 (0)