diff --git a/README.md b/README.md index 87db189..0a28172 100644 --- a/README.md +++ b/README.md @@ -298,13 +298,16 @@ draws between `Pending` and `Active`: |---|---| | Self-cancel while `Pending` | Full refund — no vote existed yet, nothing to evade | | Self-cancel while `Active` | Full forfeit — once voting is live, exiting costs as much as losing it | -| Canceled directly on the timelock (security-council veto) | Full forfeit — the ratified default | +| Canceled directly on the timelock (security-council veto) | Full forfeit — the ratified default; best-effort since the bond may already be settled (refunds open at `Succeeded`) | -`resolveBond` is permissionless and one-shot, and only ever pays out in a terminal state — -`Executed`, `Defeated`, or `Canceled`. It reverts in `Succeeded`/`Queued`: those states sit -inside the security council's timelock-veto window, and an early refund there would let a -proposer pull their bond out from under a veto before the council acts. A refund on a -passed proposal is available the moment it executes, and execution is permissionless. +`resolveBond` is permissionless and one-shot, and pays out as soon as the vote can no +longer slash — `Succeeded`, `Queued`, `Executed`, `Defeated`, or `Canceled`; only +`Pending`/`Active` revert. Refunding from `Succeeded` onward is a deliberate product +decision (2026-08-03): the bond is an anti-spam instrument, and surviving the vote +fulfills its purpose — a passed proposal's bond is not held hostage to execution. The +cost is accepted openly: the timelock-veto forfeit below is best-effort, reaching only +bonds still unsettled when the veto lands — and since resolution is permissionless, +anyone can settle a passed proposal's bond before a veto arrives. Every BondRuleset parameter — `token`, `quorumNumerator`, `bondAmount`, `treasury` — is `immutable`, with no setters, matching every other ruleset in this repo. 1,000 ENS is @@ -341,11 +344,10 @@ Accepted residuals: per-proposer active-proposal cap (never a victim's — the reentrant proposer is the ruleset itself). No reentrancy guard is added: the production `BondRuleset` transfers hook-free ENS, and the exposure is bounded to a self-inflicted cap on a governance-approved contract. -- **Bond stranded by an unexecutable-but-approved proposal.** A proposal that passes but - whose on-chain actions always revert on execution never reaches `Executed` (the timelock - has no `Expired` state), so it stays in `Queued` and its bond is never released. Accepted: - it requires the community to approve a proposal with permanently-reverting calldata, and - the stranded bond is the proposer's own. +- **Veto forfeit evadable by early settle.** Refunds open at `Succeeded`, and resolution + is permissionless — so a proposer (or anyone) can settle the bond before the security + council vetoes from the timelock, making the veto forfeit reach only bonds still + unsettled when the veto lands. ## Layout diff --git a/src/rulesets/BondRuleset.sol b/src/rulesets/BondRuleset.sol index 44ead66..de6478f 100644 --- a/src/rulesets/BondRuleset.sol +++ b/src/rulesets/BondRuleset.sol @@ -27,8 +27,8 @@ interface IBondGovernor { /// after voting opened / vetoed from the timelock. /// @dev Immutable by design: no setters. Custody invariant: the ruleset's token /// balance always covers every unsettled bond. Resolution is permissionless and -/// one-shot; refunds release only in terminal states (`Executed`/`Defeated`/`Canceled`) -/// so the security council's veto window is never front-run. +/// one-shot; refunds release once the vote can no longer slash — from `Succeeded` +/// onward — so the timelock-veto forfeit reaches only bonds still unsettled. contract BondRuleset is RulesetCounting, RulesetQuorumFraction, IProposalValidator { using SafeERC20 for IERC20; @@ -169,9 +169,10 @@ contract BondRuleset is RulesetCounting, RulesetQuorumFraction, IProposalValidat /// @notice Settles `proposalId`'s bond once its outcome is final. Permissionless and /// one-shot: anyone may trigger settlement, nobody can trigger it twice. - /// @dev Refund releases only in terminal states — `Succeeded`/`Queued` revert so the - /// security council's timelock-veto window can never be front-run by an early - /// refund. Effects (settled flag) precede the single transfer (CEI). + /// @dev Refunds release from `Succeeded` onward — the bond is an anti-spam instrument + /// and surviving the vote fulfills its purpose; the timelock-veto forfeit reaches + /// only bonds still unsettled when the veto lands. Effects (settled flag) precede + /// the single transfer (CEI). function resolveBond(uint256 proposalId) external { Bond storage bond = _bonds[proposalId]; if (bond.proposer == address(0)) revert NoBond(proposalId); @@ -180,14 +181,19 @@ contract BondRuleset is RulesetCounting, RulesetQuorumFraction, IProposalValidat _settle(proposalId, bond, _bondResolution(proposalId)); } - /// @dev Maps a terminal proposal state to the bond's resolution. `None` refunds the + /// @dev Maps a resolvable proposal state to the bond's resolution. `None` refunds the /// proposer; every other reason forfeits to the treasury — destination and event are /// derived in `_settle`, so no contradictory (reason, destination) pair is - /// representable. Non-terminal states revert, so a refund can never front-run the - /// council's veto window. + /// representable. Only `Pending`/`Active` revert: while the vote is live the + /// slash outcome is still undecided, so nothing may settle. function _bondResolution(uint256 proposalId) private view returns (SlashReason) { IGovernor.ProposalState state = IBondGovernor(governor).state(proposalId); - if (state == IGovernor.ProposalState.Executed) return SlashReason.None; + if ( + state == IGovernor.ProposalState.Succeeded || state == IGovernor.ProposalState.Queued + || state == IGovernor.ProposalState.Executed + ) { + return SlashReason.None; + } if (state == IGovernor.ProposalState.Defeated) { return _slashVoted(proposalId) ? SlashReason.SlashVote : SlashReason.None; } diff --git a/test/governor/GovernorNexus.bond.t.sol b/test/governor/GovernorNexus.bond.t.sol index bddceff..57837ac 100644 --- a/test/governor/GovernorNexus.bond.t.sol +++ b/test/governor/GovernorNexus.bond.t.sol @@ -9,7 +9,7 @@ import {BondRulesetTestBase} from "../rulesets/BondRulesetTestBase.sol"; /// @dev Integration suite for `resolveBond` against the real `GovernorNexus` + timelock — /// the ratified spam-slash predicate (EP 5.15 verbatim: combined rejections strictly -/// beat For AND slash-weight strictly beats plain Against) and the terminal-states-only +/// beat For AND slash-weight strictly beats plain Against) and the Pending/Active-only /// guard, each exercised end to end through the actual propose → vote → queue/execute/ /// cancel lifecycle rather than a mocked governor. contract GovernorNexusBondTest is BondRulesetTestBase { @@ -420,7 +420,7 @@ contract GovernorNexusBondTest is BondRulesetTestBase { bondRuleset.resolveBond(id); } - function test_resolve_revertsWhileQueued() public { + function test_resolve_refundsWhileQueued() public { address[] memory t; uint256[] memory v; bytes[] memory c; @@ -432,10 +432,100 @@ contract GovernorNexusBondTest is BondRulesetTestBase { governor.castVote(id, uint8(BondRuleset.VoteType.For)); vm.roll(governor.proposalDeadline(id) + 1); governor.queue(t, v, c, h); - vm.expectRevert( - abi.encodeWithSelector(BondRuleset.BondNotResolvable.selector, id, IGovernor.ProposalState.Queued) - ); - bondRuleset.resolveBond(id); // veto window open — no early refund + + uint256 before = token.balanceOf(bob); + vm.expectEmit(true, true, false, true); + emit BondRuleset.BondRefunded(id, bob, BOND_AMOUNT); + bondRuleset.resolveBond(id); // veto window still open — early refund is the accepted trade-off + assertEq(token.balanceOf(bob), before + BOND_AMOUNT); + (, bool settled) = bondRuleset.bondOf(id); + assertTrue(settled); + } + + function test_resolve_refundsWhileSucceeded() public { + (uint256 id,,,,) = _proposeBonded("succeeded refund"); + vm.roll(governor.proposalSnapshot(id) + 1); + vm.prank(alice); + governor.castVote(id, uint8(BondRuleset.VoteType.For)); + vm.roll(governor.proposalDeadline(id) + 1); + assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Succeeded)); + + uint256 before = token.balanceOf(bob); + vm.expectEmit(true, true, false, true); + emit BondRuleset.BondRefunded(id, bob, BOND_AMOUNT); + bondRuleset.resolveBond(id); + assertEq(token.balanceOf(bob), before + BOND_AMOUNT); + } + + /// @dev Anyone may trigger the early refund; funds always go to the proposer. + function test_resolve_thirdPartyTriggersEarlyRefund_fundsGoToProposer() public { + (uint256 id,,,,) = _proposeBonded("stranger settles"); + vm.roll(governor.proposalSnapshot(id) + 1); + vm.prank(alice); + governor.castVote(id, uint8(BondRuleset.VoteType.For)); + vm.roll(governor.proposalDeadline(id) + 1); + + uint256 strangerBefore = token.balanceOf(eoa); + uint256 proposerBefore = token.balanceOf(bob); + vm.prank(eoa); + bondRuleset.resolveBond(id); + assertEq(token.balanceOf(bob), proposerBefore + BOND_AMOUNT); + assertEq(token.balanceOf(eoa), strangerBefore); + } + + /// @dev Early settle at Succeeded, then the proposal queues and executes normally — + /// resolution replay reverts, no double payout. + function test_resolve_earlySettleThenExecute_replayReverts() public { + address[] memory t; + uint256[] memory v; + bytes[] memory c; + bytes32 h; + uint256 id; + (id, t, v, c, h) = _proposeBonded("settle then execute"); + vm.roll(governor.proposalSnapshot(id) + 1); + vm.prank(alice); + governor.castVote(id, uint8(BondRuleset.VoteType.For)); + vm.roll(governor.proposalDeadline(id) + 1); + + bondRuleset.resolveBond(id); // refund at Succeeded + + governor.queue(t, v, c, h); + vm.warp(block.timestamp + TIMELOCK_DELAY + 1); + governor.execute(t, v, c, h); // lifecycle unaffected by the settled bond + assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Executed)); + + vm.expectRevert(abi.encodeWithSelector(BondRuleset.BondAlreadySettled.selector, id)); + bondRuleset.resolveBond(id); + } + + /// @dev The accepted trade-off, pinned: bond settled while Queued, council vetoes + /// after — the forfeit is unreachable (replay reverts), the veto itself still lands. + function test_resolve_earlySettleThenVeto_noForfeit() public { + address[] memory t; + uint256[] memory v; + bytes[] memory c; + bytes32 h; + uint256 id; + (id, t, v, c, h) = _proposeBonded("settle then veto"); + vm.roll(governor.proposalSnapshot(id) + 1); + vm.prank(alice); + governor.castVote(id, uint8(BondRuleset.VoteType.For)); + vm.roll(governor.proposalDeadline(id) + 1); + governor.queue(t, v, c, h); + + bondRuleset.resolveBond(id); // refund at Queued, before the veto + + bytes32 salt = bytes20(address(governor)) ^ h; + bytes32 opId = timelock.hashOperationBatch(t, v, c, 0, salt); + vm.prank(council); + timelock.cancel(opId); + assertEq(uint8(governor.state(id)), uint8(IGovernor.ProposalState.Canceled)); + assertEq(governor.proposalCanceledAt(id), 0); + + uint256 treasuryBefore = token.balanceOf(address(timelock)); + vm.expectRevert(abi.encodeWithSelector(BondRuleset.BondAlreadySettled.selector, id)); + bondRuleset.resolveBond(id); + assertEq(token.balanceOf(address(timelock)), treasuryBefore); // forfeit never happens } function test_resolve_replayReverts() public {