Skip to content

Commit 49b8b70

Browse files
feat: transfer tokens to recipient if claim time exceeds vesting endtime in merkle lockup
docs: add notes in claim natspecs feat: add a new Claim event in merkle lockup interface test: fuzz start time in merkle lockup test: use ranged streams as default streams for integration tests perf: use schedule in memory docs: polish natspecs test(fork): assertEq for expected lockup stream test(fork): move hasClaimed assert outside if block test(claim): re order tree branches test: don't use variable names in tree branches chore: set max-line-length rule to 128 test: polish comment Co-authored-by: Andrei Vlad Birgaoanu <andreivladbrg@gmail.com>
1 parent 06c8fec commit 49b8b70

17 files changed

+427
-253
lines changed

.solhint.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"gas-custom-errors": "off",
1212
"max-states-count": ["warn", 20],
1313
"imports-order": "warn",
14-
"max-line-length": ["error", 124],
14+
"max-line-length": ["error", 128],
1515
"named-parameters-mapping": "warn",
1616
"no-empty-blocks": "off",
1717
"not-rely-on-time": "off",

src/SablierMerkleLL.sol

+49-33
Original file line numberDiff line numberDiff line change
@@ -86,46 +86,62 @@ contract SablierMerkleLL is
8686

8787
/// @inheritdoc SablierMerkleBase
8888
function _claim(uint256 index, address recipient, uint128 amount) internal override {
89+
// Load schedule from storage into memory.
90+
MerkleLL.Schedule memory schedule = _schedule;
91+
8992
// Calculate the timestamps for the stream.
9093
Lockup.Timestamps memory timestamps;
91-
if (_schedule.startTime == 0) {
94+
if (schedule.startTime == 0) {
9295
timestamps.start = uint40(block.timestamp);
9396
} else {
94-
timestamps.start = _schedule.startTime;
97+
timestamps.start = schedule.startTime;
9598
}
9699

97-
uint40 cliffTime;
100+
timestamps.end = timestamps.start + schedule.totalDuration;
101+
102+
// If the stream end time is not in the future, transfer the amount directly to the recipient.
103+
if (timestamps.end <= block.timestamp) {
104+
// Interaction: transfer the token.
105+
TOKEN.safeTransfer(recipient, amount);
98106

99-
if (_schedule.cliffDuration > 0) {
100-
cliffTime = timestamps.start + _schedule.cliffDuration;
107+
// Log the claim.
108+
emit Claim(index, recipient, amount);
109+
}
110+
// Otherwise, create the Lockup stream.
111+
else {
112+
uint40 cliffTime;
113+
114+
// Calculate cliff time if the cliff duration is greater than 0.
115+
if (schedule.cliffDuration > 0) {
116+
cliffTime = timestamps.start + schedule.cliffDuration;
117+
}
118+
119+
// Calculate the unlock amounts based on the percentages.
120+
LockupLinear.UnlockAmounts memory unlockAmounts;
121+
unlockAmounts.start = ud60x18(amount).mul(schedule.startPercentage.intoUD60x18()).intoUint128();
122+
unlockAmounts.cliff = ud60x18(amount).mul(schedule.cliffPercentage.intoUD60x18()).intoUint128();
123+
124+
// Interaction: create the stream via {SablierLockup}.
125+
uint256 streamId = LOCKUP.createWithTimestampsLL(
126+
Lockup.CreateWithTimestamps({
127+
sender: admin,
128+
recipient: recipient,
129+
depositAmount: amount,
130+
token: TOKEN,
131+
cancelable: STREAM_CANCELABLE,
132+
transferable: STREAM_TRANSFERABLE,
133+
timestamps: timestamps,
134+
shape: shape
135+
}),
136+
unlockAmounts,
137+
cliffTime
138+
);
139+
140+
// Effect: push the stream ID into the `_claimedStreams` array for the recipient.
141+
_claimedStreams[recipient].push(streamId);
142+
143+
// Log the claim.
144+
emit Claim(index, recipient, amount, streamId);
101145
}
102-
timestamps.end = timestamps.start + _schedule.totalDuration;
103-
104-
// Calculate the unlock amounts based on the percentages.
105-
LockupLinear.UnlockAmounts memory unlockAmounts;
106-
unlockAmounts.start = ud60x18(amount).mul(_schedule.startPercentage.intoUD60x18()).intoUint128();
107-
unlockAmounts.cliff = ud60x18(amount).mul(_schedule.cliffPercentage.intoUD60x18()).intoUint128();
108-
109-
// Interaction: create the stream via {SablierLockup}.
110-
uint256 streamId = LOCKUP.createWithTimestampsLL(
111-
Lockup.CreateWithTimestamps({
112-
sender: admin,
113-
recipient: recipient,
114-
depositAmount: amount,
115-
token: TOKEN,
116-
cancelable: STREAM_CANCELABLE,
117-
transferable: STREAM_TRANSFERABLE,
118-
timestamps: timestamps,
119-
shape: shape
120-
}),
121-
unlockAmounts,
122-
cliffTime
123-
);
124-
125-
// Effect: push the stream ID into the `_claimedStreams` array for the recipient.
126-
_claimedStreams[recipient].push(streamId);
127-
128-
// Log the claim.
129-
emit Claim(index, recipient, amount, streamId);
130146
}
131147
}

src/SablierMerkleLT.sol

+31-20
Original file line numberDiff line numberDiff line change
@@ -119,26 +119,37 @@ contract SablierMerkleLT is
119119
endTime = tranches[tranches.length - 1].timestamp;
120120
}
121121

122-
// Interaction: create the stream via {SablierLockup-createWithTimestampsLT}.
123-
uint256 streamId = LOCKUP.createWithTimestampsLT(
124-
Lockup.CreateWithTimestamps({
125-
sender: admin,
126-
recipient: recipient,
127-
depositAmount: amount,
128-
token: TOKEN,
129-
cancelable: STREAM_CANCELABLE,
130-
transferable: STREAM_TRANSFERABLE,
131-
timestamps: Lockup.Timestamps({ start: startTime, end: endTime }),
132-
shape: shape
133-
}),
134-
tranches
135-
);
136-
137-
// Effect: push the stream ID into the `_claimedStreams` array for the recipient.
138-
_claimedStreams[recipient].push(streamId);
139-
140-
// Log the claim.
141-
emit Claim(index, recipient, amount, streamId);
122+
// If the stream end time is not in the future, transfer the amount directly to the recipient.
123+
if (endTime <= block.timestamp) {
124+
// Interaction: transfer the token.
125+
TOKEN.safeTransfer(recipient, amount);
126+
127+
// Log the claim.
128+
emit Claim(index, recipient, amount);
129+
}
130+
// Otherwise, create the Lockup stream.
131+
else {
132+
// Interaction: create the stream via {SablierLockup-createWithTimestampsLT}.
133+
uint256 streamId = LOCKUP.createWithTimestampsLT(
134+
Lockup.CreateWithTimestamps({
135+
sender: admin,
136+
recipient: recipient,
137+
depositAmount: amount,
138+
token: TOKEN,
139+
cancelable: STREAM_CANCELABLE,
140+
transferable: STREAM_TRANSFERABLE,
141+
timestamps: Lockup.Timestamps({ start: startTime, end: endTime }),
142+
shape: shape
143+
}),
144+
tranches
145+
);
146+
147+
// Effect: push the stream ID into the `_claimedStreams` array for the recipient.
148+
_claimedStreams[recipient].push(streamId);
149+
150+
// Log the claim.
151+
emit Claim(index, recipient, amount, streamId);
152+
}
142153
}
143154

144155
/*//////////////////////////////////////////////////////////////////////////

src/interfaces/ISablierMerkleBase.sol

+8-4
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,22 @@ interface ISablierMerkleBase is IAdminable {
7373

7474
/// @notice Makes the claim.
7575
///
76-
/// @dev Depending on the Merkle campaign, it either transfers tokens to the recipient or creates a Lockup stream
77-
/// with an NFT minted to the recipient.
76+
/// @dev Emits a {Claim} event.
77+
///
78+
/// Notes:
79+
/// - For Merkle Instant and Merkle VCA campaigns, it transfers the tokens directly to the recipient.
80+
/// - For Merkle Lockup campaigns, it creates a Lockup stream only if vesting end time is in the future. Otherwise,
81+
/// it transfers the tokens directly to the recipient.
7882
///
7983
/// Requirements:
8084
/// - The campaign must not have expired.
81-
/// - The stream must not have been claimed already.
85+
/// - The airdrop must not have been claimed already.
8286
/// - The Merkle proof must be valid.
8387
/// - The `msg.value` must not be less than `minimumFee`.
8488
///
8589
/// @param index The index of the recipient in the Merkle tree.
8690
/// @param recipient The address of the airdrop recipient.
87-
/// @param amount The amount of ERC-20 tokens to be transferred to the recipient.
91+
/// @param amount The amount of ERC-20 tokens allocated to the recipient.
8892
/// @param merkleProof The proof of inclusion in the Merkle tree.
8993
function claim(uint256 index, address recipient, uint128 amount, bytes32[] calldata merkleProof) external payable;
9094

src/interfaces/ISablierMerkleLockup.sol

+4-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ interface ISablierMerkleLockup is ISablierMerkleBase {
1111
EVENTS
1212
//////////////////////////////////////////////////////////////////////////*/
1313

14-
/// @notice Emitted when a recipient claims a Lockup stream.
14+
/// @notice Emitted when the recipient receives the airdrop through a direct transfer.
15+
event Claim(uint256 index, address indexed recipient, uint128 amount);
16+
17+
/// @notice Emitted when the recipient receives the airdrop through a Lockup stream.
1518
event Claim(uint256 index, address indexed recipient, uint128 amount, uint256 indexed streamId);
1619

1720
/*//////////////////////////////////////////////////////////////////////////

tests/Base.t.sol

+12-2
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
302302
campaignOwner: campaignOwner,
303303
expiration: expiration,
304304
merkleRoot: MERKLE_ROOT,
305+
startTime: RANGED_STREAM_START_TIME,
305306
tokenAddress: dai
306307
});
307308
}
@@ -311,6 +312,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
311312
address campaignOwner,
312313
uint40 expiration,
313314
bytes32 merkleRoot,
315+
uint40 startTime,
314316
IERC20 tokenAddress
315317
)
316318
internal
@@ -322,6 +324,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
322324
lockupAddress: lockup,
323325
expiration: expiration,
324326
merkleRoot: merkleRoot,
327+
startTime: startTime,
325328
tokenAddress: tokenAddress
326329
});
327330
bytes32 salt = keccak256(abi.encodePacked(campaignCreator, abi.encode(params)));
@@ -362,6 +365,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
362365
expiration: expiration,
363366
lockupAddress: lockup,
364367
merkleRoot: MERKLE_ROOT,
368+
startTime: RANGED_STREAM_START_TIME,
365369
tokenAddress: dai
366370
});
367371
}
@@ -371,6 +375,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
371375
uint40 expiration,
372376
ISablierLockup lockupAddress,
373377
bytes32 merkleRoot,
378+
uint40 startTime,
374379
IERC20 tokenAddress
375380
)
376381
public
@@ -386,7 +391,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
386391
lockup: lockupAddress,
387392
merkleRoot: merkleRoot,
388393
schedule: MerkleLL.Schedule({
389-
startTime: ZERO,
394+
startTime: startTime,
390395
startPercentage: START_PERCENTAGE,
391396
cliffDuration: CLIFF_DURATION,
392397
cliffPercentage: CLIFF_PERCENTAGE,
@@ -407,6 +412,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
407412
campaignCreator: users.campaignOwner,
408413
campaignOwner: campaignOwner,
409414
expiration: expiration,
415+
startTime: RANGED_STREAM_START_TIME,
410416
merkleRoot: MERKLE_ROOT,
411417
tokenAddress: dai
412418
});
@@ -417,6 +423,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
417423
address campaignOwner,
418424
uint40 expiration,
419425
bytes32 merkleRoot,
426+
uint40 startTime,
420427
IERC20 tokenAddress
421428
)
422429
internal
@@ -428,6 +435,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
428435
lockupAddress: lockup,
429436
expiration: expiration,
430437
merkleRoot: merkleRoot,
438+
startTime: startTime,
431439
tokenAddress: tokenAddress
432440
});
433441
bytes32 salt = keccak256(abi.encodePacked(campaignCreator, abi.encode(params)));
@@ -469,6 +477,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
469477
expiration: expiration,
470478
lockupAddress: lockup,
471479
merkleRoot: MERKLE_ROOT,
480+
startTime: RANGED_STREAM_START_TIME,
472481
tokenAddress: dai
473482
});
474483
}
@@ -478,6 +487,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
478487
uint40 expiration,
479488
ISablierLockup lockupAddress,
480489
bytes32 merkleRoot,
490+
uint40 startTime,
481491
IERC20 tokenAddress
482492
)
483493
public
@@ -499,7 +509,7 @@ abstract contract Base_Test is Assertions, Constants, DeployOptimized, Merkle, M
499509
lockup: lockupAddress,
500510
merkleRoot: merkleRoot,
501511
shape: SHAPE,
502-
streamStartTime: ZERO,
512+
streamStartTime: startTime,
503513
token: tokenAddress,
504514
tranchesWithPercentages: tranchesWithPercentages_,
505515
transferable: TRANSFERABLE

0 commit comments

Comments
 (0)