diff --git a/audits/HashDit-PreIPODistributor-20260727.pdf b/audits/HashDit-PreIPODistributor-20260727.pdf new file mode 100644 index 0000000..d620830 Binary files /dev/null and b/audits/HashDit-PreIPODistributor-20260727.pdf differ diff --git a/contracts/dao/PreIPODistributor.sol b/contracts/dao/PreIPODistributor.sol index 6584b9d..ea98804 100644 --- a/contracts/dao/PreIPODistributor.sol +++ b/contracts/dao/PreIPODistributor.sol @@ -21,8 +21,10 @@ import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; * On the first deposit each address selects a delivery tranche, locked for the whole sale * (top-ups inherit it). The tranche does not affect accounting; it only records the choice. * - * Allocation is computed off-chain; this contract only holds deposits. Deposits are locked and - * can only be topped up, never withdrawn. Settlement and delivery are added via UUPS upgrade. + * Allocation is computed off-chain; the contract holds deposits and, after the review window, + * a finalized settlement merkle root drives claims. Deposits are locked and can only be topped + * up, never withdrawn by the user. Claiming pays the refund and, for the unlocked tranche, + * delivers the share token; the locked tranche only records the amount for off-chain delivery. */ contract PreIPODistributor is Initializable, @@ -33,11 +35,11 @@ contract PreIPODistributor is using SafeERC20 for IERC20; bytes32 public constant MANAGER = keccak256("MANAGER"); - // BOT operates the settlement lifecycle in the settlement/claim upgrade; granted here at - // deploy so the upgrade needs no extra role setup. bytes32 public constant BOT = keccak256("BOT"); - // Delivery tranche selected at first deposit; 0 = not selected. + // Delivery tranche, selected at an account's first deposit and locked for the sale; 0 = not selected. + // On claim, TRANCHE_UNLOCKED transfers the share token to the account immediately, while + // TRANCHE_LOCKED only records the share amount (delivered off-chain at maturity). uint8 public constant TRANCHE_UNLOCKED = 1; uint8 public constant TRANCHE_LOCKED = 2; @@ -68,6 +70,24 @@ contract PreIPODistributor is uint64 public nextSaleId; + struct Settlement { + bytes32 root; // finalized settlement root; claims verify against this + bytes32 pendingRoot; // pending root awaiting finalize (0 = none pending) + uint256 pendingTotalRefund; // total refund committed by the pending root + uint256 totalRefund; // total refund committed by the finalized root + uint256 lastSetTime; // block time the pending root was set + uint256 refunded; // cumulative refund already paid out via claim + } + + // saleId => Settlement + mapping(uint64 => Settlement) public settlements; + + // saleId => account => claimed + mapping(uint64 => mapping(address => bool)) public claimed; + + // review window between setSettlementRoot (step 1) and finalizeSettlement (step 2); min 6h + uint256 public waitingPeriod; + event CreateSale( uint64 indexed saleId, address depositToken, @@ -108,6 +128,23 @@ contract PreIPODistributor is uint256 pubTotalDeposits ); + event SetSettlementRoot(uint64 indexed saleId, bytes32 pendingRoot, uint256 totalRefund, uint256 setTime); + + event FinalizeSettlement(uint64 indexed saleId, bytes32 root, uint256 finalizeTime); + + event RevokeSettlementRoot(uint64 indexed saleId); + + event WaitingPeriodUpdated(uint256 waitingPeriod); + + event Claimed( + uint64 indexed saleId, + address indexed account, + uint256 refundAmount, + address shareToken, + uint256 tokenAmount, + uint8 tranche + ); + event EmergencyWithdraw(address indexed token, address indexed to, uint256 amount); /// @custom:oz-upgrades-unsafe-allow constructor @@ -132,6 +169,13 @@ contract PreIPODistributor is _setRoleAdmin(BOT, MANAGER); } + /// @dev Initializes settlement/claim state introduced by this version. Runs once, after the + /// upgrade — call it atomically via upgradeToAndCall(newImpl, abi.encodeCall(initializeV2, ())). + function initializeV2() external reinitializer(2) { + waitingPeriod = 6 hours; + emit WaitingPeriodUpdated(waitingPeriod); + } + /// @dev Create a sale (whitelist round). The public round is opened later via setPublicRound. function createSale( address _depositToken, @@ -193,6 +237,11 @@ contract PreIPODistributor is { Sale storage sale = sales[_saleId]; require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + // cannot open/reschedule a public round once settlement is pending or finalized + require( + settlements[_saleId].pendingRoot == bytes32(0) && settlements[_saleId].root == bytes32(0), + "Settlement started" + ); require(sale.pubStartTime == 0 || sale.pubStartTime > block.timestamp, "Public round started"); require(_pubStartTime > sale.endTime, "Public must follow WL"); require(_pubStartTime > block.timestamp, "Invalid pub start"); @@ -225,14 +274,15 @@ contract PreIPODistributor is require(_amount >= sale.minDeposit, "Below min deposit"); // A non-zero existing WL deposit implies the caller already passed the proof check. - if (deposits[_saleId][msg.sender] == 0) { + uint256 prior = deposits[_saleId][msg.sender]; + if (prior == 0) { bytes32 leaf = keccak256(abi.encode(block.chainid, msg.sender)); require(MerkleProof.verifyCalldata(_proof, sale.whitelistRoot, leaf), "Invalid proof"); } uint8 tranche = _applyTranche(_saleId, _tranche); - uint256 userTotal = deposits[_saleId][msg.sender] + _amount; + uint256 userTotal = prior + _amount; deposits[_saleId][msg.sender] = userTotal; sale.totalDeposits += _amount; @@ -276,7 +326,146 @@ contract PreIPODistributor is } } + /// @dev Set the pending settlement root (step 1 of 2); starts the review window. + /// A new pending root cannot be set while one is already in flight. + function setSettlementRoot(uint64 _saleId, bytes32 _root, uint256 _totalRefund) + external + onlyRole(BOT) + { + Sale storage sale = sales[_saleId]; + require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + require(!sale.paused, "Sale paused"); + // deposit windows must be closed so no deposit can land after the off-chain snapshot + require(block.timestamp > sale.endTime, "WL round not ended"); + require(sale.pubStartTime == 0 || block.timestamp > sale.pubEndTime, "Public round not ended"); + + Settlement storage s = settlements[_saleId]; + // settlement is single-shot: once a root is finalized it cannot be replaced + require(s.root == bytes32(0), "Already finalized"); + require(_root != bytes32(0), "Invalid root"); + require(s.pendingRoot == bytes32(0), "Pending root in flight"); + require(_totalRefund <= sale.totalDeposits + sale.pubTotalDeposits, "Refund exceeds deposits"); + + s.pendingRoot = _root; + s.pendingTotalRefund = _totalRefund; + s.lastSetTime = block.timestamp; + + emit SetSettlementRoot(_saleId, _root, _totalRefund, block.timestamp); + } + + /// @dev Finalize the pending settlement root (step 2 of 2); only after the review window. + function finalizeSettlement(uint64 _saleId) external onlyRole(BOT) { + require(!sales[_saleId].paused, "Sale paused"); + Settlement storage s = settlements[_saleId]; + require(s.pendingRoot != bytes32(0), "No pending root"); + require(block.timestamp >= s.lastSetTime + waitingPeriod, "Review window not passed"); + + s.root = s.pendingRoot; + s.totalRefund = s.pendingTotalRefund; + s.pendingRoot = bytes32(0); + s.pendingTotalRefund = 0; + + emit FinalizeSettlement(_saleId, s.root, block.timestamp); + } + + /// @dev Revoke the pending settlement root before it is finalized. + function revokeSettlementRoot(uint64 _saleId) external onlyRole(MANAGER) { + Settlement storage s = settlements[_saleId]; + require(s.pendingRoot != bytes32(0), "No pending root"); + s.pendingRoot = bytes32(0); + s.pendingTotalRefund = 0; + emit RevokeSettlementRoot(_saleId); + } + + /// @dev Claim a finalized allocation (whitelist + public rounds combined), once per account. + /// Pays the refund; for the unlocked tranche also transfers the share token, while the + /// locked tranche only records the amount (delivered off-chain at maturity). Permanent. + /// Permissionless: anyone may call it on behalf of any account; the refund and any share + /// delivery always go to `_account` (the address bound in the leaf), never to msg.sender. + /// @param _saleId Sale id + /// @param _account Account the allocation belongs to and that receives refund/shares + /// @param _refundAmount Refund amount in the deposit token (part of the leaf) + /// @param _shareToken Share token to deliver for the unlocked tranche (part of the leaf) + /// @param _tokenAmount Share amount (delivered for unlocked, recorded for locked; part of the leaf) + /// @param _proof Merkle proof of the leaf against the finalized settlement root + function claim( + uint64 _saleId, + address _account, + uint256 _refundAmount, + address _shareToken, + uint256 _tokenAmount, + bytes32[] calldata _proof + ) external nonReentrant { + require(!sales[_saleId].paused, "Sale paused"); + Settlement storage s = settlements[_saleId]; + require(s.root != bytes32(0), "Not finalized"); + require(!claimed[_saleId][_account], "Already claimed"); + + bytes32 leaf = + keccak256(abi.encode(block.chainid, _saleId, _account, _refundAmount, _shareToken, _tokenAmount)); + require(MerkleProof.verifyCalldata(_proof, s.root, leaf), "Invalid proof"); + + claimed[_saleId][_account] = true; + + uint8 tranche = userTranche[_saleId][_account]; + + if (_refundAmount > 0) { + // aggregate refunds can never exceed the committed total + require(s.refunded + _refundAmount <= s.totalRefund, "Refund over total"); + s.refunded += _refundAmount; + IERC20(sales[_saleId].depositToken).safeTransfer(_account, _refundAmount); + } + // Unlocked tranche receives the share token now; locked tranche is only recorded + // (delivered off-chain at maturity) via the Claimed event. A zero share token is only + // valid for the locked tranche. + if (tranche == TRANCHE_UNLOCKED) { + require(_shareToken != address(0), "Share token required"); + if (_tokenAmount > 0) { + IERC20(_shareToken).safeTransfer(_account, _tokenAmount); + } + } + + emit Claimed(_saleId, _account, _refundAmount, _shareToken, _tokenAmount, tranche); + } + + /// @dev Read-only preview of what claim() would do for the given leaf inputs, without changing + /// state. Mirrors claim()'s validation and delivery routing. + /// @return valid True if the sale is finalized and the proof matches the leaf + /// @return alreadyClaimed True if the account has already claimed + /// @return tranche The account's locked tranche (0 = never deposited) + /// @return sendShares True if a successful claim would transfer the share token now + /// (unlocked tranche, non-zero amount and share token) + function previewClaim( + uint64 _saleId, + address _account, + uint256 _refundAmount, + address _shareToken, + uint256 _tokenAmount, + bytes32[] calldata _proof + ) external view returns (bool valid, bool alreadyClaimed, uint8 tranche, bool sendShares) { + bytes32 leaf = + keccak256(abi.encode(block.chainid, _saleId, _account, _refundAmount, _shareToken, _tokenAmount)); + bytes32 root = settlements[_saleId].root; + valid = root != bytes32(0) && MerkleProof.verifyCalldata(_proof, root, leaf); + alreadyClaimed = claimed[_saleId][_account]; + tranche = userTranche[_saleId][_account]; + sendShares = + valid && !alreadyClaimed && tranche == TRANCHE_UNLOCKED && _shareToken != address(0) && _tokenAmount > 0; + } + + /// @dev Update the review window between set and finalize (min 6h). + function setWaitingPeriod(uint256 _waitingPeriod) external onlyRole(MANAGER) { + require(_waitingPeriod >= 6 hours, "Waiting period too short"); + waitingPeriod = _waitingPeriod; + emit WaitingPeriodUpdated(_waitingPeriod); + } + /// @dev Manager (multisig) safety valve; there is no normal withdrawal path. + /// WARNING: this does NOT adjust any internal accounting (totalDeposits, pubTotalDeposits, + /// settlement totals). Withdrawing the deposit token can leave the contract without enough + /// balance to satisfy outstanding refunds, causing finalized claims to revert and stranding + /// those refunds. After using it, the tracked totals must be restaged (or the contract + /// upgraded) before normal claims can resume. Use only in emergencies. function emergencyWithdraw(address _token, address _to, uint256 _amount) external onlyRole(MANAGER) diff --git a/test/dao/PreIPODistributor.t.sol b/test/dao/PreIPODistributor.t.sol index 872d7b8..46cdcc2 100644 --- a/test/dao/PreIPODistributor.t.sol +++ b/test/dao/PreIPODistributor.t.sol @@ -44,6 +44,8 @@ contract PreIPODistributorTest is Test { abi.encodeWithSelector(PreIPODistributor.initialize.selector, admin, manager, bot) ); distributor = PreIPODistributor(address(proxy)); + // settlement/claim state (mirrors the post-upgrade initializeV2 call) + distributor.initializeV2(); // build whitelist merkle tree (leaf = keccak256(abi.encode(chainid, account))) leafAlice = keccak256(abi.encode(block.chainid, alice)); @@ -108,20 +110,31 @@ contract PreIPODistributorTest is Test { assertEq(distributor.getRoleAdmin(distributor.BOT()), distributor.MANAGER()); assertEq(distributor.TRANCHE_UNLOCKED(), XKLSH); assertEq(distributor.TRANCHE_LOCKED(), PKLSH); + assertEq(distributor.waitingPeriod(), 6 hours); + } + + function test_initializeV2_onlyOnce() public { + vm.expectRevert("Initializable: contract is already initialized"); + distributor.initializeV2(); } function test_managerGrantsBot() public { address newBot = makeAddr("newBot"); bytes32 botRole = distributor.BOT(); - // MANAGER is BOT's role admin, so manager (not the default admin) can grant BOT + // MANAGER is BOT's role admin, so manager (not default admin) can grant BOT vm.prank(manager); distributor.grantRole(botRole, newBot); assertTrue(distributor.hasRole(botRole, newBot)); + + vm.prank(manager); + distributor.revokeRole(botRole, newBot); + assertFalse(distributor.hasRole(botRole, newBot)); } function test_defaultAdminCannotGrantBot() public { address newBot = makeAddr("newBot"); bytes32 botRole = distributor.BOT(); + // BOT's admin is MANAGER, so the default admin can no longer grant it vm.prank(admin); vm.expectRevert(); distributor.grantRole(botRole, newBot); @@ -411,6 +424,349 @@ contract PreIPODistributorTest is Test { assertEq(distributor.userTranche(saleId, alice), PKLSH); } + // ---- settlement: set / finalize ---- + + // create a sale, deposit in WL round, return saleId (totalDeposits = 200e18) + function _saleWithDeposit() internal returns (uint64 saleId) { + saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + _deposit(alice, saleId, XKLSH, _proofFor(leafBob), 200e18); + _closeWindows(saleId); // settlement is only allowed once deposit windows close + } + + function test_setSettlementRoot_ok() public { + uint64 saleId = _saleWithDeposit(); + bytes32 root = keccak256("settle"); + + vm.prank(bot); + distributor.setSettlementRoot(saleId, root, 50e18); + + // tuple: root, pendingRoot, pendingTotalRefund, totalRefund, lastSetTime, refunded + (bytes32 fRoot, bytes32 pRoot, uint256 pRefund,, uint256 setTime,) = distributor.settlements(saleId); + assertEq(fRoot, bytes32(0)); + assertEq(pRoot, root); + assertEq(pRefund, 50e18); + assertEq(setTime, block.timestamp); + } + + function test_setSettlementRoot_refundExceedsDeposits_reverts() public { + uint64 saleId = _saleWithDeposit(); // 200e18 deposited + vm.prank(bot); + vm.expectRevert("Refund exceeds deposits"); + distributor.setSettlementRoot(saleId, keccak256("settle"), 200e18 + 1); + } + + function test_setSettlementRoot_pendingInFlight_reverts() public { + uint64 saleId = _saleWithDeposit(); + vm.startPrank(bot); + distributor.setSettlementRoot(saleId, keccak256("a"), 10e18); + vm.expectRevert("Pending root in flight"); + distributor.setSettlementRoot(saleId, keccak256("b"), 10e18); + vm.stopPrank(); + } + + function test_setSettlementRoot_acl() public { + uint64 saleId = _saleWithDeposit(); + vm.prank(outsider); + vm.expectRevert(); + distributor.setSettlementRoot(saleId, keccak256("settle"), 10e18); + } + + function test_finalizeSettlement_beforeWindow_reverts() public { + uint64 saleId = _saleWithDeposit(); + vm.startPrank(bot); + distributor.setSettlementRoot(saleId, keccak256("settle"), 50e18); + vm.expectRevert("Review window not passed"); + distributor.finalizeSettlement(saleId); + vm.stopPrank(); + } + + function test_finalizeSettlement_ok_after6h() public { + uint64 saleId = _saleWithDeposit(); + bytes32 root = keccak256("settle"); + vm.prank(bot); + distributor.setSettlementRoot(saleId, root, 50e18); + + vm.warp(block.timestamp + 6 hours); + vm.prank(bot); + distributor.finalizeSettlement(saleId); + + (bytes32 fRoot, bytes32 pRoot, uint256 pRefund, uint256 tRefund,,) = distributor.settlements(saleId); + assertEq(fRoot, root); + assertEq(pRoot, bytes32(0)); + assertEq(pRefund, 0); + assertEq(tRefund, 50e18); + } + + function test_finalizeSettlement_noPending_reverts() public { + uint64 saleId = _saleWithDeposit(); + vm.prank(bot); + vm.expectRevert("No pending root"); + distributor.finalizeSettlement(saleId); + } + + function test_revokeSettlementRoot_ok() public { + uint64 saleId = _saleWithDeposit(); + vm.prank(bot); + distributor.setSettlementRoot(saleId, keccak256("settle"), 50e18); + vm.prank(manager); // revoke is a MANAGER action + distributor.revokeSettlementRoot(saleId); + // can set a fresh one again after revoke + vm.prank(bot); + distributor.setSettlementRoot(saleId, keccak256("settle2"), 60e18); + + (, bytes32 pRoot, uint256 pRefund,,,) = distributor.settlements(saleId); + assertEq(pRoot, keccak256("settle2")); + assertEq(pRefund, 60e18); + } + + function test_setWaitingPeriod_min6h() public { + vm.startPrank(manager); + vm.expectRevert("Waiting period too short"); + distributor.setWaitingPeriod(6 hours - 1); + distributor.setWaitingPeriod(12 hours); + vm.stopPrank(); + assertEq(distributor.waitingPeriod(), 12 hours); + } + + // settlement must wait until deposit windows close + function test_setSettlementRoot_beforeWLClose_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); // WL open + _deposit(alice, saleId, XKLSH, _proofFor(leafBob), 200e18); + vm.prank(bot); + vm.expectRevert("WL round not ended"); + distributor.setSettlementRoot(saleId, keccak256("r"), 50e18); + } + + function test_setSettlementRoot_beforePublicClose_reverts() public { + uint64 saleId = _createDefaultSale(); + _openPublicRound(saleId); // pub [end+10, end+1000] + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + vm.warp(s.endTime + 1); // WL closed, public not ended + vm.prank(bot); + vm.expectRevert("Public round not ended"); + distributor.setSettlementRoot(saleId, keccak256("r"), 0); + } + + // an active (finalized) root cannot be replaced + function test_setSettlementRoot_afterFinalize_reverts() public { + uint64 saleId = _saleWithDeposit(); + _settleAndFinalize(saleId, keccak256("r1"), 50e18); + vm.prank(bot); + vm.expectRevert("Already finalized"); + distributor.setSettlementRoot(saleId, keccak256("r2"), 50e18); + } + + // M02: cannot open a public round once settlement is pending or finalized + function test_setPublicRound_afterSettlementPending_reverts() public { + uint64 saleId = _saleWithDeposit(); // WL-only, windows closed + vm.prank(bot); + distributor.setSettlementRoot(saleId, keccak256("r"), 50e18); + uint256 endTime = distributor.getSale(saleId).endTime; + vm.prank(manager); + vm.expectRevert("Settlement started"); + distributor.setPublicRound(saleId, endTime + 100, endTime + 200); + } + + function test_setPublicRound_afterFinalize_reverts() public { + uint64 saleId = _saleWithDeposit(); + _settleAndFinalize(saleId, keccak256("r"), 50e18); + uint256 endTime = distributor.getSale(saleId).endTime; + vm.prank(manager); + vm.expectRevert("Settlement started"); + distributor.setPublicRound(saleId, endTime + 100, endTime + 200); + } + + // I03: pause is a real circuit breaker over settlement and claim + function test_paused_blocksSettlement() public { + uint64 saleId = _saleWithDeposit(); + vm.prank(manager); + distributor.setPaused(saleId, true); + vm.prank(bot); + vm.expectRevert("Sale paused"); + distributor.setSettlementRoot(saleId, keccak256("r"), 50e18); + } + + function test_paused_blocksClaim() public { + uint64 saleId = _saleWithDeposit(); // alice unlocked, 200e18 + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + _settleAndFinalize(saleId, root, 50e18); // finalize while not paused + vm.prank(manager); + distributor.setPaused(saleId, true); + vm.expectRevert("Sale paused"); + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + } + + // ---- claim ---- + + // warp past the deposit window(s) so settlement is allowed + function _closeWindows(uint64 saleId) internal { + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + uint256 closeTime = s.pubStartTime == 0 ? s.endTime : s.pubEndTime; + if (block.timestamp <= closeTime) vm.warp(closeTime + 1); + } + + function _settleAndFinalize(uint64 saleId, bytes32 root, uint256 totalRefund) internal { + _closeWindows(saleId); + vm.prank(bot); + distributor.setSettlementRoot(saleId, root, totalRefund); + vm.warp(block.timestamp + 6 hours); + vm.prank(bot); + distributor.finalizeSettlement(saleId); + } + + // single-leaf settlement tree: root == leaf, proof == [] + function _settleLeaf(uint64 saleId, address account, uint256 refund, address share, uint256 tokenAmount) + internal + view + returns (bytes32) + { + return keccak256(abi.encode(block.chainid, saleId, account, refund, share, tokenAmount)); + } + + function test_claim_unlocked_transfersRefundAndShares() public { + uint64 saleId = _saleWithDeposit(); // alice: WL 200e18, XKLSH + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + _settleAndFinalize(saleId, root, 50e18); + + uint256 beforeUsdt = usdt.balanceOf(alice); + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + + assertEq(usdt.balanceOf(alice), beforeUsdt + 50e18); + assertEq(share.balanceOf(alice), 10e18); + assertTrue(distributor.claimed(saleId, alice)); + + // refunded accumulates; outstanding = totalRefund - refunded + (,,, uint256 tRefund,, uint256 refunded) = distributor.settlements(saleId); + assertEq(tRefund, 50e18); + assertEq(refunded, 50e18); + assertEq(tRefund - refunded, 0); + } + + function test_claim_locked_registersOnly() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + _deposit(alice, saleId, PKLSH, _proofFor(leafBob), 200e18); // alice LOCKED + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + _settleAndFinalize(saleId, root, 50e18); + + uint256 beforeUsdt = usdt.balanceOf(alice); + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + + assertEq(usdt.balanceOf(alice), beforeUsdt + 50e18); // refund paid + assertEq(share.balanceOf(alice), 0); // shares NOT transferred, only recorded + assertEq(share.balanceOf(address(distributor)), 10e18); + assertTrue(distributor.claimed(saleId, alice)); + } + + function test_claim_unlocked_zeroShareToken_reverts() public { + uint64 saleId = _saleWithDeposit(); // alice XKLSH (unlocked) + // leaf carries a zero share token for an unlocked user -> invalid + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(0), 10e18); + _settleAndFinalize(saleId, root, 50e18); + + vm.expectRevert("Share token required"); + distributor.claim(saleId, alice, 50e18, address(0), 10e18, new bytes32[](0)); + } + + function test_claim_locked_zeroShareToken_ok() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + _deposit(alice, saleId, PKLSH, _proofFor(leafBob), 200e18); // alice LOCKED + // locked tranche: zero share token is fine (no transfer, only recorded) + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(0), 10e18); + _settleAndFinalize(saleId, root, 50e18); + + uint256 beforeUsdt = usdt.balanceOf(alice); + distributor.claim(saleId, alice, 50e18, address(0), 10e18, new bytes32[](0)); + assertEq(usdt.balanceOf(alice), beforeUsdt + 50e18); + assertTrue(distributor.claimed(saleId, alice)); + } + + // aggregate refunds cannot exceed the committed total + function test_claim_refundOverTotal_reverts() public { + uint64 saleId = _saleWithDeposit(); + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + _settleAndFinalize(saleId, root, 40e18); // totalRefund (40e18) < leaf refund (50e18) + vm.expectRevert("Refund over total"); + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + } + + // I13: previewClaim mirrors claim validation/routing without state change + function test_previewClaim() public { + uint64 saleId = _saleWithDeposit(); // alice unlocked, 200e18 + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + + // before finalize: not valid + (bool valid0,,,) = distributor.previewClaim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + assertFalse(valid0); + + _settleAndFinalize(saleId, root, 50e18); + + // valid, not yet claimed, unlocked tranche -> will deliver shares + (bool valid, bool claimed_, uint8 tranche, bool willDeliver) = + distributor.previewClaim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + assertTrue(valid); + assertFalse(claimed_); + assertEq(tranche, XKLSH); + assertTrue(willDeliver); + + // wrong amount -> invalid proof + (bool validBad,,,) = distributor.previewClaim(saleId, alice, 51e18, address(share), 10e18, new bytes32[](0)); + assertFalse(validBad); + + // after claiming -> alreadyClaimed true + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + (, bool claimedAfter,,) = distributor.previewClaim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + assertTrue(claimedAfter); + } + + function test_claim_alreadyClaimed_reverts() public { + uint64 saleId = _saleWithDeposit(); + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + _settleAndFinalize(saleId, root, 50e18); + + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + vm.expectRevert("Already claimed"); + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + } + + function test_claim_notFinalized_reverts() public { + uint64 saleId = _saleWithDeposit(); + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + vm.prank(bot); + distributor.setSettlementRoot(saleId, root, 50e18); // pending, not finalized + vm.expectRevert("Not finalized"); + distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0)); + } + + function test_claim_invalidProof_reverts() public { + uint64 saleId = _saleWithDeposit(); + MockERC20 share = new MockERC20(admin, "Share", "xKLSH"); + deal(address(share), address(distributor), 10e18); + bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18); + _settleAndFinalize(saleId, root, 50e18); + // wrong refund amount -> leaf mismatch + vm.expectRevert("Invalid proof"); + distributor.claim(saleId, alice, 51e18, address(share), 10e18, new bytes32[](0)); + } + // ---- emergency withdraw ---- function test_emergencyWithdraw_ok() public {