Skip to content

Commit a10a14e

Browse files
authored
Merge pull request #120 from lista-dao/fix/pre-ipo-audit
Audi fix: Pre-IPO
2 parents d6fd6bd + c76a195 commit a10a14e

3 files changed

Lines changed: 130 additions & 7 deletions

File tree

342 KB
Binary file not shown.

contracts/dao/PreIPODistributor.sol

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
2121
* On the first deposit each address selects a delivery tranche, locked for the whole sale
2222
* (top-ups inherit it). The tranche does not affect accounting; it only records the choice.
2323
*
24-
* Allocation is computed off-chain; this contract only holds deposits. Deposits are locked and
25-
* can only be topped up, never withdrawn. Settlement and delivery are added via UUPS upgrade.
24+
* Allocation is computed off-chain; the contract holds deposits and, after the review window,
25+
* a finalized settlement merkle root drives claims. Deposits are locked and can only be topped
26+
* up, never withdrawn by the user. Claiming pays the refund and, for the unlocked tranche,
27+
* delivers the share token; the locked tranche only records the amount for off-chain delivery.
2628
*/
2729
contract PreIPODistributor is
2830
Initializable,
@@ -35,7 +37,9 @@ contract PreIPODistributor is
3537
bytes32 public constant MANAGER = keccak256("MANAGER");
3638
bytes32 public constant BOT = keccak256("BOT");
3739

38-
// Delivery tranche selected at first deposit; 0 = not selected.
40+
// Delivery tranche, selected at an account's first deposit and locked for the sale; 0 = not selected.
41+
// On claim, TRANCHE_UNLOCKED transfers the share token to the account immediately, while
42+
// TRANCHE_LOCKED only records the share amount (delivered off-chain at maturity).
3943
uint8 public constant TRANCHE_UNLOCKED = 1;
4044
uint8 public constant TRANCHE_LOCKED = 2;
4145

@@ -233,6 +237,11 @@ contract PreIPODistributor is
233237
{
234238
Sale storage sale = sales[_saleId];
235239
require(sale.whitelistRoot != bytes32(0), "Invalid saleId");
240+
// cannot open/reschedule a public round once settlement is pending or finalized
241+
require(
242+
settlements[_saleId].pendingRoot == bytes32(0) && settlements[_saleId].root == bytes32(0),
243+
"Settlement started"
244+
);
236245
require(sale.pubStartTime == 0 || sale.pubStartTime > block.timestamp, "Public round started");
237246
require(_pubStartTime > sale.endTime, "Public must follow WL");
238247
require(_pubStartTime > block.timestamp, "Invalid pub start");
@@ -265,14 +274,15 @@ contract PreIPODistributor is
265274
require(_amount >= sale.minDeposit, "Below min deposit");
266275

267276
// A non-zero existing WL deposit implies the caller already passed the proof check.
268-
if (deposits[_saleId][msg.sender] == 0) {
277+
uint256 prior = deposits[_saleId][msg.sender];
278+
if (prior == 0) {
269279
bytes32 leaf = keccak256(abi.encode(block.chainid, msg.sender));
270280
require(MerkleProof.verifyCalldata(_proof, sale.whitelistRoot, leaf), "Invalid proof");
271281
}
272282

273283
uint8 tranche = _applyTranche(_saleId, _tranche);
274284

275-
uint256 userTotal = deposits[_saleId][msg.sender] + _amount;
285+
uint256 userTotal = prior + _amount;
276286
deposits[_saleId][msg.sender] = userTotal;
277287
sale.totalDeposits += _amount;
278288

@@ -324,14 +334,15 @@ contract PreIPODistributor is
324334
{
325335
Sale storage sale = sales[_saleId];
326336
require(sale.whitelistRoot != bytes32(0), "Invalid saleId");
337+
require(!sale.paused, "Sale paused");
327338
// deposit windows must be closed so no deposit can land after the off-chain snapshot
328339
require(block.timestamp > sale.endTime, "WL round not ended");
329340
require(sale.pubStartTime == 0 || block.timestamp > sale.pubEndTime, "Public round not ended");
330341

331342
Settlement storage s = settlements[_saleId];
332343
// settlement is single-shot: once a root is finalized it cannot be replaced
333344
require(s.root == bytes32(0), "Already finalized");
334-
require(_root != bytes32(0) && _root != s.pendingRoot, "Invalid root");
345+
require(_root != bytes32(0), "Invalid root");
335346
require(s.pendingRoot == bytes32(0), "Pending root in flight");
336347
require(_totalRefund <= sale.totalDeposits + sale.pubTotalDeposits, "Refund exceeds deposits");
337348

@@ -344,6 +355,7 @@ contract PreIPODistributor is
344355

345356
/// @dev Finalize the pending settlement root (step 2 of 2); only after the review window.
346357
function finalizeSettlement(uint64 _saleId) external onlyRole(BOT) {
358+
require(!sales[_saleId].paused, "Sale paused");
347359
Settlement storage s = settlements[_saleId];
348360
require(s.pendingRoot != bytes32(0), "No pending root");
349361
require(block.timestamp >= s.lastSetTime + waitingPeriod, "Review window not passed");
@@ -368,8 +380,14 @@ contract PreIPODistributor is
368380
/// @dev Claim a finalized allocation (whitelist + public rounds combined), once per account.
369381
/// Pays the refund; for the unlocked tranche also transfers the share token, while the
370382
/// locked tranche only records the amount (delivered off-chain at maturity). Permanent.
383+
/// Permissionless: anyone may call it on behalf of any account; the refund and any share
384+
/// delivery always go to `_account` (the address bound in the leaf), never to msg.sender.
385+
/// @param _saleId Sale id
386+
/// @param _account Account the allocation belongs to and that receives refund/shares
387+
/// @param _refundAmount Refund amount in the deposit token (part of the leaf)
371388
/// @param _shareToken Share token to deliver for the unlocked tranche (part of the leaf)
372-
/// @param _tokenAmount Share amount (delivered for unlocked, recorded for locked)
389+
/// @param _tokenAmount Share amount (delivered for unlocked, recorded for locked; part of the leaf)
390+
/// @param _proof Merkle proof of the leaf against the finalized settlement root
373391
function claim(
374392
uint64 _saleId,
375393
address _account,
@@ -378,6 +396,7 @@ contract PreIPODistributor is
378396
uint256 _tokenAmount,
379397
bytes32[] calldata _proof
380398
) external nonReentrant {
399+
require(!sales[_saleId].paused, "Sale paused");
381400
Settlement storage s = settlements[_saleId];
382401
require(s.root != bytes32(0), "Not finalized");
383402
require(!claimed[_saleId][_account], "Already claimed");
@@ -409,6 +428,31 @@ contract PreIPODistributor is
409428
emit Claimed(_saleId, _account, _refundAmount, _shareToken, _tokenAmount, tranche);
410429
}
411430

431+
/// @dev Read-only preview of what claim() would do for the given leaf inputs, without changing
432+
/// state. Mirrors claim()'s validation and delivery routing.
433+
/// @return valid True if the sale is finalized and the proof matches the leaf
434+
/// @return alreadyClaimed True if the account has already claimed
435+
/// @return tranche The account's locked tranche (0 = never deposited)
436+
/// @return sendShares True if a successful claim would transfer the share token now
437+
/// (unlocked tranche, non-zero amount and share token)
438+
function previewClaim(
439+
uint64 _saleId,
440+
address _account,
441+
uint256 _refundAmount,
442+
address _shareToken,
443+
uint256 _tokenAmount,
444+
bytes32[] calldata _proof
445+
) external view returns (bool valid, bool alreadyClaimed, uint8 tranche, bool sendShares) {
446+
bytes32 leaf =
447+
keccak256(abi.encode(block.chainid, _saleId, _account, _refundAmount, _shareToken, _tokenAmount));
448+
bytes32 root = settlements[_saleId].root;
449+
valid = root != bytes32(0) && MerkleProof.verifyCalldata(_proof, root, leaf);
450+
alreadyClaimed = claimed[_saleId][_account];
451+
tranche = userTranche[_saleId][_account];
452+
sendShares =
453+
valid && !alreadyClaimed && tranche == TRANCHE_UNLOCKED && _shareToken != address(0) && _tokenAmount > 0;
454+
}
455+
412456
/// @dev Update the review window between set and finalize (min 6h).
413457
function setWaitingPeriod(uint256 _waitingPeriod) external onlyRole(MANAGER) {
414458
require(_waitingPeriod >= 6 hours, "Waiting period too short");
@@ -417,6 +461,11 @@ contract PreIPODistributor is
417461
}
418462

419463
/// @dev Manager (multisig) safety valve; there is no normal withdrawal path.
464+
/// WARNING: this does NOT adjust any internal accounting (totalDeposits, pubTotalDeposits,
465+
/// settlement totals). Withdrawing the deposit token can leave the contract without enough
466+
/// balance to satisfy outstanding refunds, causing finalized claims to revert and stranding
467+
/// those refunds. After using it, the tracked totals must be restaged (or the contract
468+
/// upgraded) before normal claims can resume. Use only in emergencies.
420469
function emergencyWithdraw(address _token, address _to, uint256 _amount)
421470
external
422471
onlyRole(MANAGER)

test/dao/PreIPODistributor.t.sol

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ contract PreIPODistributorTest is Test {
441441
vm.prank(bot);
442442
distributor.setSettlementRoot(saleId, root, 50e18);
443443

444+
// tuple: root, pendingRoot, pendingTotalRefund, totalRefund, lastSetTime, refunded
444445
(bytes32 fRoot, bytes32 pRoot, uint256 pRefund,, uint256 setTime,) = distributor.settlements(saleId);
445446
assertEq(fRoot, bytes32(0));
446447
assertEq(pRoot, root);
@@ -557,6 +558,48 @@ contract PreIPODistributorTest is Test {
557558
distributor.setSettlementRoot(saleId, keccak256("r2"), 50e18);
558559
}
559560

561+
// M02: cannot open a public round once settlement is pending or finalized
562+
function test_setPublicRound_afterSettlementPending_reverts() public {
563+
uint64 saleId = _saleWithDeposit(); // WL-only, windows closed
564+
vm.prank(bot);
565+
distributor.setSettlementRoot(saleId, keccak256("r"), 50e18);
566+
uint256 endTime = distributor.getSale(saleId).endTime;
567+
vm.prank(manager);
568+
vm.expectRevert("Settlement started");
569+
distributor.setPublicRound(saleId, endTime + 100, endTime + 200);
570+
}
571+
572+
function test_setPublicRound_afterFinalize_reverts() public {
573+
uint64 saleId = _saleWithDeposit();
574+
_settleAndFinalize(saleId, keccak256("r"), 50e18);
575+
uint256 endTime = distributor.getSale(saleId).endTime;
576+
vm.prank(manager);
577+
vm.expectRevert("Settlement started");
578+
distributor.setPublicRound(saleId, endTime + 100, endTime + 200);
579+
}
580+
581+
// I03: pause is a real circuit breaker over settlement and claim
582+
function test_paused_blocksSettlement() public {
583+
uint64 saleId = _saleWithDeposit();
584+
vm.prank(manager);
585+
distributor.setPaused(saleId, true);
586+
vm.prank(bot);
587+
vm.expectRevert("Sale paused");
588+
distributor.setSettlementRoot(saleId, keccak256("r"), 50e18);
589+
}
590+
591+
function test_paused_blocksClaim() public {
592+
uint64 saleId = _saleWithDeposit(); // alice unlocked, 200e18
593+
MockERC20 share = new MockERC20(admin, "Share", "xKLSH");
594+
deal(address(share), address(distributor), 10e18);
595+
bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18);
596+
_settleAndFinalize(saleId, root, 50e18); // finalize while not paused
597+
vm.prank(manager);
598+
distributor.setPaused(saleId, true);
599+
vm.expectRevert("Sale paused");
600+
distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0));
601+
}
602+
560603
// ---- claim ----
561604

562605
// warp past the deposit window(s) so settlement is allowed
@@ -660,6 +703,37 @@ contract PreIPODistributorTest is Test {
660703
distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0));
661704
}
662705

706+
// I13: previewClaim mirrors claim validation/routing without state change
707+
function test_previewClaim() public {
708+
uint64 saleId = _saleWithDeposit(); // alice unlocked, 200e18
709+
MockERC20 share = new MockERC20(admin, "Share", "xKLSH");
710+
deal(address(share), address(distributor), 10e18);
711+
bytes32 root = _settleLeaf(saleId, alice, 50e18, address(share), 10e18);
712+
713+
// before finalize: not valid
714+
(bool valid0,,,) = distributor.previewClaim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0));
715+
assertFalse(valid0);
716+
717+
_settleAndFinalize(saleId, root, 50e18);
718+
719+
// valid, not yet claimed, unlocked tranche -> will deliver shares
720+
(bool valid, bool claimed_, uint8 tranche, bool willDeliver) =
721+
distributor.previewClaim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0));
722+
assertTrue(valid);
723+
assertFalse(claimed_);
724+
assertEq(tranche, XKLSH);
725+
assertTrue(willDeliver);
726+
727+
// wrong amount -> invalid proof
728+
(bool validBad,,,) = distributor.previewClaim(saleId, alice, 51e18, address(share), 10e18, new bytes32[](0));
729+
assertFalse(validBad);
730+
731+
// after claiming -> alreadyClaimed true
732+
distributor.claim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0));
733+
(, bool claimedAfter,,) = distributor.previewClaim(saleId, alice, 50e18, address(share), 10e18, new bytes32[](0));
734+
assertTrue(claimedAfter);
735+
}
736+
663737
function test_claim_alreadyClaimed_reverts() public {
664738
uint64 saleId = _saleWithDeposit();
665739
MockERC20 share = new MockERC20(admin, "Share", "xKLSH");

0 commit comments

Comments
 (0)