@@ -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 */
2729contract PreIPODistributor is
2830 Initializable ,
@@ -33,11 +35,11 @@ contract PreIPODistributor is
3335 using SafeERC20 for IERC20 ;
3436
3537 bytes32 public constant MANAGER = keccak256 ("MANAGER " );
36- // BOT operates the settlement lifecycle in the settlement/claim upgrade; granted here at
37- // deploy so the upgrade needs no extra role setup.
3838 bytes32 public constant BOT = keccak256 ("BOT " );
3939
40- // 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).
4143 uint8 public constant TRANCHE_UNLOCKED = 1 ;
4244 uint8 public constant TRANCHE_LOCKED = 2 ;
4345
@@ -68,6 +70,24 @@ contract PreIPODistributor is
6870
6971 uint64 public nextSaleId;
7072
73+ struct Settlement {
74+ bytes32 root; // finalized settlement root; claims verify against this
75+ bytes32 pendingRoot; // pending root awaiting finalize (0 = none pending)
76+ uint256 pendingTotalRefund; // total refund committed by the pending root
77+ uint256 totalRefund; // total refund committed by the finalized root
78+ uint256 lastSetTime; // block time the pending root was set
79+ uint256 refunded; // cumulative refund already paid out via claim
80+ }
81+
82+ // saleId => Settlement
83+ mapping (uint64 => Settlement) public settlements;
84+
85+ // saleId => account => claimed
86+ mapping (uint64 => mapping (address => bool )) public claimed;
87+
88+ // review window between setSettlementRoot (step 1) and finalizeSettlement (step 2); min 6h
89+ uint256 public waitingPeriod;
90+
7191 event CreateSale (
7292 uint64 indexed saleId ,
7393 address depositToken ,
@@ -108,6 +128,23 @@ contract PreIPODistributor is
108128 uint256 pubTotalDeposits
109129 );
110130
131+ event SetSettlementRoot (uint64 indexed saleId , bytes32 pendingRoot , uint256 totalRefund , uint256 setTime );
132+
133+ event FinalizeSettlement (uint64 indexed saleId , bytes32 root , uint256 finalizeTime );
134+
135+ event RevokeSettlementRoot (uint64 indexed saleId );
136+
137+ event WaitingPeriodUpdated (uint256 waitingPeriod );
138+
139+ event Claimed (
140+ uint64 indexed saleId ,
141+ address indexed account ,
142+ uint256 refundAmount ,
143+ address shareToken ,
144+ uint256 tokenAmount ,
145+ uint8 tranche
146+ );
147+
111148 event EmergencyWithdraw (address indexed token , address indexed to , uint256 amount );
112149
113150 /// @custom:oz-upgrades-unsafe-allow constructor
@@ -132,6 +169,13 @@ contract PreIPODistributor is
132169 _setRoleAdmin (BOT, MANAGER);
133170 }
134171
172+ /// @dev Initializes settlement/claim state introduced by this version. Runs once, after the
173+ /// upgrade — call it atomically via upgradeToAndCall(newImpl, abi.encodeCall(initializeV2, ())).
174+ function initializeV2 () external reinitializer (2 ) {
175+ waitingPeriod = 6 hours ;
176+ emit WaitingPeriodUpdated (waitingPeriod);
177+ }
178+
135179 /// @dev Create a sale (whitelist round). The public round is opened later via setPublicRound.
136180 function createSale (
137181 address _depositToken ,
@@ -193,6 +237,11 @@ contract PreIPODistributor is
193237 {
194238 Sale storage sale = sales[_saleId];
195239 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+ );
196245 require (sale.pubStartTime == 0 || sale.pubStartTime > block .timestamp , "Public round started " );
197246 require (_pubStartTime > sale.endTime, "Public must follow WL " );
198247 require (_pubStartTime > block .timestamp , "Invalid pub start " );
@@ -225,14 +274,15 @@ contract PreIPODistributor is
225274 require (_amount >= sale.minDeposit, "Below min deposit " );
226275
227276 // A non-zero existing WL deposit implies the caller already passed the proof check.
228- if (deposits[_saleId][msg .sender ] == 0 ) {
277+ uint256 prior = deposits[_saleId][msg .sender ];
278+ if (prior == 0 ) {
229279 bytes32 leaf = keccak256 (abi.encode (block .chainid , msg .sender ));
230280 require (MerkleProof.verifyCalldata (_proof, sale.whitelistRoot, leaf), "Invalid proof " );
231281 }
232282
233283 uint8 tranche = _applyTranche (_saleId, _tranche);
234284
235- uint256 userTotal = deposits[_saleId][ msg . sender ] + _amount;
285+ uint256 userTotal = prior + _amount;
236286 deposits[_saleId][msg .sender ] = userTotal;
237287 sale.totalDeposits += _amount;
238288
@@ -276,7 +326,146 @@ contract PreIPODistributor is
276326 }
277327 }
278328
329+ /// @dev Set the pending settlement root (step 1 of 2); starts the review window.
330+ /// A new pending root cannot be set while one is already in flight.
331+ function setSettlementRoot (uint64 _saleId , bytes32 _root , uint256 _totalRefund )
332+ external
333+ onlyRole (BOT)
334+ {
335+ Sale storage sale = sales[_saleId];
336+ require (sale.whitelistRoot != bytes32 (0 ), "Invalid saleId " );
337+ require (! sale.paused, "Sale paused " );
338+ // deposit windows must be closed so no deposit can land after the off-chain snapshot
339+ require (block .timestamp > sale.endTime, "WL round not ended " );
340+ require (sale.pubStartTime == 0 || block .timestamp > sale.pubEndTime, "Public round not ended " );
341+
342+ Settlement storage s = settlements[_saleId];
343+ // settlement is single-shot: once a root is finalized it cannot be replaced
344+ require (s.root == bytes32 (0 ), "Already finalized " );
345+ require (_root != bytes32 (0 ), "Invalid root " );
346+ require (s.pendingRoot == bytes32 (0 ), "Pending root in flight " );
347+ require (_totalRefund <= sale.totalDeposits + sale.pubTotalDeposits, "Refund exceeds deposits " );
348+
349+ s.pendingRoot = _root;
350+ s.pendingTotalRefund = _totalRefund;
351+ s.lastSetTime = block .timestamp ;
352+
353+ emit SetSettlementRoot (_saleId, _root, _totalRefund, block .timestamp );
354+ }
355+
356+ /// @dev Finalize the pending settlement root (step 2 of 2); only after the review window.
357+ function finalizeSettlement (uint64 _saleId ) external onlyRole (BOT) {
358+ require (! sales[_saleId].paused, "Sale paused " );
359+ Settlement storage s = settlements[_saleId];
360+ require (s.pendingRoot != bytes32 (0 ), "No pending root " );
361+ require (block .timestamp >= s.lastSetTime + waitingPeriod, "Review window not passed " );
362+
363+ s.root = s.pendingRoot;
364+ s.totalRefund = s.pendingTotalRefund;
365+ s.pendingRoot = bytes32 (0 );
366+ s.pendingTotalRefund = 0 ;
367+
368+ emit FinalizeSettlement (_saleId, s.root, block .timestamp );
369+ }
370+
371+ /// @dev Revoke the pending settlement root before it is finalized.
372+ function revokeSettlementRoot (uint64 _saleId ) external onlyRole (MANAGER) {
373+ Settlement storage s = settlements[_saleId];
374+ require (s.pendingRoot != bytes32 (0 ), "No pending root " );
375+ s.pendingRoot = bytes32 (0 );
376+ s.pendingTotalRefund = 0 ;
377+ emit RevokeSettlementRoot (_saleId);
378+ }
379+
380+ /// @dev Claim a finalized allocation (whitelist + public rounds combined), once per account.
381+ /// Pays the refund; for the unlocked tranche also transfers the share token, while the
382+ /// 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)
388+ /// @param _shareToken Share token to deliver for the unlocked tranche (part of the leaf)
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
391+ function claim (
392+ uint64 _saleId ,
393+ address _account ,
394+ uint256 _refundAmount ,
395+ address _shareToken ,
396+ uint256 _tokenAmount ,
397+ bytes32 [] calldata _proof
398+ ) external nonReentrant {
399+ require (! sales[_saleId].paused, "Sale paused " );
400+ Settlement storage s = settlements[_saleId];
401+ require (s.root != bytes32 (0 ), "Not finalized " );
402+ require (! claimed[_saleId][_account], "Already claimed " );
403+
404+ bytes32 leaf =
405+ keccak256 (abi.encode (block .chainid , _saleId, _account, _refundAmount, _shareToken, _tokenAmount));
406+ require (MerkleProof.verifyCalldata (_proof, s.root, leaf), "Invalid proof " );
407+
408+ claimed[_saleId][_account] = true ;
409+
410+ uint8 tranche = userTranche[_saleId][_account];
411+
412+ if (_refundAmount > 0 ) {
413+ // aggregate refunds can never exceed the committed total
414+ require (s.refunded + _refundAmount <= s.totalRefund, "Refund over total " );
415+ s.refunded += _refundAmount;
416+ IERC20 (sales[_saleId].depositToken).safeTransfer (_account, _refundAmount);
417+ }
418+ // Unlocked tranche receives the share token now; locked tranche is only recorded
419+ // (delivered off-chain at maturity) via the Claimed event. A zero share token is only
420+ // valid for the locked tranche.
421+ if (tranche == TRANCHE_UNLOCKED) {
422+ require (_shareToken != address (0 ), "Share token required " );
423+ if (_tokenAmount > 0 ) {
424+ IERC20 (_shareToken).safeTransfer (_account, _tokenAmount);
425+ }
426+ }
427+
428+ emit Claimed (_saleId, _account, _refundAmount, _shareToken, _tokenAmount, tranche);
429+ }
430+
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+
456+ /// @dev Update the review window between set and finalize (min 6h).
457+ function setWaitingPeriod (uint256 _waitingPeriod ) external onlyRole (MANAGER) {
458+ require (_waitingPeriod >= 6 hours, "Waiting period too short " );
459+ waitingPeriod = _waitingPeriod;
460+ emit WaitingPeriodUpdated (_waitingPeriod);
461+ }
462+
279463 /// @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.
280469 function emergencyWithdraw (address _token , address _to , uint256 _amount )
281470 external
282471 onlyRole (MANAGER)
0 commit comments