diff --git a/contracts/scripts/deployments/facets/DeployDropFacet.s.sol b/contracts/scripts/deployments/facets/DeployDropFacet.s.sol new file mode 100644 index 000000000..5f70c7016 --- /dev/null +++ b/contracts/scripts/deployments/facets/DeployDropFacet.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +//interfaces + +//libraries + +//contracts +import {Deployer} from "contracts/scripts/common/Deployer.s.sol"; +import {FacetHelper} from "contracts/test/diamond/Facet.t.sol"; +import {DropFacet} from "contracts/src/tokens/drop/DropFacet.sol"; + +contract DeployDropFacet is Deployer, FacetHelper { + // FacetHelper + constructor() { + addSelector(DropFacet.claimWithPenalty.selector); + addSelector(DropFacet.setClaimConditions.selector); + addSelector(DropFacet.getActiveClaimConditionId.selector); + addSelector(DropFacet.getClaimConditionById.selector); + addSelector(DropFacet.getSupplyClaimedByWallet.selector); + } + + // Deploying + function versionName() public pure override returns (string memory) { + return "dropFacet"; + } + + function initializer() public pure override returns (bytes4) { + return DropFacet.__DropFacet_init.selector; + } + + // function makeInitData(address claimToken) public pure returns (bytes memory) { + // return abi.encodeWithSelector(initializer(), claimToken); + // } + + function __deploy(address deployer) public override returns (address) { + vm.startBroadcast(deployer); + DropFacet dropFacet = new DropFacet(); + vm.stopBroadcast(); + return address(dropFacet); + } +} diff --git a/contracts/src/tokens/drop/DropFacet.sol b/contracts/src/tokens/drop/DropFacet.sol new file mode 100644 index 000000000..6d0a72f8e --- /dev/null +++ b/contracts/src/tokens/drop/DropFacet.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// interfaces +import {IDropFacet} from "contracts/src/tokens/drop/IDropFacet.sol"; +// libraries +import {DropStorage} from "contracts/src/tokens/drop/DropStorage.sol"; +import {CurrencyTransfer} from "contracts/src/utils/libraries/CurrencyTransfer.sol"; +import {BasisPoints} from "contracts/src/utils/libraries/BasisPoints.sol"; + +// contracts +import {Facet} from "contracts/src/diamond/facets/Facet.sol"; +import {DropFacetBase} from "contracts/src/tokens/drop/DropFacetBase.sol"; +import {OwnableBase} from "contracts/src/diamond/facets/ownable/OwnableBase.sol"; + +contract DropFacet is IDropFacet, DropFacetBase, OwnableBase, Facet { + using DropStorage for DropStorage.Layout; + + function __DropFacet_init() external onlyInitializing { + _addInterface(type(IDropFacet).interfaceId); + } + + function claimWithPenalty( + uint256 conditionId, + address account, + uint256 quantity, + bytes32[] calldata allowlistProof + ) external { + DropStorage.Layout storage ds = DropStorage.layout(); + + _verifyClaim(ds, conditionId, account, quantity, allowlistProof); + + ClaimCondition storage condition = ds.getClaimConditionById(conditionId); + + uint256 amount = quantity; + uint256 penaltyBps = condition.penaltyBps; + if (penaltyBps > 0) { + uint256 penaltyAmount = BasisPoints.calculate(quantity, penaltyBps); + amount = quantity - penaltyAmount; + } + + _updateClaim(ds, conditionId, account, amount); + + CurrencyTransfer.safeTransferERC20( + condition.currency, + address(this), + account, + amount + ); + + emit DropFacet_Claimed_WithPenalty( + conditionId, + msg.sender, + account, + amount + ); + } + + function claimAndStake( + address account, + uint256 quantity, + bytes32[] calldata allowlistProof + ) external {} + + ///@inheritdoc IDropFacet + function setClaimConditions( + ClaimCondition[] calldata conditions, + bool resetEligibility + ) external onlyOwner { + DropStorage.Layout storage ds = DropStorage.layout(); + _setClaimConditions(ds, conditions, resetEligibility); + } + + ///@inheritdoc IDropFacet + function getActiveClaimConditionId() external view returns (uint256) { + return _getActiveConditionId(DropStorage.layout()); + } + + ///@inheritdoc IDropFacet + function getClaimConditionById( + uint256 conditionId + ) external view returns (ClaimCondition memory) { + return DropStorage.layout().getClaimConditionById(conditionId); + } + + ///@inheritdoc IDropFacet + function getSupplyClaimedByWallet( + address account, + uint256 conditionId + ) external view returns (uint256) { + return DropStorage.layout().getSupplyClaimedByWallet(conditionId, account); + } +} diff --git a/contracts/src/tokens/drop/DropFacetBase.sol b/contracts/src/tokens/drop/DropFacetBase.sol new file mode 100644 index 000000000..ca256cf2f --- /dev/null +++ b/contracts/src/tokens/drop/DropFacetBase.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// interfaces +import {IDropFacetBase} from "./IDropFacet.sol"; + +// libraries +import {DropStorage} from "./DropStorage.sol"; +import {CustomRevert} from "contracts/src/utils/libraries/CustomRevert.sol"; +import {MerkleProofLib} from "solady/utils/MerkleProofLib.sol"; + +// contracts + +abstract contract DropFacetBase is IDropFacetBase { + using DropStorage for DropStorage.Layout; + using MerkleProofLib for bytes32[]; + + function _getActiveConditionId( + DropStorage.Layout storage ds + ) internal view returns (uint256) { + uint256 conditionStartId = ds.conditionStartId; + uint256 conditionCount = ds.conditionCount; + + for ( + uint256 i = conditionStartId + conditionCount; + i > conditionStartId; + i-- + ) { + ClaimCondition memory condition = ds.conditionById[i - 1]; + if ( + block.timestamp >= condition.startTimestamp && + (condition.endTimestamp == 0 || + block.timestamp < condition.endTimestamp) + ) { + return i - 1; + } + } + + CustomRevert.revertWith(DropFacet__NoActiveClaimCondition.selector); + } + + function _verifyClaim( + DropStorage.Layout storage ds, + uint256 conditionId, + address account, + uint256 quantity, + bytes32[] calldata proof + ) internal view { + ClaimCondition memory condition = ds.getClaimConditionById(conditionId); + + if (condition.merkleRoot == bytes32(0)) { + CustomRevert.revertWith(DropFacet__MerkleRootNotSet.selector); + } + + if (quantity == 0) { + CustomRevert.revertWith( + DropFacet__QuantityMustBeGreaterThanZero.selector + ); + } + + // Check if the total claimed supply (including the current claim) exceeds the maximum claimable supply + if (condition.supplyClaimed + quantity > condition.maxClaimableSupply) { + CustomRevert.revertWith(DropFacet__ExceedsMaxClaimableSupply.selector); + } + + if (block.timestamp < condition.startTimestamp) { + CustomRevert.revertWith(DropFacet__ClaimHasNotStarted.selector); + } + + if ( + condition.endTimestamp > 0 && block.timestamp >= condition.endTimestamp + ) { + CustomRevert.revertWith(DropFacet__ClaimHasEnded.selector); + } + + // check if already claimed + if (ds.supplyClaimedByWallet[conditionId][account] > 0) { + CustomRevert.revertWith(DropFacet__AlreadyClaimed.selector); + } + + bytes32 leaf = _createLeaf(account, quantity); + if (!proof.verifyCalldata(condition.merkleRoot, leaf)) { + CustomRevert.revertWith(DropFacet__InvalidProof.selector); + } + } + + function _setClaimConditions( + DropStorage.Layout storage ds, + ClaimCondition[] calldata conditions, + bool resetEligibility + ) internal { + // get the existing claim condition count and start id + uint256 existingStartId = ds.conditionStartId; + uint256 existingConditionCount = ds.conditionCount; + + /// @dev If the claim conditions are being reset, we assign a new uid to the claim conditions. + /// which ends up resetting the eligibility of the claim conditions in `supplyClaimedByWallet`. + uint256 newConditionCount = conditions.length; + uint256 newStartId = existingStartId; + if (resetEligibility) { + newStartId = existingStartId + existingConditionCount; + } + + ds.conditionCount = newConditionCount; + ds.conditionStartId = newStartId; + + uint256 lastConditionTimestamp; + for (uint256 i = 0; i < newConditionCount; i++) { + if (lastConditionTimestamp >= conditions[i].startTimestamp) { + CustomRevert.revertWith( + DropFacet__ClaimConditionsNotInAscendingOrder.selector + ); + } + + // check that amount already claimed is less than or equal to the max claimable supply + uint256 amountAlreadyClaimed = ds + .conditionById[newStartId + i] + .supplyClaimed; + + if (amountAlreadyClaimed > conditions[i].maxClaimableSupply) { + CustomRevert.revertWith(DropFacet__CannotSetClaimConditions.selector); + } + + ds.conditionById[newStartId + i] = conditions[i]; + ds.conditionById[newStartId + i].supplyClaimed = amountAlreadyClaimed; + lastConditionTimestamp = conditions[i].startTimestamp; + } + + // if _resetEligibility is true, we assign new uids to the claim conditions + // so we delete claim conditions with UID < newStartId + if (resetEligibility) { + for (uint256 i = existingStartId; i < newStartId; i++) { + delete ds.conditionById[i]; + } + } else { + if (existingConditionCount > newConditionCount) { + for (uint256 i = newConditionCount; i < existingConditionCount; i++) { + delete ds.conditionById[newStartId + i]; + } + } + } + + emit DropFacet_ClaimConditionsUpdated(conditions, resetEligibility); + } + + function _updateClaim( + DropStorage.Layout storage ds, + uint256 conditionId, + address account, + uint256 amount + ) internal { + ds.conditionById[conditionId].supplyClaimed += amount; + ds.supplyClaimedByWallet[conditionId][account] += amount; + } + + // ============================================================= + // Utilities + // ============================================================= + function _createLeaf( + address account, + uint256 amount + ) internal pure returns (bytes32 leaf) { + assembly ("memory-safe") { + // Store the account address at memory location 0 + mstore(0, account) + // Store the amount at memory location 0x20 (32 bytes after the account address) + mstore(0x20, amount) + // Compute the keccak256 hash of the account and amount, and store it at memory location 0 + mstore(0, keccak256(0, 0x40)) + // Compute the keccak256 hash of the previous hash (stored at memory location 0) and store it in the leaf variable + leaf := keccak256(0, 0x20) + } + } +} diff --git a/contracts/src/tokens/drop/DropStorage.sol b/contracts/src/tokens/drop/DropStorage.sol new file mode 100644 index 000000000..737654a6e --- /dev/null +++ b/contracts/src/tokens/drop/DropStorage.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// interfaces +import {IDropFacetBase} from "./IDropFacet.sol"; + +// libraries + +// contracts + +library DropStorage { + // keccak256(abi.encode(uint256(keccak256("diamond.facets.drop.storage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 constant STORAGE_SLOT = + 0xeda6a1e2ce6f1639b6d3066254ca87a2daf51c4f0ad5038d408bbab6cc2cab00; + + struct Layout { + address claimToken; + uint256 conditionStartId; + uint256 conditionCount; + mapping(uint256 conditionId => mapping(address => uint256)) supplyClaimedByWallet; + mapping(uint256 conditionId => IDropFacetBase.ClaimCondition) conditionById; + } + + function layout() internal pure returns (Layout storage l) { + assembly { + l.slot := STORAGE_SLOT + } + } + + function getClaimToken(Layout storage ds) internal view returns (address) { + return ds.claimToken; + } + + function getClaimConditionById( + Layout storage ds, + uint256 conditionId + ) internal view returns (IDropFacetBase.ClaimCondition storage) { + return ds.conditionById[conditionId]; + } + + function getSupplyClaimedByWallet( + Layout storage ds, + uint256 conditionId, + address account + ) internal view returns (uint256) { + return ds.supplyClaimedByWallet[conditionId][account]; + } +} diff --git a/contracts/src/tokens/drop/IDropFacet.sol b/contracts/src/tokens/drop/IDropFacet.sol new file mode 100644 index 000000000..8f403a987 --- /dev/null +++ b/contracts/src/tokens/drop/IDropFacet.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +// interfaces + +// libraries + +// contracts + +interface IDropFacetBase { + /// @notice A struct representing a claim condition + /// @param startTimestamp The timestamp at which the claim condition starts + /// @param maxClaimableSupply The maximum claimable supply for the claim condition + /// @param supplyClaimed The supply already claimed for the claim condition + /// @param merkleRoot The merkle root for the claim condition + /// @param currency The currency to claim in + /// @param penaltyBps The penalty in basis points for early withdrawal + struct ClaimCondition { + uint256 startTimestamp; + uint256 endTimestamp; + uint256 maxClaimableSupply; + uint256 supplyClaimed; + bytes32 merkleRoot; + address currency; + uint256 penaltyBps; + } + + // ============================================================= + // Events + // ============================================================= + event DropFacet_Claimed_WithPenalty( + uint256 indexed conditionId, + address indexed claimer, + address indexed account, + uint256 amount + ); + + event DropFacet_ClaimConditionsUpdated( + ClaimCondition[] conditions, + bool resetEligibility + ); + + // ============================================================= + // Errors + // ============================================================= + error DropFacet__NoActiveClaimCondition(); + error DropFacet__MerkleRootNotSet(); + error DropFacet__QuantityMustBeGreaterThanZero(); + error DropFacet__ExceedsMaxClaimableSupply(); + error DropFacet__ClaimHasNotStarted(); + error DropFacet__AlreadyClaimed(); + error DropFacet__InvalidProof(); + error DropFacet__ClaimConditionsNotInAscendingOrder(); + error DropFacet__CannotSetClaimConditions(); + error DropFacet__ClaimHasEnded(); +} + +interface IDropFacet is IDropFacetBase { + /// @notice Sets the claim conditions for the drop + /// @param conditions An array of ClaimCondition structs defining the conditions + /// @param resetEligibility If true, resets the eligibility for all wallets under the new conditions + function setClaimConditions( + ClaimCondition[] calldata conditions, + bool resetEligibility + ) external; + + /// @notice Gets the ID of the currently active claim condition + /// @return The ID of the active claim condition + function getActiveClaimConditionId() external view returns (uint256); + + /// @notice Retrieves a specific claim condition by its ID + /// @param conditionId The ID of the claim condition to retrieve + /// @return The ClaimCondition struct for the specified ID + function getClaimConditionById( + uint256 conditionId + ) external view returns (ClaimCondition memory); + + /// @notice Gets the amount of tokens claimed by a specific wallet for a given condition + /// @param account The address of the wallet to check + /// @param conditionId The ID of the claim condition + /// @return The number of tokens claimed by the wallet for the specified condition + function getSupplyClaimedByWallet( + address account, + uint256 conditionId + ) external view returns (uint256); +} diff --git a/contracts/test/airdrop/DropFacet.t.sol b/contracts/test/airdrop/DropFacet.t.sol new file mode 100644 index 000000000..a0f147c37 --- /dev/null +++ b/contracts/test/airdrop/DropFacet.t.sol @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +// utils +import {Vm} from "forge-std/Test.sol"; +import {TestUtils} from "contracts/test/utils/TestUtils.sol"; +import {DeployDiamond} from "contracts/scripts/deployments/utils/DeployDiamond.s.sol"; +import {DeployMockERC20} from "contracts/scripts/deployments/utils/DeployMockERC20.s.sol"; +import {DeployDropFacet} from "contracts/scripts/deployments/facets/DeployDropFacet.s.sol"; + +//interfaces +import {IDiamond} from "contracts/src/diamond/Diamond.sol"; +import {IDropFacetBase} from "contracts/src/tokens/drop/IDropFacet.sol"; +import {IOwnableBase} from "contracts/src/diamond/facets/ownable/IERC173.sol"; +//libraries +import {MerkleTree} from "contracts/test/utils/MerkleTree.sol"; +import {DropFacet} from "contracts/src/tokens/drop/DropFacet.sol"; +import {MockERC20} from "contracts/test/mocks/MockERC20.sol"; +import {BasisPoints} from "contracts/src/utils/libraries/BasisPoints.sol"; + +// debuggging +import {console} from "forge-std/console.sol"; + +contract DropFacetTest is TestUtils, IDropFacetBase, IOwnableBase { + uint256 internal constant TOTAL_TOKEN_AMOUNT = 1000; + + DeployDiamond internal diamondHelper = new DeployDiamond(); + DeployMockERC20 internal tokenHelper = new DeployMockERC20(); + DeployDropFacet internal dropHelper = new DeployDropFacet(); + MerkleTree internal merkleTree = new MerkleTree(); + + MockERC20 internal token; + DropFacet internal dropFacet; + + mapping(address => uint256) internal treeIndex; + address[] internal accounts; + uint256[] internal amounts; + + bytes32[][] internal tree; + bytes32 internal root; + + Vm.Wallet internal bob = vm.createWallet("bob"); + Vm.Wallet internal alice = vm.createWallet("alice"); + address internal deployer; + + function setUp() public { + // Create the Merkle tree with accounts and amounts + _createTree(); + + // Get the deployer address + deployer = getDeployer(); + + // Deploy the mock ERC20 token + address tokenAddress = tokenHelper.deploy(deployer); + + // Deploy the Drop facet + address dropAddress = dropHelper.deploy(deployer); + + // Add the Drop facet to the diamond + diamondHelper.addFacet( + dropHelper.makeCut(dropAddress, IDiamond.FacetCutAction.Add), + dropAddress, + dropHelper.makeInitData("") + ); + + // Deploy the diamond contract with the MerkleAirdrop facet + address diamond = diamondHelper.deploy(deployer); + + // Initialize the Drop facet + dropFacet = DropFacet(diamond); + + // Mint tokens to the diamond + token = MockERC20(tokenAddress); + token.mint(diamond, TOTAL_TOKEN_AMOUNT); + } + + modifier givenClaimConditionSet() { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + conditions[0].penaltyBps = 5000; + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + _; + } + + modifier givenWalletHasClaimedWithPenalty(Vm.Wallet memory _wallet) { + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + ClaimCondition memory condition = dropFacet.getClaimConditionById( + conditionId + ); + uint256 penaltyBps = condition.penaltyBps; + uint256 merkleAmount = amounts[treeIndex[_wallet.addr]]; + uint256 penaltyAmount = BasisPoints.calculate(merkleAmount, penaltyBps); + uint256 expectedAmount = merkleAmount - penaltyAmount; + bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[_wallet.addr]); + + address caller = _randomAddress(); + + vm.prank(caller); + vm.expectEmit(address(dropFacet)); + emit DropFacet_Claimed_WithPenalty( + conditionId, + caller, + _wallet.addr, + expectedAmount + ); + dropFacet.claimWithPenalty(conditionId, _wallet.addr, merkleAmount, proof); + _; + } + + // getActiveClaimConditionId + function test_getActiveClaimConditionId() external { + ClaimCondition[] memory conditions = new ClaimCondition[](3); + conditions[0] = _createClaimCondition(block.timestamp - 100, root); // expired + conditions[1] = _createClaimCondition(block.timestamp, root); // active + conditions[2] = _createClaimCondition(block.timestamp + 100, root); // future + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 id = dropFacet.getActiveClaimConditionId(); + assertEq(id, 1); + + vm.warp(block.timestamp + 100); + id = dropFacet.getActiveClaimConditionId(); + assertEq(id, 2); + } + + function test_revertWhen_noActiveClaimCondition() external { + vm.expectRevert(DropFacet__NoActiveClaimCondition.selector); + dropFacet.getActiveClaimConditionId(); + } + + // getClaimConditionById + function test_getClaimConditionById() external givenClaimConditionSet { + ClaimCondition memory condition = dropFacet.getClaimConditionById( + dropFacet.getActiveClaimConditionId() + ); + assertEq(condition.startTimestamp, block.timestamp); + assertEq(condition.maxClaimableSupply, TOTAL_TOKEN_AMOUNT); + assertEq(condition.supplyClaimed, 0); + assertEq(condition.merkleRoot, root); + assertEq(condition.currency, address(token)); + assertEq(condition.penaltyBps, 5000); + } + + // claimWithPenalty + function test_claimWithPenalty() + external + givenClaimConditionSet + givenWalletHasClaimedWithPenalty(bob) + { + uint256 expectedAmount = _calculateExpectedAmount(bob.addr); + assertEq(token.balanceOf(bob.addr), expectedAmount); + } + + function test_revertWhen_merkleRootNotSet() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, bytes32(0)); + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + vm.expectRevert(DropFacet__MerkleRootNotSet.selector); + dropFacet.claimWithPenalty(conditionId, bob.addr, 100, new bytes32[](0)); + } + + function test_revertWhen_quantityIsZero() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + vm.expectRevert(DropFacet__QuantityMustBeGreaterThanZero.selector); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: 0, + allowlistProof: new bytes32[](0) + }); + } + + function test_revertWhen_exceedsMaxClaimableSupply() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + conditions[0].maxClaimableSupply = 100; // 100 tokens in total for this condition + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + vm.expectRevert(DropFacet__ExceedsMaxClaimableSupply.selector); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: 101, + allowlistProof: new bytes32[](0) + }); + } + + function test_revertWhen_claimHasNotStarted() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[bob.addr]); + + vm.warp(block.timestamp - 100); + + vm.prank(bob.addr); + vm.expectRevert(DropFacet__ClaimHasNotStarted.selector); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: amounts[treeIndex[bob.addr]], + allowlistProof: proof + }); + } + + function test_revertWhen_claimHasEnded() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + conditions[0].endTimestamp = block.timestamp + 100; + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[bob.addr]); + + vm.warp(conditions[0].endTimestamp); + + vm.expectRevert(DropFacet__ClaimHasEnded.selector); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: amounts[treeIndex[bob.addr]], + allowlistProof: proof + }); + } + + function test_revertWhen_alreadyClaimed() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[bob.addr]); + + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: amounts[treeIndex[bob.addr]], + allowlistProof: proof + }); + + vm.expectRevert(DropFacet__AlreadyClaimed.selector); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: amounts[treeIndex[bob.addr]], + allowlistProof: proof + }); + } + + function test_revertWhen_invalidProof() external givenClaimConditionSet { + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + vm.expectRevert(DropFacet__InvalidProof.selector); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: amounts[treeIndex[bob.addr]], + allowlistProof: new bytes32[](0) + }); + } + + // setClaimConditions + function test_setClaimConditions() external { + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + assertEq(conditionId, 0); + } + + function test_setClaimConditions_resetEligibility() + external + givenClaimConditionSet + givenWalletHasClaimedWithPenalty(bob) + { + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + uint256 expectedAmount = _calculateExpectedAmount(bob.addr); + + assertEq( + dropFacet.getSupplyClaimedByWallet(bob.addr, conditionId), + expectedAmount + ); + + vm.warp(block.timestamp + 100); + + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, true); + + uint256 newConditionId = dropFacet.getActiveClaimConditionId(); + assertEq(newConditionId, 1); + + assertEq(dropFacet.getSupplyClaimedByWallet(bob.addr, newConditionId), 0); + } + + function test_revertWhen_setClaimConditions_onlyOwner() external { + address caller = _randomAddress(); + + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Ownable__NotOwner.selector, caller)); + dropFacet.setClaimConditions(new ClaimCondition[](0), false); + } + + function test_revertWhen_setClaimConditions_notInAscendingOrder() external { + ClaimCondition[] memory conditions = new ClaimCondition[](2); + conditions[0] = _createClaimCondition(block.timestamp, root); + conditions[1] = _createClaimCondition(block.timestamp - 100, root); + + vm.expectRevert(DropFacet__ClaimConditionsNotInAscendingOrder.selector); + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + } + + function test_revertWhen_setClaimConditions_exceedsMaxClaimableSupply() + external + { + // Create a single claim condition + ClaimCondition[] memory conditions = new ClaimCondition[](1); + conditions[0] = _createClaimCondition(block.timestamp, root); + conditions[0].maxClaimableSupply = 100; // Set max claimable supply to 100 tokens + + // Set the claim conditions as the deployer + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + // Get the active condition ID + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + // Generate Merkle proof for Bob + bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[bob.addr]); + + // Simulate Bob claiming tokens + vm.prank(bob.addr); + dropFacet.claimWithPenalty({ + conditionId: conditionId, + account: bob.addr, + quantity: amounts[treeIndex[bob.addr]], + allowlistProof: proof + }); + + // Move time forward + vm.warp(block.timestamp + 100); + + // Attempt to set a new max claimable supply lower than what's already been claimed + conditions[0].maxClaimableSupply = 99; // Try to set max supply to 99 tokens + + // Expect the transaction to revert when trying to set new claim conditions + vm.expectRevert(DropFacet__CannotSetClaimConditions.selector); + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + } + + // ============================================================= + // End-to-end tests + // ============================================================= + + // we create 2 claim conditions, one with no end time, one with an end time 100 blocks from now + // we claim some tokens from the first condition, and then activate the second condition + // we claim some more tokens from the second condition + // we try to claim from the first condition by alice, this should pass + // we reach the end of the second condition, and try to claim from it, this should fail + function test_endToEnd_claimWithPenalty() external { + ClaimCondition[] memory conditions = new ClaimCondition[](2); + conditions[0] = _createClaimCondition(block.timestamp, root); // endless claim condition + + conditions[1] = _createClaimCondition(block.timestamp + 100, root); + conditions[1].endTimestamp = block.timestamp + 200; // ends at block.timestamp + 200 + + vm.prank(deployer); + dropFacet.setClaimConditions(conditions, false); + + uint256 conditionId = dropFacet.getActiveClaimConditionId(); + + // bob claims from the first condition + uint256 bobIndex = treeIndex[bob.addr]; + bytes32[] memory proof = merkleTree.getProof(tree, bobIndex); + vm.prank(bob.addr); + dropFacet.claimWithPenalty(conditionId, bob.addr, amounts[bobIndex], proof); + assertEq(token.balanceOf(bob.addr), _calculateExpectedAmount(bob.addr)); + + // activate the second condition + vm.warp(block.timestamp + 100); + + // alice claims from the second condition + conditionId = dropFacet.getActiveClaimConditionId(); + uint256 aliceIndex = treeIndex[alice.addr]; + proof = merkleTree.getProof(tree, aliceIndex); + vm.prank(alice.addr); + dropFacet.claimWithPenalty( + conditionId, + alice.addr, + amounts[aliceIndex], + proof + ); + assertEq( + dropFacet.getSupplyClaimedByWallet(alice.addr, conditionId), + _calculateExpectedAmount(alice.addr) + ); + + // finalize the second condition + vm.warp(block.timestamp + 100); + + // bob tries to claim from the second condition, this should fail + vm.expectRevert(DropFacet__ClaimHasEnded.selector); + vm.prank(bob.addr); + dropFacet.claimWithPenalty(conditionId, bob.addr, amounts[bobIndex], proof); + + // alice is still able to claim from the first condition + conditionId = dropFacet.getActiveClaimConditionId(); + vm.prank(alice.addr); + dropFacet.claimWithPenalty( + conditionId, + alice.addr, + amounts[aliceIndex], + proof + ); + assertEq( + dropFacet.getSupplyClaimedByWallet(alice.addr, conditionId), + _calculateExpectedAmount(alice.addr) + ); + } + + // ============================================================= + // Internal + // ============================================================= + function _createClaimCondition( + uint256 _startTime, + bytes32 _merkleRoot + ) internal view returns (ClaimCondition memory) { + return + ClaimCondition({ + startTimestamp: _startTime, + endTimestamp: 0, + maxClaimableSupply: TOTAL_TOKEN_AMOUNT, + supplyClaimed: 0, + merkleRoot: _merkleRoot, + currency: address(token), + penaltyBps: 0 + }); + } + + function _calculateExpectedAmount( + address _account + ) internal view returns (uint256) { + ClaimCondition memory condition = dropFacet.getClaimConditionById( + dropFacet.getActiveClaimConditionId() + ); + uint256 penaltyBps = condition.penaltyBps; + uint256 bobAmount = amounts[treeIndex[_account]]; + uint256 penaltyAmount = BasisPoints.calculate(bobAmount, penaltyBps); + uint256 expectedAmount = bobAmount - penaltyAmount; + + return expectedAmount; + } + + function _createTree() internal { + treeIndex[bob.addr] = 0; + accounts.push(bob.addr); + amounts.push(100); + + treeIndex[alice.addr] = 1; + accounts.push(alice.addr); + amounts.push(200); + + (root, tree) = merkleTree.constructTree(accounts, amounts); + } +} diff --git a/contracts/test/airdrop/MerkleAirdrop.t.sol b/contracts/test/airdrop/MerkleAirdrop.t.sol deleted file mode 100644 index 102205ce5..000000000 --- a/contracts/test/airdrop/MerkleAirdrop.t.sol +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.19; - -// utils -import {Vm} from "forge-std/Test.sol"; -import {TestUtils} from "contracts/test/utils/TestUtils.sol"; -import {DeployDiamond} from "contracts/scripts/deployments/utils/DeployDiamond.s.sol"; -import {DeployMerkleAirdrop} from "contracts/scripts/deployments/facets/DeployMerkleAirdrop.s.sol"; -import {DeployMockERC20} from "contracts/scripts/deployments/utils/DeployMockERC20.s.sol"; -import {DeployEIP712Facet} from "contracts/scripts/deployments/facets/DeployEIP712Facet.s.sol"; - -//interfaces -import {IDiamond} from "contracts/src/diamond/Diamond.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IMerkleAirdropBase} from "contracts/src/utils/airdrop/merkle/IMerkleAirdrop.sol"; - -//libraries -import {MerkleTree} from "contracts/test/utils/MerkleTree.sol"; - -//contracts -import {MerkleAirdrop} from "contracts/src/utils/airdrop/merkle/MerkleAirdrop.sol"; -import {MockERC20} from "contracts/test/mocks/MockERC20.sol"; -import {EIP712Facet} from "contracts/src/diamond/utils/cryptography/signature/EIP712Facet.sol"; -contract MerkleAirdropTest is TestUtils, IMerkleAirdropBase { - uint256 internal constant TOTAL_TOKEN_AMOUNT = 1000; - - DeployDiamond diamondHelper = new DeployDiamond(); - DeployMerkleAirdrop airdropHelper = new DeployMerkleAirdrop(); - DeployMockERC20 tokenHelper = new DeployMockERC20(); - DeployEIP712Facet eip712Helper = new DeployEIP712Facet(); - MerkleTree internal merkleTree = new MerkleTree(); - - MerkleAirdrop internal merkleAirdrop; - MockERC20 internal token; - EIP712Facet internal eip712Facet; - - mapping(address => uint256) internal treeIndex; - address[] internal accounts; - uint256[] internal amounts; - - bytes32[][] internal tree; - bytes32 internal root; - - Vm.Wallet internal bob = vm.createWallet("bob"); - Vm.Wallet internal alice = vm.createWallet("alice"); - - function setUp() public { - // Create the Merkle tree with accounts and amounts - _createTree(); - - // Get the deployer address - address deployer = getDeployer(); - - // Deploy the mock ERC20 token - address tokenAddress = tokenHelper.deploy(deployer); - - // Deploy the MerkleAirdrop contract - address airdropAddress = airdropHelper.deploy(deployer); - - // Deploy the EIP712 facet - address eip712Address = eip712Helper.deploy(deployer); - - // Add the EIP712 facet to the diamond - diamondHelper.addFacet( - eip712Helper.makeCut(eip712Address, IDiamond.FacetCutAction.Add), - eip712Address, - eip712Helper.makeInitData("MerkleAirdrop", "1.0.0") - ); - - // Add the MerkleAirdrop facet to the diamond - diamondHelper.addFacet( - airdropHelper.makeCut(airdropAddress, IDiamond.FacetCutAction.Add), - airdropAddress, - airdropHelper.makeInitData(root, tokenAddress) - ); - - // Deploy the diamond contract with the MerkleAirdrop facet - address diamond = diamondHelper.deploy(deployer); - - // Initialize the MerkleAirdrop and token contracts - merkleAirdrop = MerkleAirdrop(diamond); - eip712Facet = EIP712Facet(diamond); - - // Mint tokens to the diamond - token = MockERC20(tokenAddress); - token.mint(diamond, TOTAL_TOKEN_AMOUNT); - } - - modifier givenWalletHasClaimed(Vm.Wallet memory _wallet, uint256 _amount) { - bytes memory signature = _signClaim( - _wallet, - _wallet.addr, - _amount, - address(0) - ); - bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[_wallet.addr]); - - vm.prank(_randomAddress()); - vm.expectEmit(address(merkleAirdrop)); - emit Claimed(_wallet.addr, _amount, _wallet.addr); - merkleAirdrop.claim(_wallet.addr, _amount, proof, signature, address(0)); - _; - } - - modifier givenWalletHasClaimedWithReceiver( - Vm.Wallet memory _wallet, - uint256 _amount, - address _receiver - ) { - bytes memory signature = _signClaim( - _wallet, - _wallet.addr, - _amount, - _receiver - ); - bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[_wallet.addr]); - - vm.prank(_randomAddress()); - vm.expectEmit(address(merkleAirdrop)); - emit Claimed(_wallet.addr, _amount, _receiver); - merkleAirdrop.claim(_wallet.addr, _amount, proof, signature, _receiver); - _; - } - - function test_getToken() external view { - IERC20 _token = merkleAirdrop.getToken(); - assertEq(address(_token), address(token)); - } - - function test_getMerkleRoot() external view { - bytes32 _root = merkleAirdrop.getMerkleRoot(); - assertEq(_root, root); - } - - function test_claim() external givenWalletHasClaimed(bob, 100) { - assertEq(token.balanceOf(bob.addr), 100); - } - - function test_claimWithReceiver() - external - givenWalletHasClaimedWithReceiver(bob, 100, alice.addr) - { - assertEq(token.balanceOf(alice.addr), 100); - } - - function test_revertWhen_alreadyClaimed() - external - givenWalletHasClaimed(bob, 100) - { - bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[bob.addr]); - bytes memory signature = _signClaim(bob, bob.addr, 100, address(0)); - - vm.prank(bob.addr); - vm.expectRevert(MerkleAirdrop__AlreadyClaimed.selector); - merkleAirdrop.claim(bob.addr, 100, proof, signature, address(0)); - } - - function test_revertWhen_invalidSignature() external { - bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[bob.addr]); - bytes memory signature = _signClaim(alice, bob.addr, 100, address(0)); - - vm.expectRevert(MerkleAirdrop__InvalidSignature.selector); - merkleAirdrop.claim(bob.addr, 100, proof, signature, address(0)); - } - - function test_revertWhen_invalidProof() external { - bytes32[] memory proof = merkleTree.getProof(tree, treeIndex[alice.addr]); - bytes memory signature = _signClaim(bob, bob.addr, 100, address(0)); - - vm.expectRevert(MerkleAirdrop__InvalidProof.selector); - merkleAirdrop.claim(bob.addr, 100, proof, signature, address(0)); - } - - // ============================================================= - // Internal - // ============================================================= - function _createTree() internal { - treeIndex[bob.addr] = 0; - accounts.push(bob.addr); - amounts.push(100); - - treeIndex[alice.addr] = 1; - accounts.push(alice.addr); - amounts.push(200); - - (root, tree) = merkleTree.constructTree(accounts, amounts); - } - - function _signClaim( - Vm.Wallet memory _wallet, - address _account, - uint256 _amount, - address _receiver - ) internal view returns (bytes memory) { - bytes32 typeDataHash = merkleAirdrop.getMessageHash( - _account, - _amount, - _receiver - ); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(_wallet.privateKey, typeDataHash); - return abi.encodePacked(r, s, v); - } -}