From 47ede4e6cafb06abaefa35fcb9bd614d9acdd2c6 Mon Sep 17 00:00:00 2001 From: Marcos Nicolau Date: Mon, 13 Apr 2026 12:15:06 -0300 Subject: [PATCH 1/2] feat: add vesting stages and batch claiming to airdrop Introduce per-stage claims via a validFrom timestamp encoded in the Merkle leaf, and a claimBatch entry point that claims multiple stages in a single transferFrom. Replace the per-address hasClaimed flag with a per-stage key keccak256(claimer, leaf). --- claim_contracts/src/ClaimableAirdrop.sol | 95 +++++++++++++++++++----- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/claim_contracts/src/ClaimableAirdrop.sol b/claim_contracts/src/ClaimableAirdrop.sol index fa3f699fc8..0c596da1a1 100644 --- a/claim_contracts/src/ClaimableAirdrop.sol +++ b/claim_contracts/src/ClaimableAirdrop.sol @@ -30,9 +30,9 @@ contract ClaimableAirdrop is /// @notice Merkle root of the claimants. bytes32 public claimMerkleRoot; - /// @notice Mapping of the claimants that have claimed the tokens. - /// @dev true if the claimant has claimed the tokens. - mapping(address claimer => bool claimed) public hasClaimed; + /// @notice Mapping tracking claimed leaves per address. + /// @dev Key is keccak256(abi.encode(claimer, leaf)), value is true if claimed. + mapping(bytes32 => bool) public hasClaimed; /// @notice Event emitted when a claimant claims the tokens. /// @param to address of the claimant. @@ -78,41 +78,102 @@ contract ClaimableAirdrop is _pause(); } - /// @notice Claim the tokens. - /// @param amount amount of tokens to claim. + /// @notice Claim tokens for a single vesting stage. + /// @param amount amount of tokens to claim for this stage. + /// @param validFrom timestamp from which this stage is claimable. /// @param merkleProof Merkle proof of the claim. function claim( uint256 amount, + uint256 validFrom, bytes32[] calldata merkleProof ) external nonReentrant whenNotPaused { require( - !hasClaimed[msg.sender], - "Account has already claimed the drop" + block.timestamp <= limitTimestampToClaim, + "Drop is no longer claimable" + ); + + _verifyAndMark(amount, validFrom, merkleProof); + + bool success = IERC20(tokenProxy).transferFrom( + tokenDistributor, + msg.sender, + amount ); + require(success, "Failed to transfer funds"); + + emit TokensClaimed(msg.sender, amount); + } + + /// @notice Claim tokens for multiple vesting stages in a single transaction. + /// @param amounts array of token amounts per stage. + /// @param validFroms array of timestamps from which each stage is claimable. + /// @param merkleProofs array of Merkle proofs, one per stage. + function claimBatch( + uint256[] calldata amounts, + uint256[] calldata validFroms, + bytes32[][] calldata merkleProofs + ) external nonReentrant whenNotPaused { require( block.timestamp <= limitTimestampToClaim, "Drop is no longer claimable" ); - - bytes32 leaf = keccak256( - bytes.concat(keccak256(abi.encode(msg.sender, amount))) + uint256 length = amounts.length; + require( + length == validFroms.length && length == merkleProofs.length, + "Array length mismatch" ); - bool verifies = MerkleProof.verify(merkleProof, claimMerkleRoot, leaf); - require(verifies, "Invalid Merkle proof"); + uint256 totalClaimable = 0; - // Done before the transfer call to make sure the reentrancy bug is not possible - hasClaimed[msg.sender] = true; + for (uint256 i = 0; i < length; i++) { + _verifyAndMark(amounts[i], validFroms[i], merkleProofs[i]); + totalClaimable += amounts[i]; + } + + require(totalClaimable > 0, "Nothing to claim"); bool success = IERC20(tokenProxy).transferFrom( tokenDistributor, msg.sender, - amount + totalClaimable ); - require(success, "Failed to transfer funds"); - emit TokensClaimed(msg.sender, amount); + emit TokensClaimed(msg.sender, totalClaimable); + } + + /// @notice Verify a single-stage Merkle proof and mark the stage as claimed. + /// @dev Shared by `claim` and `claimBatch`. Does not perform the token transfer. + /// @param amount amount of tokens for this stage. + /// @param validFrom timestamp from which this stage is claimable. + /// @param merkleProof Merkle proof for this stage. + function _verifyAndMark( + uint256 amount, + uint256 validFrom, + bytes32[] calldata merkleProof + ) internal { + require( + block.timestamp >= validFrom, + "Stage not yet claimable" + ); + + bytes32 leaf = keccak256( + bytes.concat( + keccak256(abi.encode(msg.sender, amount, validFrom)) + ) + ); + + bytes32 claimKey = keccak256(abi.encode(msg.sender, leaf)); + require(!hasClaimed[claimKey], "Stage already claimed"); + + bool verifies = MerkleProof.verify( + merkleProof, + claimMerkleRoot, + leaf + ); + require(verifies, "Invalid Merkle proof"); + + hasClaimed[claimKey] = true; } /// @notice Update the Merkle root. From d810a5dc654f4094ecba30b929a3997354f06a9a Mon Sep 17 00:00:00 2001 From: Marcos Nicolau Date: Tue, 14 Apr 2026 17:47:12 -0300 Subject: [PATCH 2/2] refactor: use leaf directly as hasClaimed key in airdrop The leaf already encodes msg.sender in its preimage, making the extra keccak256(abi.encode(msg.sender, leaf)) wrapper redundant. --- claim_contracts/src/ClaimableAirdrop.sol | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/claim_contracts/src/ClaimableAirdrop.sol b/claim_contracts/src/ClaimableAirdrop.sol index 0c596da1a1..d79f93cf6d 100644 --- a/claim_contracts/src/ClaimableAirdrop.sol +++ b/claim_contracts/src/ClaimableAirdrop.sol @@ -30,8 +30,8 @@ contract ClaimableAirdrop is /// @notice Merkle root of the claimants. bytes32 public claimMerkleRoot; - /// @notice Mapping tracking claimed leaves per address. - /// @dev Key is keccak256(abi.encode(claimer, leaf)), value is true if claimed. + /// @notice Mapping tracking claimed leaves. + /// @dev Key is the Merkle leaf itself. mapping(bytes32 => bool) public hasClaimed; /// @notice Event emitted when a claimant claims the tokens. @@ -163,8 +163,7 @@ contract ClaimableAirdrop is ) ); - bytes32 claimKey = keccak256(abi.encode(msg.sender, leaf)); - require(!hasClaimed[claimKey], "Stage already claimed"); + require(!hasClaimed[leaf], "Stage already claimed"); bool verifies = MerkleProof.verify( merkleProof, @@ -173,7 +172,7 @@ contract ClaimableAirdrop is ); require(verifies, "Invalid Merkle proof"); - hasClaimed[claimKey] = true; + hasClaimed[leaf] = true; } /// @notice Update the Merkle root.