Skip to content

Commit 833fcb2

Browse files
qa-august-lclaude
andcommitted
feat(dao): add topUpEpoch to remediate under-funded launchpool epochs
Some pendle-v3 launchpool epochs were configured with totalAmount less than the sum of the merkle-tree leaf amounts. Once cumulative claims exceed that under-set cap, `claim` underflows `epoch.unclaimedAmount` (Solidity 0.8 checked math) and tail users can no longer claim. Already-started epochs had no fix path: setEpochMerkleRoot is new-epoch-only and revokeEpoch is pre-start-only. topUpEpoch raises an existing epoch's totalAmount / unclaimedAmount / totalUnclaimedAmount to the correct sum-of-leaves total. The merkle root is unchanged — it already contains every user, only the accounting caps were wrong. Gated on DEFAULT_ADMIN_ROLE (like collectUnclaimed / adminTransfer), since raising fund accounting is a privileged corrective action. Guards: epoch must exist and still be within its claim window (else the top-up would strand accounting while claim stays inactive), amount must strictly increase, and a solvency check (balance >= totalUnclaimedAmount) forces funding the delta before raising the cap. No new storage variables, so the upgrade is storage-layout compatible. Tested with non-fork unit tests (mock ERC20): tail-claim goes revert -> success after top-up, plus access control (non-admin rejected), only-increase, solvency (ERC20 and native), invalid-epoch, ended-epoch, accounting and event coverage. Verified end-to-end on a BSC fork against live epoch 123: 31 blocked users (0.274742722 BNB) all become claimable after upgrade + topUpEpoch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c436a3 commit 833fcb2

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

contracts/dao/ClisBNBLaunchPoolDistributor.sol

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ contract ClisBNBLaunchPoolDistributor is Initializable, AccessControlUpgradeable
2525

2626
event Claimed(address account, uint64 epochId, address token, uint256 amount);
2727

28+
event TopUpEpoch(uint64 epochId, address token, uint256 oldTotalAmount, uint256 newTotalAmount, uint256 addedAmount);
29+
2830
struct Epoch {
2931
// merkle root of an epoch
3032
bytes32 merkleRoot;
@@ -135,6 +137,37 @@ contract ClisBNBLaunchPoolDistributor is Initializable, AccessControlUpgradeable
135137
emit UpdateEpoch(currentEpochId, epoch.merkleRoot, epoch.token, epoch.startTime, epoch.endTime, epoch.totalAmount);
136138
}
137139

140+
/**
141+
* @dev Raise an existing epoch's total to the correct amount (= sum of merkle leaves).
142+
* Fixes epochs whose totalAmount was set below the leaf sum, which otherwise makes
143+
* `claim` underflow `unclaimedAmount` for tail users once the under-set cap is drained.
144+
* The merkle root is unchanged (it already contains every user); only the accounting
145+
* caps are raised. Restricted to DEFAULT_ADMIN_ROLE (like collectUnclaimed /
146+
* adminTransfer). The admin must fund the missing tokens into this contract first;
147+
* the solvency check rejects raising the cap above the contract's actual balance. Only
148+
* allowed while the epoch is still within its claim window, since a topped-up ended
149+
* epoch would still be unclaimable via `claim` and merely strand the added amount.
150+
* @param _epochId Id of epoch
151+
* @param _newTotalAmount New total amount of the epoch, must exceed the current totalAmount
152+
*/
153+
function topUpEpoch(uint64 _epochId, uint256 _newTotalAmount) external onlyRole(DEFAULT_ADMIN_ROLE) {
154+
Epoch storage epoch = epochs[_epochId];
155+
require(epoch.merkleRoot != bytes32(0), "Invalid epochId");
156+
require(block.timestamp <= epoch.endTime, "Epoch ended");
157+
require(_newTotalAmount > epoch.totalAmount, "Not an increase");
158+
159+
uint256 oldTotalAmount = epoch.totalAmount;
160+
uint256 added = _newTotalAmount - oldTotalAmount;
161+
162+
epoch.totalAmount = _newTotalAmount;
163+
epoch.unclaimedAmount += added;
164+
totalUnclaimedAmount[epoch.token] += added;
165+
166+
require(_balanceOf(epoch.token) >= totalUnclaimedAmount[epoch.token], "Insufficient funds");
167+
168+
emit TopUpEpoch(_epochId, epoch.token, oldTotalAmount, _newTotalAmount, added);
169+
}
170+
138171
/**
139172
* @dev Revoke the reward of the given epoch;
140173
* @param _epochId Id of epoch
@@ -191,6 +224,10 @@ contract ClisBNBLaunchPoolDistributor is Initializable, AccessControlUpgradeable
191224
}
192225
}
193226

227+
function _balanceOf(address _token) private view returns (uint256) {
228+
return _token == address(0) ? address(this).balance : IERC20(_token).balanceOf(address(this));
229+
}
230+
194231
function getEpochs(uint64[] memory _epochIds) external view returns (Epoch[] memory) {
195232
Epoch[] memory _epochs = new Epoch[](_epochIds.length);
196233
for (uint256 i = 0; i < _epochIds.length; i++) {
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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

Comments
 (0)