diff --git a/contracts/sPRL/TimeLockPenaltyERC20.sol b/contracts/sPRL/TimeLockPenaltyERC20.sol index 8df287e..7648368 100644 --- a/contracts/sPRL/TimeLockPenaltyERC20.sol +++ b/contracts/sPRL/TimeLockPenaltyERC20.sol @@ -7,6 +7,7 @@ import { ERC20Votes } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20 import { IERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { AccessManaged } from "@openzeppelin/contracts/access/manager/AccessManaged.sol"; import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; import { Nonces } from "@openzeppelin/contracts/utils/Nonces.sol"; @@ -22,6 +23,7 @@ import { MathsLib } from "contracts/libraries/MathsLib.sol"; /// @custom:contact security@cooperlabs.xyz abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, AccessManaged, Pausable, ReentrancyGuard { using MathsLib for *; + using SafeERC20 for IERC20; //------------------------------------------- // Storage @@ -115,6 +117,11 @@ abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, Access /// @param newPercentage The new penalty percentage. event StartPenaltyPercentageUpdated(uint256 oldPercentage, uint256 newPercentage); + /// @notice Emitted when extra tokens are recovered to the fee receiver. + /// @param token The address of the recovered token. + /// @param amount The amount of tokens recovered. + event TokensRecovered(address token, uint256 amount); + //------------------------------------------- // Errors //------------------------------------------- @@ -129,6 +136,12 @@ abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, Access error PercentageOutOfRange(uint256 attemptedPercentage); /// @notice Thrown when the penalty percentage is too high. error MaxPenaltyPercentageExceeded(); + /// @notice Thrown when a withdrawal request is made with a zero amount. + error NullAmount(); + /// @notice Thrown when the fee receiver is set to the zero address. + error FeeReceiverZeroAddress(); + /// @notice Thrown when trying to recover the underlying token, which backs user shares. + error CannotRecoverUnderlying(); //------------------------------------------- // Constructor @@ -158,6 +171,9 @@ abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, Access if (_startPenaltyPercentage > MAX_PENALTY_PERCENTAGE) { revert PercentageOutOfRange(_startPenaltyPercentage); } + if (_feeReceiver == address(0)) { + revert FeeReceiverZeroAddress(); + } feeReceiver = _feeReceiver; underlying = IERC20(_underlying); @@ -172,6 +188,7 @@ abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, Access /// @notice Request to withdraw assets from the contract. /// @param _unlockingAmount The amount of assets to unlock. function requestWithdraw(uint256 _unlockingAmount) external { + if (_unlockingAmount == 0) revert NullAmount(); _burn(msg.sender, _unlockingAmount); uint256 id = userVsNextID[msg.sender]++; @@ -266,6 +283,9 @@ abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, Access /// @notice Allow the AccessManager to update the fee receiver address. /// @param _newFeeReceiver The new fee receiver. function updateFeeReceiver(address _newFeeReceiver) public virtual restricted { + if (_newFeeReceiver == address(0)) { + revert FeeReceiverZeroAddress(); + } emit FeeReceiverUpdated(_newFeeReceiver); feeReceiver = _newFeeReceiver; } @@ -282,6 +302,17 @@ abstract contract TimeLockPenaltyERC20 is ERC20, ERC20Permit, ERC20Votes, Access _unpause(); } + /// @notice Recover extra tokens sitting idle in the contract (e.g. reward tokens sent on its behalf). + /// @dev The underlying token cannot be recovered as it backs user shares. Recovered tokens are sent to the fee + /// receiver. + /// @param _token The token to recover. + function recoverERC20(IERC20 _token) external restricted { + if (_token == underlying) revert CannotRecoverUnderlying(); + uint256 balance = _token.balanceOf(address(this)); + emit TokensRecovered(address(_token), balance); + _token.safeTransfer(feeReceiver, balance); + } + //------------------------------------------- // Private/Internal functions //------------------------------------------- diff --git a/contracts/sPRL/sPRL2Migrator.sol b/contracts/sPRL/sPRL2Migrator.sol new file mode 100644 index 0000000..a45ad90 --- /dev/null +++ b/contracts/sPRL/sPRL2Migrator.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { sPRL2 } from "./sPRL2.sol"; +import { sPRL2V2 } from "./sPRL2V2.sol"; + +/// @title sPRL2Migrator +/// @author Cooper Labs +/// @custom:contact security@cooperlabs.xyz +/// @notice Migrates sPRL2 positions to sPRL2V2 in a single transaction. +/// @dev Requires sPRL2 to be paused so that emergencyWithdraw is available. +contract sPRL2Migrator { + using SafeERC20 for IERC20; + + sPRL2 public immutable SPRL2; + sPRL2V2 public immutable SPRL2V2; + IERC20 public immutable BPT; + + error MigrationAmountZero(); + + event Migrated(address indexed user, uint256 amount); + + constructor(sPRL2 _sprl2, sPRL2V2 _sprl2v2) { + SPRL2 = _sprl2; + SPRL2V2 = _sprl2v2; + BPT = _sprl2v2.BPT(); + + BPT.approve(address(_sprl2v2), type(uint256).max); + } + + /// @notice Migrate sPRL2 tokens to sPRL2V2. + /// @dev User must approve this contract to spend their sPRL2 tokens. + /// @dev sPRL2 must be paused for emergencyWithdraw to work. + /// @param _amount The amount of sPRL2 to migrate. + function migrate(uint256 _amount) external { + if (_amount == 0) revert MigrationAmountZero(); + + // Transfer sPRL2 from user + IERC20(address(SPRL2)).safeTransferFrom(msg.sender, address(this), _amount); + + // Emergency withdraw BPT from sPRL2 (requires paused) + SPRL2.emergencyWithdraw(_amount); + + // Deposit BPT into sPRL2V2 (mints sPRL2V2 to this contract) + SPRL2V2.depositBPT(_amount); + + // Transfer sPRL2V2 to user + IERC20(address(SPRL2V2)).safeTransfer(msg.sender, _amount); + + emit Migrated(msg.sender, _amount); + } +} diff --git a/contracts/sPRL/sPRL2V2.sol b/contracts/sPRL/sPRL2V2.sol new file mode 100644 index 0000000..3b8eb74 --- /dev/null +++ b/contracts/sPRL/sPRL2V2.sol @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { TimeLockPenaltyERC20, IERC20, IERC20Permit } from "./TimeLockPenaltyERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { IBalancerV3Router } from "contracts/interfaces/IBalancerV3Router.sol"; +import { IPermit2 } from "contracts/interfaces/IPermit2.sol"; +import { IWrappedNative } from "contracts/interfaces/IWrappedNative.sol"; + +/// @title sPRL2V2 +/// @author Cooper Labs +/// @custom:contact security@cooperlabs.xyz +/// @notice sPRL2V2 is a staking contract that allows users to deposit PRL and WETH into a Balancer V3 pool. +/// The BPT is held by the contract. +contract sPRL2V2 is TimeLockPenaltyERC20 { + using Address for address payable; + using SafeERC20 for IERC20; + + string constant NAME = "Stake 20WETH-80PRL Deposit Vault"; + string constant SYMBOL = "sPRL2V2"; + + //------------------------------------------- + // Storage + //------------------------------------------- + + /// @notice The configuration parameters for the contract. + struct BPTConfigParams { + IBalancerV3Router balancerRouter; + IERC20 balancerBPT; + IERC20 prl; + IWrappedNative weth; + IPermit2 permit2; + } + + /// @notice The Permit2 contract. + IPermit2 public immutable PERMIT2; + /// @notice The Balancer V3 router. + IBalancerV3Router public immutable BALANCER_ROUTER; + /// @notice The PRL token. + IERC20 public immutable PRL; + /// @notice The WETH token. + IWrappedNative public immutable WETH; + /// @notice The BPT token. + IERC20 public immutable BPT; + /// @notice Whether the pair is reversed. + bool public immutable isReversedBalancerPair; + + //------------------------------------------- + // Events + //------------------------------------------- + + /// @notice Event emitted when a user withdraws PRL and WETH for multiple requests. + /// @param requestIds The IDs of the withdrawal requests. + /// @param user The address of the user. + /// @param prlAmount The amount of PRL received. + /// @param wethAmount The amount of WETH received. + /// @param slashBptAmount The amount of BPT sent to the fee receiver. + event WithdrawlPRLAndWeth( + uint256[] requestIds, address user, uint256 prlAmount, uint256 wethAmount, uint256 slashBptAmount + ); + + /// @notice Event emitted when a user withdraws BPT for multiple requests. + /// @param requestIds The IDs of the withdrawal requests. + /// @param user The address of the user. + /// @param bptAmount The amount of BPT received. + /// @param slashBptAmount The amount of BPT sent to the fee receiver. + event WithdrawlBPT(uint256[] requestIds, address user, uint256 bptAmount, uint256 slashBptAmount); + + //------------------------------------------- + // Constructor + //------------------------------------------- + + /// @notice Constructor for the sPRL2V2 contract. + /// @param _feeReceiver The address of the fee receiver. + /// @param _accessManager The address of the access manager. + /// @param _startPenaltyPercentage The start penalty percentage. + /// @param _timeLockDuration The time lock duration. + /// @param _configParams The configuration parameters for the contract. + constructor( + address _feeReceiver, + address _accessManager, + uint256 _startPenaltyPercentage, + uint64 _timeLockDuration, + BPTConfigParams memory _configParams + ) + TimeLockPenaltyERC20( + NAME, + SYMBOL, + address(_configParams.balancerBPT), + _feeReceiver, + _accessManager, + _startPenaltyPercentage, + _timeLockDuration + ) + { + BALANCER_ROUTER = _configParams.balancerRouter; + PRL = _configParams.prl; + WETH = _configParams.weth; + BPT = _configParams.balancerBPT; + PERMIT2 = _configParams.permit2; + isReversedBalancerPair = address(_configParams.weth) > address(_configParams.prl); + + /// @dev Approve PERMIT2 to spend PRL and WETH + PRL.approve(address(PERMIT2), type(uint256).max); + WETH.approve(address(PERMIT2), type(uint256).max); + } + + //------------------------------------------- + // External Functions + //------------------------------------------- + + /// @notice Deposit BPT and mint the equivalent amount of sPRL2V2. + /// @param _amount The amount of BPT to deposit. + function depositBPT(uint256 _amount) external whenNotPaused nonReentrant { + BPT.safeTransferFrom(msg.sender, address(this), _amount); + _deposit(_amount); + } + + /// @notice Deposit PRL and WETH. + /// @param _maxPrlAmount The maximum amount of PRL to deposit. + /// @param _maxWethAmount The maximum amount of WETH to deposit. + /// @param _exactBptAmount The exact amount of BPT to mint. + /// @param _deadline The deadline for the permit. + /// @param _v The v parameter for the permit. + /// @param _r The r parameter for the permit. + /// @param _s The s parameter for the permit. + function depositPRLAndWeth( + uint256 _maxPrlAmount, + uint256 _maxWethAmount, + uint256 _exactBptAmount, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + external + whenNotPaused + nonReentrant + returns (uint256[] memory amountsIn, uint256 bptAmount) + { + // @dev using try catch to avoid reverting the transaction in case of front-running + try IERC20Permit(address(PRL)).permit(msg.sender, address(this), _maxPrlAmount, _deadline, _v, _r, _s) { } + catch { } + + PRL.safeTransferFrom(msg.sender, address(this), _maxPrlAmount); + WETH.transferFrom(msg.sender, address(this), _maxWethAmount); + + (amountsIn, bptAmount) = _joinPool(_maxPrlAmount, _maxWethAmount, _exactBptAmount, false); + + _deposit(bptAmount); + } + + /// @notice Deposit PRL and ETH. + /// @param _maxPrlAmount The maximum amount of PRL to deposit. + /// @param _exactBptAmount The exact amount of BPT to mint. + /// @param _deadline The deadline for the permit. + /// @param _v The v parameter for the permit. + /// @param _r The r parameter for the permit. + /// @param _s The s parameter for the permit. + function depositPRLAndEth( + uint256 _maxPrlAmount, + uint256 _exactBptAmount, + uint256 _deadline, + uint8 _v, + bytes32 _r, + bytes32 _s + ) + external + payable + whenNotPaused + nonReentrant + returns (uint256[] memory amountsIn, uint256 bptAmount) + { + // @dev using try catch to avoid reverting the transaction in case of front-running + try IERC20Permit(address(PRL)).permit(msg.sender, address(this), _maxPrlAmount, _deadline, _v, _r, _s) { } + catch { } + + PRL.safeTransferFrom(msg.sender, address(this), _maxPrlAmount); + + (amountsIn, bptAmount) = _joinPool(_maxPrlAmount, msg.value, _exactBptAmount, true); + + _deposit(bptAmount); + } + + /// @notice Withdraw BPT for multiple requests. + /// @param _requestIds The request IDs to withdraw from. + /// @param _maxAcceptablePenalty The maximum penalty percentage to apply. + function withdrawBPT(uint256[] calldata _requestIds, uint256 _maxAcceptablePenalty) external nonReentrant { + (uint256 totalBptAmount, uint256 totalBptAmountSlashed) = _withdrawMultiple(_requestIds, _maxAcceptablePenalty); + if (totalBptAmountSlashed > 0) { + BPT.safeTransfer(feeReceiver, totalBptAmountSlashed); + } + emit WithdrawlBPT(_requestIds, msg.sender, totalBptAmount, totalBptAmountSlashed); + BPT.safeTransfer(msg.sender, totalBptAmount); + } + + /// @notice Withdraw PRL and WETH for multiple requests. + /// @param _requestIds The request IDs to withdraw from. + /// @param _minPrlAmount The minimum amount of PRL to receive. + /// @param _minWethAmount The minimum amount of WETH to receive. + /// @return prlAmount The amount of PRL received. + /// @return wethAmount The amount of WETH received. + function withdrawPRLAndWeth( + uint256[] calldata _requestIds, + uint256 _minPrlAmount, + uint256 _minWethAmount, + uint256 _maxPenaltyPercentage + ) + external + nonReentrant + returns (uint256 prlAmount, uint256 wethAmount) + { + (uint256 totalBptAmount, uint256 totalBptAmountSlashed) = _withdrawMultiple(_requestIds, _maxPenaltyPercentage); + + (prlAmount, wethAmount) = _exitPool(totalBptAmount, totalBptAmountSlashed, _minPrlAmount, _minWethAmount); + + emit WithdrawlPRLAndWeth(_requestIds, msg.sender, prlAmount, wethAmount, totalBptAmountSlashed); + PRL.safeTransfer(msg.sender, prlAmount); + WETH.transfer(msg.sender, wethAmount); + } + + /// @notice Allow users to emergency withdraw assets without penalties. + /// @dev This function can only be called when the contract is paused. + /// @param _amount The amount of assets to unlock. + function emergencyWithdraw(uint256 _amount) external whenPaused nonReentrant { + _burn(msg.sender, _amount); + emit EmergencyWithdraw(msg.sender, _amount); + BPT.safeTransfer(msg.sender, _amount); + } + + /// @notice Allow ETH to be received. + receive() external payable { } + + //------------------------------------------- + // Internal Functions + //------------------------------------------- + + /// @notice Join the pool. + /// @param _maxPrlAmount The maximum amount of PRL to deposit. + /// @param _maxEthAmount The maximum amount of ETH to deposit. + /// @param _exactBptAmount The exact amount of BPT to mint. + /// @param _isEth Whether the ETH is being deposited by the user. + /// @return amountsIn The amounts of PRL and ETH deposited. + /// @return bptAmount The amount of BPT received. + function _joinPool( + uint256 _maxPrlAmount, + uint256 _maxEthAmount, + uint256 _exactBptAmount, + bool _isEth + ) + internal + returns (uint256[] memory amountsIn, uint256 bptAmount) + { + uint256[] memory maxAmountsIn = new uint256[](2); + (maxAmountsIn[0], maxAmountsIn[1]) = + isReversedBalancerPair ? (_maxPrlAmount, _maxEthAmount) : (_maxEthAmount, _maxPrlAmount); + + /// @dev Approve tokens. + PERMIT2.approve(address(PRL), address(BALANCER_ROUTER), uint160(_maxPrlAmount), 0); + PERMIT2.approve(address(WETH), address(BALANCER_ROUTER), uint160(_maxEthAmount), 0); + + /// @dev Wrap ETH. + if (_isEth) { + WETH.deposit{ value: _maxEthAmount }(); + } + /// @dev Deposit into Balancer V3 + uint256[] memory _amountsIn = + BALANCER_ROUTER.addLiquidityProportional(address(BPT), maxAmountsIn, _exactBptAmount, false, ""); + + /// @dev Return any remaining PRL. + (uint256 prlAmountToReturn, uint256 ethAmountToReturn) = isReversedBalancerPair + ? (_maxPrlAmount - _amountsIn[0], _maxEthAmount - _amountsIn[1]) + : (_maxPrlAmount - _amountsIn[1], _maxEthAmount - _amountsIn[0]); + + if (prlAmountToReturn > 0) { + PRL.transfer(msg.sender, prlAmountToReturn); + } + + /// @dev Return any remaining WETH. + if (ethAmountToReturn > 0) { + if (_isEth) { + WETH.withdraw(ethAmountToReturn); + payable(msg.sender).sendValue(ethAmountToReturn); + } else { + WETH.transfer(msg.sender, ethAmountToReturn); + } + } + + return (_amountsIn, _exactBptAmount); + } + + /// @notice Exit the pool. + /// @dev Balancer V3 will revert if the amount of tokens received is less than the minimum expected. + /// @param _bptAmount The amount of BPT to withdraw. + /// @param _minPrlAmount The minimum amount of PRL to receive. + /// @param _minWethAmount The minimum amount of WETH to receive. + /// @return _prlAmount The amount of PRL received. + /// @return _wethAmount The amount of WETH received. + function _exitPool( + uint256 _bptAmount, + uint256 _bptAmountSlashed, + uint256 _minPrlAmount, + uint256 _minWethAmount + ) + internal + returns (uint256 _prlAmount, uint256 _wethAmount) + { + if (_bptAmountSlashed > 0) { + BPT.safeTransfer(feeReceiver, _bptAmountSlashed); + } + + uint256[] memory minAmountsOut = new uint256[](2); + (minAmountsOut[0], minAmountsOut[1]) = + isReversedBalancerPair ? (_minPrlAmount, _minWethAmount) : (_minWethAmount, _minPrlAmount); + + BPT.approve(address(BALANCER_ROUTER), _bptAmount); + + (uint256[] memory amountsOut) = + BALANCER_ROUTER.removeLiquidityProportional(address(BPT), _bptAmount, minAmountsOut, false, ""); + + (_prlAmount, _wethAmount) = + isReversedBalancerPair ? (amountsOut[0], amountsOut[1]) : (amountsOut[1], amountsOut[0]); + } +} diff --git a/deploy/scripts/sprl/sPRL2V2.ts b/deploy/scripts/sprl/sPRL2V2.ts new file mode 100644 index 0000000..3353746 --- /dev/null +++ b/deploy/scripts/sprl/sPRL2V2.ts @@ -0,0 +1,59 @@ +import assert from "assert"; + +import { type DeployFunction } from "hardhat-deploy/types"; + +import { checkAddressValid, getTokenAddressFromConfig, getWalletAddressFromConfig, isAddressValid } from "../../utils"; +import { readFileSync } from "fs"; +import { ConfigData } from "../../utils/types"; + +const contractName = "sPRL2V2"; + +const deploy: DeployFunction = async (hre) => { + const { getNamedAccounts, deployments } = hre; + + const { deploy } = deployments; + const { deployer } = await getNamedAccounts(); + + assert(deployer, "Missing deployer account"); + + console.log(`Network: ${hre.network.name}`); + console.log(`Deployer: ${deployer}`); + + const config: ConfigData = JSON.parse(readFileSync(`./deploy/config/${hre.network.name}/config.json`).toString()); + const accessManager = config.accessManager; + if (!isAddressValid(accessManager)) throw new Error("Invalid access manager address"); + + const sprl2v2Data = config.sprl2v2; + const prlToken = getTokenAddressFromConfig("prl", config); + const wethToken = getTokenAddressFromConfig("weth", config); + + const permit2 = checkAddressValid(sprl2v2Data.permit2, "Permit2"); + const feeReceiver = getWalletAddressFromConfig(sprl2v2Data.feeReceiver, config); + const balancerRouter = checkAddressValid(sprl2v2Data.balancerV3Router, "Balancer V3 Router"); + const balancerBPT = checkAddressValid(sprl2v2Data.balancerBPT, "Balancer BPT"); + + const BPTConfigParams = { + balancerRouter: balancerRouter, + balancerBPT: balancerBPT, + prl: prlToken, + weth: wethToken, + permit2: permit2, + }; + const contract = await deploy(contractName, { + from: deployer, + args: [ + feeReceiver, + accessManager, + sprl2v2Data.startPenaltyPercentage, + sprl2v2Data.timeLockDuration, + BPTConfigParams, + ], + log: true, + skipIfAlreadyDeployed: false, + }); + + console.log(`Deployed contract: ${contractName}, network: ${hre.network.name}, address: ${contract.address}`); +}; + +deploy.tags = [contractName]; +export default deploy; diff --git a/deploy/utils/types.ts b/deploy/utils/types.ts index bcfc308..6c1bfff 100644 --- a/deploy/utils/types.ts +++ b/deploy/utils/types.ts @@ -41,4 +41,12 @@ export type ConfigData = { balancerBPT: Address; rewardsTokens: Address[]; }; + sprl2v2: { + permit2: Address; + feeReceiver: string; + startPenaltyPercentage: BigNumberish; + timeLockDuration: number; + balancerV3Router: Address; + balancerBPT: Address; + }; }; diff --git a/docs/audits/Bailsec - Parallel - sPRL2V2 - 1st Report - June 2026.pdf b/docs/audits/Bailsec - Parallel - sPRL2V2 - 1st Report - June 2026.pdf new file mode 100644 index 0000000..9f78db4 Binary files /dev/null and b/docs/audits/Bailsec - Parallel - sPRL2V2 - 1st Report - June 2026.pdf differ diff --git a/docs/audits/Bailsec - Parallel - sPRL2V2 - Final Report - June 2026.pdf b/docs/audits/Bailsec - Parallel - sPRL2V2 - Final Report - June 2026.pdf new file mode 100644 index 0000000..733b767 Binary files /dev/null and b/docs/audits/Bailsec - Parallel - sPRL2V2 - Final Report - June 2026.pdf differ diff --git a/test/Integrations.t.sol b/test/Integrations.t.sol index f784bcf..ae048a3 100644 --- a/test/Integrations.t.sol +++ b/test/Integrations.t.sol @@ -53,6 +53,16 @@ abstract contract Integrations_Test is Base_Test { }) ); + sprl2v2 = _deploySPRL2V2( + users.daoTreasury.addr, + address(accessManager), + DEFAULT_PENALTY_PERCENTAGE, + DEFAULT_TIME_LOCK_DURATION, + sPRL2V2.BPTConfigParams({ + balancerRouter: balancerV3RouterMock, balancerBPT: bpt, prl: prl, weth: weth, permit2: permit2 + }) + ); + rewardMerkleDistributor = _deployRewardMerkleDistributor(address(accessManager), address(par), users.daoTreasury.addr); } diff --git a/test/forks/Fork.t.sol b/test/forks/Fork.t.sol index ed0e485..c2c648c 100644 --- a/test/forks/Fork.t.sol +++ b/test/forks/Fork.t.sol @@ -9,8 +9,7 @@ abstract contract Fork_Test is Base_Test { address aura; function setUp() public virtual override { - // Fork Polygon Mainnet at a specific block number. - vm.createSelectFork({ blockNumber: 21_816_469, urlOrAlias: "mainnet" }); + vm.createSelectFork({ blockNumber: 24_905_586, urlOrAlias: "mainnet" }); // The base is set up after the fork is selected so that the base test contracts are deployed on the fork. Base_Test.setUp(); @@ -44,24 +43,24 @@ abstract contract Fork_Test is Base_Test { permit2 = Permit2Mock(0x000000000022D473030F116dDEE9F6B43aC78BA3); vm.label({ account: address(permit2), newLabel: "Permit2" }); - auraBpt = ERC20Mock(0x473dA6619e3bf97f946C6Cc991952c010e25eC3E); + auraBpt = ERC20Mock(0xC4a27dC753FF0513455D9A326d183220C078be8D); vm.label({ account: address(auraBpt), newLabel: "AuraBPT" }); balancerV3RouterMock = BalancerV3RouterMock(0x5C6fb490BDFD3246EB0bB062c168DeCAF4bD9FDd); - vm.label({ account: address(balancerV3RouterMock), newLabel: "BalancerV3RouterMock" }); + vm.label({ account: address(balancerV3RouterMock), newLabel: "BalancerV3Router" }); auraBoosterLiteMock = AuraBoosterLiteMock(0xA57b8d98dAE62B26Ec3bcC4a365338157060B234); - vm.label({ account: address(auraBoosterLiteMock), newLabel: "AuraBoosterLiteMock" }); - auraRewardPoolMock = AuraRewardPoolMock(0x473dA6619e3bf97f946C6Cc991952c010e25eC3E); - vm.label({ account: address(auraRewardPoolMock), newLabel: "AuraRewardPoolMock" }); + vm.label({ account: address(auraBoosterLiteMock), newLabel: "AuraBoosterLite" }); + auraRewardPoolMock = AuraRewardPoolMock(0x486Be73794Ec19f3e9cF57f06f03fDCf7F0A9da4); + vm.label({ account: address(auraRewardPoolMock), newLabel: "AuraRewardPool" }); bal = 0xba100000625a3754423978a60c9317c58a424e3D; vm.label({ account: address(bal), newLabel: "BAL" }); aura = 0xC0c293ce456fF0ED870ADd98a0828Dd4d2903DBF; vm.label({ account: address(aura), newLabel: "Aura" }); - bpt = ERC20Mock(0x5512fdDC40842b257e2A7742Be3DaDcf31574d53); + bpt = ERC20Mock(0x1846C6cBE0D433e152fA358e5fF27968E18bcE7c); vm.label({ account: address(bpt), newLabel: "BPT" }); - prl = ERC20Mock(0x04C154b66CB340F3Ae24111CC767e0184Ed00Cc6); + prl = ERC20Mock(0x6c0aeceeDc55c9d55d8B99216a670D85330941c3); vm.label({ account: address(prl), newLabel: "PRL" }); - weth = WrappedNativeMock(0x1e6ffa4e9F63d10B8820A3ab52566Af881Dab53c); + weth = WrappedNativeMock(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); vm.label({ account: address(weth), newLabel: "WETH" }); } } diff --git a/test/forks/ForkV2.t.sol b/test/forks/ForkV2.t.sol new file mode 100644 index 0000000..dda7756 --- /dev/null +++ b/test/forks/ForkV2.t.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import "../Base.t.sol"; + +/// @notice Common logic needed by all sPRL2V2 fork tests. +abstract contract ForkV2_Test is Base_Test { + function setUp() public virtual override { + // Fork Mainnet at a specific block number. + vm.createSelectFork({ blockNumber: 24_905_586, urlOrAlias: "mainnet" }); + + // The base is set up after the fork is selected so that the base test contracts are deployed on the fork. + Base_Test.setUp(); + + _setForkContracts(); + + sprl2v2 = _deploySPRL2V2( + users.daoTreasury.addr, + address(accessManager), + DEFAULT_PENALTY_PERCENTAGE, + DEFAULT_TIME_LOCK_DURATION, + sPRL2V2.BPTConfigParams({ + balancerRouter: IBalancerV3Router(address(balancerV3RouterMock)), + balancerBPT: bpt, + prl: prl, + weth: weth, + permit2: permit2 + }) + ); + } + + function _setForkContracts() internal virtual { + permit2 = Permit2Mock(0x000000000022D473030F116dDEE9F6B43aC78BA3); + vm.label({ account: address(permit2), newLabel: "Permit2" }); + + balancerV3RouterMock = BalancerV3RouterMock(0x5C6fb490BDFD3246EB0bB062c168DeCAF4bD9FDd); + vm.label({ account: address(balancerV3RouterMock), newLabel: "BalancerV3Router" }); + + bpt = ERC20Mock(0x1846C6cBE0D433e152fA358e5fF27968E18bcE7c); + vm.label({ account: address(bpt), newLabel: "BPT" }); + prl = ERC20Mock(0x6c0aeceeDc55c9d55d8B99216a670D85330941c3); + vm.label({ account: address(prl), newLabel: "PRL" }); + weth = WrappedNativeMock(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + vm.label({ account: address(weth), newLabel: "WETH" }); + } +} diff --git a/test/forks/sPRL2V2/deposit.t.sol b/test/forks/sPRL2V2/deposit.t.sol new file mode 100644 index 0000000..47fb729 --- /dev/null +++ b/test/forks/sPRL2V2/deposit.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import "test/forks/ForkV2.t.sol"; + +contract SPRL2V2_Deposits_Fork_Test is ForkV2_Test { + uint256 exactBptAmount = 1e18; + + function setUp() public override { + super.setUp(); + sigUtils = new SigUtils(prl.DOMAIN_SEPARATOR()); + + vm.startPrank(users.alice.addr); + deal(address(prl), address(users.alice.addr), INITIAL_BALANCE); + deal(address(weth), address(users.alice.addr), INITIAL_BALANCE); + } + + function test_fork_sPRL2V2_DepositBPT() external { + prl.approve(address(permit2), type(uint256).max); + weth.approve(address(permit2), type(uint256).max); + + uint48 deadline = uint48(block.timestamp + 15); + permit2.approve(address(prl), address(balancerV3RouterMock), uint160(INITIAL_BALANCE), deadline); + permit2.approve(address(weth), address(balancerV3RouterMock), uint160(INITIAL_BALANCE), deadline); + + balancerV3RouterMock.addLiquidityProportional( + address(bpt), _getMaxDepositAmountParams(INITIAL_BALANCE, INITIAL_BALANCE), exactBptAmount, false, "" + ); + assertEq(bpt.balanceOf(users.alice.addr), exactBptAmount); + + bpt.approve(address(sprl2v2), exactBptAmount); + sprl2v2.depositBPT(exactBptAmount); + assertEq(sprl2v2.balanceOf(users.alice.addr), exactBptAmount); + assertEq(bpt.balanceOf(users.alice.addr), 0); + } + + function test_fork_sPRL2V2_DepositPRLAndWeth() external { + weth.approve(address(sprl2v2), type(uint256).max); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), INITIAL_BALANCE, address(prl)); + + (uint256[] memory amountsIn, uint256 bptAmount) = + sprl2v2.depositPRLAndWeth(INITIAL_BALANCE, INITIAL_BALANCE, exactBptAmount, deadline, v, r, s); + assertEq(bptAmount, exactBptAmount); + assertEq(INITIAL_BALANCE - amountsIn[0], prl.balanceOf(users.alice.addr)); + assertEq(INITIAL_BALANCE - amountsIn[1], weth.balanceOf(users.alice.addr)); + assertEq(bpt.balanceOf(users.alice.addr), 0); + assertEq(sprl2v2.balanceOf(users.alice.addr), exactBptAmount); + } + + function _getMaxDepositAmountParams( + uint256 maxPrlAmount, + uint256 maxWethAmount + ) + internal + view + returns (uint256[] memory maxAmountsIn) + { + maxAmountsIn = new uint256[](2); + (maxAmountsIn[0], maxAmountsIn[1]) = + address(weth) > address(prl) ? (maxPrlAmount, maxWethAmount) : (maxWethAmount, maxPrlAmount); + } +} diff --git a/test/forks/sPRL2V2/migrator.t.sol b/test/forks/sPRL2V2/migrator.t.sol new file mode 100644 index 0000000..570c275 --- /dev/null +++ b/test/forks/sPRL2V2/migrator.t.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import "test/forks/ForkV2.t.sol"; +import { sPRL2 } from "contracts/sPRL/sPRL2.sol"; +import { sPRL2Migrator } from "contracts/sPRL/sPRL2Migrator.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IAccessManager } from "@openzeppelin/contracts/access/manager/IAccessManager.sol"; + +contract SPRL2Migrator_Fork_Test is ForkV2_Test { + sPRL2 sprl2Legacy; + sPRL2Migrator migrator; + + address admin = 0x25Fc7ffa8f9da3582a36633d04804F0004706F9b; + address user = address(0xBEEF); + + function setUp() public override { + super.setUp(); + + sprl2Legacy = sPRL2(payable(0xE8A2d848fE656E34A6caA35f375B42979e322135)); + vm.label(address(sprl2Legacy), "sPRL2Legacy"); + + migrator = new sPRL2Migrator(sprl2Legacy, sprl2v2); + + // User deposits into sPRL2 + uint256 depositAmount = 1e18; + vm.startPrank(user); + deal(address(prl), user, 100e18); + deal(address(weth), user, 100e18); + + IERC20(address(prl)).approve(address(permit2), type(uint256).max); + IERC20(address(weth)).approve(address(permit2), type(uint256).max); + + uint48 deadline = uint48(block.timestamp + 15); + permit2.approve(address(prl), address(balancerV3RouterMock), type(uint160).max, deadline); + permit2.approve(address(weth), address(balancerV3RouterMock), type(uint160).max, deadline); + + balancerV3RouterMock.addLiquidityProportional( + address(bpt), _getMaxDepositAmountParams(100e18, 100e18), depositAmount, false, "" + ); + + bpt.approve(address(sprl2Legacy), depositAmount); + sprl2Legacy.depositBPT(depositAmount); + vm.stopPrank(); + + // Admin pauses sPRL2 + vm.prank(admin); + sprl2Legacy.pause(); + } + + function test_fork_Migrator_Migrate() external { + uint256 amount = sprl2Legacy.balanceOf(user); + + vm.startPrank(user); + IERC20(address(sprl2Legacy)).approve(address(migrator), amount); + migrator.migrate(amount); + vm.stopPrank(); + + assertEq(sprl2Legacy.balanceOf(user), 0); + assertEq(sprl2v2.balanceOf(user), amount); + } + + function test_fork_Migrator_Migrate_Partial() external { + uint256 totalAmount = sprl2Legacy.balanceOf(user); + uint256 migrateAmount = totalAmount / 2; + + vm.startPrank(user); + IERC20(address(sprl2Legacy)).approve(address(migrator), migrateAmount); + migrator.migrate(migrateAmount); + vm.stopPrank(); + + assertEq(sprl2Legacy.balanceOf(user), totalAmount - migrateAmount); + assertEq(sprl2v2.balanceOf(user), migrateAmount); + } + + function _getMaxDepositAmountParams( + uint256 maxPrlAmount, + uint256 maxWethAmount + ) + internal + view + returns (uint256[] memory maxAmountsIn) + { + maxAmountsIn = new uint256[](2); + (maxAmountsIn[0], maxAmountsIn[1]) = + address(weth) > address(prl) ? (maxPrlAmount, maxWethAmount) : (maxWethAmount, maxPrlAmount); + } +} diff --git a/test/forks/sPRL2V2/withdraw.t.sol b/test/forks/sPRL2V2/withdraw.t.sol new file mode 100644 index 0000000..7353769 --- /dev/null +++ b/test/forks/sPRL2V2/withdraw.t.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.25; + +import "test/forks/ForkV2.t.sol"; + +contract SPRL2V2_Withdraw_Fork_Test is ForkV2_Test { + uint256 exactBptAmount = 1e18; + uint256 prlAmountDeposited; + uint256 wethAmountDeposited; + + function setUp() public override { + super.setUp(); + sigUtils = new SigUtils(prl.DOMAIN_SEPARATOR()); + + vm.startPrank(users.alice.addr); + deal(address(prl), address(users.alice.addr), INITIAL_BALANCE); + deal(address(weth), address(users.alice.addr), INITIAL_BALANCE); + weth.approve(address(sprl2v2), type(uint256).max); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), INITIAL_BALANCE, address(prl)); + + (uint256[] memory amountsIn,) = + sprl2v2.depositPRLAndWeth(INITIAL_BALANCE, INITIAL_BALANCE, exactBptAmount, deadline, v, r, s); + prlAmountDeposited = amountsIn[0]; + wethAmountDeposited = amountsIn[1]; + } + + modifier requestWithdrawal() { + sprl2v2.requestWithdraw(exactBptAmount); + _; + } + + function test_fork_sPRL2V2_WithdrawBPT() external requestWithdrawal { + skip(sprl2v2.timeLockDuration()); + uint256 aliceBptBalance = sprl2v2.balanceOf(users.alice.addr); + uint256[] memory requestIds = new uint256[](1); + requestIds[0] = 0; + sprl2v2.withdrawBPT(requestIds, DEFAULT_PENALTY_PERCENTAGE); + assertEq(sprl2v2.balanceOf(users.alice.addr), 0); + assertEq(bpt.balanceOf(users.alice.addr), aliceBptBalance + exactBptAmount); + } + + function test_fork_sPRL2V2_WithdrawPRLAndWeth() external requestWithdrawal { + skip(sprl2v2.timeLockDuration()); + uint256 alicePrlBalance = prl.balanceOf(users.alice.addr); + uint256 aliceWethBalance = weth.balanceOf(users.alice.addr); + + uint256[] memory requestIds = new uint256[](1); + requestIds[0] = 0; + // Calculate the minimum amounts to withdraw 1% slippage + uint256 minPrlAmount = prlAmountDeposited * 99 / 100; + uint256 minWethAmount = wethAmountDeposited * 99 / 100; + + (uint256 prlAmount, uint256 wethAmount) = + sprl2v2.withdrawPRLAndWeth(requestIds, minPrlAmount, minWethAmount, DEFAULT_PENALTY_PERCENTAGE); + + assertEq(sprl2v2.balanceOf(users.alice.addr), 0); + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalance + prlAmount); + assertEq(weth.balanceOf(users.alice.addr), aliceWethBalance + wethAmount); + } +} diff --git a/test/helpers/Deploys.sol b/test/helpers/Deploys.sol index ff708da..4067bf4 100644 --- a/test/helpers/Deploys.sol +++ b/test/helpers/Deploys.sol @@ -16,6 +16,7 @@ import { FeeCollectorCore } from "contracts/fees/FeeCollectorCore.sol"; import { sPRL1 } from "contracts/sPRL/sPRL1.sol"; import { sPRL2 } from "contracts/sPRL/sPRL2.sol"; +import { sPRL2V2 } from "contracts/sPRL/sPRL2V2.sol"; import { RewardMerkleDistributor } from "contracts/rewardMerkleDistributor/RewardMerkleDistributor.sol"; @@ -65,6 +66,7 @@ abstract contract Deploys is Test { AccessManager internal accessManager; sPRL2 internal sprl2; + sPRL2V2 internal sprl2v2; sPRL1 internal sprl1; TimeLockPenaltyERC20Mock internal timeLockPenaltyERC20; @@ -236,4 +238,20 @@ abstract contract Deploys is Test { auraRewardPoolMock = new AuraRewardPoolMock(address(auraBoosterLiteMock)); vm.label({ account: address(auraRewardPoolMock), newLabel: "AuraRewardPoolMock" }); } + + function _deploySPRL2V2( + address _feeReceiver, + address _accessManager, + uint256 _startPenaltyPercentage, + uint64 _timeLockDuration, + sPRL2V2.BPTConfigParams memory _configParams + ) + internal + returns (sPRL2V2) + { + sPRL2V2 _sPRL2V2 = + new sPRL2V2(_feeReceiver, _accessManager, _startPenaltyPercentage, _timeLockDuration, _configParams); + vm.label({ account: address(_sPRL2V2), newLabel: "sPRL2V2" }); + return _sPRL2V2; + } } diff --git a/test/integrations/sPRL2V2/cancelWithdrawalRequests.t.sol b/test/integrations/sPRL2V2/cancelWithdrawalRequests.t.sol new file mode 100644 index 0000000..8f04342 --- /dev/null +++ b/test/integrations/sPRL2V2/cancelWithdrawalRequests.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; + +import "test/Integrations.t.sol"; + +contract SPRL2V2_CancelWithdrawalRequests_Integrations_Test is Integrations_Test { + function setUp() public override { + super.setUp(); + vm.startPrank(users.alice.addr); + + deal(address(bpt), address(users.alice.addr), INITIAL_BALANCE); + bpt.approve(address(sprl2v2), type(uint256).max); + sprl2v2.depositBPT(INITIAL_BALANCE); + } + + modifier requestOneWithdraw() { + sprl2v2.requestWithdraw(INITIAL_BALANCE); + _; + } + + function test_SPRL2V2_CancelWithdrawalRequests_SingleRequest() external requestOneWithdraw { + uint256[] memory ids = new uint256[](1); + ids[0] = 0; + vm.expectEmit(address(sprl2v2)); + emit TimeLockPenaltyERC20.WithdrawalRequestCancelled(ids[0], users.alice.addr, INITIAL_BALANCE); + sprl2v2.cancelWithdrawalRequests(ids); + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_BALANCE); + assertEq(sprl2v2.unlockingAmount(), 0); + + (uint256 requestAmount, uint64 requestTime, uint64 releaseTime, TimeLockPenaltyERC20.WITHDRAW_STATUS status) = + sprl2v2.userVsWithdrawals(users.alice.addr, ids[0]); + + assertEq(requestAmount, INITIAL_BALANCE); + assertEq(requestTime, block.timestamp); + assertEq(releaseTime, block.timestamp + sprl2v2.timeLockDuration()); + assertEq(status, TimeLockPenaltyERC20.WITHDRAW_STATUS.CANCELLED); + + uint256[] memory requestIds = sprl2v2.findUnlockingIDs(users.alice.addr, 0, false, 1); + assertEq(requestIds.length, 0); + } + + modifier requestMultiWithdraw() { + sprl2v2.requestWithdraw(1); + sprl2v2.requestWithdraw(2); + sprl2v2.requestWithdraw(3); + sprl2v2.requestWithdraw(4); + sprl2v2.requestWithdraw(5); + _; + } + + function test_SPRL2V2_CancelWithdrawalRequests_MultipleRequests() external requestMultiWithdraw { + uint256[] memory ids = new uint256[](3); + ids[0] = 0; + ids[1] = 2; + ids[2] = 4; + sprl2v2.cancelWithdrawalRequests(ids); + + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_BALANCE - 6); + assertEq(sprl2v2.unlockingAmount(), 6); + + uint256[] memory requestIds = sprl2v2.findUnlockingIDs(users.alice.addr, 0, false, 10); + assertEq(requestIds.length, 2); + assertEq(requestIds[0], 1); + assertEq(requestIds[1], 3); + } + + function test_SPRL2V2_CancelWithdrawalRequests_RevertWhen_WrongRequestStatus() external { + uint256[] memory ids = new uint256[](1); + ids[0] = 0; + vm.expectRevert(abi.encodeWithSelector(TimeLockPenaltyERC20.CannotCancelWithdrawalRequest.selector, ids[0])); + sprl2v2.cancelWithdrawalRequests(ids); + } +} diff --git a/test/integrations/sPRL2V2/constructor.t.sol b/test/integrations/sPRL2V2/constructor.t.sol new file mode 100644 index 0000000..2c037f8 --- /dev/null +++ b/test/integrations/sPRL2V2/constructor.t.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "test/Integrations.t.sol"; + +contract SPRL2V2_Constructor_Integrations_Test is Integrations_Test { + function test_SPRL2V2_Constructor() external { + sprl2v2 = new sPRL2V2( + users.daoTreasury.addr, + address(accessManager), + DEFAULT_PENALTY_PERCENTAGE, + DEFAULT_TIME_LOCK_DURATION, + sPRL2V2.BPTConfigParams({ + balancerRouter: IBalancerV3Router(address(balancerV3RouterMock)), + balancerBPT: IERC20(address(bpt)), + prl: IERC20(address(prl)), + weth: IWrappedNative(address(weth)), + permit2: IPermit2(address(permit2)) + }) + ); + assertEq(sprl2v2.authority(), address(accessManager)); + assertEq(address(sprl2v2.underlying()), address(bpt)); + assertEq(sprl2v2.timeLockDuration(), DEFAULT_TIME_LOCK_DURATION); + assertEq(sprl2v2.startPenaltyPercentage(), DEFAULT_PENALTY_PERCENTAGE); + assertEq(sprl2v2.unlockingAmount(), 0); + assertEq(sprl2v2.feeReceiver(), users.daoTreasury.addr); + assertEq(sprl2v2.name(), "Stake 20WETH-80PRL Deposit Vault"); + assertEq(sprl2v2.symbol(), "sPRL2V2"); + assertEq(address(sprl2v2.BALANCER_ROUTER()), address(balancerV3RouterMock)); + assertEq(address(sprl2v2.BPT()), address(bpt)); + assertEq(address(sprl2v2.PRL()), address(prl)); + assertEq(address(sprl2v2.WETH()), address(weth)); + assertEq(address(sprl2v2.PERMIT2()), address(permit2)); + } + + function test_SPRL2V2_Constructor_RevertWhen_FeeReceiverZeroAddress() external { + vm.expectRevert(TimeLockPenaltyERC20.FeeReceiverZeroAddress.selector); + new sPRL2V2( + address(0), + address(accessManager), + DEFAULT_PENALTY_PERCENTAGE, + DEFAULT_TIME_LOCK_DURATION, + sPRL2V2.BPTConfigParams({ + balancerRouter: IBalancerV3Router(address(balancerV3RouterMock)), + balancerBPT: IERC20(address(bpt)), + prl: IERC20(address(prl)), + weth: IWrappedNative(address(weth)), + permit2: IPermit2(address(permit2)) + }) + ); + } +} diff --git a/test/integrations/sPRL2V2/deposit.t.sol b/test/integrations/sPRL2V2/deposit.t.sol new file mode 100644 index 0000000..8577d84 --- /dev/null +++ b/test/integrations/sPRL2V2/deposit.t.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "test/Integrations.t.sol"; + +contract SPRL2V2_Deposits_Integrations_Test is Integrations_Test { + uint256 exactBptAmount = 1e18; + + function setUp() public override { + super.setUp(); + sigUtils = new SigUtils(prl.DOMAIN_SEPARATOR()); + + vm.startPrank(users.alice.addr); + weth.approve(address(sprl2v2), type(uint256).max); + } + + function test_sPRL2V2_DepositBPT() external { + deal(address(bpt), address(users.alice.addr), INITIAL_BALANCE); + bpt.approve(address(sprl2v2), INITIAL_BALANCE); + vm.expectEmit(address(sprl2v2)); + emit TimeLockPenaltyERC20.Deposited(users.alice.addr, INITIAL_BALANCE); + sprl2v2.depositBPT(INITIAL_BALANCE); + + assertEq(bpt.balanceOf(address(users.alice.addr)), 0); + assertEq(sprl2v2.balanceOf(address(users.alice.addr)), INITIAL_BALANCE); + } + + function test_sPRL2V2_DepositPRLAndWeth(uint256 maxPrlAmount, uint256 maxWethAmount) external { + maxPrlAmount = bound(maxPrlAmount, 1, prl.balanceOf(users.alice.addr)); + maxWethAmount = bound(maxWethAmount, 1, weth.balanceOf(users.alice.addr)); + + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceWethBalanceBefore = weth.balanceOf(users.alice.addr); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), maxPrlAmount, address(prl)); + + vm.expectEmit(address(sprl2v2)); + emit TimeLockPenaltyERC20.Deposited(users.alice.addr, exactBptAmount); + sprl2v2.depositPRLAndWeth(maxPrlAmount, maxWethAmount, exactBptAmount, deadline, v, r, s); + + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore - maxPrlAmount); + assertEq(weth.balanceOf(users.alice.addr), aliceWethBalanceBefore - maxWethAmount); + assertEq(sprl2v2.balanceOf(users.alice.addr), exactBptAmount); + } + + function test_sPRL2V2_DepositPRLAndEth(uint256 maxPrlAmount, uint256 maxEthAmount) external { + maxPrlAmount = bound(maxPrlAmount, 1, prl.balanceOf(users.alice.addr)); + maxEthAmount = bound(maxEthAmount, 1, (users.alice.addr).balance); + + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceEthBalanceBefore = (users.alice.addr).balance; + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), maxPrlAmount, address(prl)); + + vm.expectEmit(address(sprl2v2)); + emit TimeLockPenaltyERC20.Deposited(users.alice.addr, exactBptAmount); + sprl2v2.depositPRLAndEth{ value: maxEthAmount }(maxPrlAmount, exactBptAmount, deadline, v, r, s); + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore - maxPrlAmount); + assertEq((users.alice.addr).balance, aliceEthBalanceBefore - maxEthAmount); + assertEq(sprl2v2.balanceOf(users.alice.addr), exactBptAmount); + } + + function test_sPRL2V2_Deposit_ReturnRemainingTokens_ETH(uint256 maxPrlAmount, uint256 maxEthAmount) external { + maxPrlAmount = bound(maxPrlAmount, 1, prl.balanceOf(users.alice.addr)); + maxEthAmount = bound(maxEthAmount, 1, (users.alice.addr).balance); + + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceEthBalanceBefore = (users.alice.addr).balance; + + // @dev should use half of the WETH and half of the PRL + balancerV3RouterMock.updateRatio(0.5e18); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), maxPrlAmount, address(prl)); + + sprl2v2.depositPRLAndEth{ value: maxEthAmount }(maxPrlAmount, exactBptAmount, deadline, v, r, s); + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore - maxPrlAmount / 2); + assertEq((users.alice.addr).balance, aliceEthBalanceBefore - maxEthAmount / 2); + assertEq(sprl2v2.balanceOf(users.alice.addr), exactBptAmount); + assertEq(prl.balanceOf(address(sprl2v2)), 0); + assertEq(weth.balanceOf(address(sprl2v2)), 0); + } + + function test_sPRL2V2_Deposit_ReturnRemainingTokens_WETH(uint256 maxPrlAmount, uint256 maxWethAmount) external { + maxPrlAmount = bound(maxPrlAmount, 1, prl.balanceOf(users.alice.addr)); + maxWethAmount = bound(maxWethAmount, 1, weth.balanceOf(users.alice.addr)); + + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceWethBalanceBefore = weth.balanceOf(users.alice.addr); + + // @dev should use half of the WETH and half of the PRL + balancerV3RouterMock.updateRatio(0.5e18); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), maxPrlAmount, address(prl)); + + sprl2v2.depositPRLAndWeth(maxPrlAmount, maxWethAmount, exactBptAmount, deadline, v, r, s); + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore - maxPrlAmount / 2); + assertEq(weth.balanceOf(users.alice.addr), aliceWethBalanceBefore - maxWethAmount / 2); + assertEq(sprl2v2.balanceOf(users.alice.addr), exactBptAmount); + assertEq(prl.balanceOf(address(sprl2v2)), 0); + assertEq(weth.balanceOf(address(sprl2v2)), 0); + } +} diff --git a/test/integrations/sPRL2V2/migrator.t.sol b/test/integrations/sPRL2V2/migrator.t.sol new file mode 100644 index 0000000..4cf2292 --- /dev/null +++ b/test/integrations/sPRL2V2/migrator.t.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "test/Integrations.t.sol"; +import { sPRL2Migrator } from "contracts/sPRL/sPRL2Migrator.sol"; + +contract SPRL2Migrator_Integrations_Test is Integrations_Test { + sPRL2Migrator migrator; + uint256 DEPOSIT_AMOUNT = 100e18; + + function setUp() public override { + super.setUp(); + sigUtils = new SigUtils(prl.DOMAIN_SEPARATOR()); + + migrator = new sPRL2Migrator(sprl2, sprl2v2); + + // Alice deposits into sPRL2 + vm.startPrank(users.alice.addr); + weth.approve(address(sprl2), type(uint256).max); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2), DEPOSIT_AMOUNT, address(prl)); + + sprl2.depositPRLAndWeth(DEPOSIT_AMOUNT, DEPOSIT_AMOUNT, DEPOSIT_AMOUNT, deadline, v, r, s); + vm.stopPrank(); + + // Admin pauses sPRL2 for migration + vm.prank(users.admin.addr); + sprl2.pause(); + } + + function test_Migrator_Constructor() external view { + assertEq(address(migrator.SPRL2()), address(sprl2)); + assertEq(address(migrator.SPRL2V2()), address(sprl2v2)); + assertEq(address(migrator.BPT()), address(bpt)); + } + + function test_Migrator_Migrate() external { + uint256 amount = sprl2.balanceOf(users.alice.addr); + + vm.startPrank(users.alice.addr); + IERC20(address(sprl2)).approve(address(migrator), amount); + + vm.expectEmit(address(migrator)); + emit sPRL2Migrator.Migrated(users.alice.addr, amount); + migrator.migrate(amount); + vm.stopPrank(); + + assertEq(sprl2.balanceOf(users.alice.addr), 0); + assertEq(sprl2v2.balanceOf(users.alice.addr), amount); + } + + function test_Migrator_Migrate_Partial() external { + uint256 totalAmount = sprl2.balanceOf(users.alice.addr); + uint256 migrateAmount = totalAmount / 2; + + vm.startPrank(users.alice.addr); + IERC20(address(sprl2)).approve(address(migrator), migrateAmount); + migrator.migrate(migrateAmount); + vm.stopPrank(); + + assertEq(sprl2.balanceOf(users.alice.addr), totalAmount - migrateAmount); + assertEq(sprl2v2.balanceOf(users.alice.addr), migrateAmount); + } + + function test_Migrator_Migrate_MultipleUsers() external { + // Bob deposits into sPRL2 + vm.startPrank(users.bob.addr); + weth.approve(address(sprl2), type(uint256).max); + + // sPRL2 is paused, so Bob needs to have deposited before pause + // Instead, unpause, deposit, then re-pause + vm.stopPrank(); + vm.prank(users.admin.addr); + sprl2.unpause(); + + vm.startPrank(users.bob.addr); + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.bob.privateKey, address(sprl2), DEPOSIT_AMOUNT, address(prl)); + sprl2.depositPRLAndWeth(DEPOSIT_AMOUNT, DEPOSIT_AMOUNT, DEPOSIT_AMOUNT, deadline, v, r, s); + vm.stopPrank(); + + vm.prank(users.admin.addr); + sprl2.pause(); + + // Alice migrates + uint256 aliceAmount = sprl2.balanceOf(users.alice.addr); + vm.startPrank(users.alice.addr); + IERC20(address(sprl2)).approve(address(migrator), aliceAmount); + migrator.migrate(aliceAmount); + vm.stopPrank(); + + // Bob migrates + uint256 bobAmount = sprl2.balanceOf(users.bob.addr); + vm.startPrank(users.bob.addr); + IERC20(address(sprl2)).approve(address(migrator), bobAmount); + migrator.migrate(bobAmount); + vm.stopPrank(); + + assertEq(sprl2.balanceOf(users.alice.addr), 0); + assertEq(sprl2v2.balanceOf(users.alice.addr), aliceAmount); + assertEq(sprl2.balanceOf(users.bob.addr), 0); + assertEq(sprl2v2.balanceOf(users.bob.addr), bobAmount); + } + + function test_Migrator_RevertWhen_AmountZero() external { + vm.prank(users.alice.addr); + vm.expectRevert(abi.encodeWithSelector(sPRL2Migrator.MigrationAmountZero.selector)); + migrator.migrate(0); + } + + function test_Migrator_RevertWhen_NotPaused() external { + vm.prank(users.admin.addr); + sprl2.unpause(); + + uint256 amount = sprl2.balanceOf(users.alice.addr); + vm.startPrank(users.alice.addr); + IERC20(address(sprl2)).approve(address(migrator), amount); + vm.expectRevert(abi.encodeWithSelector(Pausable.ExpectedPause.selector)); + migrator.migrate(amount); + } + + function test_Migrator_RevertWhen_InsufficientApproval() external { + vm.startPrank(users.alice.addr); + vm.expectRevert(); + migrator.migrate(DEPOSIT_AMOUNT); + } +} diff --git a/test/integrations/sPRL2V2/requestWithdraw.t.sol b/test/integrations/sPRL2V2/requestWithdraw.t.sol new file mode 100644 index 0000000..1465630 --- /dev/null +++ b/test/integrations/sPRL2V2/requestWithdraw.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol"; + +import "test/Integrations.t.sol"; + +contract SPRL2V2_RequestWithdraw_Integrations_Test is Integrations_Test { + function setUp() public override { + super.setUp(); + vm.startPrank(users.alice.addr); + + deal(address(bpt), address(users.alice.addr), INITIAL_BALANCE); + bpt.approve(address(sprl2v2), type(uint256).max); + sprl2v2.depositBPT(INITIAL_BALANCE); + } + + function test_SPRL2V2_RequestWithdraw() external { + vm.expectEmit(address(sprl2v2)); + emit TimeLockPenaltyERC20.WithdrawalRequested(0, users.alice.addr, INITIAL_BALANCE); + sprl2v2.requestWithdraw(INITIAL_BALANCE); + + assertEq(sprl2v2.balanceOf(users.alice.addr), 0); + assertEq(sprl2v2.unlockingAmount(), INITIAL_BALANCE); + + (uint256 requestAmount, uint64 requestTime, uint64 releaseTime, TimeLockPenaltyERC20.WITHDRAW_STATUS status) = + sprl2v2.userVsWithdrawals(users.alice.addr, 0); + + assertEq(requestAmount, INITIAL_BALANCE); + assertEq(requestTime, block.timestamp); + assertEq(releaseTime, block.timestamp + sprl2v2.timeLockDuration()); + assertEq(status, TimeLockPenaltyERC20.WITHDRAW_STATUS.UNLOCKING); + + uint256[] memory requestIds = sprl2v2.findUnlockingIDs(users.alice.addr, 0, false, 1); + assertEq(requestIds.length, 1); + assertEq(requestIds[0], 0); + } + + function test_SPRL2V2_RequestWithdraw_RevertWhen_AmountZero() external { + vm.expectRevert(TimeLockPenaltyERC20.NullAmount.selector); + sprl2v2.requestWithdraw(0); + } + + function testFuzz_SPRL2V2_RequestWithdraw_Several(uint16 requestTime) external { + requestTime = _boundUint16(requestTime, 1, 100); + uint256 withdrawAmount = INITIAL_BALANCE / requestTime; + uint256 expectedAmount = withdrawAmount * requestTime; + for (uint16 i = 0; i < requestTime; i++) { + sprl2v2.requestWithdraw(withdrawAmount); + } + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_BALANCE - expectedAmount); + assertEq(sprl2v2.unlockingAmount(), expectedAmount); + + uint256[] memory requestIds = sprl2v2.findUnlockingIDs(users.alice.addr, 0, false, requestTime); + assertEq(requestIds.length, requestTime); + } +} diff --git a/test/integrations/sPRL2V2/updateFeeReceiver.t.sol b/test/integrations/sPRL2V2/updateFeeReceiver.t.sol new file mode 100644 index 0000000..cfc1088 --- /dev/null +++ b/test/integrations/sPRL2V2/updateFeeReceiver.t.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "test/Integrations.t.sol"; + +contract SPRL2V2_UpdateFeeReceiver_Integrations_Test is Integrations_Test { + address internal newPaymentReceiver = makeAddr("newPaymentReceiver"); + + function test_sPRL2V2_UpdateFeeReceiver() external { + vm.startPrank(users.admin.addr); + vm.expectEmit(address(sprl2v2)); + emit TimeLockPenaltyERC20.FeeReceiverUpdated(newPaymentReceiver); + sprl2v2.updateFeeReceiver(newPaymentReceiver); + } + + function test_SPRL2V2_UpdateFeeReceiver_RevertWhen_CallerNotAuthorized() external { + vm.startPrank(users.hacker.addr); + vm.expectRevert(abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, users.hacker.addr)); + sprl2v2.updateFeeReceiver(newPaymentReceiver); + } + + function test_SPRL2V2_UpdateFeeReceiver_RevertWhen_ZeroAddress() external { + vm.startPrank(users.admin.addr); + vm.expectRevert(TimeLockPenaltyERC20.FeeReceiverZeroAddress.selector); + sprl2v2.updateFeeReceiver(address(0)); + } +} diff --git a/test/integrations/sPRL2V2/withdraw.t.sol b/test/integrations/sPRL2V2/withdraw.t.sol new file mode 100644 index 0000000..a07eecd --- /dev/null +++ b/test/integrations/sPRL2V2/withdraw.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "test/Integrations.t.sol"; + +contract SPRL2V2_Withdraw_Integrations_Test is Integrations_Test { + uint256 WITHDRAW_AMOUNT = 1e18; + uint256 INITIAL_AMOUNT = 100e18; + + function setUp() public override { + super.setUp(); + sigUtils = new SigUtils(prl.DOMAIN_SEPARATOR()); + + deal(address(weth), INITIAL_BALANCE); + + vm.startPrank(users.alice.addr); + prl.approve(address(sprl2v2), type(uint256).max); + weth.approve(address(sprl2v2), type(uint256).max); + + (uint256 deadline, uint8 v, bytes32 r, bytes32 s) = + _signPermitData(users.alice.privateKey, address(sprl2v2), INITIAL_AMOUNT, address(prl)); + + sprl2v2.depositPRLAndWeth(INITIAL_AMOUNT, INITIAL_AMOUNT, INITIAL_AMOUNT, deadline, v, r, s); + } + + modifier requestSingleWithdraw() { + sprl2v2.requestWithdraw(WITHDRAW_AMOUNT); + _; + } + + function test_sPRL2V2_WithdrawBPT_SingleRequest() external requestSingleWithdraw { + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_AMOUNT - WITHDRAW_AMOUNT); + skip(sprl2v2.timeLockDuration()); + + uint256 aliceBalanceBefore = bpt.balanceOf(users.alice.addr); + uint256[] memory requestIds = new uint256[](1); + requestIds[0] = 0; + sprl2v2.withdrawBPT(requestIds, DEFAULT_PENALTY_PERCENTAGE); + + uint256 aliceBalanceAfter = bpt.balanceOf(users.alice.addr); + assertEq(aliceBalanceAfter - aliceBalanceBefore, WITHDRAW_AMOUNT); + } + + function test_sPRL2V2_WithdrawPRLAndWeth_SingleRequest() external requestSingleWithdraw { + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_AMOUNT - WITHDRAW_AMOUNT); + skip(sprl2v2.timeLockDuration()); + + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceWethBalanceBefore = weth.balanceOf(users.alice.addr); + + uint256[] memory requestIds = new uint256[](1); + requestIds[0] = 0; + sprl2v2.withdrawPRLAndWeth(requestIds, INITIAL_AMOUNT, INITIAL_AMOUNT, DEFAULT_PENALTY_PERCENTAGE); + + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore + INITIAL_AMOUNT); + assertEq(weth.balanceOf(users.alice.addr), aliceWethBalanceBefore + INITIAL_AMOUNT); + } + + modifier requestMultiWithdraw() { + sprl2v2.requestWithdraw(WITHDRAW_AMOUNT); + sprl2v2.requestWithdraw(WITHDRAW_AMOUNT); + sprl2v2.requestWithdraw(WITHDRAW_AMOUNT); + _; + } + + function test_sPRL2V2_WithdrawBPT_MultipleRequests() external requestMultiWithdraw { + uint256 withdrawAmount = WITHDRAW_AMOUNT * 3; + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_AMOUNT - withdrawAmount); + skip(sprl2v2.timeLockDuration()); + + uint256 aliceBalanceBefore = bpt.balanceOf(users.alice.addr); + uint256[] memory requestIds = new uint256[](3); + requestIds[0] = 0; + requestIds[1] = 1; + requestIds[2] = 2; + sprl2v2.withdrawBPT(requestIds, DEFAULT_PENALTY_PERCENTAGE); + + uint256 aliceBalanceAfter = bpt.balanceOf(users.alice.addr); + assertEq(aliceBalanceAfter - aliceBalanceBefore, withdrawAmount); + } + + function test_sPRL2V2_WithdrawPRLAndWeth_MultipleRequests() external requestMultiWithdraw { + uint256 withdrawAmount = WITHDRAW_AMOUNT * 3; + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_AMOUNT - withdrawAmount); + skip(sprl2v2.timeLockDuration()); + + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceWethBalanceBefore = weth.balanceOf(users.alice.addr); + uint256[] memory requestIds = new uint256[](3); + requestIds[0] = 0; + requestIds[1] = 1; + requestIds[2] = 2; + sprl2v2.withdrawPRLAndWeth(requestIds, withdrawAmount, withdrawAmount, DEFAULT_PENALTY_PERCENTAGE); + + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore + withdrawAmount); + assertEq(weth.balanceOf(users.alice.addr), aliceWethBalanceBefore + withdrawAmount); + } + + function test_sPRL2V2_WithdrawPRLAndWeth_BeforeUnLockTimeShouldSendUnderlyingTokenToFeeReceiver() + external + requestMultiWithdraw + { + skip(sprl2v2.timeLockDuration() / 2); + uint256 withdrawAmount = WITHDRAW_AMOUNT * 3; + uint256 expectedAmountAliceReceived = withdrawAmount / 2; + uint256 expectedAmountFeeReceiverReceived = withdrawAmount / 2; + uint256 alicePrlBalanceBefore = prl.balanceOf(users.alice.addr); + uint256 aliceWethBalanceBefore = weth.balanceOf(users.alice.addr); + uint256 aliceEthBalanceBefore = users.alice.addr.balance; + + uint256[] memory requestIds = new uint256[](3); + requestIds[0] = 0; + requestIds[1] = 1; + requestIds[2] = 2; + sprl2v2.withdrawPRLAndWeth( + requestIds, expectedAmountAliceReceived, expectedAmountAliceReceived, DEFAULT_PENALTY_PERCENTAGE + ); + + assertEq(bpt.balanceOf(address(users.daoTreasury.addr)), expectedAmountFeeReceiverReceived); + assertEq(sprl2v2.balanceOf(users.alice.addr), INITIAL_AMOUNT - withdrawAmount); + assertEq(prl.balanceOf(users.alice.addr), alicePrlBalanceBefore + expectedAmountAliceReceived); + assertEq(weth.balanceOf(users.alice.addr), aliceWethBalanceBefore + expectedAmountAliceReceived); + assertEq(users.alice.addr.balance, aliceEthBalanceBefore); + } + + function test_SPRL2V2_WithdrawBPT_MultipleRequests_RevertWhen_PenaltyPercentageExceedMaxAcceptance() + external + requestMultiWithdraw + { + uint256[] memory requestIds = new uint256[](1); + requestIds[0] = 0; + vm.expectRevert(abi.encodeWithSelector(TimeLockPenaltyERC20.MaxPenaltyPercentageExceeded.selector)); + sprl2v2.withdrawBPT(requestIds, DEFAULT_PENALTY_PERCENTAGE - 1); + } + + function test_SPRL2V2_WithdrawPRLAndWeth_MultipleRequests_RevertWhen_PenaltyPercentageExceedMaxAcceptance() + external + requestMultiWithdraw + { + uint256[] memory requestIds = new uint256[](1); + requestIds[0] = 0; + vm.expectRevert(abi.encodeWithSelector(TimeLockPenaltyERC20.MaxPenaltyPercentageExceeded.selector)); + sprl2v2.withdrawPRLAndWeth(requestIds, WITHDRAW_AMOUNT, WITHDRAW_AMOUNT, DEFAULT_PENALTY_PERCENTAGE - 1); + } + + modifier PauseContract() { + vm.startPrank(users.admin.addr); + sprl2v2.pause(); + _; + } + + function test_SPRL2V2_EmergencyWithdraw() external PauseContract { + vm.startPrank(users.alice.addr); + uint256 aliceBalanceBefore = bpt.balanceOf(users.alice.addr); + + sprl2v2.emergencyWithdraw(WITHDRAW_AMOUNT); + + uint256 aliceBalanceAfter = bpt.balanceOf(users.alice.addr); + assertEq(aliceBalanceAfter - aliceBalanceBefore, WITHDRAW_AMOUNT); + + assertEq(sprl2v2.balanceOf(users.daoTreasury.addr), 0); + } +} diff --git a/test/integrations/timeLockPenaltyERC20/recoverERC20.t.sol b/test/integrations/timeLockPenaltyERC20/recoverERC20.t.sol new file mode 100644 index 0000000..5fc4613 --- /dev/null +++ b/test/integrations/timeLockPenaltyERC20/recoverERC20.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.25; + +import "test/Integrations.t.sol"; + +contract TimeLockPenaltyERC20_RecoverERC20_Integrations_Test is Integrations_Test { + uint256 internal constant REWARD_AMOUNT = 1e18; + + function setUp() public override { + super.setUp(); + // Simulate reward tokens being sent to the contract on its behalf (e.g. MERKL claim). + rewardToken.mint(address(timeLockPenaltyERC20), REWARD_AMOUNT); + } + + function test_TimeLockPenaltyERC20_RecoverERC20() external { + vm.startPrank(users.admin.addr); + vm.expectEmit(address(timeLockPenaltyERC20)); + emit TimeLockPenaltyERC20.TokensRecovered(address(rewardToken), REWARD_AMOUNT); + timeLockPenaltyERC20.recoverERC20(IERC20(address(rewardToken))); + + assertEq(rewardToken.balanceOf(address(timeLockPenaltyERC20)), 0); + assertEq(rewardToken.balanceOf(timeLockPenaltyERC20.feeReceiver()), REWARD_AMOUNT); + } + + function test_TimeLockPenaltyERC20_RecoverERC20_RevertWhen_TokenIsUnderlying() external { + // The mock's underlying is PRL (see Integrations setUp). + vm.startPrank(users.admin.addr); + vm.expectRevert(TimeLockPenaltyERC20.CannotRecoverUnderlying.selector); + timeLockPenaltyERC20.recoverERC20(IERC20(address(prl))); + } + + function test_TimeLockPenaltyERC20_RecoverERC20_RevertWhen_CallerNotAuthorized() external { + vm.startPrank(users.hacker.addr); + vm.expectRevert(abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, users.hacker.addr)); + timeLockPenaltyERC20.recoverERC20(IERC20(address(rewardToken))); + } +}