diff --git a/script/DeployVotiumInitiative.s.sol b/script/DeployVotiumInitiative.s.sol new file mode 100644 index 0000000..22dda64 --- /dev/null +++ b/script/DeployVotiumInitiative.s.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.24; + +import {Script} from "forge-std/Script.sol"; +import "forge-std/console2.sol"; + +import "src/VotiumInitiative.sol"; + +contract DeployVotiumInitiative is Script { + address constant GOVERNANCE_ADDRESS = 0x807DEf5E7d057DF05C796F4bc75C3Fe82Bd6EeE1; + address constant BOLD_TOKEN_ADDRESS = 0x6440f144b7e50D6a8439336510312d2F54beB01D; + address constant CRV_TOKEN_ADDRESS = 0xD533a949740bb3306d119CC777fa900bA034cd52; + address constant VOTIUM_ADDRESS = 0x63942E31E98f1833A234077f47880A66136a2D1e; + address constant GAUGE_ADDRESS = 0x07a01471fA544D9C6531B631E6A96A79a9AD05E9; + uint32 constant EPOCH_DURATION = 604800; + + address deployer; + + function run() external { + if (vm.envBytes("DEPLOYER").length == 20) { + // address + deployer = vm.envAddress("DEPLOYER"); + vm.startBroadcast(deployer); + } else { + // private key + uint256 privateKey = vm.envUint("DEPLOYER"); + deployer = vm.addr(privateKey); + vm.startBroadcast(privateKey); + } + + console2.log("deployer: ", deployer); + console2.log("Chain Id: ", block.chainid); + + VotiumInitiative votiumInitiative = new VotiumInitiative( + GOVERNANCE_ADDRESS, BOLD_TOKEN_ADDRESS, CRV_TOKEN_ADDRESS, VOTIUM_ADDRESS, GAUGE_ADDRESS, EPOCH_DURATION + ); + + console2.log("Deployed VotiumInitiative: ", address(votiumInitiative)); + } +} diff --git a/src/VotiumInitiative.sol b/src/VotiumInitiative.sol new file mode 100644 index 0000000..c15677e --- /dev/null +++ b/src/VotiumInitiative.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IVotium} from "./interfaces/IVotium.sol"; + +import {BribeInitiative} from "./BribeInitiative.sol"; + +contract VotiumInitiative is BribeInitiative { + IVotium public immutable votium; + address public immutable gauge; + uint256 public immutable duration; + + uint256 public remainder; + + event DepositIntoVotium(uint256 amount); + + constructor( + address _governance, + address _bold, + address _bribeToken, + address _votium, + address _gauge, + uint256 _duration + ) BribeInitiative(_governance, _bold, _bribeToken) { + votium = IVotium(_votium); + gauge = _gauge; + duration = _duration; + } + + /// @notice Governance transfers Bold, and we deposit it into the gauge + /// @dev Doing this allows anyone to trigger the claim + function onClaimForInitiative(uint256, uint256 _bold) external override onlyGovernance { + _depositIntoVotium(_bold); + } + + function _depositIntoVotium(uint256 _amount) internal { + uint256 total = _amount + remainder; + + // For small donations queue them into the contract + if (total < duration * 1000) { + remainder += _amount; + return; + } + + remainder = 0; + + uint256 available = bold.balanceOf(address(this)); + if (available < total) { + total = available; // Cap due to rounding error causing a bit more bold being given away + } + + bold.approve(address(votium), total); + votium.depositIncentiveSimple(address(bold), total, gauge); + + emit DepositIntoVotium(total); + } +} diff --git a/src/interfaces/IVotium.sol b/src/interfaces/IVotium.sol new file mode 100644 index 0000000..fe6b525 --- /dev/null +++ b/src/interfaces/IVotium.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IVotium { + // Deposit vote incentive for a single gauge in a active round with no max and no exclusions -- for gas efficiency + function depositIncentiveSimple(address _token, uint256 _amount, address _gauge) external; +} diff --git a/test/VotiumInitiative.t.sol b/test/VotiumInitiative.t.sol new file mode 100644 index 0000000..e9ef7ac --- /dev/null +++ b/test/VotiumInitiative.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +import {IGovernance} from "../src/interfaces/IGovernance.sol"; +import {ICurveStableswapFactoryNG} from "../src/interfaces/ICurveStableswapFactoryNG.sol"; +import {ICurveStableswapNG} from "../src/interfaces/ICurveStableswapNG.sol"; +import {IVotium} from "../src/interfaces/IVotium.sol"; + +import {VotiumInitiative} from "../src/VotiumInitiative.sol"; +import {Governance} from "../src/Governance.sol"; + +contract ForkedVotiumInitiativeTest is Test { + IERC20 private constant lqty = IERC20(0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D); + IERC20 private constant bold = IERC20(0x6440f144b7e50D6a8439336510312d2F54beB01D); + address private constant stakingV1 = 0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d; + //address private constant boldHolder = 0xabF2A7d999d7eBF2A0e29F267E6Bc93198818a96; + address public constant votium = 0x63942E31E98f1833A234077f47880A66136a2D1e; + address private constant gauge = 0x07a01471fA544D9C6531B631E6A96A79a9AD05E9; + + uint128 private constant REGISTRATION_FEE = 1e18; + uint128 private constant REGISTRATION_THRESHOLD_FACTOR = 0.01e18; + uint128 private constant UNREGISTRATION_THRESHOLD_FACTOR = 4e18; + uint16 private constant UNREGISTRATION_AFTER_EPOCHS = 4; + uint128 private constant VOTING_THRESHOLD_FACTOR = 0.04e18; + uint256 private constant MIN_CLAIM = 500e18; + uint256 private constant MIN_ACCRUAL = 1000e18; + uint32 private constant EPOCH_DURATION = 604800; + uint32 private constant EPOCH_VOTING_CUTOFF = 518400; + uint256 private constant DEPOSIT_THRESHOLD = EPOCH_DURATION * 1000; + uint256 private constant VOTIUM_FEE = 0.02 ether; // 2% + uint256 private constant DECIMAL_PRECISION = 1e18; + + Governance private governance; + address[] private initialInitiatives; + VotiumInitiative private votiumInitiative; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 25260251); + + IGovernance.Configuration memory config = IGovernance.Configuration({ + registrationFee: REGISTRATION_FEE, + registrationThresholdFactor: REGISTRATION_THRESHOLD_FACTOR, + unregistrationThresholdFactor: UNREGISTRATION_THRESHOLD_FACTOR, + unregistrationAfterEpochs: UNREGISTRATION_AFTER_EPOCHS, + votingThresholdFactor: VOTING_THRESHOLD_FACTOR, + minClaim: MIN_CLAIM, + minAccrual: MIN_ACCRUAL, + epochStart: uint32(block.timestamp), + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }); + + governance = new Governance( + address(lqty), address(bold), stakingV1, address(bold), config, address(this), initialInitiatives + ); + + votiumInitiative = new VotiumInitiative( + // address(vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1)), + address(governance), + address(bold), + address(lqty), + address(votium), + gauge, + EPOCH_DURATION + ); + + initialInitiatives.push(address(votiumInitiative)); + governance.registerInitialInitiatives(initialInitiatives); + } + + function test_claimAndDepositIntoGaugeFuzz(uint128 amt) public { + deal(address(bold), address(governance), amt); + vm.assume(amt > DEPOSIT_THRESHOLD); + + // Pretend a Proposal has passed + vm.startPrank(address(governance)); + bold.transfer(address(votiumInitiative), amt); + + assertEq(bold.balanceOf(address(votiumInitiative)), amt); + votiumInitiative.onClaimForInitiative(0, amt); + assertEq(bold.balanceOf(address(votiumInitiative)), 0); + assertApproxEqAbs(bold.balanceOf(address(votium)), getNetAmount(amt), 1); + } + + /// @dev If the amount rounds down below 1 per second it reverts + function test_claimAndDepositIntoGaugeGrief() public { + uint256 amt = DEPOSIT_THRESHOLD - 1; + deal(address(bold), address(governance), amt); + + // Pretend a Proposal has passed + vm.startPrank(address(governance)); + bold.transfer(address(votiumInitiative), amt); + + assertEq(bold.balanceOf(address(votiumInitiative)), amt); + votiumInitiative.onClaimForInitiative(0, amt); + assertEq(bold.balanceOf(address(votiumInitiative)), amt); + assertEq(bold.balanceOf(address(votium)), 0); + } + + /// @dev Fuzz test that shows that given a total = amt + dust, the dust is lost permanently + function test_noDustGriefFuzz(uint128 amt, uint128 dust) public { + uint256 total = uint256(amt) + uint256(dust); + deal(address(bold), address(governance), total); + + // Pretend a Proposal has passed + vm.startPrank(address(governance)); + // Dust amount + bold.transfer(address(votiumInitiative), amt); + // Rest + bold.transfer(address(votiumInitiative), dust); + + assertEq(bold.balanceOf(address(votiumInitiative)), total); + votiumInitiative.onClaimForInitiative(0, amt); + assertEq(bold.balanceOf(address(votiumInitiative)), votiumInitiative.remainder() + dust); + } + + function test_Counterexample_BribeInsolvency() external { + uint256 lqtyStake = 1_000 ether; + uint256 boldIncentive = 1_000 ether; // to Governance + uint256 boldBribe = 1_000 ether; + + deal(address(lqty), address(this), lqtyStake); + deal(address(bold), address(this), boldIncentive + boldBribe); + + // Stake LQTY + lqty.approve(governance.deriveUserProxyAddress(address(this)), lqtyStake); + governance.depositLQTY(lqtyStake); + + // Enter next epoch to unlock voting + skip(1 weeks); + + // Vote on VotiumInitiative + address[] memory initiatives = new address[](1); + int256[] memory votes = new int256[](1); + initiatives[0] = address(votiumInitiative); + votes[0] = int256(lqtyStake); + governance.allocateLQTY(new address[](0), initiatives, votes, new int256[](1)); + + // Simulate interest being sent to Governance + bold.transfer(address(governance), boldIncentive); + + // Also deposit some BOLD as bribes + bold.approve(address(votiumInitiative), boldBribe); + votiumInitiative.depositBribe({_boldAmount: boldBribe, _bribeTokenAmount: 0, _epoch: governance.epoch()}); + + // Enter next epoch + skip(1 weeks); + + // Claim on behalf of VotiumInitiative + governance.claimForInitiative(address(votiumInitiative)); + + // Attempt to claim bribes + VotiumInitiative.ClaimData[] memory claimData = new VotiumInitiative.ClaimData[](1); + claimData[0].epoch = governance.epoch() - 1; + claimData[0].prevLQTYAllocationEpoch = claimData[0].epoch; + claimData[0].prevTotalLQTYAllocationEpoch = claimData[0].epoch; + votiumInitiative.claimBribes(claimData); + // [FAIL: ERC20: transfer amount exceeds balance] + } + + function getNetAmount(uint256 _amount) internal pure returns (uint256) { + return (DECIMAL_PRECISION - VOTIUM_FEE) * _amount / DECIMAL_PRECISION; + } +} diff --git a/test/VotiumInitiativeDeployed.t.sol b/test/VotiumInitiativeDeployed.t.sol new file mode 100644 index 0000000..262d139 --- /dev/null +++ b/test/VotiumInitiativeDeployed.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/interfaces/IERC20.sol"; + +import {IGovernance} from "../src/interfaces/IGovernance.sol"; +import {ICurveStableswapFactoryNG} from "../src/interfaces/ICurveStableswapFactoryNG.sol"; +import {ICurveStableswapNG} from "../src/interfaces/ICurveStableswapNG.sol"; +import {IVotium} from "../src/interfaces/IVotium.sol"; + +import {VotiumInitiative} from "../src/VotiumInitiative.sol"; +import {Governance} from "../src/Governance.sol"; + +contract ForkedVotiumInitiativeDeployedTest is Test { + IERC20 private constant lqty = IERC20(0x6DEA81C8171D0bA574754EF6F8b412F2Ed88c54D); + IERC20 private constant bold = IERC20(0x6440f144b7e50D6a8439336510312d2F54beB01D); + address private constant stakingV1 = 0x4f9Fbb3f1E99B56e0Fe2892e623Ed36A76Fc605d; + //address private constant boldHolder = 0xabF2A7d999d7eBF2A0e29F267E6Bc93198818a96; + address constant GOVERNANCE_WHALE = 0xF30da4E4e7e20Dbf5fBE9adCD8699075D62C60A4; + address public constant votium = 0x63942E31E98f1833A234077f47880A66136a2D1e; + //address private constant gauge = 0x07a01471fA544D9C6531B631E6A96A79a9AD05E9; + Governance private constant governance = Governance(0x807DEf5E7d057DF05C796F4bc75C3Fe82Bd6EeE1); + VotiumInitiative private constant votiumInitiative = VotiumInitiative(0x69eFEc83296c711db4A403B1Ee281E87f99590d6); + + uint32 private constant EPOCH_DURATION = 604800; + uint256 private constant DEPOSIT_THRESHOLD = EPOCH_DURATION * 1000; + uint256 private constant VOTIUM_FEE = 0.02 ether; // 2% + uint256 private constant DECIMAL_PRECISION = 1e18; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 25336275); + + uint256 REGISTRATION_FEE = governance.REGISTRATION_FEE(); + + deal(address(bold), GOVERNANCE_WHALE, REGISTRATION_FEE); + vm.startPrank(GOVERNANCE_WHALE); + bold.approve(address(governance), REGISTRATION_FEE); + governance.registerInitiative(address(votiumInitiative)); + vm.stopPrank(); + + vm.warp(block.timestamp + 1 weeks); + } + + function test_claimAndDepositIntoGaugeFuzz(uint128 amt) public { + deal(address(bold), address(governance), amt); + vm.assume(amt > DEPOSIT_THRESHOLD); + + // Pretend a Proposal has passed + vm.startPrank(address(governance)); + bold.transfer(address(votiumInitiative), amt); + + assertEq(bold.balanceOf(address(votiumInitiative)), amt); + votiumInitiative.onClaimForInitiative(0, amt); + assertEq(bold.balanceOf(address(votiumInitiative)), 0); + assertApproxEqAbs(bold.balanceOf(address(votium)), getNetAmount(amt), 1); + } + + /// @dev If the amount rounds down below 1 per second it reverts + function test_claimAndDepositIntoGaugeGrief() public { + uint256 amt = DEPOSIT_THRESHOLD - 1; + deal(address(bold), address(governance), amt); + + // Pretend a Proposal has passed + vm.startPrank(address(governance)); + bold.transfer(address(votiumInitiative), amt); + + assertEq(bold.balanceOf(address(votiumInitiative)), amt); + votiumInitiative.onClaimForInitiative(0, amt); + assertEq(bold.balanceOf(address(votiumInitiative)), amt); + assertEq(bold.balanceOf(address(votium)), 0); + } + + /// @dev Fuzz test that shows that given a total = amt + dust, the dust is lost permanently + function test_noDustGriefFuzz(uint128 amt, uint128 dust) public { + uint256 total = uint256(amt) + uint256(dust); + deal(address(bold), address(governance), total); + + // Pretend a Proposal has passed + vm.startPrank(address(governance)); + // Dust amount + bold.transfer(address(votiumInitiative), amt); + // Rest + bold.transfer(address(votiumInitiative), dust); + + assertEq(bold.balanceOf(address(votiumInitiative)), total); + votiumInitiative.onClaimForInitiative(0, amt); + + assertEq(bold.balanceOf(address(votiumInitiative)), votiumInitiative.remainder() + dust); + } + + function getNetAmount(uint256 _amount) internal pure returns (uint256) { + return (DECIMAL_PRECISION - VOTIUM_FEE) * _amount / DECIMAL_PRECISION; + } +}