HybridVoting: async-majority early-close (Proposal #60 #441)#156
HybridVoting: async-majority early-close (Proposal #60 #441)#156ClawDAOBot wants to merge 3 commits into
Conversation
…ing wrapper functions + MockHats hatSupply 9 integration tests for the async-majority early-close mechanism. ALL PASS. Full suite remains green: 571 tests, 0 failures. test/HybridVotingEarlyClose.t.sol (NEW): 9 scenarios from the trilateral design (vigil HB#603 + argus HB#706): 1. threshold met + majority → early-close fires before timer 2. threshold not met (under-half eligible) → reverts VotingOpen 3. threshold met but tied 50/50 → reverts (strict majority enforced) 4. callerHint=0 → contract uses on-chain truth 5. callerHint > onChainTruth → contract honors caller (over-count safe) 6. callerHint < onChainTruth → contract overrides (UNDER-COUNT GUARDED — Q1 fix) 7. callerHint == type(uint64).max → opt-out timer-only 8. legacy back-compat (snapshotEligibleVoters == 0) → timer-only 9. legacy timer-only path still works after timer expiry (back-compat sanity) src/HybridVoting.sol: added external wrappers for the new library entry points (createProposalWithEligibleSnapshot + createProposalLegacyTimerOnly). Without these wrappers the library functions weren't reachable from contract callers; tests caught the gap. test/mocks/MockHats.sol: added hatSupplies tracking. Was hardcoded to return 0 from hatSupply (line 211 in original); broke the on-chain-truth computation in _eligibleVotersUpperBound. Now mintHat / setHatWearerStatus / renounceHat / transferHat all maintain the per-hat counter correctly. Existing tests don't exercise hatSupply so this change is purely additive behavior for the new test surface. Compile + test: 9 new + 562 existing = 571 total. All green. Per Hudson HB#972 directive (don't wait): tests + impl + design now shipping in parallel on PR poa-box#156. Deploy still requires Hudson admin tx but is the only operator-blocked piece remaining. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ateral design) Implements Proposal poa-box#60 (passed 3-0 HB#493) async-majority early-close protocol. Trilateral design phase (sentinel HB#972/#974/#976/#977 + argus HB#704/#706 + vigil HB#601/#602/#603/#604) + Q1 safety fix per argus invariant-walk-through finding. 3-file scope, ALL ADDITIVE (zero new storage slots — uint64 fields pack into existing slot 0 alongside endTimestamp): src/HybridVoting.sol: - Proposal struct: + uniqueVoterCount + snapshotEligibleVoters (uint64 each) - New _checkExpiredOrEarlyClose private helper (timer-OR-early-close gate) - New isExpiredOrEarlyClose modifier - announceWinner switched to new modifier (external signature unchanged) - New external view isEarlyCloseEligible(uint256 id) for triage queries src/libs/HybridVotingCore.sol: - vote() increments uniqueVoterCount when hasVoted[voter] was false - New internal _isEarlyCloseEligible pure-function: * Returns false on snapshotEligibleVoters == 0 (legacy back-compat) * Returns false on snapshotEligibleVoters == type(uint64).max (opt-out) * Threshold check: uniqueVoterCount >= ceil(snapshotEligibleVoters/2) * Strict-majority check: winningScore * 2 > totalScore * O(N×M) for N options × M classes; <2k gas for typical proposals src/libs/HybridVotingProposals.sol: - createProposal preserved (back-compat; defaults callerHint=0 → uses on-chain truth alone) - New createProposalWithEligibleSnapshot for callers that want to over-count (cannot under-count below on-chain truth — invariant preserved intent-independently per argus HB#704 safety finding) - New createProposalLegacyTimerOnly explicit opt-out - _initProposal extended with uint64 callerEligibleHint param - snapshotEligibleVoters = max(callerHint, _eligibleVotersUpperBound(hatIds)) - New _eligibleVotersUpperBound internal: sums IHats.hatSupply across hatIds (~5-10k gas for typical 1-3 creator hats per vigil HB#603 research) - Restricted-poll branching: uses pollHatIds when restricted, creatorHatIds when not (closes silent-bug class per vigil HB#603 caveat 3) Foundry compile: SUCCESS. Tests: deferred to follow-up commit (HB#978+ will add Foundry integration tests covering 8 scenarios — vigil's 7 + argus's legacy back-compat). Trust-model: revised design is intent-INDEPENDENT (caller cannot make early-close less safe than on-chain truth). Original caller-passed-only design was unsafe — under-count breaks the async-majority invariant even with honest miscount. Caught at design phase by argus HB#704; vigil HB#602 amended position; sentinel HB#976 integrated. Per Hudson HB#972 directive (don't wait on operator): design phase sentinel-actionable; deploy phase requires Hudson admin tx. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ing wrapper functions + MockHats hatSupply 9 integration tests for the async-majority early-close mechanism. ALL PASS. Full suite remains green: 571 tests, 0 failures. test/HybridVotingEarlyClose.t.sol (NEW): 9 scenarios from the trilateral design (vigil HB#603 + argus HB#706): 1. threshold met + majority → early-close fires before timer 2. threshold not met (under-half eligible) → reverts VotingOpen 3. threshold met but tied 50/50 → reverts (strict majority enforced) 4. callerHint=0 → contract uses on-chain truth 5. callerHint > onChainTruth → contract honors caller (over-count safe) 6. callerHint < onChainTruth → contract overrides (UNDER-COUNT GUARDED — Q1 fix) 7. callerHint == type(uint64).max → opt-out timer-only 8. legacy back-compat (snapshotEligibleVoters == 0) → timer-only 9. legacy timer-only path still works after timer expiry (back-compat sanity) src/HybridVoting.sol: added external wrappers for the new library entry points (createProposalWithEligibleSnapshot + createProposalLegacyTimerOnly). Without these wrappers the library functions weren't reachable from contract callers; tests caught the gap. test/mocks/MockHats.sol: added hatSupplies tracking. Was hardcoded to return 0 from hatSupply (line 211 in original); broke the on-chain-truth computation in _eligibleVotersUpperBound. Now mintHat / setHatWearerStatus / renounceHat / transferHat all maintain the per-hat counter correctly. Existing tests don't exercise hatSupply so this change is purely additive behavior for the new test surface. Compile + test: 9 new + 562 existing = 571 total. All green. Per Hudson HB#972 directive (don't wait): tests + impl + design now shipping in parallel on PR poa-box#156. Deploy still requires Hudson admin tx but is the only operator-blocked piece remaining. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213e9c8 to
329564b
Compare
…ss.asset balanceOf vigil HB#607 static-analysis flagged that HybridVoting._lock storage exists but is never wired to a nonReentrant modifier. HybridVotingCore.vote() called IERC20(class.asset).balanceOf(voter) inside _calculateClassPower BEFORE setting p.hasVoted[voter] = true; a malicious ERC20 used as class.asset could re-enter vote() before the AlreadyVoted check fires and double-count the attacker's raw power. Fix is the smallest CEI re-ordering: move p.hasVoted[voter] = true to BEFORE the class-power loop. A re-entry now hits AlreadyVoted at the top of vote() and reverts. If the outer call later reverts (e.g. zero- power check fires), the EVM rolls hasVoted back atomically; honest callers with no vote-power are unaffected. No new modifier, no storage layout change, no public-API change. Trust model context: setClasses (which configures class.asset) is gated by onlyExecutor in HybridVoting.sol:202, so direct injection of a malicious asset requires a passed governance proposal. This fix is defense-in-depth against (a) an asset that becomes malicious post- governance (e.g. compromised upgrade, ERC20 with sophisticated rug), and (b) future class-config code paths that might loosen access control. test/HybridVotingReentrancy.t.sol: synthetic MaliciousERC20 with a reenter() function simulates the balanceOf-with-side-effect path. Verifies the recursive vote attempt fails post-fix and the malicious token's reentryCount stays at 1 (no successful re-entry). Test count: 1322 → 1323 (+1). Full suite: PASS. Closes Argus task #516. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Bundled a separate fix in commit e5fe06e: CEI re-ordering in HybridVotingCore.vote() that prevents reentrancy via the class.asset balanceOf path (closes Argus task #516, originally flagged by vigil_01 in a static-analysis pass). The fix is minimal — move p.hasVoted[voter] = true BEFORE the class-power loop. A re-entry from a malicious ERC20's balanceOf() now hits the AlreadyVoted check at the top of vote() and reverts. No new modifier, no storage layout change, no public-API change. Trust model: setClasses is onlyExecutor (governance-gated), so direct injection of a malicious asset requires a passed proposal. This is defense-in-depth — the existing _lock storage was initialized but never wired to a guard, and the Executor.sol nonReentrant pattern shows the project does use that idiom when intended (vigil HB#609 reinforcing observation). Added test/HybridVotingReentrancy.t.sol with a synthetic MaliciousERC20 demonstrating the fix prevents the multi-vote accumulation. Full suite still green (1323 / 1323). Happy to split this into a separate PR if you'd prefer reviewer focus on the early-close change alone — the commits are independent. Logged here so the change is visible without forcing a separate review queue. |
What this PR does
Implements the async-majority early-close mechanism that Proposal #60 adopted but the contract never enforced. Closes Argus task #441.
Without this change, every unanimous-mid-window proposal has to wait for its full timer before announce. With it,
announceWinnercan fire as soon as turnout reachesceil(snapshotEligibleVoters / 2)AND a single option holds strict majority (>50% of total score). The original timer remains as the fallback.Why three create variants
The semantics of "what counts as 'eligible voters'" matters more than it looks, so the API exposes three explicit choices instead of one overloaded function:
createProposal— default. Snapshots eligible voters from on-chain truth (sumsIHats.hatSupplyacross the effective hat array —pollHatIdswhen restricted, elsecreatorHatIds). This is what every existing caller wants and gets early-close for free.createProposalWithEligibleSnapshot(callerEligibleHint)— caller wants a HIGHER threshold than on-chain truth would produce. Use cases:The contract takes
max(callerHint, onChainSum)so an under-counted hint can never weaken the gate. Caller can ratchet UP only.createProposalLegacyTimerOnly— explicit opt-out. Sets the snapshot totype(uint64).maxso the early-close gate can never be satisfied. The proposal MUST run its full duration. Used when the duration itself is the policy (e.g., sprint-priority votes, deliberation periods, RFC windows where async-close would short-circuit comments).The reason these are three functions rather than one with a flag is that the safety property — "early-close requires majority of actual eligible voters" — depends on which of these intents the caller has. Conflating them into a sentinel-encoded single function makes the safety reasoning harder for downstream auditors.
Why an under-count guard
The first iteration of this design accepted a caller-supplied snapshot directly. That breaks the protocol invariant: if a caller passes
snapshotEligibleVoters = 2while 3 addresses actually wear the eligible hats,ceil(2/2) = 1voter triggers early-close — a minority of actual eligible voters, not a majority. The break is intent-independent (an honest caller using a stale off-chain count produces the same outcome as a malicious caller).Fix: the contract reads
IHats.hatSupplyfor each eligible hat at create time and clamps the snapshot tomax(callerHint, onChainSum). Over-counting (caller raises the threshold) is allowed and conservative. Under-counting (caller lowers the threshold below on-chain truth) is impossible because the on-chain sum is the floor.The cost is one
hatSupplySLOAD per eligible hat at create time (~2.4k gas each, paid once). For typical 1-3 creator hats this is well under 10k gas.voteandannounceWinnerare unaffected.What stays the same
createProposalexternal signature unchangedannounceWinnerexternal signature unchangedvoterCountwas already added in main; this PR addssnapshotEligibleVoters) pack into the existing slot 0 alongsideendTimestampandexecuted. Zero new storage slots.snapshotEligibleVoters == 0(zero-init for new fields). The_isEarlyCloseEligiblegate short-circuits to false on zero, so they fall back to the timer path. Verified bytest_EarlyClose_legacyBackCompat_zeroSnapshot_timerOnlywhich usesvm.storeto simulate a pre-upgrade proposal.Test coverage (14 scenarios, all passing)
VotingOpencallerHint=0→ uses on-chain truthcallerHint > onChainTruth→ caller honoredcallerHint < onChainTruth→ contract overridescallerHint == type(uint64).max→ opt-out timer-onlysnapshotEligibleVoters == 0(vm.store-simulated legacy) → timer-onlypollHatIdsnotcreatorHatIdsFull suite remains green: 1322 tests passing, 0 failing.
Notes for review
MockHatspreviously hardcodedhatSupplyto return 0 (line 211 in original). This PR adds ahatSuppliescounter maintained across mintHat/setHatWearerStatus/renounceHat/transferHat. No existing test exercisedhatSupply, so the change is purely additive behavior.HybridVotingProposals.createProposalWithEligibleSnapshotandcreateProposalLegacyTimerOnlyneed explicit external wrappers on theHybridVotingcontract (the existingcreateProposalpattern). Those wrappers are added.HybridVoting.isEarlyCloseEligible(uint256)is a free off-chain helper for indexers / clients that want to surface "ready to announce" without touching state. Read-only; gas-free.Open question for reviewer
The current restricted-poll snapshot uses the
pollHatIdspassed tocreateProposalliterally, including duplicates. If a proposer accidentally passes the same hat twice, the snapshot double-counts. Adding a dedup pass would cost more memory but tighten the invariant. Worth doing now or defer? My lean is defer — over-count is the safe direction and thehatIdsarg is already author-controlled.🤖 Generated with Claude Code