diff --git a/contracts/dao/PreIPODistributor.sol b/contracts/dao/PreIPODistributor.sol new file mode 100644 index 0000000..6584b9d --- /dev/null +++ b/contracts/dao/PreIPODistributor.sol @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +/** + * @title PreIPODistributor + * @author Lista + * @dev Escrow subscription contract with two rounds per sale. + * + * - Whitelist round: gated by a merkle root; leaf = keccak256(abi.encode(chainid, account)). + * The leaf proves membership only, so one tree can be reused across sales. + * - Public round: open to everyone, opened by the manager after the whitelist round. + * + * 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. + */ +contract PreIPODistributor is + Initializable, + AccessControlUpgradeable, + ReentrancyGuardUpgradeable, + UUPSUpgradeable +{ + 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. + uint8 public constant TRANCHE_UNLOCKED = 1; + uint8 public constant TRANCHE_LOCKED = 2; + + struct Sale { + address depositToken; + bytes32 whitelistRoot; + // whitelist round window + uint256 startTime; + uint256 endTime; + // minimum amount per single deposit, shared by both rounds + uint256 minDeposit; + uint256 totalDeposits; + bool paused; + // public round (0 = not configured) + uint256 pubStartTime; + uint256 pubEndTime; + uint256 pubTotalDeposits; + } + + // saleId => Sale + mapping(uint64 => Sale) public sales; + // saleId => user => cumulative whitelist-round deposit + mapping(uint64 => mapping(address => uint256)) public deposits; + // saleId => user => cumulative public-round deposit + mapping(uint64 => mapping(address => uint256)) public pubDeposits; + // saleId => user => selected tranche (locked at first deposit; 0 = unset) + mapping(uint64 => mapping(address => uint8)) public userTranche; + + uint64 public nextSaleId; + + event CreateSale( + uint64 indexed saleId, + address depositToken, + bytes32 whitelistRoot, + uint256 startTime, + uint256 endTime, + uint256 minDeposit + ); + + event UpdateSale( + uint64 indexed saleId, + address depositToken, + bytes32 whitelistRoot, + uint256 startTime, + uint256 endTime, + uint256 minDeposit + ); + + event SetPublicRound(uint64 indexed saleId, uint256 pubStartTime, uint256 pubEndTime); + + event SetPaused(uint64 indexed saleId, bool paused); + + event DepositWhitelist( + uint64 indexed saleId, + address indexed account, + uint8 tranche, + uint256 amount, + uint256 userTotal, + uint256 totalDeposits + ); + + event DepositPublic( + uint64 indexed saleId, + address indexed account, + uint8 tranche, + uint256 amount, + uint256 userTotal, + uint256 pubTotalDeposits + ); + + event EmergencyWithdraw(address indexed token, address indexed to, uint256 amount); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address _admin, address _manager, address _bot) external initializer { + require(_admin != address(0), "Invalid admin address"); + require(_manager != address(0), "Invalid manager address"); + require(_bot != address(0), "Invalid bot address"); + + __AccessControl_init(); + __ReentrancyGuard_init(); + __UUPSUpgradeable_init(); + + _setupRole(DEFAULT_ADMIN_ROLE, _admin); + _setupRole(MANAGER, _manager); + _setupRole(BOT, _bot); + + // MANAGER manages the BOT role + _setRoleAdmin(BOT, MANAGER); + } + + /// @dev Create a sale (whitelist round). The public round is opened later via setPublicRound. + function createSale( + address _depositToken, + bytes32 _whitelistRoot, + uint256 _startTime, + uint256 _endTime, + uint256 _minDeposit + ) external onlyRole(MANAGER) returns (uint64 saleId) { + require(_depositToken != address(0), "Invalid deposit token"); + require(_whitelistRoot != bytes32(0), "Invalid merkle root"); + require(_startTime > block.timestamp, "Invalid start time"); + require(_endTime > _startTime, "Invalid end time"); + require(_minDeposit > 0, "Invalid min deposit"); + + saleId = nextSaleId++; + Sale storage sale = sales[saleId]; + sale.depositToken = _depositToken; + sale.whitelistRoot = _whitelistRoot; + sale.startTime = _startTime; + sale.endTime = _endTime; + sale.minDeposit = _minDeposit; + + emit CreateSale(saleId, _depositToken, _whitelistRoot, _startTime, _endTime, _minDeposit); + } + + /// @dev Update a sale's whitelist-round config. Only allowed before the sale starts. + function updateSale( + uint64 _saleId, + address _depositToken, + bytes32 _whitelistRoot, + uint256 _startTime, + uint256 _endTime, + uint256 _minDeposit + ) external onlyRole(MANAGER) { + Sale storage sale = sales[_saleId]; + require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + require(sale.startTime > block.timestamp, "Sale already started"); + require(_depositToken != address(0), "Invalid deposit token"); + require(_whitelistRoot != bytes32(0), "Invalid merkle root"); + require(_startTime > block.timestamp, "Invalid start time"); + require(_endTime > _startTime, "Invalid end time"); + // if a public round is already scheduled, the WL round must still end before it starts + require(sale.pubStartTime == 0 || _endTime < sale.pubStartTime, "WL end must precede public"); + require(_minDeposit > 0, "Invalid min deposit"); + + sale.depositToken = _depositToken; + sale.whitelistRoot = _whitelistRoot; + sale.startTime = _startTime; + sale.endTime = _endTime; + sale.minDeposit = _minDeposit; + + emit UpdateSale(_saleId, _depositToken, _whitelistRoot, _startTime, _endTime, _minDeposit); + } + + /// @dev Open / reschedule the public round; only before it starts, and strictly after the WL round. + function setPublicRound(uint64 _saleId, uint256 _pubStartTime, uint256 _pubEndTime) + external + onlyRole(MANAGER) + { + Sale storage sale = sales[_saleId]; + require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + 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"); + require(_pubEndTime > _pubStartTime, "Invalid pub end"); + + sale.pubStartTime = _pubStartTime; + sale.pubEndTime = _pubEndTime; + + emit SetPublicRound(_saleId, _pubStartTime, _pubEndTime); + } + + /// @dev Pause or unpause a sale (affects both rounds). + function setPaused(uint64 _saleId, bool _paused) external onlyRole(MANAGER) { + Sale storage sale = sales[_saleId]; + require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + sale.paused = _paused; + emit SetPaused(_saleId, _paused); + } + + /// @dev Whitelist-round deposit. First deposit needs a valid proof; top-ups skip it. + /// The tranche is locked at the first deposit in this sale. + function depositWhitelist(uint64 _saleId, uint8 _tranche, bytes32[] calldata _proof, uint256 _amount) + external + nonReentrant + { + Sale storage sale = sales[_saleId]; + require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + require(!sale.paused, "Sale paused"); + require(block.timestamp >= sale.startTime && block.timestamp <= sale.endTime, "WL round not active"); + 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) { + 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; + deposits[_saleId][msg.sender] = userTotal; + sale.totalDeposits += _amount; + + IERC20(sale.depositToken).safeTransferFrom(msg.sender, address(this), _amount); + + emit DepositWhitelist(_saleId, msg.sender, tranche, _amount, userTotal, sale.totalDeposits); + } + + /// @dev Public-round deposit. Open to everyone; whitelist users inherit their locked tranche. + function depositPublic(uint64 _saleId, uint8 _tranche, uint256 _amount) external nonReentrant { + Sale storage sale = sales[_saleId]; + require(sale.whitelistRoot != bytes32(0), "Invalid saleId"); + require(sale.pubStartTime != 0, "Public round not set"); + require(!sale.paused, "Sale paused"); + require( + block.timestamp >= sale.pubStartTime && block.timestamp <= sale.pubEndTime, + "Public round not active" + ); + require(_amount >= sale.minDeposit, "Below min deposit"); + + uint8 tranche = _applyTranche(_saleId, _tranche); + + uint256 userTotal = pubDeposits[_saleId][msg.sender] + _amount; + pubDeposits[_saleId][msg.sender] = userTotal; + sale.pubTotalDeposits += _amount; + + IERC20(sale.depositToken).safeTransferFrom(msg.sender, address(this), _amount); + + emit DepositPublic(_saleId, msg.sender, tranche, _amount, userTotal, sale.pubTotalDeposits); + } + + /// @dev Set the tranche on first deposit, or enforce it matches on subsequent deposits. + function _applyTranche(uint64 _saleId, uint8 _tranche) private returns (uint8 tranche) { + tranche = userTranche[_saleId][msg.sender]; + if (tranche == 0) { + require(_tranche == TRANCHE_UNLOCKED || _tranche == TRANCHE_LOCKED, "Invalid tranche"); + userTranche[_saleId][msg.sender] = _tranche; + tranche = _tranche; + } else { + require(_tranche == tranche, "Tranche locked"); + } + } + + /// @dev Manager (multisig) safety valve; there is no normal withdrawal path. + function emergencyWithdraw(address _token, address _to, uint256 _amount) + external + onlyRole(MANAGER) + { + require(_to != address(0), "Invalid recipient"); + require(_amount > 0, "Invalid amount"); + + if (_token == address(0)) { + (bool success,) = payable(_to).call{value: _amount}(""); + require(success, "Transfer native failed"); + } else { + IERC20(_token).safeTransfer(_to, _amount); + } + + emit EmergencyWithdraw(_token, _to, _amount); + } + + function getSale(uint64 _saleId) external view returns (Sale memory) { + return sales[_saleId]; + } + + function getSales(uint64[] calldata _saleIds) external view returns (Sale[] memory) { + Sale[] memory _sales = new Sale[](_saleIds.length); + for (uint256 i = 0; i < _saleIds.length; i++) { + _sales[i] = sales[_saleIds[i]]; + } + return _sales; + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} +} diff --git a/scripts/foundry/dao/deploy_PreIPO_phase1.sol b/scripts/foundry/dao/deploy_PreIPO_phase1.sol new file mode 100644 index 0000000..9acd1b2 --- /dev/null +++ b/scripts/foundry/dao/deploy_PreIPO_phase1.sol @@ -0,0 +1,60 @@ +pragma solidity ^0.8.10; + +import { Script, console } from "forge-std/Script.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { PreIPODistributor } from "../../../contracts/dao/PreIPODistributor.sol"; + +/** + * Phase 1 (subscription) deploy to BSC mainnet. + * + * Grants each role to BOTH the deployer AND the real role holders, so the deployer can finish + * setup afterwards (e.g. createSale, run separately once the whitelist root is ready). The + * deployer's roles are removed later by the separate revoke script (revoke_PreIPO_deployer.sol). + * + * Real roles (launch table): + * - admin (DEFAULT_ADMIN_ROLE, timelock): 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253 + * - manager (MANAGER): 0x8d388136d578dCD791D081c6042284CED6d9B0c6 + * - bot (BOT, strategy): 0x91fC4BA20685339781888eCA3E9E1c12d40F0e13 + * + * Usage: + * forge script scripts/foundry/dao/deploy_PreIPO_phase1.sol:DeployPreIPOPhase1 \ + * --rpc-url bsc --broadcast --verify -vvvv + * + * Requires env: DEPLOYER_BSC_PRIVATE_KEY, BSCSCAN_API_KEY (verify). + */ +contract DeployPreIPOPhase1 is Script { + // ---- real role holders (BSC mainnet) ---- + address constant ADMIN = 0x07D274a68393E8b8a2CCf19A2ce4Ba3518735253; // timelock + address constant MANAGER = 0x8d388136d578dCD791D081c6042284CED6d9B0c6; + address constant BOT = 0x91fC4BA20685339781888eCA3E9E1c12d40F0e13; // strategy + + function run() public { + require(block.chainid == 56, "BSC mainnet only"); + uint256 pk = vm.envUint("DEPLOYER_BSC_PRIVATE_KEY"); + address deployer = vm.addr(pk); + + console.log("Deployer:", deployer); + console.log("Admin:", ADMIN); + console.log("Manager:", MANAGER); + console.log("Bot:", BOT); + + vm.startBroadcast(pk); + + // deploy impl + proxy; deployer holds all three roles initially (so it can grant + operate) + PreIPODistributor impl = new PreIPODistributor(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeCall(PreIPODistributor.initialize, (deployer, deployer, deployer)) + ); + PreIPODistributor dist = PreIPODistributor(address(proxy)); + console.log("Implementation:", address(impl)); + console.log("PreIPODistributor proxy:", address(proxy)); + + // grant the real role holders (deployer keeps its roles until the revoke script runs) + dist.grantRole(dist.DEFAULT_ADMIN_ROLE(), ADMIN); + dist.grantRole(dist.MANAGER(), MANAGER); + dist.grantRole(dist.BOT(), BOT); // BOT role-admin is MANAGER; deployer holds MANAGER + + vm.stopBroadcast(); + } +} diff --git a/test/dao/PreIPODistributor.t.sol b/test/dao/PreIPODistributor.t.sol new file mode 100644 index 0000000..872d7b8 --- /dev/null +++ b/test/dao/PreIPODistributor.t.sol @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.10; + +import "forge-std/Test.sol"; + +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import "../../contracts/dao/PreIPODistributor.sol"; +import "../../contracts/mock/MockERC20.sol"; + +contract PreIPODistributorTest is Test { + address admin = makeAddr("admin"); + address manager = makeAddr("manager"); + address bot = makeAddr("bot"); + address treasury = makeAddr("treasury"); + address outsider = makeAddr("outsider"); + + address alice; + address bob; + address carol; // not whitelisted + + PreIPODistributor distributor; + MockERC20 usdt; + + // whitelist tree over {alice, bob} + bytes32 rootWL; + bytes32 leafAlice; + bytes32 leafBob; + + uint256 constant MIN_DEPOSIT = 100e18; + uint8 constant XKLSH = 1; // TRANCHE_UNLOCKED + uint8 constant PKLSH = 2; // TRANCHE_LOCKED + + function setUp() public { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + carol = makeAddr("carol"); + + usdt = new MockERC20(admin, "Mock USDT", "USDT"); + + PreIPODistributor impl = new PreIPODistributor(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeWithSelector(PreIPODistributor.initialize.selector, admin, manager, bot) + ); + distributor = PreIPODistributor(address(proxy)); + + // build whitelist merkle tree (leaf = keccak256(abi.encode(chainid, account))) + leafAlice = keccak256(abi.encode(block.chainid, alice)); + leafBob = keccak256(abi.encode(block.chainid, bob)); + rootWL = _hashPair(leafAlice, leafBob); + + deal(address(usdt), alice, 1_000_000e18); + deal(address(usdt), bob, 1_000_000e18); + deal(address(usdt), carol, 1_000_000e18); + } + + // ---- helpers ---- + + function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { + return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); + } + + function _proofFor(bytes32 sibling) internal pure returns (bytes32[] memory proof) { + proof = new bytes32[](1); + proof[0] = sibling; + } + + function _createDefaultSale() internal returns (uint64 saleId) { + vm.prank(manager); + saleId = distributor.createSale( + address(usdt), + rootWL, + block.timestamp + 100, + block.timestamp + 1000, + MIN_DEPOSIT + ); + } + + // open a public round after WL end (WL end is start(+100)+... => endTime = created+1000) + function _openPublicRound(uint64 saleId) internal { + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + vm.prank(manager); + distributor.setPublicRound(saleId, s.endTime + 10, s.endTime + 1000); + } + + function _deposit(address who, uint64 saleId, uint8 tranche, bytes32[] memory proof, uint256 amount) internal { + vm.startPrank(who); + usdt.approve(address(distributor), amount); + distributor.depositWhitelist(saleId, tranche, proof, amount); + vm.stopPrank(); + } + + function _depositPublic(address who, uint64 saleId, uint8 tranche, uint256 amount) internal { + vm.startPrank(who); + usdt.approve(address(distributor), amount); + distributor.depositPublic(saleId, tranche, amount); + vm.stopPrank(); + } + + // ---- setup / roles ---- + + function test_setUp() public view { + assertEq(distributor.nextSaleId(), 0); + assertTrue(distributor.hasRole(distributor.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(distributor.hasRole(distributor.MANAGER(), manager)); + assertTrue(distributor.hasRole(distributor.BOT(), bot)); + assertEq(distributor.getRoleAdmin(distributor.BOT()), distributor.MANAGER()); + assertEq(distributor.TRANCHE_UNLOCKED(), XKLSH); + assertEq(distributor.TRANCHE_LOCKED(), PKLSH); + } + + 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 + vm.prank(manager); + distributor.grantRole(botRole, newBot); + assertTrue(distributor.hasRole(botRole, newBot)); + } + + function test_defaultAdminCannotGrantBot() public { + address newBot = makeAddr("newBot"); + bytes32 botRole = distributor.BOT(); + vm.prank(admin); + vm.expectRevert(); + distributor.grantRole(botRole, newBot); + } + + function test_createSale_ok() public { + uint64 saleId = _createDefaultSale(); + assertEq(saleId, 0); + assertEq(distributor.nextSaleId(), 1); + + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + assertEq(s.depositToken, address(usdt)); + assertEq(s.whitelistRoot, rootWL); + assertEq(s.startTime, block.timestamp + 100); + assertEq(s.endTime, block.timestamp + 1000); + assertEq(s.minDeposit, MIN_DEPOSIT); + assertEq(s.totalDeposits, 0); + assertEq(s.pubStartTime, 0); + assertFalse(s.paused); + } + + function test_createSale_acl() public { + vm.prank(outsider); + vm.expectRevert(); + distributor.createSale(address(usdt), rootWL, block.timestamp + 100, block.timestamp + 1000, MIN_DEPOSIT); + } + + function test_createSale_invalidParams() public { + vm.startPrank(manager); + + vm.expectRevert("Invalid deposit token"); + distributor.createSale(address(0), rootWL, block.timestamp + 100, block.timestamp + 1000, MIN_DEPOSIT); + + vm.expectRevert("Invalid merkle root"); + distributor.createSale(address(usdt), bytes32(0), block.timestamp + 100, block.timestamp + 1000, MIN_DEPOSIT); + + vm.expectRevert("Invalid start time"); + distributor.createSale(address(usdt), rootWL, block.timestamp, block.timestamp + 1000, MIN_DEPOSIT); + + vm.expectRevert("Invalid end time"); + distributor.createSale(address(usdt), rootWL, block.timestamp + 100, block.timestamp + 100, MIN_DEPOSIT); + + vm.expectRevert("Invalid min deposit"); + distributor.createSale(address(usdt), rootWL, block.timestamp + 100, block.timestamp + 1000, 0); + + vm.stopPrank(); + } + + function test_updateSale_ok() public { + uint64 saleId = _createDefaultSale(); + + bytes32 newRoot = keccak256("new"); + vm.prank(manager); + distributor.updateSale(saleId, address(usdt), newRoot, block.timestamp + 200, block.timestamp + 2000, 50e18); + + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + assertEq(s.whitelistRoot, newRoot); + assertEq(s.startTime, block.timestamp + 200); + assertEq(s.endTime, block.timestamp + 2000); + assertEq(s.minDeposit, 50e18); + } + + function test_updateSale_afterStart_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 150); // past start + vm.prank(manager); + vm.expectRevert("Sale already started"); + distributor.updateSale(saleId, address(usdt), rootWL, block.timestamp + 200, block.timestamp + 2000, 50e18); + } + + function test_updateSale_afterPublicSet_overlap_reverts() public { + uint64 saleId = _createDefaultSale(); // WL end = now+1000 + _openPublicRound(saleId); // pub start = end+10 = now+1010 + // extending WL end past the public start must revert + vm.prank(manager); + vm.expectRevert("WL end must precede public"); + distributor.updateSale(saleId, address(usdt), rootWL, block.timestamp + 100, block.timestamp + 1500, 50e18); + } + + function test_updateSale_afterPublicSet_noOverlap_ok() public { + uint64 saleId = _createDefaultSale(); + _openPublicRound(saleId); // pub start = now+1010 + // new WL end still before public start -> ok + vm.prank(manager); + distributor.updateSale(saleId, address(usdt), rootWL, block.timestamp + 100, block.timestamp + 1005, 50e18); + assertEq(distributor.getSale(saleId).endTime, block.timestamp + 1005); + } + + // ---- whitelist deposit ---- + + function test_deposit_ok() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + + _deposit(alice, saleId, XKLSH, _proofFor(leafBob), 200e18); + + assertEq(distributor.deposits(saleId, alice), 200e18); + assertEq(distributor.userTranche(saleId, alice), XKLSH); + assertEq(distributor.getSale(saleId).totalDeposits, 200e18); + assertEq(usdt.balanceOf(address(distributor)), 200e18); + } + + function test_deposit_topUp_skipsProof_inheritsTranche() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + + _deposit(alice, saleId, PKLSH, _proofFor(leafBob), 200e18); + // top-up with empty proof, passing same tranche + _deposit(alice, saleId, PKLSH, new bytes32[](0), 300e18); + + assertEq(distributor.deposits(saleId, alice), 500e18); + assertEq(distributor.userTranche(saleId, alice), PKLSH); + assertEq(distributor.getSale(saleId).totalDeposits, 500e18); + } + + function test_deposit_changingTranche_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + + _deposit(alice, saleId, XKLSH, _proofFor(leafBob), 200e18); + + vm.startPrank(alice); + usdt.approve(address(distributor), 100e18); + vm.expectRevert("Tranche locked"); + distributor.depositWhitelist(saleId, PKLSH, new bytes32[](0), 100e18); + vm.stopPrank(); + } + + function test_deposit_invalidTranche_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + + vm.startPrank(alice); + usdt.approve(address(distributor), 200e18); + vm.expectRevert("Invalid tranche"); + distributor.depositWhitelist(saleId, 0, _proofFor(leafBob), 200e18); + vm.expectRevert("Invalid tranche"); + distributor.depositWhitelist(saleId, 3, _proofFor(leafBob), 200e18); + vm.stopPrank(); + } + + function test_deposit_notWhitelisted_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + + vm.startPrank(carol); + usdt.approve(address(distributor), 200e18); + vm.expectRevert("Invalid proof"); + distributor.depositWhitelist(saleId, XKLSH, _proofFor(leafBob), 200e18); + vm.stopPrank(); + } + + function test_deposit_belowMin_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + + vm.startPrank(alice); + usdt.approve(address(distributor), 99e18); + vm.expectRevert("Below min deposit"); + distributor.depositWhitelist(saleId, XKLSH, _proofFor(leafBob), 99e18); + vm.stopPrank(); + } + + function test_deposit_beforeStart_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.startPrank(alice); + usdt.approve(address(distributor), 200e18); + vm.expectRevert("WL round not active"); + distributor.depositWhitelist(saleId, XKLSH, _proofFor(leafBob), 200e18); + vm.stopPrank(); + } + + function test_deposit_afterEnd_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 1001); + vm.startPrank(alice); + usdt.approve(address(distributor), 200e18); + vm.expectRevert("WL round not active"); + distributor.depositWhitelist(saleId, XKLSH, _proofFor(leafBob), 200e18); + vm.stopPrank(); + } + + function test_deposit_whenPaused_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.prank(manager); + distributor.setPaused(saleId, true); + + vm.warp(block.timestamp + 100); + vm.startPrank(alice); + usdt.approve(address(distributor), 200e18); + vm.expectRevert("Sale paused"); + distributor.depositWhitelist(saleId, XKLSH, _proofFor(leafBob), 200e18); + vm.stopPrank(); + } + + function test_deposit_invalidSaleId_reverts() public { + vm.warp(block.timestamp + 100); + vm.startPrank(alice); + usdt.approve(address(distributor), 200e18); + vm.expectRevert("Invalid saleId"); + distributor.depositWhitelist(0, XKLSH, _proofFor(leafBob), 200e18); + vm.stopPrank(); + } + + // ---- public round ---- + + function test_setPublicRound_ok() public { + uint64 saleId = _createDefaultSale(); + _openPublicRound(saleId); + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + assertEq(s.pubStartTime, s.endTime + 10); + assertEq(s.pubEndTime, s.endTime + 1000); + } + + function test_setPublicRound_mustFollowWL_reverts() public { + uint64 saleId = _createDefaultSale(); + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + vm.prank(manager); + vm.expectRevert("Public must follow WL"); + distributor.setPublicRound(saleId, s.endTime, s.endTime + 100); // not strictly after WL end + } + + function test_setPublicRound_acl() public { + uint64 saleId = _createDefaultSale(); + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + vm.prank(outsider); + vm.expectRevert(); + distributor.setPublicRound(saleId, s.endTime + 10, s.endTime + 100); + } + + function test_depositPublic_ok_openToAll() public { + uint64 saleId = _createDefaultSale(); + _openPublicRound(saleId); + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + vm.warp(s.pubStartTime); + + // carol is NOT whitelisted, but public round is open to everyone + _depositPublic(carol, saleId, XKLSH, 250e18); + + assertEq(distributor.pubDeposits(saleId, carol), 250e18); + assertEq(distributor.userTranche(saleId, carol), XKLSH); + assertEq(distributor.getSale(saleId).pubTotalDeposits, 250e18); + assertEq(distributor.getSale(saleId).totalDeposits, 0); // WL pool untouched + } + + function test_depositPublic_beforeSet_reverts() public { + uint64 saleId = _createDefaultSale(); + vm.startPrank(carol); + usdt.approve(address(distributor), 250e18); + vm.expectRevert("Public round not set"); + distributor.depositPublic(saleId, XKLSH, 250e18); + vm.stopPrank(); + } + + function test_depositPublic_outsideWindow_reverts() public { + uint64 saleId = _createDefaultSale(); + _openPublicRound(saleId); + // still in WL window, public not started + vm.startPrank(carol); + usdt.approve(address(distributor), 250e18); + vm.expectRevert("Public round not active"); + distributor.depositPublic(saleId, XKLSH, 250e18); + vm.stopPrank(); + } + + function test_tranche_lockedAcrossRounds() public { + uint64 saleId = _createDefaultSale(); + _openPublicRound(saleId); + + // WL round: alice picks PKLSH + vm.warp(block.timestamp + 100); + _deposit(alice, saleId, PKLSH, _proofFor(leafBob), 200e18); + + // Public round: alice tries XKLSH -> must inherit PKLSH + PreIPODistributor.Sale memory s = distributor.getSale(saleId); + vm.warp(s.pubStartTime); + vm.startPrank(alice); + usdt.approve(address(distributor), 100e18); + vm.expectRevert("Tranche locked"); + distributor.depositPublic(saleId, XKLSH, 100e18); + // same tranche works + distributor.depositPublic(saleId, PKLSH, 100e18); + vm.stopPrank(); + + assertEq(distributor.pubDeposits(saleId, alice), 100e18); + assertEq(distributor.deposits(saleId, alice), 200e18); + assertEq(distributor.userTranche(saleId, alice), PKLSH); + } + + // ---- emergency withdraw ---- + + function test_emergencyWithdraw_ok() public { + uint64 saleId = _createDefaultSale(); + vm.warp(block.timestamp + 100); + _deposit(alice, saleId, XKLSH, _proofFor(leafBob), 200e18); + + vm.prank(manager); + distributor.emergencyWithdraw(address(usdt), treasury, 200e18); + assertEq(usdt.balanceOf(treasury), 200e18); + assertEq(usdt.balanceOf(address(distributor)), 0); + } + + function test_emergencyWithdraw_acl() public { + vm.prank(outsider); + vm.expectRevert(); + distributor.emergencyWithdraw(address(usdt), treasury, 1e18); + } + + // ---- upgrade ---- + + function test_upgrade_onlyAdmin() public { + PreIPODistributor newImpl = new PreIPODistributor(); + + vm.prank(outsider); + vm.expectRevert(); + distributor.upgradeTo(address(newImpl)); + + vm.prank(admin); + distributor.upgradeTo(address(newImpl)); + } +}