diff --git a/script/DeployOracleBond.s.sol b/script/DeployOracleBond.s.sol new file mode 100644 index 0000000..3312503 --- /dev/null +++ b/script/DeployOracleBond.s.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "../src/CountersigOracleBond.sol"; + +/** + * @title DeployOracleBond + * @notice Deploys CountersigOracleBond behind a UUPS proxy (tokenomics §8 oracle + * operator performance bonds). Governance later admits operators and grants + * them ORACLE_ROLE on CountersigReputation / CountersigEpochFees off + * isActiveOperator(). + * + * Usage: + * forge script script/DeployOracleBond.s.sol --rpc-url $RPC --broadcast --verify -vvvv + * + * Required env: + * DEPLOYER_PRIVATE_KEY + * CSIG_ADDRESS — $CSIG token + * Optional env (default to the deployer): + * SLASHER_ADDRESS — granted SLASHER_ROLE (governance/committee) + * SLASH_BENEFICIARY — destination for slashed bonds (defaults to deployer) + * ORACLE_BOND_AMOUNT — minimum bond in wei (default 1,000 CSIG) + * ORACLE_UNBONDING — unbonding cooldown seconds (default 7 days) + */ +contract DeployOracleBond is Script { + uint256 constant DEFAULT_BOND = 1_000e18; + uint256 constant DEFAULT_UNBONDING = 7 days; + + function run() external { + uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + + address csig = vm.envAddress("CSIG_ADDRESS"); + address slasher = vm.envOr("SLASHER_ADDRESS", deployer); + address beneficiary = vm.envOr("SLASH_BENEFICIARY", deployer); + uint256 bondAmount = vm.envOr("ORACLE_BOND_AMOUNT", DEFAULT_BOND); + uint256 unbonding = vm.envOr("ORACLE_UNBONDING", DEFAULT_UNBONDING); + + vm.startBroadcast(deployerKey); + + CountersigOracleBond impl = new CountersigOracleBond(); + CountersigOracleBond bond = CountersigOracleBond(address(new ERC1967Proxy( + address(impl), + abi.encodeCall( + CountersigOracleBond.initialize, + (deployer, slasher, csig, bondAmount, unbonding, beneficiary) + ) + ))); + + vm.stopBroadcast(); + + require(address(bond.csig()) == csig, "csig not set"); + require(bond.bondAmount() == bondAmount, "bondAmount not set"); + require(bond.hasRole(bond.SLASHER_ROLE(), slasher), "slasher role not granted"); + + console2.log("=== Countersig OracleBond Deployment ==="); + console2.log("Chain: ", block.chainid); + console2.log("OracleBond proxy: ", address(bond)); + console2.log("OracleBond impl: ", address(impl)); + console2.log("Slasher: ", slasher); + console2.log("Slash beneficiary: ", beneficiary); + console2.log("Bond amount (wei): ", bondAmount); + console2.log("Unbonding (s): ", unbonding); + console2.log("CSIG: ", csig); + } +} diff --git a/src/CountersigOracleBond.sol b/src/CountersigOracleBond.sol new file mode 100644 index 0000000..fd24af5 --- /dev/null +++ b/src/CountersigOracleBond.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title CountersigOracleBond + * @notice Performance-bond registry for the oracle operator set (tokenomics v0.3 + * §8). This is a SEPARATE accounting + slashing path from CountersigStaking + * (which bonds agents, not operators). + * + * Lifecycle: + * depositBond() — an applicant posts >= bondAmount $CSIG -> Bonded + * admit() — governance admits a bonded applicant -> Active + * initiateUnbond / removeOperator -> Exiting + * withdrawBond() — after the unbonding cooldown -> None + * + * Slashing (§8): a SLASHER (governance/committee) partially slashes an + * operator's bond for provably-incorrect reputation data or >24h downtime. + * Slashed $CSIG goes to `slashBeneficiary`. If a slash drops an Active + * operator below bondAmount, it is demoted out of the active set. A bond in + * the unbonding cooldown is still slashable, so an operator cannot exit to + * dodge accountability for behavior discovered before withdrawal clears. + * + * This contract tracks bonds and active status only. Granting the actual + * ORACLE_ROLE on CountersigReputation / CountersigEpochFees stays a + * governance action, gated off `isActiveOperator()`. + */ +contract CountersigOracleBond is + Initializable, + AccessControlUpgradeable, + UUPSUpgradeable, + ReentrancyGuard +{ + using SafeERC20 for IERC20; + + // ------------------------------------------------------------------------- + // Roles + // ------------------------------------------------------------------------- + + /// Slashes operator bonds. On mainnet: governance multisig / committee. + bytes32 public constant SLASHER_ROLE = keccak256("SLASHER_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + + // ------------------------------------------------------------------------- + // Types + // ------------------------------------------------------------------------- + + enum Status { None, Bonded, Active, Exiting } + + struct Operator { + uint256 bond; + Status status; + uint256 unbondingAt; // timestamp initiateUnbond/removeOperator was called + } + + // ------------------------------------------------------------------------- + // Storage — append-only (see storage-layout test). + // ------------------------------------------------------------------------- + + IERC20 public csig; // slot 0 + uint256 public bondAmount; // slot 1 — minimum bond to be admitted + uint256 public unbondingPeriod; // slot 2 — cooldown before withdrawal + address public slashBeneficiary; // slot 3 — destination for slashed bond + uint256 public activeCount; // slot 4 — number of Active operators + mapping(address => Operator) public operators; // slot 5 + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + event BondDeposited(address indexed operator, uint256 amount, uint256 newBond); + event OperatorAdmitted(address indexed operator); + event OperatorSlashed(address indexed operator, uint256 amount, uint256 remaining, bool demoted); + event UnbondInitiated(address indexed operator, uint256 claimableAt); + event BondWithdrawn(address indexed operator, uint256 amount); + event OperatorRemoved(address indexed operator); + event BondAmountUpdated(uint256 newBondAmount); + event UnbondingPeriodUpdated(uint256 newPeriod); + event SlashBeneficiaryUpdated(address newBeneficiary); + + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + error ZeroAddress(); + error ZeroAmount(); + error WrongStatus(address operator, Status current); + error InsufficientBond(address operator, uint256 have, uint256 required); + error SlashExceedsBond(address operator, uint256 amount, uint256 bond); + error UnbondingActive(address operator, uint256 claimableAt); + error NoBond(address operator); + + // ------------------------------------------------------------------------- + // Constructor / Initializer + // ------------------------------------------------------------------------- + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address admin, + address slasher, + address csigToken, + uint256 bondAmount_, + uint256 unbondingPeriod_, + address slashBeneficiary_ + ) external initializer { + if (admin == address(0) || csigToken == address(0) || slashBeneficiary_ == address(0)) { + revert ZeroAddress(); + } + + __AccessControl_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(UPGRADER_ROLE, admin); + if (slasher != address(0)) _grantRole(SLASHER_ROLE, slasher); + + csig = IERC20(csigToken); + bondAmount = bondAmount_; + unbondingPeriod = unbondingPeriod_; + slashBeneficiary = slashBeneficiary_; + } + + // ------------------------------------------------------------------------- + // Operator actions + // ------------------------------------------------------------------------- + + /** + * @notice Post or top up a performance bond. A fresh applicant becomes Bonded + * (pending governance admission). Not allowed while Exiting — claim the + * withdrawal first. Caller must approve this contract for `amount`. + */ + function depositBond(uint256 amount) external nonReentrant { + if (amount == 0) revert ZeroAmount(); + Operator storage op = operators[msg.sender]; + if (op.status == Status.Exiting) revert WrongStatus(msg.sender, op.status); + + csig.safeTransferFrom(msg.sender, address(this), amount); + op.bond += amount; + if (op.status == Status.None) op.status = Status.Bonded; + + emit BondDeposited(msg.sender, amount, op.bond); + } + + /** + * @notice Signal exit from the operator set. Bonded/Active -> Exiting and starts + * the unbonding cooldown. The bond stays slashable during the cooldown. + */ + function initiateUnbond() external nonReentrant { + Operator storage op = operators[msg.sender]; + if (op.status != Status.Bonded && op.status != Status.Active) { + revert WrongStatus(msg.sender, op.status); + } + if (op.status == Status.Active) activeCount -= 1; + + op.status = Status.Exiting; + op.unbondingAt = block.timestamp; + + emit UnbondInitiated(msg.sender, block.timestamp + unbondingPeriod); + } + + /// @notice Withdraw the remaining bond once the unbonding cooldown has elapsed. + function withdrawBond() external nonReentrant { + Operator storage op = operators[msg.sender]; + if (op.status != Status.Exiting) revert WrongStatus(msg.sender, op.status); + + uint256 claimableAt = op.unbondingAt + unbondingPeriod; + if (block.timestamp < claimableAt) revert UnbondingActive(msg.sender, claimableAt); + + uint256 amount = op.bond; + if (amount == 0) revert NoBond(msg.sender); + + op.bond = 0; + op.status = Status.None; + op.unbondingAt = 0; + + csig.safeTransfer(msg.sender, amount); + emit BondWithdrawn(msg.sender, amount); + } + + // ------------------------------------------------------------------------- + // Governance + // ------------------------------------------------------------------------- + + /// @notice Admit a bonded applicant into the active operator set (§8 "join via governance vote"). + function admit(address operator) external onlyRole(DEFAULT_ADMIN_ROLE) { + Operator storage op = operators[operator]; + if (op.status != Status.Bonded) revert WrongStatus(operator, op.status); + if (op.bond < bondAmount) revert InsufficientBond(operator, op.bond, bondAmount); + + op.status = Status.Active; + activeCount += 1; + emit OperatorAdmitted(operator); + } + + /// @notice Force an operator out of the set into unbonding (e.g. after misbehavior). + function removeOperator(address operator) external onlyRole(DEFAULT_ADMIN_ROLE) { + Operator storage op = operators[operator]; + if (op.status != Status.Active && op.status != Status.Bonded) { + revert WrongStatus(operator, op.status); + } + if (op.status == Status.Active) activeCount -= 1; + + op.status = Status.Exiting; + op.unbondingAt = block.timestamp; + emit OperatorRemoved(operator); + } + + /** + * @notice Partially slash an operator's bond (§8). Slashed $CSIG goes to + * slashBeneficiary. An Active operator whose bond drops below bondAmount + * is demoted out of the active set (must top up + be re-admitted). + */ + function slash(address operator, uint256 amount) external nonReentrant onlyRole(SLASHER_ROLE) { + Operator storage op = operators[operator]; + if (op.bond == 0) revert NoBond(operator); + if (amount == 0) revert ZeroAmount(); + if (amount > op.bond) revert SlashExceedsBond(operator, amount, op.bond); + + op.bond -= amount; + + bool demoted; + if (op.status == Status.Active && op.bond < bondAmount) { + op.status = Status.Bonded; + activeCount -= 1; + demoted = true; + } + + csig.safeTransfer(slashBeneficiary, amount); + emit OperatorSlashed(operator, amount, op.bond, demoted); + } + + // ------------------------------------------------------------------------- + // Admin params + // ------------------------------------------------------------------------- + + function setBondAmount(uint256 newBondAmount) external onlyRole(DEFAULT_ADMIN_ROLE) { + bondAmount = newBondAmount; + emit BondAmountUpdated(newBondAmount); + } + + function setUnbondingPeriod(uint256 newPeriod) external onlyRole(DEFAULT_ADMIN_ROLE) { + unbondingPeriod = newPeriod; + emit UnbondingPeriodUpdated(newPeriod); + } + + function setSlashBeneficiary(address newBeneficiary) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newBeneficiary == address(0)) revert ZeroAddress(); + slashBeneficiary = newBeneficiary; + emit SlashBeneficiaryUpdated(newBeneficiary); + } + + // ------------------------------------------------------------------------- + // Views + // ------------------------------------------------------------------------- + + function isActiveOperator(address operator) external view returns (bool) { + return operators[operator].status == Status.Active; + } + + function bondOf(address operator) external view returns (uint256) { + return operators[operator].bond; + } + + // ------------------------------------------------------------------------- + // UUPS upgrade authorization + // ------------------------------------------------------------------------- + + function _authorizeUpgrade(address) internal override onlyRole(UPGRADER_ROLE) {} +} diff --git a/test/CountersigOracleBond.t.sol b/test/CountersigOracleBond.t.sol new file mode 100644 index 0000000..ee67a10 --- /dev/null +++ b/test/CountersigOracleBond.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "@openzeppelin/contracts/access/IAccessControl.sol"; + +import "../src/CountersigOracleBond.sol"; +import "../src/CSIGToken.sol"; + +contract CountersigOracleBondTest is Test { + CountersigOracleBond bond; + CSIGToken csig; + + address admin = makeAddr("admin"); + address slasher = makeAddr("slasher"); + address beneficiary = makeAddr("beneficiary"); + address op1 = makeAddr("op1"); + address op2 = makeAddr("op2"); + address stranger = makeAddr("stranger"); + + uint256 constant BOND = 1000e18; + uint256 constant UNBOND = 7 days; + + function setUp() public { + csig = new CSIGToken(address(this)); + + CountersigOracleBond impl = new CountersigOracleBond(); + bond = CountersigOracleBond(address(new ERC1967Proxy( + address(impl), + abi.encodeCall( + CountersigOracleBond.initialize, + (admin, slasher, address(csig), BOND, UNBOND, beneficiary) + ) + ))); + + csig.mint(op1, 10_000e18); + csig.mint(op2, 10_000e18); + vm.prank(op1); csig.approve(address(bond), type(uint256).max); + vm.prank(op2); csig.approve(address(bond), type(uint256).max); + } + + function _bondAndAdmit(address op, uint256 amount) internal { + vm.prank(op); + bond.depositBond(amount); + vm.prank(admin); + bond.admit(op); + } + + // ------------------------------------------------------------------------- + // Init + // ------------------------------------------------------------------------- + + function test_init() public view { + assertEq(address(bond.csig()), address(csig)); + assertEq(bond.bondAmount(), BOND); + assertEq(bond.unbondingPeriod(), UNBOND); + assertEq(bond.slashBeneficiary(), beneficiary); + assertTrue(bond.hasRole(bond.SLASHER_ROLE(), slasher)); + } + + // ------------------------------------------------------------------------- + // Deposit / admit + // ------------------------------------------------------------------------- + + function test_depositBond_bondsApplicant() public { + vm.prank(op1); + bond.depositBond(1500e18); + assertEq(bond.bondOf(op1), 1500e18); + (, CountersigOracleBond.Status status,) = bond.operators(op1); + assertEq(uint8(status), uint8(CountersigOracleBond.Status.Bonded)); + assertEq(csig.balanceOf(address(bond)), 1500e18); + } + + function test_depositBond_zero_reverts() public { + vm.expectRevert(CountersigOracleBond.ZeroAmount.selector); + vm.prank(op1); + bond.depositBond(0); + } + + function test_admit_activatesOperator() public { + _bondAndAdmit(op1, BOND); + assertTrue(bond.isActiveOperator(op1)); + assertEq(bond.activeCount(), 1); + } + + function test_admit_insufficientBond_reverts() public { + vm.prank(op1); + bond.depositBond(BOND - 1); + vm.expectRevert(abi.encodeWithSelector(CountersigOracleBond.InsufficientBond.selector, op1, BOND - 1, BOND)); + vm.prank(admin); + bond.admit(op1); + } + + function test_admit_notBonded_reverts() public { + vm.expectRevert(abi.encodeWithSelector( + CountersigOracleBond.WrongStatus.selector, op1, CountersigOracleBond.Status.None + )); + vm.prank(admin); + bond.admit(op1); + } + + function test_admit_notAdmin_reverts() public { + vm.prank(op1); + bond.depositBond(BOND); + vm.expectRevert(abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, bytes32(0) + )); + vm.prank(stranger); + bond.admit(op1); + } + + // ------------------------------------------------------------------------- + // Slashing + // ------------------------------------------------------------------------- + + function test_slash_reducesBond_toBeneficiary() public { + _bondAndAdmit(op1, 2000e18); + vm.prank(slasher); + bond.slash(op1, 500e18); + assertEq(bond.bondOf(op1), 1500e18); + assertEq(csig.balanceOf(beneficiary), 500e18); + assertTrue(bond.isActiveOperator(op1)); // still >= bondAmount + assertEq(bond.activeCount(), 1); + } + + function test_slash_demotesBelowMinimum() public { + _bondAndAdmit(op1, 1500e18); + vm.prank(slasher); + bond.slash(op1, 600e18); // 900 < 1000 + assertEq(bond.bondOf(op1), 900e18); + assertFalse(bond.isActiveOperator(op1)); + assertEq(bond.activeCount(), 0); + } + + function test_slash_exceedsBond_reverts() public { + _bondAndAdmit(op1, BOND); + vm.expectRevert(abi.encodeWithSelector(CountersigOracleBond.SlashExceedsBond.selector, op1, BOND + 1, BOND)); + vm.prank(slasher); + bond.slash(op1, BOND + 1); + } + + function test_slash_notSlasher_reverts() public { + _bondAndAdmit(op1, BOND); + vm.expectRevert(abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, bond.SLASHER_ROLE() + )); + vm.prank(stranger); + bond.slash(op1, 1e18); + } + + function test_slash_duringUnbonding_stillWorks() public { + _bondAndAdmit(op1, 2000e18); + vm.prank(op1); + bond.initiateUnbond(); // Exiting + + vm.prank(slasher); + bond.slash(op1, 500e18); // still slashable in cooldown + assertEq(bond.bondOf(op1), 1500e18); + assertEq(csig.balanceOf(beneficiary), 500e18); + } + + // ------------------------------------------------------------------------- + // Unbonding + // ------------------------------------------------------------------------- + + function test_initiateUnbond_exitsActiveSet() public { + _bondAndAdmit(op1, BOND); + vm.prank(op1); + bond.initiateUnbond(); + assertFalse(bond.isActiveOperator(op1)); + assertEq(bond.activeCount(), 0); + } + + function test_withdrawBond_afterCooldown() public { + _bondAndAdmit(op1, 1500e18); + vm.prank(op1); + bond.initiateUnbond(); + + vm.warp(block.timestamp + UNBOND); + uint256 before = csig.balanceOf(op1); + vm.prank(op1); + bond.withdrawBond(); + assertEq(csig.balanceOf(op1), before + 1500e18); + assertEq(bond.bondOf(op1), 0); + (, CountersigOracleBond.Status status,) = bond.operators(op1); + assertEq(uint8(status), uint8(CountersigOracleBond.Status.None)); + } + + function test_withdrawBond_beforeCooldown_reverts() public { + _bondAndAdmit(op1, BOND); + vm.prank(op1); + bond.initiateUnbond(); + uint256 claimableAt = block.timestamp + UNBOND; + vm.expectRevert(abi.encodeWithSelector(CountersigOracleBond.UnbondingActive.selector, op1, claimableAt)); + vm.prank(op1); + bond.withdrawBond(); + } + + function test_withdrawBond_whileActive_reverts() public { + _bondAndAdmit(op1, BOND); + vm.expectRevert(abi.encodeWithSelector( + CountersigOracleBond.WrongStatus.selector, op1, CountersigOracleBond.Status.Active + )); + vm.prank(op1); + bond.withdrawBond(); + } + + function test_removeOperator_forcesExit() public { + _bondAndAdmit(op1, BOND); + vm.prank(admin); + bond.removeOperator(op1); + assertFalse(bond.isActiveOperator(op1)); + assertEq(bond.activeCount(), 0); + (, CountersigOracleBond.Status status,) = bond.operators(op1); + assertEq(uint8(status), uint8(CountersigOracleBond.Status.Exiting)); + } + + // ------------------------------------------------------------------------- + // activeCount across multiple operators + // ------------------------------------------------------------------------- + + function test_activeCount_tracksSet() public { + _bondAndAdmit(op1, BOND); + _bondAndAdmit(op2, BOND); + assertEq(bond.activeCount(), 2); + vm.prank(op1); + bond.initiateUnbond(); + assertEq(bond.activeCount(), 1); + } + + // ------------------------------------------------------------------------- + // Admin params + // ------------------------------------------------------------------------- + + function test_setters_adminOnly() public { + vm.startPrank(admin); + bond.setBondAmount(2000e18); + bond.setUnbondingPeriod(14 days); + bond.setSlashBeneficiary(stranger); + vm.stopPrank(); + assertEq(bond.bondAmount(), 2000e18); + assertEq(bond.unbondingPeriod(), 14 days); + assertEq(bond.slashBeneficiary(), stranger); + + vm.expectRevert(CountersigOracleBond.ZeroAddress.selector); + vm.prank(admin); + bond.setSlashBeneficiary(address(0)); + + vm.expectRevert(abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, stranger, bytes32(0) + )); + vm.prank(stranger); + bond.setBondAmount(1); + } + + // ------------------------------------------------------------------------- + // Storage layout — pins operators mapping to slot 5 + // ------------------------------------------------------------------------- + + function test_storageLayout_operatorsPinnedToSlot5() public { + vm.prank(op1); + bond.depositBond(1234e18); + // First field of Operator is `bond`. + bytes32 slot = keccak256(abi.encode(op1, uint256(5))); + assertEq(uint256(vm.load(address(bond), slot)), 1234e18); + } +}