Skip to content

Commit

Permalink
feat: implement setMinimumFeeToZero function
Browse files Browse the repository at this point in the history
  • Loading branch information
andreivladbrg committed Feb 25, 2025
1 parent d275dd0 commit 93e0a30
Show file tree
Hide file tree
Showing 18 changed files with 141 additions and 22 deletions.
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
]
gas_limit = 9223372036854775807
optimizer = true
optimizer_runs = 1_000_000
optimizer_runs = 100_000_000
out = "out"
script = "script"
sender = "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38"
Expand Down
31 changes: 25 additions & 6 deletions src/abstracts/SablierMerkleBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ abstract contract SablierMerkleBase is
/// @inheritdoc ISablierMerkleBase
bytes32 public immutable override MERKLE_ROOT;

/// @inheritdoc ISablierMerkleBase
uint256 public immutable override MINIMUM_FEE;

/// @inheritdoc ISablierMerkleBase
IERC20 public immutable override TOKEN;

Expand All @@ -49,6 +46,9 @@ abstract contract SablierMerkleBase is
/// @inheritdoc ISablierMerkleBase
string public override ipfsCID;

/// @inheritdoc ISablierMerkleBase
uint256 public override minimumFee;

/// @dev Packed booleans that record the history of claims.
BitMaps.BitMap internal _claimedBitMap;

Expand All @@ -75,10 +75,10 @@ abstract contract SablierMerkleBase is
CHAINLINK_PRICE_FEED = ISablierMerkleFactoryBase(FACTORY).chainlinkPriceFeed();
EXPIRATION = expiration;
MERKLE_ROOT = merkleRoot;
MINIMUM_FEE = ISablierMerkleFactoryBase(FACTORY).getFee(campaignCreator);
TOKEN = token;
campaignName = _campaignName;
ipfsCID = _ipfsCID;
minimumFee = ISablierMerkleFactoryBase(FACTORY).getFee(campaignCreator);
}

/*//////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -195,6 +195,25 @@ abstract contract SablierMerkleBase is
}
}

/// @inheritdoc ISablierMerkleBase
function setMinimumFeeToZero() external override {
// Retrieve the factory admin.
address factoryAdmin = ISablierMerkleFactoryBase(FACTORY).admin();

// Check: the caller is the factory admin.
if (msg.sender != factoryAdmin) {
revert Errors.SablierMerkleBase_CallerNotFactoryAdmin(factoryAdmin, msg.sender);
}

uint256 previousMinimumFee = minimumFee;

// Effect: set the minimum fee to zero.
minimumFee = 0;

// Log the event.
emit MinimumFeeSetToZero(factoryAdmin, previousMinimumFee);
}

/*//////////////////////////////////////////////////////////////////////////
INTERNAL CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/
Expand All @@ -207,7 +226,7 @@ abstract contract SablierMerkleBase is
}

// If the minimum fee is 0, return 0.
if (MINIMUM_FEE == 0) {
if (minimumFee == 0) {
return 0;
}

Expand All @@ -216,7 +235,7 @@ abstract contract SablierMerkleBase is
// Q: should we check the price is greater than 0 ? If yes, should we revert?

// Calculate the minimum fee in wei.
return 1e18 * MINIMUM_FEE / uint256(price);
return 1e18 * minimumFee / uint256(price);
}

/// @notice Returns a flag indicating whether the grace period has passed.
Expand Down
19 changes: 15 additions & 4 deletions src/interfaces/ISablierMerkleBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ interface ISablierMerkleBase is IAdminable {
/// @notice Emitted when the admin claws back the unclaimed tokens.
event Clawback(address indexed admin, address indexed to, uint128 amount);

/// @notice Emitted when the minimum fee is set to zero.
event MinimumFeeSetToZero(address indexed factoryAdmin, uint256 previousFee);

/*//////////////////////////////////////////////////////////////////////////
CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/
Expand All @@ -22,10 +25,6 @@ interface ISablierMerkleBase is IAdminable {
/// @dev This is an immutable state variable.
function CHAINLINK_PRICE_FEED() external view returns (address);

/// @notice Retrieves the minimum fee required to claim the airdrop, paid in the native token of the chain.
/// @dev The fee is denominated in Chainlink's 8-decimal format for USD prices, where $1 is 1e8.
function MINIMUM_FEE() external view returns (uint256);

/// @notice The cut-off point for the campaign, as a Unix timestamp. A value of zero means there is no expiration.
/// @dev This is an immutable state variable.
function EXPIRATION() external returns (uint40);
Expand Down Expand Up @@ -63,6 +62,10 @@ interface ISablierMerkleBase is IAdminable {
/// @notice The content identifier for indexing the campaign on IPFS.
function ipfsCID() external view returns (string memory);

/// @notice Retrieves the minimum fee required to claim the airdrop, paid in the native token of the chain.
/// @dev The fee is denominated in Chainlink's 8-decimal format for USD prices, where $1 is 1e8.
function minimumFee() external view returns (uint256);

/*//////////////////////////////////////////////////////////////////////////
NON-CONSTANT FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -106,4 +109,12 @@ interface ISablierMerkleBase is IAdminable {
/// @param factoryAdmin The address of the `FACTORY` admin.
/// @return feeAmount The amount of native tokens (e.g., ETH) collected as fees.
function collectFees(address factoryAdmin) external returns (uint256 feeAmount);

/// @notice Sets the minimum fee required to claim the airdrop to zero.
///
/// @dev Emits a {MinimumFeeSetToZero} event.
///
/// Requirements:
/// - `msg.sender` must be the `FACTORY` admin.
function setMinimumFeeToZero() external;
}
3 changes: 3 additions & 0 deletions src/libraries/Errors.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ library Errors {
/// @notice Thrown when caller is not the factory contract.
error SablierMerkleBase_CallerNotFactory(address factory, address caller);

/// @notice Thrown when caller is not the factory admin.
error SablierMerkleBase_CallerNotFactoryAdmin(address factoryAdmin, address caller);

/// @notice Thrown when trying to claim after the campaign has expired.
error SablierMerkleBase_CampaignExpired(uint256 blockTimestamp, uint40 expiration);

Expand Down
10 changes: 10 additions & 0 deletions tests/integration/concrete/campaign/instant/MerkleInstant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CollectFees_Integration_Test } from "./../shared/collect-fees/collectFe
import { GetFirstClaimTime_Integration_Test } from "./../shared/get-first-claim-time/getFirstClaimTime.t.sol";
import { HasClaimed_Integration_Test } from "./../shared/has-claimed/hasClaimed.t.sol";
import { HasExpired_Integration_Test } from "./../shared/has-expired/hasExpired.t.sol";
import { SetMinimumFeeToZero_Integration_Test } from "./../shared/set-minimum-fee-to-zero/setMinimumFeeToZero.t.sol";

/*//////////////////////////////////////////////////////////////////////////
NON-SHARED TESTS
Expand Down Expand Up @@ -71,3 +72,12 @@ contract HasExpired_MerkleInstant_Integration_Test is
MerkleInstant_Integration_Shared_Test.setUp();
}
}

contract SetMinimumFeeToZero_MerkleInstant_Integration_Test is
MerkleInstant_Integration_Shared_Test,
SetMinimumFeeToZero_Integration_Test
{
function setUp() public override(MerkleInstant_Integration_Shared_Test, Integration_Test) {
MerkleInstant_Integration_Shared_Test.setUp();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ contract Constructor_MerkleInstant_Integration_Test is MerkleInstant_Integration
assertEq(constructedInstant.FACTORY(), address(merkleFactoryInstant), "factory");
assertEq(constructedInstant.ipfsCID(), IPFS_CID, "ipfsCID");
assertEq(constructedInstant.MERKLE_ROOT(), MERKLE_ROOT, "merkleRoot");
assertEq(constructedInstant.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(constructedInstant.minimumFee(), MINIMUM_FEE, "minimum fee");
assertEq(address(constructedInstant.TOKEN()), address(dai), "token");
}
}
10 changes: 10 additions & 0 deletions tests/integration/concrete/campaign/ll/MerkleLL.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CollectFees_Integration_Test } from "./../shared/collect-fees/collectFe
import { GetFirstClaimTime_Integration_Test } from "./../shared/get-first-claim-time/getFirstClaimTime.t.sol";
import { HasClaimed_Integration_Test } from "./../shared/has-claimed/hasClaimed.t.sol";
import { HasExpired_Integration_Test } from "./../shared/has-expired/hasExpired.t.sol";
import { SetMinimumFeeToZero_Integration_Test } from "./../shared/set-minimum-fee-to-zero/setMinimumFeeToZero.t.sol";

/*//////////////////////////////////////////////////////////////////////////
NON-SHARED TESTS
Expand Down Expand Up @@ -62,3 +63,12 @@ contract HasExpired_MerkleLL_Integration_Test is MerkleLL_Integration_Shared_Tes
MerkleLL_Integration_Shared_Test.setUp();
}
}

contract SetMinimumFeeToZero_MerkleLL_Integration_Test is
MerkleLL_Integration_Shared_Test,
SetMinimumFeeToZero_Integration_Test
{
function setUp() public override(MerkleLL_Integration_Shared_Test, Integration_Test) {
MerkleLL_Integration_Shared_Test.setUp();
}
}
2 changes: 1 addition & 1 deletion tests/integration/concrete/campaign/ll/constructor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ contract Constructor_MerkleLL_Integration_Test is Integration_Test {
assertEq(constructedLL.ipfsCID(), IPFS_CID, "ipfsCID");
assertEq(address(constructedLL.LOCKUP()), address(lockup), "lockup");
assertEq(constructedLL.MERKLE_ROOT(), MERKLE_ROOT, "merkleRoot");
assertEq(constructedLL.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(constructedLL.minimumFee(), MINIMUM_FEE, "minimum fee");
assertEq(constructedLL.shape(), SHAPE, "shape");
assertEq(constructedLL.STREAM_CANCELABLE(), CANCELABLE, "stream cancelable");
assertEq(constructedLL.STREAM_TRANSFERABLE(), TRANSFERABLE, "stream transferable");
Expand Down
10 changes: 10 additions & 0 deletions tests/integration/concrete/campaign/lt/MerkleLT.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CollectFees_Integration_Test } from "./../shared/collect-fees/collectFe
import { GetFirstClaimTime_Integration_Test } from "./../shared/get-first-claim-time/getFirstClaimTime.t.sol";
import { HasClaimed_Integration_Test } from "./../shared/has-claimed/hasClaimed.t.sol";
import { HasExpired_Integration_Test } from "./../shared/has-expired/hasExpired.t.sol";
import { SetMinimumFeeToZero_Integration_Test } from "./../shared/set-minimum-fee-to-zero/setMinimumFeeToZero.t.sol";

/*//////////////////////////////////////////////////////////////////////////
NON-SHARED TESTS
Expand Down Expand Up @@ -62,3 +63,12 @@ contract HasExpired_MerkleLT_Integration_Test is MerkleLT_Integration_Shared_Tes
MerkleLT_Integration_Shared_Test.setUp();
}
}

contract SetMinimumFeeToZero_MerkleLT_Integration_Test is
MerkleLT_Integration_Shared_Test,
SetMinimumFeeToZero_Integration_Test
{
function setUp() public override(MerkleLT_Integration_Shared_Test, Integration_Test) {
MerkleLT_Integration_Shared_Test.setUp();
}
}
2 changes: 1 addition & 1 deletion tests/integration/concrete/campaign/lt/constructor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ contract Constructor_MerkleLT_Integration_Test is Integration_Test {
assertEq(constructedLT.ipfsCID(), IPFS_CID, "ipfsCID");
assertEq(address(constructedLT.LOCKUP()), address(lockup), "lockup");
assertEq(constructedLT.MERKLE_ROOT(), MERKLE_ROOT, "merkleRoot");
assertEq(constructedLT.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(constructedLT.minimumFee(), MINIMUM_FEE, "minimum fee");
assertEq(constructedLT.shape(), SHAPE, "shape");
assertEq(constructedLT.STREAM_CANCELABLE(), CANCELABLE, "stream cancelable");
assertEq(constructedLT.STREAM_START_TIME(), ZERO, "stream start time");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity >=0.8.22 <0.9.0;

import { ISablierMerkleBase } from "src/interfaces/ISablierMerkleBase.sol";
import { Errors } from "src/libraries/Errors.sol";

import { Integration_Test } from "../../../../Integration.t.sol";

abstract contract SetMinimumFeeToZero_Integration_Test is Integration_Test {
function test_RevertWhen_CallerNotFactoryAdmin() external {
resetPrank({ msgSender: users.eve });
vm.expectRevert(
abi.encodeWithSelector(Errors.SablierMerkleBase_CallerNotFactoryAdmin.selector, users.admin, users.eve)
);
merkleBase.setMinimumFeeToZero();
}

modifier whenCallerFactoryAdmin() {
_;
}

function test_GivenMinimumFeeAlreadyZero() external whenCallerFactoryAdmin {
resetPrank(users.admin);
merkleBase.setMinimumFeeToZero();
assertEq(merkleBase.minimumFee(), 0);
merkleBase.setMinimumFeeToZero();
assertEq(merkleBase.minimumFee(), 0);
}

function test_GivenMinimumFeeNotZero() external whenCallerFactoryAdmin {
resetPrank(users.admin);
vm.expectEmit({ emitter: address(merkleBase) });
emit ISablierMerkleBase.MinimumFeeSetToZero(users.admin, MINIMUM_FEE);
merkleBase.setMinimumFeeToZero();
assertEq(merkleBase.minimumFee(), 0);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
SetMinimumFeeToZero_Integration_Test
├── when caller not factory admin
│ └── it should revert
└── when caller factory admin
├── given minimum fee already zero
│ └── it should do nothing
└── given minimum fee not zero
├── it should set minimum fee to zero
└── it should emit event MinimumFeeSetToZero
10 changes: 10 additions & 0 deletions tests/integration/concrete/campaign/vca/MerkleVCA.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CollectFees_Integration_Test } from "./../shared/collect-fees/collectFe
import { GetFirstClaimTime_Integration_Test } from "./../shared/get-first-claim-time/getFirstClaimTime.t.sol";
import { HasClaimed_Integration_Test } from "./../shared/has-claimed/hasClaimed.t.sol";
import { HasExpired_Integration_Test } from "./../shared/has-expired/hasExpired.t.sol";
import { SetMinimumFeeToZero_Integration_Test } from "./../shared/set-minimum-fee-to-zero/setMinimumFeeToZero.t.sol";

/*//////////////////////////////////////////////////////////////////////////
NON-SHARED TESTS
Expand Down Expand Up @@ -62,3 +63,12 @@ contract HasExpired_MerkleVCA_Integration_Test is MerkleVCA_Integration_Shared_T
MerkleVCA_Integration_Shared_Test.setUp();
}
}

contract SetMinimumFeeToZero_MerkleVCA_Integration_Test is
MerkleVCA_Integration_Shared_Test,
SetMinimumFeeToZero_Integration_Test
{
function setUp() public override(MerkleVCA_Integration_Shared_Test, Integration_Test) {
MerkleVCA_Integration_Shared_Test.setUp();
}
}
2 changes: 1 addition & 1 deletion tests/integration/concrete/campaign/vca/constructor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ contract Constructor_MerkleVCA_Integration_Test is MerkleVCA_Integration_Shared_
assertEq(constructedVCA.FACTORY(), address(merkleFactoryVCA), "factory");
assertEq(constructedVCA.ipfsCID(), IPFS_CID, "ipfsCID");
assertEq(constructedVCA.MERKLE_ROOT(), MERKLE_ROOT, "merkleRoot");
assertEq(constructedVCA.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(constructedVCA.minimumFee(), MINIMUM_FEE, "minimum fee");
assertEq(constructedVCA.forgoneAmount(), 0, "forgoneAmount");
assertEq(constructedVCA.timestamps().start, RANGED_STREAM_START_TIME, "unlock start");
assertEq(constructedVCA.timestamps().end, RANGED_STREAM_END_TIME, "unlock end");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ contract CreateMerkleInstant_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualInstant.FACTORY(), address(merkleFactoryInstant));
assertEq(actualInstant.MINIMUM_FEE(), customFee, "minimum fee");
assertEq(actualInstant.minimumFee(), customFee, "minimum fee");
}

function test_GivenCustomFeeNotSet(address campaignOwner, uint40 expiration) external givenCampaignNotExists {
Expand All @@ -74,6 +74,6 @@ contract CreateMerkleInstant_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualInstant.FACTORY(), address(merkleFactoryInstant));
assertEq(actualInstant.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(actualInstant.minimumFee(), MINIMUM_FEE, "minimum fee");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ contract CreateMerkleLL_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualLL.FACTORY(), address(merkleFactoryLL), "factory");
assertEq(actualLL.MINIMUM_FEE(), customFee, "minimum fee");
assertEq(actualLL.minimumFee(), customFee, "minimum fee");
}

function test_GivenCustomFeeNotSet(address campaignOwner, uint40 expiration) external givenCampaignNotExists {
Expand All @@ -73,6 +73,6 @@ contract CreateMerkleLL_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualLL.FACTORY(), address(merkleFactoryLL), "factory");
assertEq(actualLL.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(actualLL.minimumFee(), MINIMUM_FEE, "minimum fee");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ contract CreateMerkleLT_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualLT.FACTORY(), address(merkleFactoryLT), "factory");
assertEq(actualLT.MINIMUM_FEE(), customFee, "minimum fee");
assertEq(actualLT.minimumFee(), customFee, "minimum fee");
}

function test_GivenCustomFeeNotSet(address campaignOwner, uint40 expiration) external givenCampaignNotExists {
Expand All @@ -73,6 +73,6 @@ contract CreateMerkleLT_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualLT.FACTORY(), address(merkleFactoryLT), "factory");
assertEq(actualLT.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(actualLT.minimumFee(), MINIMUM_FEE, "minimum fee");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ contract CreateMerkleVCA_Integration_Test is Integration_Test {

// It should set the current factory address.
assertEq(actualVCA.FACTORY(), address(merkleFactoryVCA), "factory");
assertEq(actualVCA.MINIMUM_FEE(), MINIMUM_FEE, "minimum fee");
assertEq(actualVCA.minimumFee(), MINIMUM_FEE, "minimum fee");

// It should set return the correct unlock schedule.
assertEq(actualVCA.timestamps().start, RANGED_STREAM_START_TIME, "unlock start");
Expand Down

0 comments on commit 93e0a30

Please sign in to comment.