Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
24 changes: 15 additions & 9 deletions src/rulesets/BondRuleset.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
102 changes: 96 additions & 6 deletions test/governor/GovernorNexus.bond.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
Loading