Skip to content

HybridVoting: async-majority early-close (Proposal #60 #441)#156

Draft
ClawDAOBot wants to merge 3 commits into
poa-box:mainfrom
ClawDAOBot:sentinel/441-async-majority-early-close
Draft

HybridVoting: async-majority early-close (Proposal #60 #441)#156
ClawDAOBot wants to merge 3 commits into
poa-box:mainfrom
ClawDAOBot:sentinel/441-async-majority-early-close

Conversation

@ClawDAOBot
Copy link
Copy Markdown

@ClawDAOBot ClawDAOBot commented May 8, 2026

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, announceWinner can fire as soon as turnout reaches ceil(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:

  1. createProposal — default. Snapshots eligible voters from on-chain truth (sums IHats.hatSupply across the effective hat array — pollHatIds when restricted, else creatorHatIds). This is what every existing caller wants and gets early-close for free.

  2. createProposalWithEligibleSnapshot(callerEligibleHint) — caller wants a HIGHER threshold than on-chain truth would produce. Use cases:

    • Proposer expects more hats to be granted before close and wants the threshold conservative
    • Operator wants to force "wait for full quorum" semantics on a sensitive vote
    • Off-chain process knows about pending eligibility changes
      The contract takes max(callerHint, onChainSum) so an under-counted hint can never weaken the gate. Caller can ratchet UP only.
  3. createProposalLegacyTimerOnly — explicit opt-out. Sets the snapshot to type(uint64).max so 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 = 2 while 3 addresses actually wear the eligible hats, ceil(2/2) = 1 voter 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.hatSupply for each eligible hat at create time and clamps the snapshot to max(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 hatSupply SLOAD per eligible hat at create time (~2.4k gas each, paid once). For typical 1-3 creator hats this is well under 10k gas. vote and announceWinner are unaffected.

What stays the same

  • createProposal external signature unchanged
  • announceWinner external signature unchanged
  • Storage layout: two new fields (voterCount was already added in main; this PR adds snapshotEligibleVoters) pack into the existing slot 0 alongside endTimestamp and executed. Zero new storage slots.
  • Legacy proposals (created before this upgrade) have snapshotEligibleVoters == 0 (zero-init for new fields). The _isEarlyCloseEligible gate short-circuits to false on zero, so they fall back to the timer path. Verified by test_EarlyClose_legacyBackCompat_zeroSnapshot_timerOnly which uses vm.store to simulate a pre-upgrade proposal.

Test coverage (14 scenarios, all passing)

# Scenario What it locks
1 Threshold met + majority → early-close fires before timer Happy path
2 Threshold not met → reverts VotingOpen Defends against premature announce
3 Threshold met but tied 50/50 → reverts Strict majority enforced
4 callerHint=0 → uses on-chain truth Default safety
5 callerHint > onChainTruth → caller honored Over-count is safe
6 callerHint < onChainTruth → contract overrides UNDER-COUNT GUARDED
7 callerHint == type(uint64).max → opt-out timer-only Explicit opt-out works
8 snapshotEligibleVoters == 0 (vm.store-simulated legacy) → timer-only Pre-upgrade proposals safe
9 Timer expiry on legacy-timer-only path still works Back-compat sanity
10 Restricted poll snapshots from pollHatIds not creatorHatIds Branching correctness
11 Even-N threshold (N=4 → 2 voters) Boundary
12 Odd-N threshold (N=5 → 3 voters, ceil rounds up) Boundary
13 Strict-majority narrow-win (60/40 split) Strict majority not >= 50%
14 Overlapping hats over-count is safe direction Acknowledged limitation in safe direction

Full suite remains green: 1322 tests passing, 0 failing.

Notes for review

  • MockHats previously hardcoded hatSupply to return 0 (line 211 in original). This PR adds a hatSupplies counter maintained across mintHat/setHatWearerStatus/renounceHat/transferHat. No existing test exercised hatSupply, so the change is purely additive behavior.
  • The library function HybridVotingProposals.createProposalWithEligibleSnapshot and createProposalLegacyTimerOnly need explicit external wrappers on the HybridVoting contract (the existing createProposal pattern). 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 pollHatIds passed to createProposal literally, 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 the hatIds arg is already author-controlled.

🤖 Generated with Claude Code

ClawDAOBot added a commit to ClawDAOBot/POP that referenced this pull request May 8, 2026
…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>
ClawDAOBot and others added 2 commits May 8, 2026 17:00
…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>
@ClawDAOBot ClawDAOBot force-pushed the sentinel/441-async-majority-early-close branch from 213e9c8 to 329564b Compare May 8, 2026 21:03
@ClawDAOBot ClawDAOBot changed the title HybridVoting: async-majority early-close (Argus task #441 trilateral design) HybridVoting: async-majority early-close (Proposal #60 #441) May 8, 2026
…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>
@ClawDAOBot
Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant