Skip to content

HybridVoting: async-majority early-close (Proposal #60, task #441)#163

Open
hudsonhrh wants to merge 7 commits into
mainfrom
investigate-pr-156
Open

HybridVoting: async-majority early-close (Proposal #60, task #441)#163
hudsonhrh wants to merge 7 commits into
mainfrom
investigate-pr-156

Conversation

@hudsonhrh
Copy link
Copy Markdown
Member

@hudsonhrh hudsonhrh commented May 13, 2026

What this PR does

Two bundled changes to HybridVoting:

  1. Configurable early-close turnout gate (closes Argus task #441, Proposal Add DirectDemocracyVoting contract documentation #60). Adds a turnout-percent gate that lets announceWinner fire before the timer expires once enough eligible voters have weighed in. The percent is org-configurable (default 100 = wait for everyone) and per-proposal overridable.

  2. CEI re-ordering in vote() (closes Argus task #516). Moves p.hasVoted = true before the external IERC20.balanceOf call in _calculateClassPower — defense-in-depth for future class strategies that might use non-view external calls on cls.asset.

Without (1), every unanimous-mid-window proposal has to wait for its full timer before announce. With it, announceWinner can fire as soon as voterCount ≥ ⌈snapshotEligibleVoters × effectivePct / 100⌉ (the org's chosen turnout threshold). The original timer remains as the fallback.

Why configurable turnout (not a hardcoded gate)

The first design of this feature used ceil(N/2) + strict-majority as the gate, which had a real disenfranchisement risk: half the eligible voters could be cut off by a fast majority. In small orgs that means one or two voices entirely silenced. The redesigned gate is purely turnout-based and lets each org choose its own deliberation/speed tradeoff:

  • 100% (default for new orgs): every eligible voter must vote before early-close fires. No risk of cutting anyone off; the timer remains the cap.
  • 51-99%: bigger orgs that want fast closes on clear consensus opt down.
  • Per-proposal override: a sensitive proposal (constitutional change, large treasury move) can ratchet UP to a stricter floor than the org default — never DOWN.

announceWinner's existing pickWinnerNSlices logic remains the validity arbiter — the gate is permission to attempt announce, not a guarantee of validity. If turnout is reached but no option meets thresholdPct/strict margin, announceWinner returns (0, false) instead of locking in an outcome.

Three create variants

  1. createProposal — default. Uses the org's earlyCloseTurnoutPct. This is what every existing caller wants and gets configurable early-close for free.

  2. createProposalWithEligibleSnapshot(callerEligibleHint) — caller wants a HIGHER eligibility-snapshot floor than on-chain truth would produce. The contract stores max(callerHint, on-chain hatSupply sum) so under-count is impossible by construction.

    Special sentinel: passing callerEligibleHint = type(uint64).max opts the proposal out of early-close entirely — it must run its full timer regardless of how many voters participate. Use this for proposals where the duration is the policy (RFC windows, mandatory deliberation periods, externally-coordinated timing). The dedicated createProposalLegacyTimerOnly function from the earlier design was removed to save ~670 bytes of runtime bytecode; behavior is preserved via the sentinel.

  3. createProposalWithTurnoutPct(pct) — caller wants a HIGHER turnout-percent floor than the org default. Validated pct >= orgDefault && pct <= 100. Used for sensitive proposals where the proposer wants stricter participation than the org's normal threshold.

The reason these are three named functions (instead of one with flags) is that the safety property — "callers can ratchet UP, never DOWN" — is easier to audit when each intent has its own entry point.

Why an under-count guard (eligibility hint)

The original snapshot-hint design accepted a caller-supplied count directly. That breaks the protocol invariant: if a caller passes snapshotEligibleVoters = 2 while 3 addresses actually wear the eligible hats, then ⌈2 × pct / 100⌉ < 3 for any pct ≤ 100 — i.e., a minority of actual eligible voters could trigger early-close. 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.

Cost: 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 and vote external signatures unchanged.
  • announceWinner external signature unchanged.
  • Storage layout: purely additive at struct tails.
    • Proposal gains uint32 voterCount, uint64 snapshotEligibleVoters, uint8 turnoutPctOverride — all packing with the existing bool executed into one trailing slot (14 of 32 bytes used).
    • Layout gains uint8 earlyCloseTurnoutPct — packs into the slot with quorum.
    • Zero new dedicated storage slots beyond those.
  • Legacy proposals (created before this upgrade) have snapshotEligibleVoters == 0 (zero-init for new fields). The 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.
  • Existing orgs upgrading have earlyCloseTurnoutPct == 0 in storage. The gate reads 0 as 100 — the safest default — so existing orgs are MORE strict by default after the upgrade, not less. Executors can call setConfig(EARLY_CLOSE_TURNOUT_PCT, abi.encode(pct)) post-upgrade to opt the org into a looser threshold via governance.
  • type(uint64).max remains the unique opt-out sentinel for snapshotEligibleVoters. Overflow in _eligibleVotersUpperBound clamps to type(uint64).max - 1 so on-chain sums can never collide with the sentinel.

Threading through OrgDeployer

The per-org default flows from caller config all the way through to HybridVoting.initialize:

OrgDeployer.DeploymentParams.hybridEarlyCloseTurnoutPct
  → GovernanceFactory.GovernanceParams.hybridEarlyCloseTurnoutPct
  → ModuleDeploymentLib.deployHybridVoting(..., earlyCloseTurnoutPct, ...)
  → HybridVoting.initialize(..., earlyCloseTurnoutPct_, ...)

HybridVoting.initialize validates 1 ≤ earlyCloseTurnoutPct_ ≤ 100 and reverts with InvalidTurnoutPct otherwise. Emits EarlyCloseTurnoutPctSet(pct).

New / changed external surface

// new param at position 6:
function initialize(
    address hats_,
    address executor_,
    uint256[] initialCreatorHats,
    address[] initialTargets,
    uint8 thresholdPct,
    uint8 earlyCloseTurnoutPct,   // <-- NEW
    ClassConfig[] initialClasses
) external;

// new create variants:
function createProposalWithEligibleSnapshot(...) external;       // existing
function createProposalWithTurnoutPct(...) external;             // NEW

// new view helpers:
function earlyCloseTurnoutPct() external view returns (uint8);
function proposalTurnoutPct(uint256 id) external view returns (uint8);
function isEarlyCloseEligible(uint256 id) external view returns (bool);  // existing

// new ConfigKey enum entry for setConfig:
enum ConfigKey {
    THRESHOLD,
    TARGET_ALLOWED,
    EXECUTOR,
    QUORUM,
    EARLY_CLOSE_TURNOUT_PCT       // <-- NEW
}

// new events:
event EarlyCloseTurnoutPctSet(uint8 pct);
event ProposalEarlyCloseConfig(
    uint256 indexed id,
    uint64 snapshotEligibleVoters,
    uint8 turnoutPctOverride,
    bool isTimerOnly
);

Subgraph discoverability

A single ProposalEarlyCloseConfig event fires once per proposal creation regardless of which variant the caller used. Indexers can subscribe to:

  • EarlyCloseTurnoutPctSet(pct) on every HybridVoting proxy → track per-org default (fires from initialize at deploy time, and from setConfig later)
  • ProposalEarlyCloseConfig(id, snapshot, override, isTimerOnly) → track per-proposal config

The subgraph can compute the effective threshold per proposal without on-chain reads:

effectivePct = turnoutPctOverride != 0 ? turnoutPctOverride : org.hybridEarlyCloseTurnoutPct
threshold = ceil(snapshot × effectivePct / 100)

Subgraph integration is tracked at poa-box/subgraph-pop#178. Frontend wiring at poa-box/Poa-frontend#403. Both repos will pull the new ABI from this branch.

CEI fix in vote() (bundled, task #516)

HybridVotingCore.vote() now sets p.hasVoted[voter] = true before calling IERC20(cls.asset).balanceOf(voter). The originally-cited balanceOf-callback attack is NOT exploitable today — Solidity emits STATICCALL for view functions through interfaces, and the EVM enforces no-state-modification on the entire static-call subtree. CEI re-ordering is forward-defense against future class strategies that might use non-view external calls. The test file HybridVotingReentrancy.t.sol honestly documents this (replacing the original test that claimed to demonstrate reentry prevention but actually passed for an unrelated reason).

Test coverage

test/HybridVotingEarlyClose.t.sol — 41 tests in three groups:

Group A — original PR coverage (14 tests): the 8 scenarios from the trilateral design + 6 robustness scenarios.

Group B — supplementary coverage (12 tests): pause blocks announce, snapshot frozen at create time, batch execution under early-close, threshold=1 single-voter case, out-of-range id safety, empty creatorHats fallback, gate-stays-eligible behavior, callerHint boundary cases.

Group C — new gate semantics (15 tests):

  • turnoutPct100_requiresFullTurnout — the new safe default
  • turnoutPct51_fires_onMajorityTurnout — looser threshold
  • setConfigUpdatesTurnoutPct + setConfigRejectsInvalidPct + setConfigOnlyExecutor — executor-driven updates
  • perProposalOverride_ratchetsUp — override ratchet
  • overrideRejectedBelowOrgDefault + overrideRejectedAbove100 — validation
  • overrideEqualsOrgDefault_ok — boundary
  • initializeRejectsInvalidPct — init validation
  • eventEmittedOnCreateProposal / …WithEligibleSnapshot / …LegacyTimerOnly (sentinel) / …WithTurnoutPctProposalEarlyCloseConfig event verified for each entry point
  • thresholdMetButTied_announceReturnsInvalid — gate fires, announceWinner returns invalid (new turnout-only semantics)
  • threeOptionEqualSplit_gateFires_announceInvalid — same pattern with 3 options
  • gateStaysEligibleOnTie_announceInvalidates — replaces old "gate revokes on tie" test

test/HybridVotingReentrancy.t.sol — 2 CEI ordering property tests (honest framing, no false attack claims).

Full suite: 1351 / 1351 passing. forge fmt clean. Storage layout purely additive vs upgrades/baseline.

Upgrade script + simulation

script/upgrades/UpgradeHybridVotingV11.s.sol ships a 3-step + 1-sim pattern matching UpgradeVotingSelfTarget.s.sol:

# 0. Sim against Arbitrum mainnet fork (CLAUDE.md mandate: simulate before broadcast)
source .env && FOUNDRY_PROFILE=production forge script \
  script/upgrades/UpgradeHybridVotingV11.s.sol:SimulateHybridVotingV11Upgrade \
  --rpc-url arbitrum -vvv

# 1. Deploy impl on Gnosis via DeterministicDeployer
source .env && FOUNDRY_PROFILE=production forge script \
  script/upgrades/UpgradeHybridVotingV11.s.sol:Step1_DeployOnGnosis \
  --rpc-url gnosis --broadcast --slow --private-key $DEPLOYER_PRIVATE_KEY

# 2. Deploy impl on Arbitrum + cross-chain beacon upgrade
source .env && FOUNDRY_PROFILE=production forge script \
  script/upgrades/UpgradeHybridVotingV11.s.sol:Step2_UpgradeFromArbitrum \
  --rpc-url arbitrum --broadcast --slow --private-key $DEPLOYER_PRIVATE_KEY

# 3. Verify Gnosis after ~5 min Hyperlane relay
FOUNDRY_PROFILE=production forge script \
  script/upgrades/UpgradeHybridVotingV11.s.sol:Step3_Verify \
  --rpc-url gnosis

Sim passes end-to-end on Arbitrum fork: pre/post-upgrade state read, v11 impl deployed via DD, beacon upgrade applied under Hudson's prank, KUBI proxy reads the new impl, isEarlyCloseEligible correctly returns false for out-of-range ids, end-to-end create+vote+announce works, and the turnout gate fires correctly on a unanimous single-voter vote.

Notes for review

  • MockHats previously hardcoded hatSupply to return 0. This PR adds a hatSupplies counter maintained across mintHat / setHatWearerStatus / renounceHat / transferHat. No existing test exercised hatSupply, so the change is purely additive behavior.
  • HybridVotingProposals._eligibleVotersUpperBound is split into Calldata and Storage variants to avoid a redundant calldata→memory copy on the unrestricted branch.
  • HybridVoting.MIN_DURATION is bumped from 1 to 10 to match the library constant that's actually enforced. Resolves a pre-existing inconsistency.
  • The CEI fix is bundled (originally a separate Argus task #516). PR author had offered to split; keeping bundled because the test rewrite is intertwined with HybridVoting test infrastructure.

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? Lean: defer — over-count is the safe direction (raises the threshold, makes early-close harder) and the hatIds arg is already author-controlled.

🤖 Generated with Claude Code

ClawDAOBot and others added 4 commits May 8, 2026 17:00
…ateral design)

Implements Proposal #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 #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>
…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>
…ning

Production-grade follow-up to PR #156. Five tightly-coupled fixes:

1. _isEarlyCloseEligible now uses slice-weighted scoring matching
   VotingMath.pickWinnerNSlices. The previous raw-sum gate could fire
   on a winner that announceWinner would not pick (token whale in a
   small-slice class produces raw-sum majority for the wrong option).
   New gate predicts announceWinner's valid=true exactly: thresholdPct,
   strict margin, strict majority of total slice-weighted score, AND
   quorum.

2. _eligibleVotersUpperBound overflow clamps to type(uint64).max - 1
   so the opt-out sentinel (type(uint64).max) stays unambiguous.

3. _eligibleVotersUpperBound split into calldata and storage variants,
   eliminating the redundant calldata->memory copy on the unrestricted
   path.

4. HybridVoting.MIN_DURATION bumped 1 -> 10 to match the library
   constant actually enforced in HybridVotingProposals._validateDuration.
   Resolves a pre-existing inconsistency.

5. Reentrancy test rewritten as honest CEI property tests. The original
   test claimed to demonstrate reentry prevention but passed for an
   unrelated reason (the malicious contract had no voting hats so the
   inner vote() hit the no-power check, not AlreadyVoted). The
   originally-cited balanceOf-callback vector is not exploitable today
   (Solidity emits STATICCALL for IERC20.balanceOf, EVM enforces no
   state modification subtree-wide). CEI ordering remains correct
   hygiene and forward-defense against future class strategies that
   call non-view functions on cls.asset.

Tests: PR's 14 + supplementary 12 + new 3 gate-semantics tests merged
into test/HybridVotingEarlyClose.t.sol (29 total). 2 CEI property
tests in test/HybridVotingReentrancy.t.sol. Full suite: 1339 / 1339
passing. forge fmt clean. Storage layout purely additive
(snapshotEligibleVoters appended at struct tail).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hudsonhrh and others added 3 commits May 13, 2026 18:10
…vent

Redesigns the early-close gate from "ceil(N/2) + strict majority" to a
purely turnout-based gate with org-configurable percent (1-100, default
100 = wait for everyone). Drops the disenfranchisement risk where half
the eligible voters could be cut off by a fast majority. Per-proposal
override (createProposalWithTurnoutPct) lets sensitive proposals
ratchet UP from the org default. Adds full subgraph discoverability.

Configurability:
- HybridVoting.Layout gains uint8 earlyCloseTurnoutPct (org default).
  Zero in storage reads as 100 (back-compat default for upgrading orgs
  — safest possible turnout floor).
- HybridVoting.initialize takes a new uint8 earlyCloseTurnoutPct_ param;
  validates 1-100; emits EarlyCloseTurnoutPctSet.
- New ConfigKey.EARLY_CLOSE_TURNOUT_PCT for executor-driven updates via
  setConfig (also emits EarlyCloseTurnoutPctSet).
- OrgDeployer.DeploymentParams.hybridEarlyCloseTurnoutPct threads through
  GovernanceFactory.GovernanceParams → ModuleDeploymentLib.deployHybridVoting
  → HybridVoting.initialize.

Per-proposal:
- New Proposal.turnoutPctOverride field (uint8; 0 = use org default).
  Packs in the existing tail slot with executed / voterCount /
  snapshotEligibleVoters. Purely additive storage.
- New createProposalWithTurnoutPct(pct) external; pct must be in
  [orgDefault, 100] — callers can ratchet UP only, never DOWN
  (mirrors the under-count guard for the eligibility snapshot).
- New view helpers earlyCloseTurnoutPct() and proposalTurnoutPct(id).

Gate logic (HybridVotingCore._isEarlyCloseEligible):
- Drops slice-weighted scoring loop entirely. Gate is now purely:
    voterCount >= ceil(snapshot * effectivePct / 100)
    AND quorum (if set) satisfied
- announceWinner's existing pickWinnerNSlices remains the validity
  arbiter — if turnout met but no valid winner, returns (0, false)
  instead of reverting. Gate is permission to attempt announce, not
  a guarantee of validity.

Subgraph discoverability:
- New event ProposalEarlyCloseConfig(uint256 indexed id, uint64
  snapshotEligibleVoters, uint8 turnoutPctOverride, bool isTimerOnly)
  emitted from _initProposal once per proposal regardless of which
  create variant. Lets indexers track full config without on-chain reads.
- EarlyCloseTurnoutPctSet event fires on init + setConfig.

Bytecode size:
- Removed createProposalLegacyTimerOnly (external + library function).
  Behavior preserved via the existing sentinel: callers pass
  callerEligibleHint = type(uint64).max to createProposalWithEligibleSnapshot
  to opt out of early-close entirely. The dedicated function was 30
  lines of explicit-but-redundant API.
- Net runtime delta vs prior commit: -324B HybridVoting, -348B
  HybridVotingProposals (-672B total runtime).

Other:
- _eligibleVotersUpperBound overflow clamps to type(uint64).max - 1 so
  the timer-only sentinel stays unambiguous.
- HybridVoting.MIN_DURATION 1 -> 10 (matches the library constant
  actually enforced; pre-existing inconsistency).
- VotingErrors.InvalidTurnoutPct added.

Tests: 1339 -> 1351 (+12). New coverage: turnoutPct=100 strict full
turnout, turnoutPct=51 partial threshold, setConfig path + invalid pct
rejections, per-proposal override ratcheting + below-default rejection,
override-equals-default ok, initialize rejects invalid pct, 4 dedicated
ProposalEarlyCloseConfig event-emission tests (one per create variant).
forge fmt clean; storage layout purely additive; Arbitrum-fork sim
script (UpgradeHybridVotingV11.s.sol) passes end-to-end.

Tracking:
- Subgraph: poa-box/subgraph-pop#178
- Frontend: poa-box/Poa-frontend#403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aggressive cleanup pass. Multi-paragraph block comments collapsed to
one-liners or removed entirely where the function/event/test name
already conveys what's happening. Per CLAUDE.md: "Don't explain WHAT
the code does, since well-named identifiers already do that. Default
to writing no comments."

Net: -338 lines / +42 lines (~300 lines of commentary removed).
1351 / 1351 tests still passing; sim PASS on Arbitrum fork.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants