Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions contracts/sPRL/TimeLockPenaltyERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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
//-------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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]++;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
//-------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion contracts/sPRL/sPRL2V2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ contract sPRL2V2 is TimeLockPenaltyERC20 {
using SafeERC20 for IERC20;

string constant NAME = "Stake 20WETH-80PRL Deposit Vault";
string constant SYMBOL = "sPRL2";
string constant SYMBOL = "sPRL2V2";

//-------------------------------------------
// Storage
Expand Down
Binary file not shown.
Binary file not shown.
19 changes: 18 additions & 1 deletion test/integrations/sPRL2V2/constructor.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,28 @@ contract SPRL2V2_Constructor_Integrations_Test is Integrations_Test {
assertEq(sprl2v2.unlockingAmount(), 0);
assertEq(sprl2v2.feeReceiver(), users.daoTreasury.addr);
assertEq(sprl2v2.name(), "Stake 20WETH-80PRL Deposit Vault");
assertEq(sprl2v2.symbol(), "sPRL2");
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))
})
);
}
}
5 changes: 5 additions & 0 deletions test/integrations/sPRL2V2/requestWithdraw.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ contract SPRL2V2_RequestWithdraw_Integrations_Test is Integrations_Test {
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;
Expand Down
6 changes: 6 additions & 0 deletions test/integrations/sPRL2V2/updateFeeReceiver.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,10 @@ contract SPRL2V2_UpdateFeeReceiver_Integrations_Test is Integrations_Test {
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));
}
}
37 changes: 37 additions & 0 deletions test/integrations/timeLockPenaltyERC20/recoverERC20.t.sol
Original file line number Diff line number Diff line change
@@ -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)));
}
}
Loading