|
| 1 | +// SPDX-License-Identifier: UNLICENSED |
| 2 | +pragma solidity ^0.8.10; |
| 3 | + |
| 4 | +import "forge-std/Test.sol"; |
| 5 | + |
| 6 | +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; |
| 7 | +import "@openzeppelin/contracts/utils/Strings.sol"; |
| 8 | + |
| 9 | +import "../../contracts/dao/ClisBNBLaunchPoolDistributor.sol"; |
| 10 | +import "../../contracts/mock/MockERC20.sol"; |
| 11 | + |
| 12 | +/// @dev Non-fork unit tests for ClisBNBLaunchPoolDistributor.topUpEpoch. |
| 13 | +/// Reproduces the launchpool under-funding bug (totalAmount < Σ leaves) |
| 14 | +/// and verifies topUpEpoch remediates it. topUpEpoch is DEFAULT_ADMIN_ROLE-gated |
| 15 | +/// (like collectUnclaimed / adminTransfer), while setEpochMerkleRoot stays OPERATOR. |
| 16 | +/// Uses a MockERC20 reward token so no mainnet fork is needed (dev unit style, |
| 17 | +/// cf. PreIPODistributor.t.sol). |
| 18 | +contract ClisBNBLaunchPoolDistributorUnitTest is Test { |
| 19 | + address admin = makeAddr("admin"); // holds DEFAULT_ADMIN_ROLE (set in initialize) |
| 20 | + address operator = makeAddr("operator"); // holds OPERATOR only |
| 21 | + address outsider = makeAddr("outsider"); |
| 22 | + address alice = makeAddr("alice"); |
| 23 | + address bob = makeAddr("bob"); |
| 24 | + |
| 25 | + ClisBNBLaunchPoolDistributor dist; |
| 26 | + MockERC20 token; |
| 27 | + |
| 28 | + uint64 constant EPOCH = 0; |
| 29 | + uint256 constant ALICE_AMT = 100e18; |
| 30 | + uint256 constant BOB_AMT = 200e18; |
| 31 | + uint256 constant SIGMA = 300e18; // Σ leaves = the correct total |
| 32 | + |
| 33 | + bytes32 leafAlice; |
| 34 | + bytes32 leafBob; |
| 35 | + bytes32 root; |
| 36 | + |
| 37 | + // mirror of the contract event, for expectEmit |
| 38 | + event TopUpEpoch(uint64 epochId, address token, uint256 oldTotalAmount, uint256 newTotalAmount, uint256 addedAmount); |
| 39 | + |
| 40 | + function setUp() public { |
| 41 | + token = new MockERC20(admin, "Mock", "MCK"); |
| 42 | + |
| 43 | + ClisBNBLaunchPoolDistributor impl = new ClisBNBLaunchPoolDistributor(); |
| 44 | + ERC1967Proxy proxy = new ERC1967Proxy( |
| 45 | + address(impl), |
| 46 | + abi.encodeWithSelector(ClisBNBLaunchPoolDistributor.initialize.selector, admin) |
| 47 | + ); |
| 48 | + dist = ClisBNBLaunchPoolDistributor(payable(address(proxy))); |
| 49 | + |
| 50 | + // admin already holds DEFAULT_ADMIN_ROLE from initialize; grant OPERATOR to operator |
| 51 | + vm.startPrank(admin); |
| 52 | + dist.grantRole(dist.OPERATOR(), operator); |
| 53 | + vm.stopPrank(); |
| 54 | + |
| 55 | + // leaf = keccak256(abi.encode(chainid, epochId, account, amount)) |
| 56 | + leafAlice = keccak256(abi.encode(block.chainid, EPOCH, alice, ALICE_AMT)); |
| 57 | + leafBob = keccak256(abi.encode(block.chainid, EPOCH, bob, BOB_AMT)); |
| 58 | + root = _hashPair(leafAlice, leafBob); |
| 59 | + } |
| 60 | + |
| 61 | + // ---- helpers ---- |
| 62 | + |
| 63 | + function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { |
| 64 | + return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); |
| 65 | + } |
| 66 | + |
| 67 | + function _proof(bytes32 sibling) internal pure returns (bytes32[] memory p) { |
| 68 | + p = new bytes32[](1); |
| 69 | + p[0] = sibling; |
| 70 | + } |
| 71 | + |
| 72 | + /// @dev create epoch with a (possibly under-set) totalAmount and activate it. Uses |
| 73 | + /// OPERATOR since setEpochMerkleRoot is OPERATOR-gated (unchanged by this feature). |
| 74 | + function _createEpoch(address _token, uint256 _totalAmount) internal { |
| 75 | + vm.prank(operator); |
| 76 | + dist.setEpochMerkleRoot(EPOCH, root, _token, block.timestamp + 10, block.timestamp + 1000, _totalAmount); |
| 77 | + skip(11); // pass startTime so claims are active |
| 78 | + } |
| 79 | + |
| 80 | + function _epoch() internal view returns (ClisBNBLaunchPoolDistributor.Epoch memory) { |
| 81 | + uint64[] memory ids = new uint64[](1); |
| 82 | + ids[0] = EPOCH; |
| 83 | + return dist.getEpochs(ids)[0]; |
| 84 | + } |
| 85 | + |
| 86 | + // ---- primary behavioral test ---- |
| 87 | + |
| 88 | + function test_topUpEpoch_unblocksTailClaim() public { |
| 89 | + // under-funded epoch: totalAmount(100) < Σ leaves(300) |
| 90 | + _createEpoch(address(token), ALICE_AMT); |
| 91 | + deal(address(token), address(dist), ALICE_AMT); // funded only to the under-set total |
| 92 | + |
| 93 | + // alice is within the cap and claims fine |
| 94 | + dist.claim(EPOCH, alice, ALICE_AMT, _proof(leafBob)); |
| 95 | + assertEq(token.balanceOf(alice), ALICE_AMT); |
| 96 | + |
| 97 | + // bob (tail) is blocked: accounting underflows once the under-set cap is drained |
| 98 | + vm.expectRevert(stdError.arithmeticError); |
| 99 | + dist.claim(EPOCH, bob, BOB_AMT, _proof(leafAlice)); |
| 100 | + |
| 101 | + // remediation: fund the missing delta, then raise the cap to the correct total (admin) |
| 102 | + deal(address(token), address(dist), BOB_AMT); |
| 103 | + vm.prank(admin); |
| 104 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 105 | + |
| 106 | + // bob can now claim his full reward |
| 107 | + dist.claim(EPOCH, bob, BOB_AMT, _proof(leafAlice)); |
| 108 | + assertEq(token.balanceOf(bob), BOB_AMT); |
| 109 | + assertEq(token.balanceOf(address(dist)), 0); |
| 110 | + } |
| 111 | + |
| 112 | + // ---- guards ---- |
| 113 | + |
| 114 | + function test_topUpEpoch_revertsWhenNotAdmin() public { |
| 115 | + _createEpoch(address(token), ALICE_AMT); |
| 116 | + deal(address(token), address(dist), SIGMA); |
| 117 | + |
| 118 | + // holding OPERATOR is not enough: topUpEpoch requires DEFAULT_ADMIN_ROLE |
| 119 | + string memory err = string( |
| 120 | + abi.encodePacked( |
| 121 | + "AccessControl: account ", |
| 122 | + Strings.toHexString(operator), |
| 123 | + " is missing role ", |
| 124 | + Strings.toHexString(uint256(dist.DEFAULT_ADMIN_ROLE()), 32) |
| 125 | + ) |
| 126 | + ); |
| 127 | + vm.prank(operator); |
| 128 | + vm.expectRevert(bytes(err)); |
| 129 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 130 | + } |
| 131 | + |
| 132 | + function test_topUpEpoch_revertsWhenNotIncrease() public { |
| 133 | + _createEpoch(address(token), SIGMA); // already the correct total |
| 134 | + deal(address(token), address(dist), SIGMA); |
| 135 | + |
| 136 | + vm.startPrank(admin); |
| 137 | + vm.expectRevert("Not an increase"); |
| 138 | + dist.topUpEpoch(EPOCH, SIGMA); // equal -> reject |
| 139 | + vm.expectRevert("Not an increase"); |
| 140 | + dist.topUpEpoch(EPOCH, SIGMA - 1); // lower -> reject |
| 141 | + vm.stopPrank(); |
| 142 | + } |
| 143 | + |
| 144 | + function test_topUpEpoch_revertsWhenInsufficientFunds() public { |
| 145 | + _createEpoch(address(token), ALICE_AMT); // under-set to 100 |
| 146 | + deal(address(token), address(dist), ALICE_AMT); // only 100 funded |
| 147 | + |
| 148 | + vm.prank(admin); |
| 149 | + vm.expectRevert("Insufficient funds"); // raising cap to 300 > balance 100 |
| 150 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 151 | + } |
| 152 | + |
| 153 | + function test_topUpEpoch_revertsWhenInvalidEpochId() public { |
| 154 | + vm.prank(admin); |
| 155 | + vm.expectRevert("Invalid epochId"); |
| 156 | + dist.topUpEpoch(5, SIGMA); // no such epoch |
| 157 | + } |
| 158 | + |
| 159 | + function test_topUpEpoch_updatesAccountingAndEmits() public { |
| 160 | + _createEpoch(address(token), ALICE_AMT); // 100 |
| 161 | + deal(address(token), address(dist), SIGMA); // fund to correct total |
| 162 | + |
| 163 | + vm.expectEmit(true, true, true, true, address(dist)); |
| 164 | + emit TopUpEpoch(EPOCH, address(token), ALICE_AMT, SIGMA, SIGMA - ALICE_AMT); |
| 165 | + vm.prank(admin); |
| 166 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 167 | + |
| 168 | + ClisBNBLaunchPoolDistributor.Epoch memory e = _epoch(); |
| 169 | + assertEq(e.totalAmount, SIGMA); |
| 170 | + assertEq(e.unclaimedAmount, SIGMA); // no claims yet |
| 171 | + assertEq(dist.totalUnclaimedAmount(address(token)), SIGMA); |
| 172 | + } |
| 173 | + |
| 174 | + function test_topUpEpoch_revertsWhenEpochEnded() public { |
| 175 | + _createEpoch(address(token), ALICE_AMT); |
| 176 | + deal(address(token), address(dist), SIGMA); |
| 177 | + |
| 178 | + skip(2000); // move past endTime (set to now + 1000 at creation) |
| 179 | + |
| 180 | + // topping up a dead epoch is a no-op for claim() and would only strand accounting |
| 181 | + vm.prank(admin); |
| 182 | + vm.expectRevert("Epoch ended"); |
| 183 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 184 | + } |
| 185 | + |
| 186 | + function test_topUpEpoch_bnb_solvencyUsesNativeBalance() public { |
| 187 | + _createEpoch(address(0), ALICE_AMT); // BNB epoch, under-set to 100 |
| 188 | + |
| 189 | + // unfunded -> solvency must use native balance and revert |
| 190 | + vm.prank(admin); |
| 191 | + vm.expectRevert("Insufficient funds"); |
| 192 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 193 | + |
| 194 | + // fund native, retry -> succeeds |
| 195 | + vm.deal(address(dist), SIGMA); |
| 196 | + vm.prank(admin); |
| 197 | + dist.topUpEpoch(EPOCH, SIGMA); |
| 198 | + assertEq(_epoch().totalAmount, SIGMA); |
| 199 | + assertEq(dist.totalUnclaimedAmount(address(0)), SIGMA); |
| 200 | + } |
| 201 | +} |
0 commit comments