From 103fadc17f91d497c49aa25ef3a8bdef6d85e1b2 Mon Sep 17 00:00:00 2001 From: Ethan Bennett Date: Mon, 29 Apr 2024 06:13:24 -0400 Subject: [PATCH] audit: boost unit test coverage, add invariant tests --- contracts/test/PointTokenVault.t.sol | 37 ++- .../invariant/PointTokenVaultInvariants.t.sol | 67 +++++ .../handlers/PointTokenVaultHandler.sol | 276 ++++++++++++++++++ contracts/test/mock/MockPointTokenVault.sol | 252 ++++++++++++++++ .../mock/script/MockPointTokenVault.s.sol | 134 +++++++++ foundry.toml | 9 + 6 files changed, 774 insertions(+), 1 deletion(-) create mode 100644 contracts/test/invariant/PointTokenVaultInvariants.t.sol create mode 100644 contracts/test/invariant/handlers/PointTokenVaultHandler.sol create mode 100644 contracts/test/mock/MockPointTokenVault.sol create mode 100644 contracts/test/mock/script/MockPointTokenVault.s.sol diff --git a/contracts/test/PointTokenVault.t.sol b/contracts/test/PointTokenVault.t.sol index 05a4ee8..4b9f03e 100644 --- a/contracts/test/PointTokenVault.t.sol +++ b/contracts/test/PointTokenVault.t.sol @@ -471,10 +471,16 @@ contract PointTokenVaultTest is Test { rewardToken.mint(address(pointTokenVault), 3e18); + // Cannot redeem pTokens or convert rewards before redemption data is set + bytes32[] memory empty = new bytes32[](0); + vm.expectRevert(PointTokenVault.RewardsNotReleased.selector); + pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 2e18, 2e18, empty), vitalik); + vm.expectRevert(PointTokenVault.RewardsNotReleased.selector); + pointTokenVault.convertRewardsToPTokens(vitalik, eigenPointsId, 1e18); + vm.prank(operator); pointTokenVault.setRedemption(eigenPointsId, rewardToken, 2e18, false); - bytes32[] memory empty = new bytes32[](0); vm.prank(vitalik); pointTokenVault.redeemRewards(PointTokenVault.Claim(eigenPointsId, 2e18, 2e18, empty), vitalik); @@ -513,6 +519,35 @@ contract PointTokenVaultTest is Test { uint256 newBalance = address(pointTokenVault).balance; assertEq(newBalance, initialBalance + amountToSend); } + + function test_PTokenNotDeployed() public { + // Deploy new instance of vault (without pToken deployed) + PointTokenVault mockVault = _deployAdditionalVault(); + + bytes32 root = 0x4e40a10ce33f33a4786960a8bb843fe0e170b651acd83da27abc97176c4bed3c; + bytes32[] memory proof = new bytes32[](1); + proof[0] = 0x6d0fcb8de12b1f57f81e49fa18b641487b932cdba4f064409fde3b05d3824ca2; + + vm.prank(merkleUpdater); + mockVault.updateRoot(root); + + // Cannot claim if pToken hasn't been deployed yet + vm.prank(vitalik); + vm.expectRevert(PointTokenVault.PTokenNotDeployed.selector); + mockVault.claimPTokens(PointTokenVault.Claim(eigenPointsId, 1e18, 1e18, proof), vitalik); + } + + // Internal + function _deployAdditionalVault() internal returns (PointTokenVault mockVault) { + PointTokenVaultScripts scripts = new PointTokenVaultScripts(); + + mockVault = scripts.run("0.0.1"); + + mockVault.grantRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), admin); + mockVault.grantRole(pointTokenVault.MERKLE_UPDATER_ROLE(), merkleUpdater); + mockVault.grantRole(pointTokenVault.OPERATOR_ROLE(), operator); + mockVault.revokeRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), address(this)); + } } contract Echo { diff --git a/contracts/test/invariant/PointTokenVaultInvariants.t.sol b/contracts/test/invariant/PointTokenVaultInvariants.t.sol new file mode 100644 index 0000000..6999c06 --- /dev/null +++ b/contracts/test/invariant/PointTokenVaultInvariants.t.sol @@ -0,0 +1,67 @@ +pragma solidity ^0.8.13; + +import {Test, console, console2} from "forge-std/Test.sol"; + +import {MockPointTokenVault} from "../mock/MockPointTokenVault.sol"; +import {MockPointTokenVaultScripts} from "../mock/script/MockPointTokenVault.s.sol"; + +import {PointTokenVaultHandler} from "./handlers/PointTokenVaultHandler.sol"; + +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {LibString} from "solady/utils/LibString.sol"; + +contract PointTokenVaultInvariants is Test { + PointTokenVaultHandler handler; + + MockPointTokenVault pointTokenVault; + + function setUp() public { + // Mock vault bypasses merkle validation to allow for fuzzing. + // Merkle validation is tested in PointTokenVault.t.sol + MockPointTokenVaultScripts scripts = new MockPointTokenVaultScripts(); + + // Deploy the PointTokenVault + pointTokenVault = scripts.run("0.0.1"); + address[3] memory admins = [ + makeAddr("admin"), + makeAddr("operator"), + makeAddr("merkleUpdater") + ]; + + pointTokenVault.grantRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), admins[0]); + pointTokenVault.grantRole(pointTokenVault.MERKLE_UPDATER_ROLE(), admins[2]); + pointTokenVault.grantRole(pointTokenVault.OPERATOR_ROLE(), admins[1]); + pointTokenVault.revokeRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), address(this)); + + handler = new PointTokenVaultHandler( + pointTokenVault, + admins + ); + + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = handler.deposit.selector; + selectors[1] = handler.withdraw.selector; + selectors[2] = handler.claimPTokens.selector; + + targetSelector( + FuzzSelector({ + addr: address(handler), + selectors: selectors + }) + ); + + targetContract(address(handler)); + } + + function invariant_point_earning_token_balances_remain_accurate_over_time() public view { + require(handler.checkPointEarningTokenGhosts(), "local pointsEarningTokens balances do not match balances stored in contract"); + } + + function invariant_claimed_ptoken_balances_remain_accurate_over_time() public view { + require(handler.checkClaimedPTokensGhosts(), "local claimed pTokens balances do not match balances stored in contract"); + } + + function invariant_ptoken_total_supplies_equal_sum_of_balances() public view { + require(handler.checkSumOfPTokenBalances(), "sum of a pToken's balances does not equal its total supply"); + } +} \ No newline at end of file diff --git a/contracts/test/invariant/handlers/PointTokenVaultHandler.sol b/contracts/test/invariant/handlers/PointTokenVaultHandler.sol new file mode 100644 index 0000000..a210327 --- /dev/null +++ b/contracts/test/invariant/handlers/PointTokenVaultHandler.sol @@ -0,0 +1,276 @@ +pragma solidity ^0.8.13; + +import {Test, console, console2} from "forge-std/Test.sol"; + +import {MockPointTokenVault} from "../../mock/MockPointTokenVault.sol"; + +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {LibString} from "solady/utils/LibString.sol"; + +contract PointTokenVaultHandler is Test { + MockPointTokenVault public pointTokenVault; + + MockERC20[10] pointEarningTokens = [ + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18), + new MockERC20("Test Token", "TST", 18) + ]; + + bytes32[10] pointsIds = [ + LibString.packTwo("First", "test"), + LibString.packTwo("Second", "test"), + LibString.packTwo("Third", "test"), + LibString.packTwo("Fourth", "test"), + LibString.packTwo("Fifth", "test"), + LibString.packTwo("Sixth", "test"), + LibString.packTwo("Seventh", "test"), + LibString.packTwo("Eighth", "test"), + LibString.packTwo("Ninth", "test"), + LibString.packTwo("Tenth", "test") + ]; + + address public currentActor; + + struct Actor { + address addr; + uint256 key; + } + + Actor[] public actors; + Actor[] public dsts; + Actor internal actor; + + bytes32[] internal expectedErrors; + + mapping(address => mapping(address => uint256)) pointEarningTokenGhosts; + mapping(address => mapping(bytes32 => uint256)) claimedPTokensGhosts; + + modifier useRandomActor(uint256 _actorIndex) { + actor = _selectActor(_actorIndex); + vm.stopPrank(); + vm.startPrank(actor.addr); + _; + delete actor; + vm.stopPrank(); + } + + modifier resetErrors() { + _; + delete expectedErrors; + } + + constructor( + MockPointTokenVault pointTokenVault_, + address[3] memory admins + ) { + pointTokenVault = pointTokenVault_; + + vm.prank(admins[1]); + pointTokenVault.setIsCapped(false); + + for (uint256 i = 0; i < admins.length; i++) { + Actor memory _actor; + _actor.addr = admins[i]; + _actor.key = 0; + actors.push(_actor); + dsts.push(_actor); + } + + for (uint256 j = 0; j < 10; j++) { + Actor memory _actor; + (_actor.addr, _actor.key) = makeAddrAndKey(string(abi.encodePacked("Actor", vm.toString(j)))); + actors.push(_actor); + dsts.push(_actor); + } + + Actor memory zero; + (zero.addr, zero.key) = makeAddrAndKey(string(abi.encodePacked("Zero"))); + zero.addr = address(0); + dsts.push(zero); + + for (uint256 k = 0; k < pointsIds.length; k++) { + pointTokenVault.deployPToken(pointsIds[k]); + } + } + + function deposit( + uint256 actorIndex, + uint256 dstIndex, + uint256 amount, + uint256 tokenIndex + ) public useRandomActor(actorIndex) { + actorIndex = bound(actorIndex, 0, 12); + dstIndex = bound(dstIndex, 0, 12); + amount = bound(amount, 0, 100000 * 1e18); + tokenIndex = bound(tokenIndex, 0, 9); + + MockERC20 token = pointEarningTokens[tokenIndex]; + + token.mint(currentActor, amount); + token.approve(address(pointTokenVault), amount); + + uint256 depositorBalanceBefore = token.balanceOf(currentActor); + uint256 receiverBalanceBefore = pointTokenVault.balances(actors[dstIndex].addr, token); + + try pointTokenVault.deposit(token, amount, actors[dstIndex].addr) { + uint256 depositorBalanceAfter = token.balanceOf(currentActor); + uint256 receiverBalanceAfter = pointTokenVault.balances(actors[dstIndex].addr, token); + + pointEarningTokenGhosts[actors[dstIndex].addr][address(token)] += amount; + + assertEq(depositorBalanceBefore - depositorBalanceAfter, amount); + assertEq(receiverBalanceAfter - receiverBalanceBefore, amount); + } catch (bytes memory reason) { + console.log("Unexpected revert: deposit failed!"); + console.logBytes(reason); + } + } + + function withdraw( + uint256 actorIndex, + uint256 dstIndex, + uint256 amount, + uint256 tokenIndex + ) public useRandomActor(actorIndex) { + actorIndex = bound(actorIndex, 0, 12); + dstIndex = bound(dstIndex, 0, 12); + amount = bound(amount, 0, 100000 * 1e18); + tokenIndex = bound(tokenIndex, 0, 9); + + MockERC20 token = pointEarningTokens[tokenIndex]; + + token.mint(currentActor, amount); + token.approve(address(pointTokenVault), amount); + pointTokenVault.deposit(token, amount, currentActor); + pointEarningTokenGhosts[currentActor][address(token)] += amount; + + uint256 actorBalanceBefore = pointTokenVault.balances(currentActor, token); + uint256 receiverBalanceBefore = token.balanceOf(actors[dstIndex].addr); + + try pointTokenVault.withdraw(token, amount, actors[dstIndex].addr) { + uint256 actorBalanceAfter = pointTokenVault.balances(currentActor, token); + uint256 receiverBalanceAfter = token.balanceOf(actors[dstIndex].addr); + + pointEarningTokenGhosts[currentActor][address(token)] -= amount; + + assertEq(actorBalanceBefore - actorBalanceAfter, amount); + assertEq(receiverBalanceAfter - receiverBalanceBefore, amount); + } catch (bytes memory reason) { + console.log("Unexpected revert: withdraw failed!"); + console.logBytes(reason); + } + } + + function claimPTokens( + uint256 actorIndex, + uint256 dstIndex, + uint256 idIndex, + uint256 totalClaimable, + uint256 amount + ) public useRandomActor(actorIndex) { + actorIndex = bound(actorIndex, 0, 12); + dstIndex = bound(dstIndex, 0, 12); + totalClaimable = bound(totalClaimable, 0, 100000 * 1e18); + amount = bound(amount, 0, 100000 * 1e18); + idIndex = bound(idIndex, 0, 9); + + bytes32[] memory emptyProof = new bytes32[](0); + + MockPointTokenVault.Claim memory claim = MockPointTokenVault.Claim( + pointsIds[idIndex], + totalClaimable, + amount, + emptyProof + ); + + uint256 pTokenBalanceBefore = pointTokenVault.pTokens(pointsIds[idIndex]).balanceOf(actors[dstIndex].addr); + uint256 claimedBalanceBefore = pointTokenVault.claimedPTokens(actors[dstIndex].addr, pointsIds[idIndex]); + + try pointTokenVault.claimPTokens(claim, actors[dstIndex].addr) { + uint256 pTokenBalanceAfter = pointTokenVault.pTokens(pointsIds[idIndex]).balanceOf(actors[dstIndex].addr); + uint256 claimedBalanceAfter = pointTokenVault.claimedPTokens(actors[dstIndex].addr, pointsIds[idIndex]); + + claimedPTokensGhosts[actors[dstIndex].addr][pointsIds[idIndex]] += amount; + + assertEq(pTokenBalanceAfter - pTokenBalanceBefore, amount); + assertEq(claimedBalanceAfter - claimedBalanceBefore, amount); + } catch (bytes memory reason) { + if (totalClaimable < claimedBalanceBefore + amount) { + console.log("Expected revert: totalClaimable < amount"); + assertEq(bytes4(reason), MockPointTokenVault.ClaimTooLarge.selector); + } else { + console.log("Unexpected revert: claim failed!"); + console.logBytes(reason); + } + } + } + + // Helper functions --- + function checkPointEarningTokenGhosts() public view returns (bool) { + for (uint256 i; i < actors.length; i++) { + for (uint256 j; j < pointEarningTokens.length; j++) { + if ( + pointEarningTokenGhosts[actors[i].addr][address(pointEarningTokens[j])] + != pointTokenVault.balances(actors[i].addr, pointEarningTokens[j]) + ) { + console.log("Ghost balance:", pointEarningTokenGhosts[actors[i].addr][address(pointEarningTokens[j])]); + console.log("Balance according to contract:", pointTokenVault.balances(actors[i].addr, pointEarningTokens[j])); + + return false; + } + } + } + + return true; + } + + function checkClaimedPTokensGhosts() public view returns (bool) { + for (uint i; i < actors.length; i++) { + for (uint256 j; j < pointsIds.length; j++) { + if ( + claimedPTokensGhosts[actors[i].addr][pointsIds[j]] + != pointTokenVault.claimedPTokens(actors[i].addr, pointsIds[j]) + ) { + console.log("Ghost balance:", claimedPTokensGhosts[actors[i].addr][pointsIds[j]]); + console.log("Balance according to contract:", pointTokenVault.claimedPTokens(actors[i].addr, pointsIds[j])); + + return false; + } + } + } + + return true; + } + + function checkSumOfPTokenBalances() public view returns (bool) { + uint256 sumOfBalances; + for (uint256 i; i < pointsIds.length; i++) { + sumOfBalances = 0; + for (uint256 j; j < actors.length; j++) { + sumOfBalances += claimedPTokensGhosts[actors[j].addr][pointsIds[i]]; + } + + if (sumOfBalances != pointTokenVault.pTokens(pointsIds[i]).totalSupply()) { + console.log("PToken index:", i); + console.log("Sum of balances:", sumOfBalances); + + return false; + } + } + + return true; + } + + function _selectActor(uint256 _actorIndex) internal returns (Actor memory actor_) { + uint256 index = bound(_actorIndex, 0, actors.length - 1); + currentActor = actors[index].addr; + actor_ = actors[index]; + } +} \ No newline at end of file diff --git a/contracts/test/mock/MockPointTokenVault.sol b/contracts/test/mock/MockPointTokenVault.sol new file mode 100644 index 0000000..44a6806 --- /dev/null +++ b/contracts/test/mock/MockPointTokenVault.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {console} from "forge-std/Test.sol"; + +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlUpgradeable} from + "openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; +import {MulticallUpgradeable} from "openzeppelin-contracts-upgradeable/contracts/utils/MulticallUpgradeable.sol"; + +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol"; + +import {LibString} from "solady/utils/LibString.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +import {PToken} from "../../PToken.sol"; + +/// @title Point Token Vault +/// @notice Manages deposits and withdrawals for points-earning assets, point token claims, and reward redemptions. +contract MockPointTokenVault is UUPSUpgradeable, AccessControlUpgradeable, MulticallUpgradeable { + using SafeTransferLib for ERC20; + using MerkleProof for bytes32[]; + + bytes32 public constant REDEMPTION_RIGHTS_PREFIX = keccak256("REDEMPTION_RIGHTS"); + bytes32 public constant MERKLE_UPDATER_ROLE = keccak256("MERKLE_UPDATER_ROLE"); + bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + // Deposit asset balancess. + mapping(address => mapping(ERC20 => uint256)) public balances; // user => point-earning token => balance + + // Merkle root distribution. + bytes32 public currRoot; + bytes32 public prevRoot; + mapping(address => mapping(bytes32 => uint256)) public claimedPTokens; // user => pointsId => claimed + mapping(address => mapping(bytes32 => uint256)) public claimedRedemptionRights; // user => pointsId => claimed + + mapping(bytes32 => PToken) public pTokens; // pointsId => pTokens + + mapping(bytes32 => RedemptionParams) public redemptions; // pointsId => redemptionParams + + mapping(address => uint256) public caps; // asset => deposit cap + bool public isCapped; + + struct Claim { + bytes32 pointsId; + uint256 totalClaimable; + uint256 amountToClaim; + bytes32[] proof; + } + + struct RedemptionParams { + ERC20 rewardToken; + uint256 rewardsPerPToken; // Assume 18 decimals. + bool isMerkleBased; + } + + event Deposit(address indexed receiver, address indexed token, uint256 amount); + event Withdraw(address indexed user, address indexed token, uint256 amount); + event RootUpdated(bytes32 prevRoot, bytes32 newRoot); + event PTokensClaimed(address indexed account, bytes32 indexed pointsId, uint256 amount); + event RewardsClaimed(address indexed owner, address indexed receiver, bytes32 indexed pointsId, uint256 amount); + event RewardRedemptionSet( + bytes32 indexed pointsId, ERC20 rewardToken, uint256 rewardsPerPToken, bool isMerkleBased + ); + event PTokenDeployed(bytes32 indexed pointsId, address indexed pToken); + event CapSet(address indexed token, uint256 cap); + + error ProofInvalidOrExpired(); + error ClaimTooLarge(); + error RewardsNotReleased(); + error PTokenAlreadyDeployed(); + error DepositExceedsCap(); + error PTokenNotDeployed(); + + constructor() { + _disableInitializers(); + } + + function initialize(address _admin) public initializer { + __UUPSUpgradeable_init(); + __AccessControl_init(); + __Multicall_init(); + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + isCapped = true; + } + + // Rebasing and fee-on-transfer tokens must be wrapped before depositing. + function deposit(ERC20 _token, uint256 _amount, address _receiver) public { + if (isCapped && (_amount + _token.balanceOf(address(this)) > caps[address(_token)])) { + revert DepositExceedsCap(); + } + + _token.safeTransferFrom(msg.sender, address(this), _amount); + + balances[_receiver][_token] += _amount; + + emit Deposit(_receiver, address(_token), _amount); + } + + function withdraw(ERC20 _token, uint256 _amount, address _receiver) public { + balances[msg.sender][_token] -= _amount; + + _token.safeTransfer(_receiver, _amount); + + emit Withdraw(_receiver, address(_token), _amount); + } + + /// @notice Claims point tokens after verifying the merkle proof + /// @param _claim The claim details including the merkle proof + /// @param _account The account to claim for + // Adapted from Morpho's RewardsDistributor.sol (https://github.com/morpho-org/morpho-optimizers/blob/ffd702f045d24b911d6c8c6c2194dd15cf9387ff/src/common/rewards-distribution/RewardsDistributor.sol) + function claimPTokens(Claim calldata _claim, address _account) public { + bytes32 pointsId = _claim.pointsId; + + bytes32 claimHash = keccak256(abi.encodePacked(_account, pointsId, _claim.totalClaimable)); + _verifyClaimAndUpdateClaimed(_claim, claimHash, _account, claimedPTokens); + + if (address(pTokens[pointsId]) == address(0)) { + revert PTokenNotDeployed(); + } + + pTokens[pointsId].mint(_account, _claim.amountToClaim); + + emit PTokensClaimed(_account, pointsId, _claim.amountToClaim); + } + + /// @notice Redeems rewards for point tokens + /// @param _claim Details of the claim including the amount and merkle proof + /// @param _receiver The account that will receive the msg.sender redeemed rewards + function redeemRewards(Claim calldata _claim, address _receiver) public { + (bytes32 pointsId, uint256 amountToClaim) = (_claim.pointsId, _claim.amountToClaim); + + RedemptionParams memory params = redemptions[pointsId]; + (ERC20 rewardToken, uint256 rewardsPerPToken, bool isMerkleBased) = + (params.rewardToken, params.rewardsPerPToken, params.isMerkleBased); + + if (address(rewardToken) == address(0)) { + revert RewardsNotReleased(); + } + + if (isMerkleBased) { + // If it's merkle-based, only those callers with redemption rights can redeem their point token for rewards. + + bytes32 claimHash = + keccak256(abi.encodePacked(REDEMPTION_RIGHTS_PREFIX, msg.sender, pointsId, _claim.totalClaimable)); + _verifyClaimAndUpdateClaimed(_claim, claimHash, msg.sender, claimedRedemptionRights); + } + + // Will fail if the user doesn't also have enough point tokens. Assume rewardsPerPToken is 18 decimals. + pTokens[pointsId].burn(msg.sender, FixedPointMathLib.divWadUp(amountToClaim, rewardsPerPToken)); // Round up for burn. + rewardToken.safeTransfer(_receiver, amountToClaim); + emit RewardsClaimed(msg.sender, _receiver, pointsId, amountToClaim); + } + + /// @notice Mints point tokens for rewards after redemption has been enabled + function convertRewardsToPTokens(address _receiver, bytes32 _pointsId, uint256 _amountToConvert) public { + RedemptionParams memory params = redemptions[_pointsId]; + (ERC20 rewardToken, uint256 rewardsPerPToken) = (params.rewardToken, params.rewardsPerPToken); + + if (address(rewardToken) == address(0)) { + revert RewardsNotReleased(); + } + + rewardToken.safeTransferFrom(msg.sender, address(this), _amountToConvert); + pTokens[_pointsId].mint(_receiver, FixedPointMathLib.divWadDown(_amountToConvert, rewardsPerPToken)); // Round down for mint. + } + + function deployPToken(bytes32 _pointsId) public { + if (address(pTokens[_pointsId]) != address(0)) { + revert PTokenAlreadyDeployed(); + } + + (string memory name, string memory symbol) = LibString.unpackTwo(_pointsId); // Assume the points id was created using LibString.packTwo. + pTokens[_pointsId] = new PToken{salt: _pointsId}(name, symbol, 18); + emit PTokenDeployed(_pointsId, address(pTokens[_pointsId])); + } + + // Internal --- + + function _verifyClaimAndUpdateClaimed( + Claim calldata _claim, + bytes32 _claimHash, + address _account, + mapping(address => mapping(bytes32 => uint256)) storage _claimed + ) internal { + // bytes32 candidateRoot = _claim.proof.processProof(_claimHash); + // The following line exists only here in the mock, just to silence the warning about _claimHash being unused + _claimHash; + bytes32 pointsId = _claim.pointsId; + uint256 amountToClaim = _claim.amountToClaim; + + // Check if the root is valid. + // if (candidateRoot != currRoot && candidateRoot != prevRoot) { + // revert ProofInvalidOrExpired(); + // } + + uint256 alreadyClaimed = _claimed[_account][pointsId]; + + // Can claim up to the total claimable amount from the hash. + // IMPORTANT: totalClaimable must be in the claim hash passed into this function. + if (_claim.totalClaimable < alreadyClaimed + amountToClaim) revert ClaimTooLarge(); + + // Update the total claimed amount. + unchecked { + _claimed[_account][pointsId] = alreadyClaimed + amountToClaim; + } + } + + // Admin --- + + function updateRoot(bytes32 _newRoot) external onlyRole(MERKLE_UPDATER_ROLE) { + prevRoot = currRoot; + currRoot = _newRoot; + emit RootUpdated(prevRoot, currRoot); + } + + function setCap(address _token, uint256 _cap) external onlyRole(OPERATOR_ROLE) { + caps[_token] = _cap; + emit CapSet(_token, _cap); + } + + function setIsCapped(bool _isCapped) external onlyRole(OPERATOR_ROLE) { + isCapped = _isCapped; + } + + // Can be used to unlock reward token redemption (can also modify a live redemption, so use with care). + function setRedemption(bytes32 _pointsId, ERC20 _rewardToken, uint256 _rewardsPerPToken, bool _isMerkleBased) + external + onlyRole(OPERATOR_ROLE) + { + redemptions[_pointsId] = RedemptionParams(_rewardToken, _rewardsPerPToken, _isMerkleBased); + emit RewardRedemptionSet(_pointsId, _rewardToken, _rewardsPerPToken, _isMerkleBased); + } + + // To handle arbitrary reward claiming logic. + function execute(address _to, bytes memory _data, uint256 _txGas) + external + onlyRole(DEFAULT_ADMIN_ROLE) + returns (bool success) + { + assembly { + success := delegatecall(_txGas, _to, add(_data, 0x20), mload(_data), 0, 0) + } + } + + function _authorizeUpgrade(address _newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} + + receive() external payable {} +} diff --git a/contracts/test/mock/script/MockPointTokenVault.s.sol b/contracts/test/mock/script/MockPointTokenVault.s.sol new file mode 100644 index 0000000..ad6e344 --- /dev/null +++ b/contracts/test/mock/script/MockPointTokenVault.s.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {BatchScript} from "forge-safe/src/BatchScript.sol"; + +import {MockPointTokenVault} from "../MockPointTokenVault.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {MockERC20} from "solmate/test/utils/mocks/MockERC20.sol"; +import {ERC20} from "solmate/tokens/ERC20.sol"; +import {CREATE3} from "solmate/utils/CREATE3.sol"; +import {LibString} from "solady/utils/LibString.sol"; + +import {console} from "forge-std/console.sol"; + +contract MockPointTokenVaultScripts is BatchScript { + // Sepolia Test Accounts + address payable public JIM = payable(0xD6633c1382896079D3576eC43519d844a8C80B56); + address payable public SAM = payable(0xeeD5B3026060218Dc270AE672be6468053e65E39); + address payable public AVA = payable(0xb30C79546800EF35Ea1fAae56A5faA5C03332D9F); + + uint256 JIM_PRIVATE_KEY = 0x70be68eaa723b433c6b8806f3851d3e04f51a1beed15146dc9fba0873f3b7772; + uint256 SAM_PRIVATE_KEY = 0x8563bad6b0b906b890cd3272ee8748b7d0e20d6e49917e769af364598e96b466; + uint256 AVA_PRIVATE_KEY = 0x7617580e9556785c7f9bb93e652df98b6acd0de459300711afbcf53e40ce0358; + + address public SEOPLIA_MERKLE_BOT_SAFE = 0xec48011b60be299A2684F36Bdb3B498a61A6CbF3; + address public SEPOLIA_OPERATOR_SAFE = 0xec48011b60be299A2684F36Bdb3B498a61A6CbF3; + address public SEOPLIA_ADMIN_SAFE = 0xec48011b60be299A2684F36Bdb3B498a61A6CbF3; + + address public MAINNET_MERKLE_UPDATER = 0xfDE9f367c933A7D7E7348D4a3e6e096d814F5828; + address public MAINNET_OPERATOR = 0x0c0264Ba7799dA7aF0fd141ba5Ba976E6DcC6C17; + address public MAINNET_ADMIN = 0x9D89745fD63Af482ce93a9AdB8B0BbDbb98D3e06; + + string public VERSION = "0.0.1"; + + function run() public returns (address) { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + + vm.startBroadcast(deployerPrivateKey); + + MockPointTokenVault pointTokenVault = run(VERSION); + + // Set roles + pointTokenVault.grantRole(pointTokenVault.MERKLE_UPDATER_ROLE(), SEOPLIA_MERKLE_BOT_SAFE); + pointTokenVault.grantRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), SEOPLIA_ADMIN_SAFE); + pointTokenVault.grantRole(pointTokenVault.OPERATOR_ROLE(), SEPOLIA_OPERATOR_SAFE); + + // Remove self + pointTokenVault.revokeRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), msg.sender); + + require(!pointTokenVault.hasRole(pointTokenVault.DEFAULT_ADMIN_ROLE(), msg.sender), "Self role not removed"); + + vm.stopBroadcast(); + + return address(pointTokenVault); + } + + function run(string memory version) public returns (MockPointTokenVault) { + MockPointTokenVault pointTokenVaultImplementation = new MockPointTokenVault{salt: keccak256(abi.encode(version))}(); + + MockPointTokenVault pointTokenVault = MockPointTokenVault(payable( + address( + new ERC1967Proxy{salt: keccak256(abi.encode(version))}( + address(pointTokenVaultImplementation), + abi.encodeCall(MockPointTokenVault.initialize, (msg.sender)) // msg.sender is admin + ) + )) + ); + + return pointTokenVault; + } + + function deposit() public returns (uint256) { + vm.startBroadcast(JIM_PRIVATE_KEY); + + ERC20 token = ERC20(0x791a051631c9c4cDf4E03Fb7Aec3163AE164A34B); + MockPointTokenVault pointTokenVault = MockPointTokenVault(payable(0xbff7Fb79efC49504afc97e74F83EE618768e63E9)); + token.symbol(); + + token.approve(address(pointTokenVault), 2.5e18); + pointTokenVault.deposit(token, 2.5e18, JIM); + + vm.stopBroadcast(); + + return token.balanceOf(JIM); + } + + function upgrade() public { + vm.startBroadcast(); + + MockPointTokenVault currentPointTokenVault = MockPointTokenVault(payable(0xbff7Fb79efC49504afc97e74F83EE618768e63E9)); + + MockPointTokenVault PointTokenVaultImplementation = new MockPointTokenVault(); + + currentPointTokenVault.upgradeToAndCall(address(PointTokenVaultImplementation), bytes("")); + + vm.stopBroadcast(); + } + + function deployPToken() public { + vm.startBroadcast(JIM_PRIVATE_KEY); + + MockPointTokenVault pointTokenVault = MockPointTokenVault(payable(0xbff7Fb79efC49504afc97e74F83EE618768e63E9)); + + pointTokenVault.deployPToken(LibString.packTwo("ETHERFI Points", "pEF")); + + vm.stopBroadcast(); + } + + function deployMockERC20() public { + vm.startBroadcast(JIM_PRIVATE_KEY); + + MockERC20 token = new MockERC20("ETHFI", "eETH", 18); + + token.mint(JIM, 100e18); + token.mint(SAM, 100e18); + token.mint(AVA, 100e18); + + vm.stopBroadcast(); + } + + function setCap() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + address pointTokenVault = 0xbff7Fb79efC49504afc97e74F83EE618768e63E9; + + bytes memory txn = + abi.encodeWithSelector(MockPointTokenVault.setCap.selector, 0x791a051631c9c4cDf4E03Fb7Aec3163AE164A34B, 10e18); + addToBatch(pointTokenVault, 0, txn); + + executeBatch(SEOPLIA_ADMIN_SAFE, true); + vm.stopBroadcast(); + } +} diff --git a/foundry.toml b/foundry.toml index c166df4..e85422b 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,6 +6,7 @@ out = "contracts/out" libs = ["lib"] ffi = true evm_version = "cancun" +gas_reports = ["PointTokenVault"] [rpc_endpoints] sepolia = "${SEPOLIA_RPC_URL}" @@ -13,4 +14,12 @@ sepolia = "${SEPOLIA_RPC_URL}" [etherscan] sepolia = {key = "${ETHERSCAN_API_KEY}"} +[fuzz] +runs = 256 + +[invariant] +depth = 20 +fail_on_revert = true +dictionary_weight = 60 + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options