Skip to content

Commit b625c9c

Browse files
Merge pull request #119 from lista-dao/feat/pre-ipo-claim
Feat/pre ipo claim
2 parents b60cdc8 + a10a14e commit b625c9c

3 files changed

Lines changed: 553 additions & 8 deletions

File tree

342 KB
Binary file not shown.

contracts/dao/PreIPODistributor.sol

Lines changed: 196 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,
@@ -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

Comments
 (0)