Skip to content

Commit 47ede4e

Browse files
committed
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).
1 parent 1a19f5c commit 47ede4e

1 file changed

Lines changed: 78 additions & 17 deletions

File tree

claim_contracts/src/ClaimableAirdrop.sol

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ contract ClaimableAirdrop is
3030
/// @notice Merkle root of the claimants.
3131
bytes32 public claimMerkleRoot;
3232

33-
/// @notice Mapping of the claimants that have claimed the tokens.
34-
/// @dev true if the claimant has claimed the tokens.
35-
mapping(address claimer => bool claimed) public hasClaimed;
33+
/// @notice Mapping tracking claimed leaves per address.
34+
/// @dev Key is keccak256(abi.encode(claimer, leaf)), value is true if claimed.
35+
mapping(bytes32 => bool) public hasClaimed;
3636

3737
/// @notice Event emitted when a claimant claims the tokens.
3838
/// @param to address of the claimant.
@@ -78,41 +78,102 @@ contract ClaimableAirdrop is
7878
_pause();
7979
}
8080

81-
/// @notice Claim the tokens.
82-
/// @param amount amount of tokens to claim.
81+
/// @notice Claim tokens for a single vesting stage.
82+
/// @param amount amount of tokens to claim for this stage.
83+
/// @param validFrom timestamp from which this stage is claimable.
8384
/// @param merkleProof Merkle proof of the claim.
8485
function claim(
8586
uint256 amount,
87+
uint256 validFrom,
8688
bytes32[] calldata merkleProof
8789
) external nonReentrant whenNotPaused {
8890
require(
89-
!hasClaimed[msg.sender],
90-
"Account has already claimed the drop"
91+
block.timestamp <= limitTimestampToClaim,
92+
"Drop is no longer claimable"
93+
);
94+
95+
_verifyAndMark(amount, validFrom, merkleProof);
96+
97+
bool success = IERC20(tokenProxy).transferFrom(
98+
tokenDistributor,
99+
msg.sender,
100+
amount
91101
);
102+
require(success, "Failed to transfer funds");
103+
104+
emit TokensClaimed(msg.sender, amount);
105+
}
106+
107+
/// @notice Claim tokens for multiple vesting stages in a single transaction.
108+
/// @param amounts array of token amounts per stage.
109+
/// @param validFroms array of timestamps from which each stage is claimable.
110+
/// @param merkleProofs array of Merkle proofs, one per stage.
111+
function claimBatch(
112+
uint256[] calldata amounts,
113+
uint256[] calldata validFroms,
114+
bytes32[][] calldata merkleProofs
115+
) external nonReentrant whenNotPaused {
92116
require(
93117
block.timestamp <= limitTimestampToClaim,
94118
"Drop is no longer claimable"
95119
);
96-
97-
bytes32 leaf = keccak256(
98-
bytes.concat(keccak256(abi.encode(msg.sender, amount)))
120+
uint256 length = amounts.length;
121+
require(
122+
length == validFroms.length && length == merkleProofs.length,
123+
"Array length mismatch"
99124
);
100-
bool verifies = MerkleProof.verify(merkleProof, claimMerkleRoot, leaf);
101125

102-
require(verifies, "Invalid Merkle proof");
126+
uint256 totalClaimable = 0;
103127

104-
// Done before the transfer call to make sure the reentrancy bug is not possible
105-
hasClaimed[msg.sender] = true;
128+
for (uint256 i = 0; i < length; i++) {
129+
_verifyAndMark(amounts[i], validFroms[i], merkleProofs[i]);
130+
totalClaimable += amounts[i];
131+
}
132+
133+
require(totalClaimable > 0, "Nothing to claim");
106134

107135
bool success = IERC20(tokenProxy).transferFrom(
108136
tokenDistributor,
109137
msg.sender,
110-
amount
138+
totalClaimable
111139
);
112-
113140
require(success, "Failed to transfer funds");
114141

115-
emit TokensClaimed(msg.sender, amount);
142+
emit TokensClaimed(msg.sender, totalClaimable);
143+
}
144+
145+
/// @notice Verify a single-stage Merkle proof and mark the stage as claimed.
146+
/// @dev Shared by `claim` and `claimBatch`. Does not perform the token transfer.
147+
/// @param amount amount of tokens for this stage.
148+
/// @param validFrom timestamp from which this stage is claimable.
149+
/// @param merkleProof Merkle proof for this stage.
150+
function _verifyAndMark(
151+
uint256 amount,
152+
uint256 validFrom,
153+
bytes32[] calldata merkleProof
154+
) internal {
155+
require(
156+
block.timestamp >= validFrom,
157+
"Stage not yet claimable"
158+
);
159+
160+
bytes32 leaf = keccak256(
161+
bytes.concat(
162+
keccak256(abi.encode(msg.sender, amount, validFrom))
163+
)
164+
);
165+
166+
bytes32 claimKey = keccak256(abi.encode(msg.sender, leaf));
167+
require(!hasClaimed[claimKey], "Stage already claimed");
168+
169+
bool verifies = MerkleProof.verify(
170+
merkleProof,
171+
claimMerkleRoot,
172+
leaf
173+
);
174+
require(verifies, "Invalid Merkle proof");
175+
176+
hasClaimed[claimKey] = true;
116177
}
117178

118179
/// @notice Update the Merkle root.

0 commit comments

Comments
 (0)