diff --git a/test/BribeInitiativeFireAndForget.t.sol b/test/BribeInitiativeFireAndForget.t.sol new file mode 100644 index 0000000..ad85b71 --- /dev/null +++ b/test/BribeInitiativeFireAndForget.t.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {console2 as console} from "forge-std/console2.sol"; +import {Strings} from "openzeppelin/contracts/utils/Strings.sol"; +import {Math} from "openzeppelin/contracts/utils/math/Math.sol"; +import {IBribeInitiative} from "../src/interfaces/IBribeInitiative.sol"; +import {IGovernance} from "../src/interfaces/IGovernance.sol"; +import {BribeInitiative} from "../src/BribeInitiative.sol"; +import {Governance} from "../src/Governance.sol"; +import {MockERC20Tester} from "./mocks/MockERC20Tester.sol"; +import {MockStakingV1} from "./mocks/MockStakingV1.sol"; +import {MockStakingV1Deployer} from "./mocks/MockStakingV1Deployer.sol"; +import {Random} from "./util/Random.sol"; +import {UintArray} from "./util/UintArray.sol"; +import {StringFormatting} from "./util/StringFormatting.sol"; + +contract BribeInitiativeFireAndForgetTest is MockStakingV1Deployer { + using Random for Random.Context; + using UintArray for uint256[]; + using Strings for *; + using StringFormatting for *; + + uint32 constant START_TIME = 1732873631; + uint32 constant EPOCH_DURATION = 7 days; + uint32 constant EPOCH_VOTING_CUTOFF = 6 days; + + uint16 constant MAX_NUM_EPOCHS = 100; + uint88 constant MAX_VOTE = 1e6 ether; + uint128 constant MAX_BRIBE = 1e6 ether; + uint256 constant MAX_CLAIMS_PER_CALL = 10; + uint256 constant MEAN_TIME_BETWEEN_VOTES = 2 * EPOCH_DURATION; + uint256 constant VOTER_PROBABILITY = type(uint256).max / 10; + + address constant voter = address(uint160(uint256(keccak256("voter")))); + address constant other = address(uint160(uint256(keccak256("other")))); + address constant briber = address(uint160(uint256(keccak256("briber")))); + + IGovernance.Configuration config = IGovernance.Configuration({ + registrationFee: 0, + registrationThresholdFactor: 0, + unregistrationThresholdFactor: 4 ether, + unregistrationAfterEpochs: 4, + votingThresholdFactor: 1e4, // min value that doesn't result in division by zero + minClaim: 0, + minAccrual: 0, + epochStart: START_TIME - EPOCH_DURATION, + epochDuration: EPOCH_DURATION, + epochVotingCutoff: EPOCH_VOTING_CUTOFF + }); + + struct Vote { + uint16 epoch; + uint256 amount; + } + + MockStakingV1 stakingV1; + MockERC20Tester lqty; + MockERC20Tester lusd; + MockERC20Tester bold; + MockERC20Tester bryb; + Governance governance; + BribeInitiative bribeInitiative; + + mapping(address who => address[]) initiativesToReset; + mapping(address who => Vote) latestVote; + mapping(uint256 epoch => uint256) boldAtEpoch; + mapping(uint256 epoch => uint256) brybAtEpoch; + mapping(uint256 epoch => uint256) voteAtEpoch; // number of LQTY allocated by "voter" + mapping(uint256 epoch => uint256) toteAtEpoch; // number of LQTY allocated in total ("voter" + "other") + mapping(uint256 epoch => IBribeInitiative.ClaimData) claimDataAtEpoch; + IBribeInitiative.ClaimData[] claimData; + + function setUp() external { + vm.warp(START_TIME); + + vm.label(voter, "voter"); + vm.label(other, "other"); + vm.label(briber, "briber"); + + (stakingV1, lqty, lusd) = deployMockStakingV1(); + + bold = new MockERC20Tester("BOLD Stablecoin", "BOLD"); + vm.label(address(bold), "BOLD"); + + bryb = new MockERC20Tester("Bribe Token", "BRYB"); + vm.label(address(bryb), "BRYB"); + + governance = new Governance({ + _lqty: address(lqty), + _lusd: address(lusd), + _stakingV1: address(stakingV1), + _bold: address(bold), + _config: config, + _owner: address(this), + _initiatives: new address[](0) + }); + + bribeInitiative = + new BribeInitiative({_governance: address(governance), _bold: address(bold), _bribeToken: address(bryb)}); + + address[] memory initiatives = new address[](1); + initiatives[0] = address(bribeInitiative); + governance.registerInitialInitiatives(initiatives); + + address voterProxy = governance.deriveUserProxyAddress(voter); + vm.label(voterProxy, "voterProxy"); + + address otherProxy = governance.deriveUserProxyAddress(other); + vm.label(otherProxy, "otherProxy"); + + lqty.mint(voter, MAX_VOTE); + lqty.mint(other, MAX_VOTE); + + vm.startPrank(voter); + lqty.approve(voterProxy, MAX_VOTE); + governance.depositLQTY(MAX_VOTE); + vm.stopPrank(); + + vm.startPrank(other); + lqty.approve(otherProxy, MAX_VOTE); + governance.depositLQTY(MAX_VOTE); + vm.stopPrank(); + + vm.startPrank(briber); + bold.approve(address(bribeInitiative), type(uint256).max); + bryb.approve(address(bribeInitiative), type(uint256).max); + vm.stopPrank(); + } + + function test_AbleToClaimBribesInAnyOrder_EvenFromEpochsWhereVoterStayedInactive(bytes32 seed) external { + Random.Context memory random = Random.init(seed); + uint16 startingEpoch = governance.epoch(); + uint16 lastEpoch = startingEpoch; + + for (uint16 i = 0; i < MAX_NUM_EPOCHS; ++i) { + uint128 boldAmount = uint128(random.generate(MAX_BRIBE)); + uint128 brybAmount = uint128(random.generate(MAX_BRIBE)); + + bold.mint(briber, boldAmount); + bryb.mint(briber, brybAmount); + + vm.prank(briber); + bribeInitiative.depositBribe(boldAmount, brybAmount, startingEpoch + i); + } + + for (;;) { + vm.warp(block.timestamp + random.generate(2 * MEAN_TIME_BETWEEN_VOTES)); + uint16 epoch = governance.epoch(); + + for (uint16 i = lastEpoch; i < epoch; ++i) { + voteAtEpoch[i] = latestVote[voter].amount; + toteAtEpoch[i] = latestVote[voter].amount + latestVote[other].amount; + claimDataAtEpoch[i].epoch = i; + claimDataAtEpoch[i].prevLQTYAllocationEpoch = latestVote[voter].epoch; + claimDataAtEpoch[i].prevTotalLQTYAllocationEpoch = + uint16(Math.max(latestVote[voter].epoch, latestVote[other].epoch)); + + console.log( + string.concat( + "epoch #", + i.toString(), + ": vote = ", + voteAtEpoch[i].decimal(), + ", tote = ", + toteAtEpoch[i].decimal() + ) + ); + } + + lastEpoch = epoch; + if (epoch >= startingEpoch + MAX_NUM_EPOCHS) break; + + (IGovernance.InitiativeStatus status,,) = governance.getInitiativeState(address(bribeInitiative)); + + if (status == IGovernance.InitiativeStatus.CLAIMABLE) { + governance.claimForInitiative(address(bribeInitiative)); + } + + if (status == IGovernance.InitiativeStatus.UNREGISTERABLE) { + governance.unregisterInitiative(address(bribeInitiative)); + break; + } + + address who = random.generate() < VOTER_PROBABILITY ? voter : other; + uint256 vote = governance.secondsWithinEpoch() <= EPOCH_VOTING_CUTOFF ? random.generate(MAX_VOTE) : 0; + + if (vote > 0 || latestVote[who].amount > 0) { + // can't reset when already reset + latestVote[who].epoch = epoch; + latestVote[who].amount = vote; + _vote(who, address(bribeInitiative), latestVote[who].amount); + } + } + + uint256[] memory epochPermutation = UintArray.seq(startingEpoch, lastEpoch + 1).permute(random); + uint256 start = 0; + uint256 expectedBold = 0; + uint256 expectedBryb = 0; + + while (start < epochPermutation.length) { + uint256 end = Math.min(start + random.generate(MAX_CLAIMS_PER_CALL), epochPermutation.length); + + for (uint256 i = start; i < end; ++i) { + if ( + voteAtEpoch[epochPermutation[i]] > 0 + && (boldAtEpoch[epochPermutation[i]] > 0 || brybAtEpoch[epochPermutation[i]] > 0) + ) { + claimData.push(claimDataAtEpoch[epochPermutation[i]]); + expectedBold += boldAtEpoch[epochPermutation[i]] * voteAtEpoch[epochPermutation[i]] + / toteAtEpoch[epochPermutation[i]]; + expectedBryb += brybAtEpoch[epochPermutation[i]] * voteAtEpoch[epochPermutation[i]] + / toteAtEpoch[epochPermutation[i]]; + } + } + + vm.prank(voter); + bribeInitiative.claimBribes(claimData); + delete claimData; + + assertEqDecimal(bold.balanceOf(voter), expectedBold, 18, "bold.balanceOf(voter) != expectedBold"); + assertEqDecimal(bryb.balanceOf(voter), expectedBryb, 18, "bryb.balanceOf(voter) != expectedBryb"); + + start = end; + } + } + + ///////////// + // Helpers // + ///////////// + + function _vote(address who, address initiative, uint256 vote) internal { + assertLeDecimal(vote, uint256(int256(type(int88).max)), 18, "vote > type(uint88).max"); + vm.startPrank(who); + + if (vote > 0) { + address[] memory initiatives = new address[](1); + int88[] memory votes = new int88[](1); + int88[] memory vetos = new int88[](1); + + initiatives[0] = initiative; + votes[0] = int88(uint88(vote)); + governance.allocateLQTY(initiativesToReset[who], initiatives, votes, vetos); + + if (initiativesToReset[who].length != 0) initiativesToReset[who].pop(); + initiativesToReset[who].push(initiative); + } else { + if (initiativesToReset[who].length != 0) { + governance.resetAllocations(initiativesToReset[who], true); + initiativesToReset[who].pop(); + } + } + + vm.stopPrank(); + } +} diff --git a/test/util/Random.sol b/test/util/Random.sol new file mode 100644 index 0000000..e5ebc3b --- /dev/null +++ b/test/util/Random.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +function bound(uint256 x, uint256 min, uint256 max) pure returns (uint256) { + require(min <= max, "min > max"); + return min == 0 && max == type(uint256).max ? x : min + x % (max - min + 1); +} + +library Random { + struct Context { + bytes32 seed; + } + + function init(bytes32 seed) internal pure returns (Random.Context memory c) { + init(c, seed); + } + + function init(Context memory c, bytes32 seed) internal pure { + c.seed = seed; + } + + function generate(Context memory c) internal pure returns (uint256) { + return generate(c, 0, type(uint256).max); + } + + function generate(Context memory c, uint256 max) internal pure returns (uint256) { + return generate(c, 0, max); + } + + function generate(Context memory c, uint256 min, uint256 max) internal pure returns (uint256) { + c.seed = keccak256(abi.encode(c.seed)); + return bound(uint256(c.seed), min, max); + } +} diff --git a/test/util/StringFormatting.sol b/test/util/StringFormatting.sol new file mode 100644 index 0000000..c2aac48 --- /dev/null +++ b/test/util/StringFormatting.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Strings} from "openzeppelin/contracts/utils/Strings.sol"; + +library StringFormatting { + using Strings for uint256; + using StringFormatting for uint256; + using StringFormatting for string; + using StringFormatting for bytes; + + bytes1 constant GROUP_SEPARATOR = "_"; + string constant DECIMAL_SEPARATOR = "."; + string constant DECIMAL_UNIT = " ether"; + + uint256 constant GROUP_DIGITS = 3; + uint256 constant DECIMALS = 18; + uint256 constant ONE = 10 ** DECIMALS; + + function equals(string memory a, string memory b) internal pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } + + function toString(bytes memory str) internal pure returns (string memory) { + return string(str); + } + + function toString(bool b) internal pure returns (string memory) { + return b ? "true" : "false"; + } + + function decimal(int256 n) internal pure returns (string memory) { + if (n == type(int256).max) { + return "type(int256).max"; + } else if (n == type(int256).min) { + return "type(int256).min"; + } else if (n < 0) { + return string.concat("-", uint256(-n).decimal()); + } else { + return uint256(n).decimal(); + } + } + + function decimal(uint256 n) internal pure returns (string memory) { + if (n == type(uint256).max) { + return "type(uint256).max"; + } + + uint256 integerPart = n / ONE; + uint256 fractionalPart = n % ONE; + + if (fractionalPart == 0) { + return string.concat(integerPart.groupRight(), DECIMAL_UNIT); + } else { + return string.concat( + integerPart.groupRight(), + DECIMAL_SEPARATOR, + (ONE + fractionalPart).toString().slice(1).trimEnd("0"), + DECIMAL_UNIT + ); + } + } + + function groupRight(uint256 n) internal pure returns (string memory) { + return n.toString().groupRight(); + } + + function groupRight(string memory str) internal pure returns (string memory) { + return bytes(str).groupRight().toString(); + } + + function groupRight(bytes memory str) internal pure returns (bytes memory ret) { + uint256 length = str.length; + if (length == 0) return ""; + + uint256 retLength = length + (length - 1) / GROUP_DIGITS; + ret = new bytes(retLength); + + uint256 j = 1; + for (uint256 i = 1; i <= retLength; ++i) { + if (i % (GROUP_DIGITS + 1) == 0) { + ret[retLength - i] = GROUP_SEPARATOR; + } else { + ret[retLength - i] = str[length - j++]; + } + } + } + + function slice(string memory str, int256 start) internal pure returns (string memory) { + return bytes(str).slice(start).toString(); + } + + function slice(string memory str, int256 start, int256 end) internal pure returns (string memory) { + return bytes(str).slice(start, end).toString(); + } + + function slice(bytes memory str, int256 start) internal pure returns (bytes memory) { + return str.slice(start, int256(str.length)); + } + + // Should only be used on ASCII strings + function slice(bytes memory str, int256 start, int256 end) internal pure returns (bytes memory ret) { + uint256 uStart = uint256(start < 0 ? int256(str.length) + start : start); + uint256 uEnd = uint256(end < 0 ? int256(str.length) + end : end); + assert(0 <= uStart && uStart <= uEnd && uEnd <= str.length); + + ret = new bytes(uEnd - uStart); + + for (uint256 i = uStart; i < uEnd; ++i) { + ret[i - uStart] = str[i]; + } + } + + function trimEnd(string memory str, bytes1 char) internal pure returns (string memory) { + return bytes(str).trimEnd(char).toString(); + } + + function trimEnd(bytes memory str, bytes1 char) internal pure returns (bytes memory) { + uint256 end; + for (end = str.length; end > 0 && str[end - 1] == char; --end) {} + return str.slice(0, int256(end)); + } + + function join(string[] memory strs, string memory sep) internal pure returns (string memory ret) { + if (strs.length == 0) return ""; + + ret = strs[0]; + for (uint256 i = 1; i < strs.length; ++i) { + ret = string.concat(ret, sep, strs[i]); + } + } +} diff --git a/test/util/UintArray.sol b/test/util/UintArray.sol new file mode 100644 index 0000000..855ff00 --- /dev/null +++ b/test/util/UintArray.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Random} from "./Random.sol"; + +library UintArray { + using Random for Random.Context; + + function seq(uint256 last) internal pure returns (uint256[] memory) { + return seq(0, last); + } + + function seq(uint256 first, uint256 last) internal pure returns (uint256[] memory array) { + require(first <= last, "first > last"); + return seq(new uint256[](last - first), first); + } + + function seq(uint256[] memory array) internal pure returns (uint256[] memory) { + return seq(array, 0); + } + + function seq(uint256[] memory array, uint256 first) internal pure returns (uint256[] memory) { + for (uint256 i = 0; i < array.length; ++i) { + array[i] = first + i; + } + + return array; + } + + function slice(uint256[] memory array) internal pure returns (uint256[] memory) { + return slice(array, uint256(0), array.length); + } + + function slice(uint256[] memory array, uint256 start) internal pure returns (uint256[] memory) { + return slice(array, start, array.length); + } + + function slice(uint256[] memory array, int256 start) internal pure returns (uint256[] memory) { + return slice(array, start, array.length); + } + + function slice(uint256[] memory array, uint256 start, int256 end) internal pure returns (uint256[] memory) { + return slice(array, start, uint256(end < 0 ? int256(array.length) + end : end)); + } + + function slice(uint256[] memory array, int256 start, uint256 end) internal pure returns (uint256[] memory) { + return slice(array, uint256(start < 0 ? int256(array.length) + start : start), end); + } + + function slice(uint256[] memory array, int256 start, int256 end) internal pure returns (uint256[] memory) { + return slice( + array, + uint256(start < 0 ? int256(array.length) + start : start), + uint256(end < 0 ? int256(array.length) + end : end) + ); + } + + function slice(uint256[] memory array, uint256 start, uint256 end) internal pure returns (uint256[] memory ret) { + require(start <= end, "start > end"); + require(end <= array.length, "end > array.length"); + + ret = new uint256[](end - start); + + for (uint256 i = start; i < end; ++i) { + ret[i - start] = array[i]; + } + } + + function permute(uint256[] memory array, bytes32 seed) internal pure returns (uint256[] memory) { + return permute(array, Random.init(seed)); + } + + function permute(uint256[] memory array, Random.Context memory random) internal pure returns (uint256[] memory) { + for (uint256 i = 0; i < array.length - 1; ++i) { + uint256 j = random.generate(i, array.length - 1); + (array[i], array[j]) = (array[j], array[i]); + } + + return array; + } +}