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/sPRL2V2.sol b/contracts/sPRL/sPRL2V2.sol index 2cd74cd..3b8eb74 100644 --- a/contracts/sPRL/sPRL2V2.sol +++ b/contracts/sPRL/sPRL2V2.sol @@ -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 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/sPRL2V2/constructor.t.sol b/test/integrations/sPRL2V2/constructor.t.sol index 36c46bc..2c037f8 100644 --- a/test/integrations/sPRL2V2/constructor.t.sol +++ b/test/integrations/sPRL2V2/constructor.t.sol @@ -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)) + }) + ); + } } diff --git a/test/integrations/sPRL2V2/requestWithdraw.t.sol b/test/integrations/sPRL2V2/requestWithdraw.t.sol index bd69af9..1465630 100644 --- a/test/integrations/sPRL2V2/requestWithdraw.t.sol +++ b/test/integrations/sPRL2V2/requestWithdraw.t.sol @@ -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; diff --git a/test/integrations/sPRL2V2/updateFeeReceiver.t.sol b/test/integrations/sPRL2V2/updateFeeReceiver.t.sol index 4bbc985..cfc1088 100644 --- a/test/integrations/sPRL2V2/updateFeeReceiver.t.sol +++ b/test/integrations/sPRL2V2/updateFeeReceiver.t.sol @@ -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)); + } } 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))); + } +}