feat(dao): add topUpEpoch to remediate under-funded launchpool epochs - #123
Conversation
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>
833fcb2 to
4ffd42b
Compare
HashDit audit [M02]: topUpEpoch only bounded the upper edge of the claim window (block.timestamp <= endTime), which a pre-start epoch always satisfies. Topping one up pulls its funding forward into the shared totalUnclaimedAmount[token] -- possibly blocking a legitimate top-up of an active sibling epoch -- and since revokeEpoch adjusts accounting without returning tokens, top-up-then-revoke leaves an unearmarked surplus behind. That surplus is exactly what lets a later top-up pass the token-wide solvency check without the delta being funded ([M01]). A pre-start epoch should be corrected via revokeEpoch + recreate instead. Adds `require(block.timestamp >= epoch.startTime, "Epoch not started")` so the window is bounded on both edges. Ended epochs still revert with "Epoch ended". Tests: two M02 cases (pre-start rejection with accounting untouched; the top-up-then-revoke surplus route stays closed) and four multi-epoch cases on a shared token, which the previous suite did not cover -- per-epoch isolation of the caps, end-to-end tail-claim remediation alongside an untouched sibling, and both directions of the aggregate solvency check ([M01], accepted): a sibling surplus satisfying an unfunded delta, and a sibling deficit blocking a funded top-up. The last two assert current behaviour deliberately, so moving to per-epoch funding later fails them instead of drifting silently. Also trims the topUpEpoch NatSpec to the @dev summary. 28 tests pass across both distributor suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pull Request ReviewThis Solidity PR adds an admin-gated Sensitive ContentNo sensitive content detected. Security IssuesNo serious security issues detected. Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pull Request ReviewThis Solidity PR adds an admin-gated Sensitive ContentNo sensitive content detected. Security IssuesNo serious security issues detected. Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits. |
Summary
Adds
topUpEpochtoClisBNBLaunchPoolDistributorto remediate launchpool epochs that were configured with a total reward smaller than the sum of their merkle-tree leaf amounts, which leaves tail users unable to claim.Problem & root cause
Several pendle-v3 launchpool epochs were set up with
epoch.totalAmount < Σ(leaf amounts):setEpochMerkleRoot()stores the operator-supplied_totalAmountas bothepoch.totalAmountandepoch.unclaimedAmountand adds it tototalUnclaimedAmount[token](ClisBNBLaunchPoolDistributor.sol:131-133). Nothing on-chain ties_totalAmountto the leaf sum (the full tree isn't available on-chain).claim()doesepoch.unclaimedAmount -= _amount(:101). Once cumulative claims exceed the under-set cap, this underflows (Solidity 0.8 checked math) and every remaining claim reverts. First-come-first-served, so the tail users are permanently blocked.setEpochMerkleRootonly accepts_epochId == nextEpochId(:119) andrevokeEpochrequiresstartTime > block.timestamp(:144).The merkle root is already correct — it contains every user, and
Σ leavesis the correct total. Only thetotalAmountaccounting cap was set too low.Fix
topUpEpoch(uint64 _epochId, uint256 _newTotalAmount)raises an existing epoch'stotalAmount/unclaimedAmount/totalUnclaimedAmount[token]to the correct sum-of-leaves total. The merkle root is unchanged, so already-claimed users are unaffected and remaining users' proofs stay valid; raisingunclaimedAmountremoves the underflow so the tail can claim.DEFAULT_ADMIN_ROLE(same ascollectUnclaimed/adminTransfer) — raising fund accounting is a privileged corrective action.Σ leaves; strictly-increasing + idempotent-safe (re-applying reverts on"Not an increase").Invalid epochId), still be within its claim window (Epoch ended— a topped-up ended epoch would only strand accounting sinceclaimstays inactive), amount must strictly increase (Not an increase), and a solvency checkbalanceOf(token) >= totalUnclaimedAmount[token](Insufficient funds) forces funding the delta before raising the cap (native BNB viaaddress(this).balance, ERC20 viabalanceOf).forge inspect storage-layout).Testing
test/dao/ClisBNBLaunchPoolDistributorUnit.t.sol, non-fork, mock ERC20): tail-claim goesrevert -> successafter top-up, plus access control (non-admin rejected), only-increase, solvency (ERC20 + native BNB), invalid-epoch, ended-epoch, and accounting/event coverage. 8/8 pass.ClisBNBLaunchPoolDistributor.t.sol): 14/14 pass, no regression (change is purely additive).pendle-v3-CHIP, BNB): 247 leaves, Σ = 2.520985730 BNB vs on-chain totalAmount 2.246244. Before: 31 tail users blocked with the accounting underflow, totaling 0.274742722 BNB (exactly the shortfall). After upgrading the impl +topUpEpoch(123, Σ): all 31 become claimable, 0 blocked, epoch budget lands exactly at 0.Rollout note
The distributor is a shared pool that already holds enough BNB (the block is purely per-epoch accounting, not a token shortage), so remediation for each under-set epoch is: deploy the new impl → upgrade the proxy →
topUpEpoch(epochId, Σleaves_wei). The solvency guard verifies the contract holds enough before raising the cap.🤖 Generated with Claude Code