diff --git a/LICENSE b/LICENSE index 659bbae..0ee82a9 100644 --- a/LICENSE +++ b/LICENSE @@ -3,41 +3,43 @@ Business Source License 1.1 License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved. “Business Source License” is a trademark of MariaDB Corporation Ab. ---- +----------------------------------------------------------------------------- Parameters -Licensor: Aave DAO, represented by its governance smart contracts +Licensor: Aave DAO, represented by its governance smart contracts -Licensed Work: Stake Token -The Licensed Work is (c) 2024 Aave DAO, represented by its governance smart contracts + +Licensed Work: Aave v3.1 + The Licensed Work is (c) 2024 Aave DAO, represented by its governance smart contracts Additional Use Grant: You are permitted to use, copy, and modify the Licensed Work, subject to -the following conditions: - -- Your use of the Licensed Work shall not, directly or indirectly, enable, facilitate, - or assist in any way with the migration of users and/or funds from the Aave ecosystem. - The "Aave ecosystem" is defined in the context of this License as the collection of - software protocols and applications approved by the Aave governance, including all - those produced within compensated service provider engagements with the Aave DAO. - The Aave DAO is able to waive this requirement for one or more third-parties, if and - only if explicitly indicating it on a record 'authorizations' on staketoken.aavelicense.eth. -- You are neither an individual nor a direct or indirect participant in any incorporated - organization, DAO, or identifiable group, that has deployed in production any original - or derived software ("fork") of the Aave ecosystem for purposes competitive to Aave, - within the preceding two years. - The Aave DAO is able to waive this requirement for one or more third-parties, if and - only if explicitly indicating it on a record 'authorizations' on staketoken.aavelicense.eth. -- You must ensure that the usage of the Licensed Work does not result in any direct or - indirect harm to the Aave ecosystem or the Aave brand. This encompasses, but is not limited to, - reputational damage, omission of proper credit/attribution, or utilization for any malicious - intent. - -Change Date: The earlier of: - 2028-01-08 - The date specified in the 'change-date' record on staketoken.aavelicense.eth - -Change License: MIT - ---- + the following conditions: + - Your use of the Licensed Work shall not, directly or indirectly, enable, facilitate, + or assist in any way with the migration of users and/or funds from the Aave ecosystem. + The "Aave ecosystem" is defined in the context of this License as the collection of + software protocols and applications approved by the Aave governance, including all + those produced within compensated service provider engagements with the Aave DAO. + The Aave DAO is able to waive this requirement for one or more third-parties, if and + only if explicitly indicating it on a record 'authorizations' on v31.aavelicense.eth. + - You are neither an individual nor a direct or indirect participant in any incorporated + organization, DAO, or identifiable group, that has deployed in production any original + or derived software ("fork") of the Aave ecosystem for purposes competitive to Aave, + within the preceding four years. + The Aave DAO is able to waive this requirement for one or more third-parties, if and + only if explicitly indicating it on a record 'authorizations' on v31.aavelicense.eth. + - You must ensure that the usage of the Licensed Work does not result in any direct or + indirect harm to the Aave ecosystem or the Aave brand. This encompasses, but is not limited to, + reputational damage, omission of proper credit/attribution, or utilization for any malicious + intent. + +Change Date: The earlier of: + - 2027-03-06 + - If specified, the date in the 'change-date' record on stake-token-v2.aavelicense.eth + +Change License: MIT + +----------------------------------------------------------------------------- Notice @@ -45,7 +47,7 @@ The Business Source License (this document, or the “License”) is not an Open Source license. However, the Licensed Work will eventually be made available under an Open Source License, as stated in this License. ---- +----------------------------------------------------------------------------- Terms diff --git a/README.md b/README.md index 5e3ffcf..0af8cfd 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,77 @@ -# Stake token +# StakeToken - Vault -New version of the Aave Safety Module stk tokens. +The new version of the Aave Safety Module stk tokens, intended for the Umbrella project. -## Summary of Changes +## About -The `StakeToken` is a token deployed on Ethereum, with the main utility of participating in the Aave safety module. +The `StakeToken` contains an EIP-4626 generic token vault for all non-rebase tokens (especially targeting `static-a-tokens`). -There are currently two proxy contracts which utilize a `StakeToken`: +## Features -- [stkAAVE](https://etherscan.io/token/0x4da27a545c0c5b758a6ba100e3a049001de870f5) with the [StakedAaveV3 implementation](https://etherscan.io/address/0xaa9faa887bce5182c39f68ac46c43f36723c395b#code) -- [stkABPT](https://etherscan.io/address/0xa1116930326D21fB917d5A27F1E9943A9595fb47#code) with the [StakedTokenV3 implementation](https://etherscan.io/address/0x9921c8cea5815364d0f8350e6cbe9042a92448c9#code) +- **Full [EIP-4626](https://eips.ethereum.org/EIPS/eip-4626) compatibility.** +- Withdrawal of funds from the storage can be carried out only after activation of cooldown after a certain time. +- The `StakeToken` is designed to cover small `Bad Debt`'s in a semi-automatic mode, but can withdraw almost all funds up to the `getMaxSlashableAssets()` amount in emergencies. +- Providing liquidity in the `StakeToken` includes the risk of slashing and is therefore paid for with additional rewards through `REWARDS_CONTROLLER`. +- **Permit-transactions support.** To enable interfaces to offer gas-less transactions to deposit with a permit. +- **Upgradable by the Aave governance.** Similar to other contracts of the Aave ecosystem, the Level 1 executor (short executor) will be able to add new features to the deployed instances of the `stakeTokens`. -The implementation can be found [here](https://github.com/bgd-labs/aave-stk-gov-v3) -Together with all the standard ERC20 functionalities, the current implementation includes extra logic for: +See [`IERC4626StakeToken.sol`](src/contracts/interfaces/IERC4626StakeToken.sol) for detailed method documentation. -- entering and exiting the safety module -- management & accounting for safety module rewards -- management & accounting of voting and proposition power -- slashing mechanics for slashing in the case of shortfall events +## Deployed Addresses -The new iteration of the generic `StakeToken` is intended for new Deployments **only**. -While it does not alter any core mechanics, the new iteration cleans up numerous historical artifacts. +An up-to-date address can be fetched from the respective [address-book pool library](https://github.com/bgd-labs/aave-address-book/blob/main/src/AaveV3Ethereum.sol). -The main goals here are: +## Limitations -- simpler inheritance chain -- cleaner storage layout -- updated/modernized libraries +The `StakeToken` is not natively integrated into the aave protocol and therefore cannot use multiple sources of additional incentives. Additional incentives included in the `static-a-tokens` are disabled when using the `StakeTokens`. -## Development +Since the `StakeToken` implies a decrease in `totalAssets()` over time (loss of funds), irreversible losses associated with the accuracy of calculations could occur over time. -This project uses [Foundry](https://getfoundry.sh). See the [book](https://book.getfoundry.sh/getting-started/installation.html) for detailed instructions on how to install and use Foundry. -The template ships with sensible default so you can use default `foundry` commands without resorting to `MakeFile`. +Losses do not exceed 1 wei if calculated relative to assets, but they can be more significant when using functions related to calculations through shares (due to OZ roundings). It is recommended to manually check and find more profitable ways to deposit/withdraw liquidity. However, in most cases, the difference should not exceed any significant amount. -### Setup +### Inheritance -```sh -cp .env.example .env -forge install -``` +The `StakeToken` is based on [`open-zeppelin-upgradeable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable) contracts. -### Test +The `StakeToken` is separated into 2 different contracts, where `ERC4626StakeTokenUpgradeable` inherits `ERC4626Upgradeable`. -```sh -forge test -``` +- `ERC4626StakeTokenUpgradeable` is an abstract contract implementing the [EIP-4626](https://eips.ethereum.org/EIPS/eip-4626) methods for an underlying asset. It provides basic functionality for the `StakeToken` without any access control or pausability. +- `StataTokenV2` is the main contract stitching things together, while adding `Pausable`, `Rescuable`, `Permit`, and the actual initialization. + +#### depositWithPermit + +[`ERC20PermitUpgradeable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/9a47a37c4b8ce2ac465e8656f31d32ac6fe26eaa/contracts/token/ERC20/extensions/ERC20PermitUpgradeable.sol) has been added to the `StakeToken`, which added the ability to make a deposit using a valid signature and 1 tx via `permit()`. -## Advanced features +#### Rescuable -### Diffing +[`Rescuable`](https://github.com/bgd-labs/solidity-utils/blob/main/src/contracts/utils/Rescuable.sol) has been applied to +the `StakeToken` which will allow the `owner()` of the corresponding `StakeToken` to rescue tokens on the contract. -For contracts upgrading implementations it's quite important to diff the implementation code to spot potential issues and ensure only the intended changes are included. -Therefore the `Makefile` includes some commands to streamline the diffing process. +#### Pausable -#### Download +The `StakeToken` implements the [`PausableUpgradeable`](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/9a47a37c4b8ce2ac465e8656f31d32ac6fe26eaa/contracts/utils/PausableUpgradeable.sol) allowing `owner()` to pause the vault in case of an emergency. +As long as the vault is paused, any non-view actions (deposit/redeem/slash) are impossible. -You can `download` the current contract code of a deployed contract via `make download chain=polygon address=0x00`. This will download the contract source for specified address to `src/etherscan/chain_address`. This command works for all chains with a etherscan compatible block explorer. +## Dependencies -#### Git diff +- Foundry, [how-to install](https://book.getfoundry.sh/getting-started/installation) (we recommend also update to the last version with `foundryup`) +- Lcov + - Optional, only needed for coverage testing + - For Ubuntu, you can install via `apt install lcov` + - For Mac, you can install via `brew install lcov` + +### Setup + +```sh +cp .env.example .env + +forge install + +# optional, to install prettier +bun install +``` -You can `git-diff` a downloaded contract against your src via `make git-diff before=./etherscan/chain_address after=./src out=filename`. This command will diff the two folders via git patience algorithm and write the output to `diffs/filename.md`. +### Tests -**Caveat**: If the onchain implementation was verified using flatten, for generating the diff you need to flatten the new contract via `forge flatten` and supply the flattened file instead fo the whole `./src` folder. +- To run the full test suite: `make test` +- To re-generate the coverage report: `make coverage` diff --git a/foundry.toml b/foundry.toml index 3972cc2..8c0fdc1 100644 --- a/foundry.toml +++ b/foundry.toml @@ -13,7 +13,7 @@ ffi = true [fuzz] runs = 1024 -max_test_rejects = 1_000_000 +max_test_rejects = 1000000 [rpc_endpoints] mainnet = "${RPC_MAINNET}" diff --git a/package.json b/package.json index 8e0e93d..e4aba6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "stake-token", - "version": "1.0.0", + "name": "stake-token-v2", + "version": "2.0.0", "scripts": { "lint": "prettier ./", "lint:fix": "npm run lint -- --write" @@ -10,7 +10,7 @@ "url": "git+https://github.com/bgd-labs/stake-token.git" }, "keywords": [], - "author": "BGD labs", + "author": "BGD Labs", "license": "BUSL-1.1", "bugs": { "url": "https://github.com/bgd-labs/stake-token/issues" @@ -20,4 +20,4 @@ "prettier": "2.8.7", "prettier-plugin-solidity": "1.1.3" } -} +} \ No newline at end of file diff --git a/src/contracts/StakeToken.sol b/src/contracts/StakeToken.sol index 67ad43a..d0d739f 100644 --- a/src/contracts/StakeToken.sol +++ b/src/contracts/StakeToken.sol @@ -1,31 +1,42 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; -import {IERC20Permit} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol'; -import {IERC20Metadata} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol'; - -import {IPoolAddressesProvider} from './interfaces/IPoolAddressesProvider.sol'; -import {IRewardsController} from './interfaces/IRewardsController.sol'; - import {Initializable} from 'openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol'; +import {OwnableUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol'; import {PausableUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol'; import {ERC20Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/ERC20Upgradeable.sol'; import {ERC20PermitUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC20PermitUpgradeable.sol'; import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {IERC20Permit} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sol'; +import {IERC20Metadata} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol'; + +import {Rescuable, IRescuable} from 'solidity-utils/contracts/utils/Rescuable.sol'; + +import {IERC4626StakeToken} from './interfaces/IERC4626StakeToken.sol'; +import {IRewardsController} from './interfaces/IRewardsController.sol'; import {ERC4626StakeTokenUpgradeable} from './extension/ERC4626StakeTokenUpgradeable.sol'; +/** + * @title StakeToken + * @notice StakeToken is an `ERC-4626` contract that aims to supply assets as debt repayment in the event of a `Bad Debt`. + * Stakers will be paid rewards through `REWARDS_CONTROLLER` for providing underlying assets. In a situation where a `Bad Debt` + * exceeds a threshold value, the `slash()` function will be called, which will take the part of the assets + * necessary for repayment from this vault and transfer them to the desired address. + * @author BGD labs + */ contract StakeToken is Initializable, PausableUpgradeable, ERC20PermitUpgradeable, - ERC4626StakeTokenUpgradeable + ERC4626StakeTokenUpgradeable, + OwnableUpgradeable, + Rescuable { constructor( - IRewardsController rewardsController, - IPoolAddressesProvider provider - ) ERC4626StakeTokenUpgradeable(rewardsController, provider) { + IRewardsController rewardsController + ) ERC4626StakeTokenUpgradeable(rewardsController) { _disableInitializers(); } @@ -34,7 +45,6 @@ contract StakeToken is string calldata name, string calldata symbol, address owner, - address guardian, uint256 cooldown_, uint256 unstakeWindow_ ) external initializer { @@ -44,11 +54,11 @@ contract StakeToken is __Pausable_init(); __Ownable_init(owner); - __Ownable_With_Guardian_init(guardian); __StakeTokenUpgradeable_init(stakedToken, cooldown_, unstakeWindow_); } + /// @inheritdoc IERC4626StakeToken function depositWithPermit( uint256 assets, address receiver, @@ -70,14 +80,39 @@ contract StakeToken is return deposit(assets, receiver); } - function pause() external onlyOwnerOrGuardian { + /// @inheritdoc IERC4626StakeToken + function pause() external onlyOwner { _pause(); } - function unpause() external onlyOwnerOrGuardian { + /// @inheritdoc IERC4626StakeToken + function unpause() external onlyOwner { _unpause(); } + /// @inheritdoc IERC4626StakeToken + function slash( + address destination, + uint256 amount + ) external override onlyOwner returns (uint256) { + return _slash(destination, amount); + } + + /// @inheritdoc IERC4626StakeToken + function setUnstakeWindow(uint256 newUnstakeWindow) external override onlyOwner { + _setUnstakeWindow(newUnstakeWindow); + } + + /// @inheritdoc IERC4626StakeToken + function setCooldown(uint256 newCooldown) external override onlyOwner { + _setCooldown(newCooldown); + } + + /// @inheritdoc IRescuable + function whoCanRescue() public view override returns (address) { + return owner(); + } + function decimals() public view diff --git a/src/contracts/extension/ERC4626StakeTokenUpgradeable.sol b/src/contracts/extension/ERC4626StakeTokenUpgradeable.sol index 7dcabed..e8addd8 100644 --- a/src/contracts/extension/ERC4626StakeTokenUpgradeable.sol +++ b/src/contracts/extension/ERC4626StakeTokenUpgradeable.sol @@ -1,28 +1,29 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; -import {IERC4626} from 'openzeppelin-contracts/contracts/interfaces/IERC4626.sol'; -import {IAccessControl} from 'openzeppelin-contracts/contracts/access/IAccessControl.sol'; - -import {IPoolAddressesProvider} from '../interfaces/IPoolAddressesProvider.sol'; -import {IRewardsController} from '../interfaces/IRewardsController.sol'; -import {IStakeToken} from '../interfaces/IStakeToken.sol'; - -import {UpgradeableOwnableWithGuardian} from 'solidity-utils/contracts/access-control/UpgradeableOwnableWithGuardian.sol'; - import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; import {Initializable} from 'openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol'; +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; +import {IERC4626} from 'openzeppelin-contracts/contracts/interfaces/IERC4626.sol'; + import {SafeCast} from 'openzeppelin-contracts/contracts/utils/math/SafeCast.sol'; import {SafeERC20} from 'openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol'; import {Math} from 'openzeppelin-contracts/contracts/utils/math/Math.sol'; +import {IRewardsController} from '../interfaces/IRewardsController.sol'; +import {IERC4626StakeToken} from '../interfaces/IERC4626StakeToken.sol'; + +/** + * @title ERC4626StakeTokenUpgradeable + * @notice Stake smart contract, which allows covering bad debt at the expense of stakers. In return, stakers receive rewards. + * @dev ERC20 extension, so ERC20 initialization should be done by the children contract/s + * @author BGD labs + */ abstract contract ERC4626StakeTokenUpgradeable is Initializable, ERC4626Upgradeable, - UpgradeableOwnableWithGuardian, - IStakeToken + IERC4626StakeToken { using SafeERC20 for IERC20; using SafeCast for uint256; @@ -32,12 +33,12 @@ abstract contract ERC4626StakeTokenUpgradeable is struct StakeTokenStorage { /// @notice User cooldown options mapping(address => CooldownSnapshot) _stakerCooldown; - /// @notice Current exchangeRate of the stk - uint256 _currentExchangeRate; /// @notice Cooldown duration - uint32 cooldown; + uint32 _cooldown; /// @notice Time period during which funds can be withdrawn - uint32 unstakeWindow; + uint32 _unstakeWindow; + /// @notice Virtual accounting of assets + uint192 _totalAssets; } // keccak256(abi.encode(uint256(keccak256("aave.storage.StakeToken")) - 1)) & ~bytes32(uint256(0xff)) @@ -50,17 +51,12 @@ abstract contract ERC4626StakeTokenUpgradeable is } } - uint256 public constant INITIAL_EXCHANGE_RATE = 1e18; - uint256 public constant EXCHANGE_RATE_UNIT = 1e18; - uint256 public constant MIN_ASSETS_REMAINING = 1e6; IRewardsController public immutable REWARDS_CONTROLLER; - IPoolAddressesProvider public immutable ADDRESSES_PROVIDER; - constructor(IRewardsController rewardsController, IPoolAddressesProvider provider) { + constructor(IRewardsController rewardsController) { REWARDS_CONTROLLER = rewardsController; - ADDRESSES_PROVIDER = provider; } function __StakeTokenUpgradeable_init( @@ -79,23 +75,14 @@ abstract contract ERC4626StakeTokenUpgradeable is ) internal onlyInitializing { _setCooldown(cooldown_); _setUnstakeWindow(unstakeWindow_); - - _updateExchangeRate(INITIAL_EXCHANGE_RATE); - } - - modifier onlySlashingAdmin() { - if ( - !IAccessControl(ADDRESSES_PROVIDER.getACLManager()).hasRole('SLASHING_ADMIN', _msgSender()) - ) { - revert CallerIsNotSlashingAdmin(); - } - _; } + /// @inheritdoc IERC4626StakeToken function cooldown() external { _cooldown(_msgSender()); } + /// @inheritdoc IERC4626StakeToken function cooldownOnBehalfOf(address owner) external { if (allowance(owner, _msgSender()) == 0) { revert NotApprovedForCooldown(owner, _msgSender()); @@ -104,24 +91,27 @@ abstract contract ERC4626StakeTokenUpgradeable is _cooldown(owner); } - function slash(address destination, uint256 amount) external onlySlashingAdmin returns (uint256) { - return _slash(destination, amount); - } + ///// @dev Methods requiring mandatory access control, because of it kept undefined - function setUnstakeWindow(uint256 newUnstakeWindow) external onlyOwner { - _setUnstakeWindow(newUnstakeWindow); - } + /// @inheritdoc IERC4626StakeToken + function slash(address destination, uint256 amount) external virtual returns (uint256); - function setCooldown(uint256 newCooldown) external onlyOwner { - _setCooldown(newCooldown); - } + /// @inheritdoc IERC4626StakeToken + function setUnstakeWindow(uint256 newUnstakeWindow) external virtual; + + /// @inheritdoc IERC4626StakeToken + function setCooldown(uint256 newCooldown) external virtual; + /////////////////////////////////////////////////////////////////////////////////// + + /// @inheritdoc IERC4626 function maxWithdraw( address owner ) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { return _convertToAssets(maxRedeem(owner), Math.Rounding.Floor); } + /// @inheritdoc IERC4626 function maxRedeem( address owner ) public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { @@ -130,7 +120,7 @@ abstract contract ERC4626StakeTokenUpgradeable is if ( block.timestamp >= cooldownSnapshot.timestamp && - block.timestamp - cooldownSnapshot.timestamp <= $.unstakeWindow + block.timestamp - cooldownSnapshot.timestamp <= $._unstakeWindow ) { return cooldownSnapshot.amount; } @@ -138,27 +128,55 @@ abstract contract ERC4626StakeTokenUpgradeable is return 0; } + /// @inheritdoc IERC4626 + function totalAssets() public view override(ERC4626Upgradeable, IERC4626) returns (uint256) { + return _getStakeTokenStorage()._totalAssets; + } + + /// @inheritdoc IERC4626StakeToken function getMaxSlashableAssets() public view returns (uint256) { uint256 currentAssets = totalAssets(); return MIN_ASSETS_REMAINING > currentAssets ? 0 : currentAssets - MIN_ASSETS_REMAINING; } - function getExchangeRate() public view returns (uint256) { - return _getStakeTokenStorage()._currentExchangeRate; - } - + /// @inheritdoc IERC4626StakeToken function getCooldown() public view returns (uint256) { - return _getStakeTokenStorage().cooldown; + return _getStakeTokenStorage()._cooldown; } + /// @inheritdoc IERC4626StakeToken function getUnstakeWindow() public view returns (uint256) { - return _getStakeTokenStorage().unstakeWindow; + return _getStakeTokenStorage()._unstakeWindow; } + /// @inheritdoc IERC4626StakeToken function getStakerCooldown(address user) public view returns (CooldownSnapshot memory) { return _getStakeTokenStorage()._stakerCooldown[user]; } + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal override { + _getStakeTokenStorage()._totalAssets += assets.toUint192(); + + super._deposit(caller, receiver, assets, shares); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal override { + _getStakeTokenStorage()._totalAssets -= assets.toUint192(); + + super._withdraw(caller, receiver, owner, assets, shares); + } + function _cooldown(address from) internal virtual { uint256 amount = balanceOf(from); @@ -168,7 +186,7 @@ abstract contract ERC4626StakeTokenUpgradeable is StakeTokenStorage storage $ = _getStakeTokenStorage(); - uint32 timeToUnlock = (block.timestamp + $.cooldown).toUint32(); + uint32 timeToUnlock = (block.timestamp + $._cooldown).toUint32(); $._stakerCooldown[from] = CooldownSnapshot({ amount: amount.toUint224(), @@ -182,11 +200,13 @@ abstract contract ERC4626StakeTokenUpgradeable is uint256 cachedTotalSupply = totalSupply(); // stake & transfer + // `handleAction` to update rewards for user `to` if (to != address(0)) { REWARDS_CONTROLLER.handleAction(to, cachedTotalSupply, balanceOf(to)); } // redeem & transfer + // `handleAction` to update rewards for user `from` if (from != address(0) && from != to) { uint256 balanceOfFrom = balanceOf(from); REWARDS_CONTROLLER.handleAction(from, cachedTotalSupply, balanceOfFrom); @@ -194,33 +214,32 @@ abstract contract ERC4626StakeTokenUpgradeable is StakeTokenStorage storage $ = _getStakeTokenStorage(); CooldownSnapshot memory cooldownSnapshot = $._stakerCooldown[from]; + // if cooldown was activated and user is trying to transfer/redeem tokens + // we don't take into account that cooldown could be already outdated if (cooldownSnapshot.timestamp != 0) { if (to == address(0)) { - // redeem - if (cooldownSnapshot.amount == value) { - delete $._stakerCooldown[from]; - - emit StakerCooldownDeleted(from); - } else { - uint224 amount = cooldownSnapshot.amount - value.toUint224(); - - $._stakerCooldown[from].amount = amount; - - emit StakerCooldownAmountChanged(from, amount); - } + // `from` redeems tokens here + // reduce amount available for redeem in the future + cooldownSnapshot.amount -= value.toUint224(); } else { - // transfer + // `from` transfers tokens here + // if balance of user decrease less than the amount of tokens in cooldown, than his `cooldownSnapshot.amount` should be reduced too + // we don't pay attention if balanceAfter is greater than users `cooldownSnapshot.amount`, because we assume these are "other" tokens + // tokens that have been cooldowned are always at the bottom of the balance uint224 balanceAfter = (balanceOfFrom - value).toUint224(); + if (balanceAfter <= cooldownSnapshot.amount) { + cooldownSnapshot.amount = balanceAfter; + } + } - if (balanceAfter == 0) { - delete $._stakerCooldown[from]; - - emit StakerCooldownDeleted(from); - } else if (balanceAfter < cooldownSnapshot.amount) { - $._stakerCooldown[from].amount = balanceAfter; - - emit StakerCooldownAmountChanged(from, balanceAfter); + // reduce an amount under cooldown if something was spent + if ($._stakerCooldown[from].amount != cooldownSnapshot.amount) { + if (cooldownSnapshot.amount == 0) { + // if user spend all balance or already redeem whole amount + cooldownSnapshot.timestamp = 0; } + $._stakerCooldown[from] = cooldownSnapshot; + emit StakerCooldownChanged(from, cooldownSnapshot.amount, cooldownSnapshot.timestamp); } } } @@ -243,10 +262,7 @@ abstract contract ERC4626StakeTokenUpgradeable is amount = maxSlashable; } - uint256 currentShares = totalSupply(); - uint256 balance = convertToAssets(currentShares); - - _updateExchangeRate(_getExchangeRate(balance - amount, currentShares)); + _getStakeTokenStorage()._totalAssets -= amount.toUint192(); IERC20(asset()).safeTransfer(destination, amount); @@ -256,45 +272,18 @@ abstract contract ERC4626StakeTokenUpgradeable is } function _setUnstakeWindow(uint256 newUnstakeWindow) internal { - _getStakeTokenStorage().unstakeWindow = newUnstakeWindow.toUint32(); + _getStakeTokenStorage()._unstakeWindow = newUnstakeWindow.toUint32(); emit UnstakeWindowChanged(newUnstakeWindow); } function _setCooldown(uint256 newCooldown) internal { - _getStakeTokenStorage().cooldown = newCooldown.toUint32(); + _getStakeTokenStorage()._cooldown = newCooldown.toUint32(); emit CooldownChanged(newCooldown); } - function _updateExchangeRate(uint256 newExchangeRate) internal { - if (newExchangeRate == 0) { - revert ZeroExchangeRate(); - } - - _getStakeTokenStorage()._currentExchangeRate = newExchangeRate; - - emit ExchangeRateChanged(newExchangeRate); - } - - function _getExchangeRate( - uint256 newTotalAssets, - uint256 newTotalShares - ) internal pure returns (uint256) { - return newTotalShares.mulDiv(EXCHANGE_RATE_UNIT, newTotalAssets, Math.Rounding.Ceil); - } - - function _convertToShares( - uint256 assets, - Math.Rounding rounding - ) internal view override returns (uint256) { - return assets.mulDiv(getExchangeRate(), EXCHANGE_RATE_UNIT, rounding); - } - - function _convertToAssets( - uint256 shares, - Math.Rounding rounding - ) internal view override returns (uint256) { - return shares.mulDiv(EXCHANGE_RATE_UNIT, getExchangeRate(), rounding); + function _decimalsOffset() internal pure override returns (uint8) { + return 3; } } diff --git a/src/contracts/interfaces/IStakeToken.sol b/src/contracts/interfaces/IERC4626StakeToken.sol similarity index 86% rename from src/contracts/interfaces/IStakeToken.sol rename to src/contracts/interfaces/IERC4626StakeToken.sol index 8691768..17d25f2 100644 --- a/src/contracts/interfaces/IStakeToken.sol +++ b/src/contracts/interfaces/IERC4626StakeToken.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.0; import {IERC4626} from 'openzeppelin-contracts/contracts/interfaces/IERC4626.sol'; -interface IStakeToken is IERC4626 { +interface IERC4626StakeToken is IERC4626 { struct CooldownSnapshot { - /// @notice Amount of tokens available for withdrawal + /// @notice Amount of shares available to redeem uint224 amount; /// @notice Time to unlock funds for withdrawal uint32 timestamp; @@ -18,20 +18,13 @@ interface IStakeToken is IERC4626 { } event CooldownSet(address indexed user, uint256 amount, uint256 timestamp); - event StakerCooldownAmountChanged(address indexed user, uint256 amount); - event StakerCooldownDeleted(address indexed user); + event StakerCooldownChanged(address indexed user, uint256 amount, uint256 timestamp); event Slashed(address indexed destination, uint256 amount); event CooldownChanged(uint256 cooldown); event UnstakeWindowChanged(uint256 unstakeWindow); event ExchangeRateChanged(uint256 exchangeRate); - event SlashingAdminChanged(address newAdmin); - - /** - * @dev Attempted to set zero `exchangeRate`. - */ - error ZeroExchangeRate(); /** * @dev Attempted to call cooldown without locked liquidity. @@ -48,16 +41,6 @@ interface IStakeToken is IERC4626 { */ error ZeroFundsAvailable(); - /** - * @dev Attempt to make permit, which wasn't succeded. - */ - error PermitNotSucceded(); - - /** - * @dev Attempt to call slash not from `slashingAdmin` address. - */ - error CallerIsNotSlashingAdmin(); - /** * @dev Attempt to call cooldown without allowance for `stakeToken`. */ @@ -133,11 +116,6 @@ interface IStakeToken is IERC4626 { */ function setUnstakeWindow(uint256 newUnstakeWindow) external; - /** - * @dev Returns the current exchange rate with a 1e18 precision. - */ - function getExchangeRate() external view returns (uint256); - /** * @dev Returns current `cooldown` duration. */ diff --git a/src/contracts/interfaces/IPoolAddressesProvider.sol b/src/contracts/interfaces/IPoolAddressesProvider.sol deleted file mode 100644 index c368d96..0000000 --- a/src/contracts/interfaces/IPoolAddressesProvider.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -interface IPoolAddressesProvider { - /** - * @notice Returns the address of the ACL manager. - * @return The address of the ACLManager - */ - function getACLManager() external view returns (address); -} diff --git a/tests/Cooldown.t.sol b/tests/Cooldown.t.sol index b9654d4..c1e3a4e 100644 --- a/tests/Cooldown.t.sol +++ b/tests/Cooldown.t.sol @@ -1,41 +1,41 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - -import {IStakeToken} from 'src/contracts/interfaces/IStakeToken.sol'; - import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; +import {IERC4626StakeToken} from 'src/contracts/interfaces/IERC4626StakeToken.sol'; + import {StakeTestBase} from './utils/StakeTestBase.sol'; contract CooldownTests is StakeTestBase { - function test_cooldown(uint224 amountToStake, uint224 amountToRedeem) public { - vm.assume(amountToStake > amountToRedeem && amountToRedeem > 0); + function test_cooldown(uint192 amountToStake, uint192 amountToWithdraw) public { + vm.assume(amountToStake > amountToWithdraw && amountToWithdraw > 0); _deposit(amountToStake, user, user); vm.startPrank(user); stakeToken.cooldown(); - IStakeToken.CooldownSnapshot memory snapshotBefore = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshotBefore = stakeToken.getStakerCooldown(user); assertEq(snapshotBefore.timestamp, block.timestamp + stakeToken.getCooldown()); - assertEq(snapshotBefore.amount, amountToStake); + assertEq(snapshotBefore.amount, stakeToken.convertToShares(amountToStake)); skip(stakeToken.getCooldown()); - stakeToken.withdraw(amountToRedeem, user, user); + stakeToken.withdraw(amountToWithdraw, user, user); - IStakeToken.CooldownSnapshot memory snapshotAfter = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshotAfter = stakeToken.getStakerCooldown(user); - assertEq(snapshotAfter.amount, amountToStake - amountToRedeem); + assertEq(snapshotAfter.amount, stakeToken.convertToShares(amountToStake - amountToWithdraw)); assertEq(snapshotAfter.timestamp, snapshotBefore.timestamp); } - function test_cooldownNoIncreaseInAmount(uint224 amountToStake, uint224 amountToTopUp) public { + function test_cooldownNoIncreaseInAmount(uint192 amountToStake, uint192 amountToTopUp) public { vm.assume( - amountToStake > 0 && amountToTopUp > 0 && type(uint224).max - amountToStake > amountToTopUp + amountToStake > 0 && + amountToTopUp > 0 && + uint256(type(uint192).max) > 2 * uint256(amountToTopUp) + amountToStake ); _deposit(amountToStake, user, user); @@ -43,17 +43,17 @@ contract CooldownTests is StakeTestBase { vm.startPrank(user); stakeToken.cooldown(); - IStakeToken.CooldownSnapshot memory snapshotBefore = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshotBefore = stakeToken.getStakerCooldown(user); _deposit(amountToTopUp, user, user); - IStakeToken.CooldownSnapshot memory snapshotAfter = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshotAfter = stakeToken.getStakerCooldown(user); assertEq(snapshotBefore.timestamp, snapshotAfter.timestamp); assertEq(snapshotBefore.amount, snapshotAfter.amount); assertEq(snapshotAfter.timestamp, block.timestamp + stakeToken.getCooldown()); - assertEq(snapshotAfter.amount, amountToStake); + assertEq(snapshotAfter.amount, stakeToken.convertToShares(amountToStake)); _deposit(amountToTopUp, someone, someone); @@ -62,84 +62,81 @@ contract CooldownTests is StakeTestBase { stakeToken.transfer(user, stakeToken.convertToShares(amountToTopUp)); - IStakeToken.CooldownSnapshot memory snapshotAfterSecondTopUp = stakeToken.getStakerCooldown( - user - ); + IERC4626StakeToken.CooldownSnapshot memory snapshotAfterSecondTopUp = stakeToken + .getStakerCooldown(user); assertEq(snapshotBefore.timestamp, snapshotAfterSecondTopUp.timestamp); assertEq(snapshotBefore.amount, snapshotAfterSecondTopUp.amount); assertEq(snapshotAfterSecondTopUp.timestamp, block.timestamp + stakeToken.getCooldown()); - assertEq(snapshotAfterSecondTopUp.amount, amountToStake); + assertEq(snapshotAfterSecondTopUp.amount, stakeToken.convertToShares(amountToStake)); } - function test_cooldownChangeOnTransfer(uint224 amountToStake, uint224 amountToTransfer) public { - vm.assume(amountToStake > 0); - vm.assume(amountToTransfer > 0 && amountToStake > amountToTransfer); + function test_cooldownChangeOnTransfer(uint192 amountToStake, uint224 sharesToTransfer) public { + vm.assume(stakeToken.convertToShares(amountToStake) > sharesToTransfer && sharesToTransfer > 0); _deposit(amountToStake, user, user); vm.startPrank(user); stakeToken.cooldown(); - IStakeToken.CooldownSnapshot memory snapshot0 = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshot0 = stakeToken.getStakerCooldown(user); - stakeToken.transfer(someone, amountToTransfer); + stakeToken.transfer(someone, sharesToTransfer); - IStakeToken.CooldownSnapshot memory snapshot1 = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshot1 = stakeToken.getStakerCooldown(user); assertEq(snapshot0.timestamp, snapshot1.timestamp); - assertEq(snapshot0.amount, snapshot1.amount + amountToTransfer); + assertEq(snapshot0.amount, snapshot1.amount + sharesToTransfer); - stakeToken.transfer(someone, amountToStake - amountToTransfer); + stakeToken.transfer(someone, stakeToken.balanceOf(user)); - IStakeToken.CooldownSnapshot memory snapshot2 = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshot2 = stakeToken.getStakerCooldown(user); assertEq(snapshot2.timestamp, 0); assertEq(snapshot2.amount, 0); } - function test_cooldownChangeOnRedeem(uint224 amountToStake, uint224 amountToRedeem) public { - vm.assume(amountToStake > 0); - vm.assume(amountToRedeem > 0 && amountToStake > amountToRedeem); + function test_cooldownChangeOnRedeem(uint192 amountToStake, uint224 sharesToRedeem) public { + vm.assume(stakeToken.convertToShares(amountToStake) > sharesToRedeem && sharesToRedeem > 0); _deposit(amountToStake, user, user); vm.startPrank(user); stakeToken.cooldown(); - IStakeToken.CooldownSnapshot memory snapshot0 = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshot0 = stakeToken.getStakerCooldown(user); skip(stakeToken.getCooldown()); - stakeToken.redeem(amountToRedeem, user, user); + stakeToken.redeem(sharesToRedeem, user, user); - IStakeToken.CooldownSnapshot memory snapshot1 = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshot1 = stakeToken.getStakerCooldown(user); assertEq(snapshot0.timestamp, snapshot1.timestamp); - assertEq(snapshot0.amount, snapshot1.amount + amountToRedeem); + assertEq(snapshot0.amount, snapshot1.amount + sharesToRedeem); - stakeToken.redeem(amountToStake - amountToRedeem, user, user); + stakeToken.redeem(stakeToken.balanceOf(user), user, user); - IStakeToken.CooldownSnapshot memory snapshot2 = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshot2 = stakeToken.getStakerCooldown(user); assertEq(snapshot2.timestamp, 0); assertEq(snapshot2.amount, 0); } function test_cooldownInsufficientTime( - uint224 amountToStake, - uint32 AfterCooldownActivation + uint192 amountToStake, + uint32 afterCooldownActivation ) public { vm.assume(amountToStake > 0); - vm.assume(AfterCooldownActivation < stakeToken.getCooldown()); + vm.assume(afterCooldownActivation < stakeToken.getCooldown()); _deposit(amountToStake, user, user); vm.startPrank(user); stakeToken.cooldown(); - skip(AfterCooldownActivation); + skip(afterCooldownActivation); vm.expectRevert(); @@ -155,9 +152,9 @@ contract CooldownTests is StakeTestBase { stakeToken.withdraw(1, user, user); } - function test_cooldownWindowClosed(uint224 amountToStake, uint32 GreaterThanNeeded) public { + function test_cooldownWindowClosed(uint192 amountToStake, uint32 greaterThanNeeded) public { vm.assume(amountToStake > 0); - vm.assume(GreaterThanNeeded > stakeToken.getCooldown() + stakeToken.getUnstakeWindow()); + vm.assume(greaterThanNeeded > stakeToken.getCooldown() + stakeToken.getUnstakeWindow()); _deposit(amountToStake, user, user); @@ -165,7 +162,7 @@ contract CooldownTests is StakeTestBase { stakeToken.cooldown(); - skip(GreaterThanNeeded); + skip(greaterThanNeeded); vm.expectRevert( abi.encodeWithSelector( @@ -179,8 +176,8 @@ contract CooldownTests is StakeTestBase { stakeToken.withdraw(1, user, user); } - function test_cooldownOnBehalf(uint224 amountToStake, uint224 amountToRedeem) public { - vm.assume(amountToStake > amountToRedeem && amountToRedeem > 0); + function test_cooldownOnBehalf(uint192 amountToStake, uint224 sharesToRedeem) public { + vm.assume(stakeToken.convertToShares(amountToStake) > sharesToRedeem && sharesToRedeem > 0); _deposit(amountToStake, user, user); @@ -193,22 +190,22 @@ contract CooldownTests is StakeTestBase { stakeToken.cooldownOnBehalfOf(user); - IStakeToken.CooldownSnapshot memory snapshotBefore = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshotBefore = stakeToken.getStakerCooldown(user); assertEq(snapshotBefore.timestamp, block.timestamp + stakeToken.getCooldown()); - assertEq(snapshotBefore.amount, amountToStake); + assertEq(snapshotBefore.amount, stakeToken.convertToShares(amountToStake)); skip(stakeToken.getCooldown()); - stakeToken.withdraw(amountToRedeem, someone, user); + stakeToken.redeem(sharesToRedeem, someone, user); - IStakeToken.CooldownSnapshot memory snapshotAfter = stakeToken.getStakerCooldown(user); + IERC4626StakeToken.CooldownSnapshot memory snapshotAfter = stakeToken.getStakerCooldown(user); - assertEq(snapshotAfter.amount, amountToStake - amountToRedeem); + assertEq(snapshotAfter.amount + sharesToRedeem, snapshotBefore.amount); assertEq(snapshotAfter.timestamp, snapshotBefore.timestamp); } - function test_cooldownOnBehalfNotApproved(uint224 amountToStake) public { + function test_cooldownOnBehalfNotApproved(uint192 amountToStake) public { vm.assume(amountToStake > 0); _deposit(amountToStake, user, user); @@ -216,7 +213,7 @@ contract CooldownTests is StakeTestBase { vm.startPrank(someone); vm.expectRevert( - abi.encodeWithSelector(IStakeToken.NotApprovedForCooldown.selector, user, someone) + abi.encodeWithSelector(IERC4626StakeToken.NotApprovedForCooldown.selector, user, someone) ); stakeToken.cooldownOnBehalfOf(user); } @@ -224,7 +221,7 @@ contract CooldownTests is StakeTestBase { function test_cooldownZeroAmount() public { vm.startPrank(user); - vm.expectRevert(abi.encodeWithSelector(IStakeToken.ZeroBalanceInStaking.selector)); + vm.expectRevert(abi.encodeWithSelector(IERC4626StakeToken.ZeroBalanceInStaking.selector)); stakeToken.cooldown(); } } diff --git a/tests/ERC20.t.sol b/tests/ERC20.t.sol index 23d26f7..3b3c0f1 100644 --- a/tests/ERC20.t.sol +++ b/tests/ERC20.t.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - import {IERC20Errors} from 'openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol'; import {StakeTestBase} from './utils/StakeTestBase.sol'; @@ -17,67 +15,71 @@ contract ERC20Tests is StakeTestBase { } // mint - function test_mint(uint224 amount) public { + function test_mint(uint192 amount) public { vm.assume(amount > 0); _mint(amount, user, user); - assertEq(stakeToken.totalAssets(), amount); + assertEq(stakeToken.totalAssets(), underlying.balanceOf(address(stakeToken))); assertEq(stakeToken.totalSupply(), stakeToken.balanceOf(user)); + + assertLe( + getDiff(stakeToken.previewRedeem(amount), underlying.balanceOf(address(stakeToken))), + 10 + ); } // burn - function test_redeem(uint224 amountStaked, uint224 amountRedeemed) public { - vm.assume(amountStaked > 0); - vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); + function test_withdraw(uint192 amountStaked, uint192 amountWithdraw) public { + vm.assume(amountStaked > amountWithdraw && amountWithdraw > 0); - _mint(amountStaked, user, user); + _deposit(amountStaked, user, user); vm.startPrank(user); stakeToken.cooldown(); skip(stakeToken.getCooldown()); - stakeToken.redeem(amountRedeemed, user, user); + stakeToken.withdraw(amountWithdraw, user, user); - assertEq(stakeToken.totalAssets(), amountStaked - amountRedeemed); - assertEq(underlying.balanceOf(user), amountRedeemed); + assertEq(stakeToken.totalAssets(), amountStaked - amountWithdraw); + assertEq(underlying.balanceOf(user), amountWithdraw); assertEq(stakeToken.balanceOf(user), stakeToken.totalSupply()); - assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); + assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountWithdraw)); } - function test_approve(uint224 amount) public { + function test_approve(uint192 amount) public { assertTrue(stakeToken.approve(user, amount)); assertEq(stakeToken.allowance(address(this), user), amount); } - function test_resetApproval(uint224 amount) public { + function test_resetApproval(uint192 amount) public { assertTrue(stakeToken.approve(user, amount)); assertTrue(stakeToken.approve(user, 0)); assertEq(stakeToken.allowance(address(this), user), 0); } function test_transferWithoutCooldownInStake( - uint224 amountStake, - uint224 amountTransfer + uint192 amountStake, + uint224 sharesTransfer ) external { vm.assume(amountStake > 0); - vm.assume(amountTransfer <= stakeToken.convertToShares(amountStake)); + vm.assume(sharesTransfer <= stakeToken.convertToShares(amountStake)); _deposit(amountStake, user, user); vm.startPrank(user); - stakeToken.transfer(someone, amountTransfer); + stakeToken.transfer(someone, sharesTransfer); - assertEq(stakeToken.balanceOf(someone), amountTransfer); - assertEq(stakeToken.balanceOf(user), amountStake - amountTransfer); + assertEq(stakeToken.balanceOf(someone), sharesTransfer); + assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStake) - sharesTransfer); } - function test_transferWithCooldownInStake(uint224 amountStake, uint224 amountTransfer) external { + function test_transferWithCooldownInStake(uint192 amountStake, uint224 sharesTransfer) external { vm.assume(amountStake > 0); - vm.assume(amountTransfer <= stakeToken.convertToShares(amountStake)); + vm.assume(sharesTransfer <= stakeToken.convertToShares(amountStake)); _deposit(amountStake, user, user); @@ -87,38 +89,38 @@ contract ERC20Tests is StakeTestBase { skip(1); - stakeToken.transfer(someone, amountTransfer); + stakeToken.transfer(someone, sharesTransfer); - assertEq(stakeToken.balanceOf(someone), amountTransfer); - assertEq(stakeToken.balanceOf(user), amountStake - amountTransfer); + assertEq(stakeToken.balanceOf(someone), sharesTransfer); + assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStake) - sharesTransfer); } - function test_transferFrom(uint224 amountStake, uint224 amountTransfer) external { + function test_transferFrom(uint192 amountStake, uint224 sharesTransfer) external { vm.assume(amountStake > 0); - vm.assume(amountTransfer <= stakeToken.convertToShares(amountStake)); + vm.assume(sharesTransfer <= stakeToken.convertToShares(amountStake)); - _deposit(amountStake, user, user); + uint256 sharesMinted = _deposit(amountStake, user, user); vm.startPrank(user); - stakeToken.approve(someone, amountStake); + stakeToken.approve(someone, sharesTransfer); vm.stopPrank(); vm.startPrank(someone); - assertTrue(stakeToken.transferFrom(user, someone, amountTransfer)); + assertTrue(stakeToken.transferFrom(user, someone, sharesTransfer)); vm.stopPrank(); - assertEq(stakeToken.allowance(user, someone), amountStake - amountTransfer); + assertEq(stakeToken.allowance(user, someone), 0); - assertEq(stakeToken.balanceOf(user), amountStake - amountTransfer); - assertEq(stakeToken.balanceOf(someone), amountTransfer); + assertEq(stakeToken.balanceOf(user), sharesMinted - sharesTransfer); + assertEq(stakeToken.balanceOf(someone), sharesTransfer); } - function test_transferFromWithoutApprove(uint224 amountStake, uint224 amountTransfer) external { + function test_transferFromWithoutApprove(uint192 amountStake, uint224 sharesTransfer) external { vm.assume(amountStake > 0); - vm.assume(0 < amountTransfer && amountTransfer <= stakeToken.convertToShares(amountStake)); + vm.assume(0 < sharesTransfer && sharesTransfer <= stakeToken.convertToShares(amountStake)); _deposit(amountStake, user, user); @@ -129,9 +131,9 @@ contract ERC20Tests is StakeTestBase { IERC20Errors.ERC20InsufficientAllowance.selector, someone, 0, - amountTransfer + sharesTransfer ) ); - stakeToken.transferFrom(user, someone, amountTransfer); + stakeToken.transferFrom(user, someone, sharesTransfer); } } diff --git a/tests/ERC4626.t.sol b/tests/ERC4626.t.sol index 7a7a4b9..bbde643 100644 --- a/tests/ERC4626.t.sol +++ b/tests/ERC4626.t.sol @@ -1,13 +1,11 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; +import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; import {IERC20Errors} from 'openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol'; -import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; - import {StakeTestBase} from './utils/StakeTestBase.sol'; contract ERC4626Tests is StakeTestBase { @@ -32,7 +30,7 @@ contract ERC4626Tests is StakeTestBase { } // Due to default 1e18 exchange rate there's no rounding here at all, so I checked these values striclty - function test_previewFunctions(uint224 assets) public view { + function test_previewFunctions(uint192 assets) public view { uint256 shares = stakeToken.convertToShares(assets); uint256 previewDeposit = stakeToken.previewDeposit(assets); @@ -48,7 +46,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(previewRedeem, assets); } - function test_deposit(uint224 amountToStake) public { + function test_deposit(uint192 amountToStake) public { vm.assume(amountToStake > 0); uint256 shares = _deposit(amountToStake, user, user); @@ -60,7 +58,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), shares); } - function test_depositToSomeone(uint224 amountToStake) public { + function test_depositToSomeone(uint192 amountToStake) public { vm.assume(amountToStake > 0); uint256 shares = _deposit(amountToStake, user, someone); @@ -72,10 +70,10 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(someone), shares); } - function test_mint(uint224 amountOfShares) public { + function test_mint(uint192 amountOfShares) public { vm.assume(amountOfShares > 0); - uint256 amountToStake = stakeToken.convertToAssets(amountOfShares); + uint256 amountToStake = stakeToken.previewMint(amountOfShares); uint256 assets = _mint(amountOfShares, user, user); assertEq(assets, amountToStake); @@ -87,10 +85,10 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), amountOfShares); } - function test_mintToSomeone(uint224 amountOfShares) public { + function test_mintToSomeone(uint192 amountOfShares) public { vm.assume(amountOfShares > 0); - uint256 amountToStake = stakeToken.convertToAssets(amountOfShares); + uint256 amountToStake = stakeToken.previewMint(amountOfShares); uint256 assets = _mint(amountOfShares, user, someone); assertEq(assets, amountToStake); @@ -102,7 +100,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(someone), amountOfShares); } - function test_maxWithdraw(uint224 amountToStake) public { + function test_maxWithdraw(uint192 amountToStake) public { vm.assume(amountToStake > 0); deal(address(underlying), user, amountToStake); @@ -124,7 +122,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(assetsAvailable, amountToStake); } - function test_maxRedeem(uint224 amountToStake) public { + function test_maxRedeem(uint192 amountToStake) public { vm.assume(amountToStake > 0); uint256 shares = _deposit(amountToStake, user, user); @@ -144,9 +142,9 @@ contract ERC4626Tests is StakeTestBase { assertEq(sharesAvailable, shares); } - function test_redeem(uint224 amountStaked, uint224 amountRedeemed) public { + function test_redeem(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); - vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); + vm.assume(amountRedeemed != 0 && amountRedeemed < amountStaked); _deposit(amountStaked, user, user); uint256 sharesToRedeem = stakeToken.convertToShares(amountRedeemed); @@ -166,7 +164,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); } - function test_redeemToSomeone(uint224 amountStaked, uint224 amountRedeemed) public { + function test_redeemToSomeone(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -188,7 +186,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); } - function test_redeemWithApprove(uint224 amountStaked, uint224 amountRedeemed) public { + function test_redeemWithApprove(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -213,7 +211,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); } - function test_redeemWithoutApprove(uint224 amountStaked, uint224 amountRedeemed) public { + function test_redeemWithoutApprove(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -239,7 +237,7 @@ contract ERC4626Tests is StakeTestBase { stakeToken.redeem(sharesToRedeem, someone, user); } - function test_redeemMoreThanHave(uint224 amountStaked) public { + function test_redeemMoreThanHave(uint192 amountStaked) public { vm.assume(amountStaked > 0); uint256 shares = _deposit(amountStaked, user, user); @@ -261,7 +259,7 @@ contract ERC4626Tests is StakeTestBase { stakeToken.redeem(shares + 1, user, user); } - function test_withdraw(uint224 amountStaked, uint224 amountRedeemed) public { + function test_withdraw(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -285,7 +283,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); } - function test_withdrawToSomeone(uint224 amountStaked, uint224 amountRedeemed) public { + function test_withdrawToSomeone(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -309,7 +307,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); } - function test_withdrawWithApprove(uint224 amountStaked, uint224 amountRedeemed) public { + function test_withdrawWithApprove(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -336,7 +334,7 @@ contract ERC4626Tests is StakeTestBase { assertEq(stakeToken.balanceOf(user), stakeToken.convertToShares(amountStaked - amountRedeemed)); } - function test_withdrawWithoutApprove(uint224 amountStaked, uint224 amountRedeemed) public { + function test_withdrawWithoutApprove(uint192 amountStaked, uint192 amountRedeemed) public { vm.assume(amountStaked > 0); vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); @@ -363,7 +361,7 @@ contract ERC4626Tests is StakeTestBase { stakeToken.withdraw(amountRedeemed, someone, user); } - function test_withdrawMoreThanHave(uint224 amountStaked) public { + function test_withdrawMoreThanHave(uint192 amountStaked) public { vm.assume(amountStaked > 0); _deposit(amountStaked, user, user); @@ -385,9 +383,28 @@ contract ERC4626Tests is StakeTestBase { stakeToken.withdraw(uint256(amountStaked) + 1, user, user); } - function test_events(uint224 amountStaked, uint224 amountRedeemed) public { + function test_donationDoesntChangeTotalAssets(uint192 amountStaked, uint192 donation) public { vm.assume(amountStaked > 0); - vm.assume(amountRedeemed != 0 && amountRedeemed <= amountStaked); + + _deposit(amountStaked, user, user); + + uint256 totalAssets = stakeToken.totalAssets(); + + _dealUnderlying(donation, someone); + + vm.startPrank(someone); + + IERC20(underlying).transfer(address(stakeToken), donation); + + vm.stopPrank(); + + uint256 totalAssetsAfterDonation = stakeToken.totalAssets(); + + assertEq(totalAssets, totalAssetsAfterDonation); + } + + function test_events(uint192 amountStaked, uint224 sharesRedeemed) public { + vm.assume(stakeToken.convertToShares(amountStaked) > sharesRedeemed && sharesRedeemed > 0); _dealUnderlying(amountStaked, user); @@ -404,7 +421,7 @@ contract ERC4626Tests is StakeTestBase { skip(stakeToken.getCooldown()); vm.expectEmit(true, true, false, true); - emit Withdraw(user, user, user, amountRedeemed, stakeToken.convertToShares(amountRedeemed)); - stakeToken.redeem(amountRedeemed, user, user); + emit Withdraw(user, user, user, stakeToken.convertToAssets(sharesRedeemed), sharesRedeemed); + stakeToken.redeem(sharesRedeemed, user, user); } } diff --git a/tests/ExchangeRate.t.sol b/tests/ExchangeRate.t.sol index c0beb2a..d72f79e 100644 --- a/tests/ExchangeRate.t.sol +++ b/tests/ExchangeRate.t.sol @@ -1,88 +1,114 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; +import {SafeCast} from 'openzeppelin-contracts/contracts/utils/math/SafeCast.sol'; -import {IPoolAddressesProvider} from 'src/contracts/interfaces/IPoolAddressesProvider.sol'; import {IRewardsController} from 'src/contracts/interfaces/IRewardsController.sol'; -import {MockToken} from './utils/mock/MockTokenForExchangeRate.sol'; - -import {SafeCast} from 'openzeppelin-contracts/contracts/utils/math/SafeCast.sol'; +import {StakeTestBase} from './utils/StakeTestBase.sol'; -contract ExchangeRateTest is Test { +contract ExchangeRateTest is StakeTestBase { using SafeCast for uint256; - MockToken public mock; + /// forge-config: default.fuzz.runs = 100000 + function test_precisionLossWithSlash(uint192 assets, uint192 assetsToSlash) public { + vm.assume(assets > stakeToken.MIN_ASSETS_REMAINING()); + vm.assume(assetsToSlash > 0 && assetsToSlash < assets); + vm.assume(assets - stakeToken.MIN_ASSETS_REMAINING() >= assetsToSlash); + + uint256 shares = stakeToken.previewDeposit(assets); + + _deposit(assets, user, user); - function setUp() public { - mock = new MockToken(IRewardsController(address(0)), IPoolAddressesProvider(address(0))); + vm.startPrank(admin); + + stakeToken.slash(someone, assetsToSlash); + + vm.stopPrank(); + + uint192 assetsAfterRedeem = stakeToken.previewRedeem(shares).toUint192(); + + assertLe(assetsAfterRedeem, assets); + + assertLe(getDiff(assetsAfterRedeem, assets - assetsToSlash), 1); } /// forge-config: default.fuzz.runs = 100000 - function test_precisionLossStartingWithAssets(uint128 assets, uint128 exchangeRate) public { - // Since initial exchange rate is 1e18 and after slash it should increase only - vm.assume(assets > 0 && exchangeRate >= 1e18); - mock.setExchangeRate(exchangeRate); + function test_precisionLossStartingWithAssets( + uint192 assetsToStake, + uint192 assetsToCheck + ) public { + vm.assume(assetsToStake > assetsToCheck && assetsToCheck > 0); - uint256 shares = mock.previewDeposit(assets); - uint128 assetsAfterRedeem = mock.previewRedeem(shares).toUint128(); + _deposit(assetsToStake, user, user); - assertLe(assetsAfterRedeem, assets); - assertLe(assets - assetsAfterRedeem, 10); + uint256 sharesFromDeposit = stakeToken.previewDeposit(assetsToCheck); + uint256 assetsFromMint = stakeToken.previewMint(sharesFromDeposit); + + assertLe(getDiff(assetsToCheck, assetsFromMint), 1); + + uint256 sharesFromWithdrawal = stakeToken.previewWithdraw(assetsToCheck); + uint256 assetsFromRedeem = stakeToken.previewRedeem(sharesFromWithdrawal); + + assertLe(getDiff(assetsToCheck, assetsFromRedeem), 1); } /// forge-config: default.fuzz.runs = 100000 - function test_precisionLossStartingWithShares(uint128 sharesToMint, uint128 exchangeRate) public { - // Since initial exchange rate is 1e18 and after slash it should increase only - vm.assume(sharesToMint > 0 && exchangeRate >= 1e18); - mock.setExchangeRate(exchangeRate); + function test_precisionLossStartingWithShares( + uint192 assetsToStake, + uint224 sharesToCheck + ) public { + vm.assume( + assetsToStake > stakeToken.convertToAssets(sharesToCheck) && + sharesToCheck > sharesMultiplier() + ); - // mint function have some troubles with precision and results in worse results - uint256 assets = mock.previewMint(sharesToMint); - uint256 sharesFromDeposit = mock.previewDeposit(assets); + _deposit(assetsToStake, user, user); - assert(sharesFromDeposit >= sharesToMint); + uint256 assetsFromMint = stakeToken.previewMint(sharesToCheck); + uint256 sharesFromDeposit = stakeToken.previewDeposit(assetsFromMint); - // withdraw have the same problems - uint256 sharesAfterWithdraw = mock.previewWithdraw(assets); - uint256 assetsAfterRedeem = mock.previewRedeem(sharesFromDeposit); + assertLe(getDiff(sharesToCheck, sharesFromDeposit), 1000); - assert(assets >= assetsAfterRedeem); - assert(sharesAfterWithdraw >= sharesFromDeposit); + uint256 assetsFromRedeem = stakeToken.previewRedeem(sharesToCheck); + uint256 sharesFromWithdrawal = stakeToken.previewWithdraw(assetsFromRedeem); + + assertLe(getDiff(sharesToCheck, sharesFromWithdrawal), 1000); } /// forge-config: default.fuzz.runs = 100000 - // function test_precisionLossPower(uint128 sharesToMint, uint128 exchangeRate) public { - // // Since initial exchange rate is 1e18 and after slash it should increase only - // vm.assume(sharesToMint > 0 && exchangeRate >= 1e18); - // mock.setExchangeRate(exchangeRate); + function test_precisionLossCombinedTest( + uint192 assets, + uint192 assetsToSlash, + uint192 assetsToCheck + ) public { + vm.assume(1e20 > assets && assets > stakeToken.MIN_ASSETS_REMAINING()); + vm.assume(assetsToSlash > 0 && assetsToSlash < assets); + vm.assume(assets - stakeToken.MIN_ASSETS_REMAINING() >= assetsToSlash); - // // mint function have some troubles with precision and results in worse results - // uint256 assets = mock.previewMint(sharesToMint); - // uint256 sharesFromDeposit = mock.previewDeposit(assets); + vm.assume(assets > assetsToCheck && assetsToCheck > 0); - // uint256 checkPowerLossDiff = checkPowerLoss(sharesFromDeposit, sharesToMint); + stakeToken.previewDeposit(assets); - // console.log(checkPowerLossDiff); + _deposit(assets, user, user); - // assert(checkPowerLossDiff < 1); - // } + vm.startPrank(admin); - function checkPowerLoss(uint256 expected, uint256 get) internal pure returns (uint256 power) { - uint256 diff = getDiff(expected, get); + stakeToken.slash(someone, assetsToSlash); - while (true) { - if (diff == 0) { - return power; - } + vm.stopPrank(); - diff = diff / 10; - power++; - } - } + uint256 sharesFromDeposit_1 = stakeToken.previewDeposit(assetsToCheck); + uint256 assetsFromMint_1 = stakeToken.previewMint(sharesFromDeposit_1); + + assertLe(getDiff(assetsToCheck, assetsFromMint_1), 1); + + uint256 sharesFromWithdrawal_1 = stakeToken.previewWithdraw(assetsToCheck); + uint256 assetsFromRedeem_1 = stakeToken.previewRedeem(sharesFromWithdrawal_1); + + assertLe(getDiff(assetsToCheck, assetsFromRedeem_1), 1); - function getDiff(uint256 a, uint256 b) internal pure returns (uint256) { - return a > b ? a - b : b - a; + // check, cause they have different rounding, but same convertToShares with the same assets started + assertLe(getDiff(sharesFromDeposit_1, sharesFromWithdrawal_1), 1000); } } diff --git a/tests/Invariants.t.sol b/tests/Invariants.t.sol index 6fdda55..32487f2 100644 --- a/tests/Invariants.t.sol +++ b/tests/Invariants.t.sol @@ -1,12 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - import {StakeTestBase} from './utils/StakeTestBase.sol'; contract InvariantTest is StakeTestBase { - // we will use 192 instead of uint256 or 224, cause it will lead to overflow in this fuzzing test, due to mulDiv with new ExchangeRate /// forge-config: default.fuzz.runs = 100000 function test_exchangeRateAfterSlashingAlwaysIncreasing( uint192 amountToDeposit, @@ -19,14 +16,14 @@ contract InvariantTest is StakeTestBase { _deposit(amountToDeposit, user, user); - uint256 initialExchangeRate = stakeToken.getExchangeRate(); + uint256 defaultExchangeRate = stakeToken.previewDeposit(1); - vm.startPrank(slashingAdmin); + vm.startPrank(admin); stakeToken.slash(someone, amountToSlash); - uint256 exchangeRateAfterSlash = stakeToken.getExchangeRate(); + uint256 newExchangeRate = stakeToken.previewDeposit(1); - assertLe(initialExchangeRate, exchangeRateAfterSlash); + assertLe(defaultExchangeRate, newExchangeRate); } } diff --git a/tests/Pause.t.sol b/tests/Pause.t.sol index 61a9523..09c81e1 100644 --- a/tests/Pause.t.sol +++ b/tests/Pause.t.sol @@ -1,17 +1,16 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - +import {OwnableUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol'; import {PausableUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/utils/PausableUpgradeable.sol'; import {StakeTestBase} from './utils/StakeTestBase.sol'; contract PauseTests is StakeTestBase { - function test_setPauseByGuardian() external { + function test_setPauseByAdmin() external { assertEq(PausableUpgradeable(address(stakeToken)).paused(), false); - vm.startPrank(guardian); + vm.startPrank(admin); stakeToken.pause(); @@ -22,18 +21,20 @@ contract PauseTests is StakeTestBase { assertEq(PausableUpgradeable(address(stakeToken)).paused(), false); } - function test_setPauseByAdmin() external { + function test_setPauseNotByAdmin(address anyone) external { + vm.assume(anyone != admin && anyone != proxyAdmin); + assertEq(PausableUpgradeable(address(stakeToken)).paused(), false); - vm.startPrank(admin); + vm.startPrank(anyone); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + address(anyone) + ) + ); stakeToken.pause(); - - assertEq(PausableUpgradeable(address(stakeToken)).paused(), true); - - stakeToken.unpause(); - - assertEq(PausableUpgradeable(address(stakeToken)).paused(), false); } function test_shouldRevertWhenPauseIsActive() external { @@ -84,7 +85,7 @@ contract PauseTests is StakeTestBase { stakeToken.transfer(someone, 1); vm.stopPrank(); - vm.startPrank(slashingAdmin); + vm.startPrank(admin); vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); stakeToken.slash(someone, 1); diff --git a/tests/PermitDeposit.t.sol b/tests/PermitDeposit.t.sol index dc3cd3d..4b832c9 100644 --- a/tests/PermitDeposit.t.sol +++ b/tests/PermitDeposit.t.sol @@ -1,15 +1,13 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - -import {IStakeToken} from 'src/contracts/interfaces/IStakeToken.sol'; +import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; import {IERC20Permit} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sol'; import {IERC20Errors} from 'openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sol'; -import {ERC4626Upgradeable} from 'openzeppelin-contracts-upgradeable/contracts/token/ERC20/extensions/ERC4626Upgradeable.sol'; +import {IERC4626StakeToken} from 'src/contracts/interfaces/IERC4626StakeToken.sol'; import {StakeTestBase} from './utils/StakeTestBase.sol'; @@ -23,7 +21,7 @@ contract PermitDepositTests is StakeTestBase { bytes32 _hashedName = keccak256(bytes('MockToken')); bytes32 _hashedVersion = keccak256(bytes('1')); - function test_permitAndDepositSeparate(uint224 amountToStake) public { + function test_permitAndDepositSeparate(uint192 amountToStake) public { vm.assume(amountToStake > 0); vm.startPrank(user); @@ -39,6 +37,8 @@ contract PermitDepositTests is StakeTestBase { (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + assertEq(IERC20Permit(address(underlying)).nonces(user), 0); + IERC20Permit(address(underlying)).permit( user, address(stakeToken), @@ -49,6 +49,8 @@ contract PermitDepositTests is StakeTestBase { s ); + assertEq(IERC20Permit(address(underlying)).nonces(user), 1); + stakeToken.deposit(amountToStake, user); uint256 shares = stakeToken.previewDeposit(amountToStake); @@ -60,7 +62,7 @@ contract PermitDepositTests is StakeTestBase { assertEq(stakeToken.balanceOf(user), shares); } - function test_permitDeposit(uint224 amountToStake) public { + function test_permitDeposit(uint192 amountToStake) public { vm.assume(amountToStake > 0); vm.startPrank(user); @@ -76,10 +78,14 @@ contract PermitDepositTests is StakeTestBase { (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); - IStakeToken.SignatureParams memory sig = IStakeToken.SignatureParams(v, r, s); + IERC4626StakeToken.SignatureParams memory sig = IERC4626StakeToken.SignatureParams(v, r, s); + + assertEq(IERC20Permit(address(underlying)).nonces(user), 0); stakeToken.depositWithPermit(amountToStake, user, deadline, sig); + assertEq(IERC20Permit(address(underlying)).nonces(user), 1); + uint256 shares = stakeToken.previewDeposit(amountToStake); assertEq(stakeToken.totalAssets(), amountToStake); @@ -89,6 +95,35 @@ contract PermitDepositTests is StakeTestBase { assertEq(stakeToken.balanceOf(user), shares); } + function test_permitDepositInvalidSignature(uint192 amountToStake) public { + vm.assume(amountToStake > 1); + + vm.startPrank(user); + + uint256 deadline = block.timestamp + 1e6; + _dealUnderlying(amountToStake, user); + + bytes32 digest = keccak256( + abi.encode(PERMIT_TYPEHASH, user, address(stakeToken), 1, 0, deadline) + ); + + bytes32 hash = toTypedDataHash(_domainSeparator(), digest); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPrivateKey, hash); + + IERC4626StakeToken.SignatureParams memory sig = IERC4626StakeToken.SignatureParams(v, r, s); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(stakeToken), + 0, + amountToStake + ) + ); + stakeToken.depositWithPermit(amountToStake, user, deadline, sig); + } + // copy from OZ function _domainSeparator() private view returns (bytes32) { return diff --git a/tests/Rescuable.t.sol b/tests/Rescuable.t.sol new file mode 100644 index 0000000..4adddbb --- /dev/null +++ b/tests/Rescuable.t.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {IERC20} from 'openzeppelin-contracts/contracts/token/ERC20/IERC20.sol'; + +import {StakeTestBase} from './utils/StakeTestBase.sol'; + +contract ReceiveEther { + event Received(uint256 amount); + + receive() external payable { + emit Received(msg.value); + } +} + +contract RescuableTests is StakeTestBase { + function test_checkWhoCanRescue() public view { + assertEq(stakeToken.whoCanRescue(), admin); + } + + function test_rescue() public { + // will use the same token, but imagine if it's not underlying) + _dealUnderlying(1 ether, someone); + + vm.startPrank(someone); + + IERC20(underlying).transfer(address(stakeToken), 1 ether); + + vm.stopPrank(); + vm.startPrank(admin); + + stakeToken.emergencyTokenTransfer(address(underlying), someone, 1 ether); + + assertEq(underlying.balanceOf(address(stakeToken)), 0); + assertEq(underlying.balanceOf(someone), 1 ether); + } + + function test_rescueEther() public { + deal(address(stakeToken), 1 ether); + + address sendToMe = address(new ReceiveEther()); + + vm.stopPrank(); + vm.startPrank(admin); + + stakeToken.emergencyEtherTransfer(sendToMe, 1 ether); + + assertEq(sendToMe.balance, 1 ether); + } + + function test_rescueFromNotAdmin(address anyone) public { + vm.assume(anyone != admin && anyone != proxyAdmin); + _dealUnderlying(1 ether, someone); + + vm.startPrank(someone); + + IERC20(underlying).transfer(address(stakeToken), 1 ether); + + vm.stopPrank(); + vm.startPrank(anyone); + + vm.expectRevert('ONLY_RESCUE_GUARDIAN'); + stakeToken.emergencyTokenTransfer(address(underlying), someone, 1 ether); + } +} diff --git a/tests/Slashing.t.sol b/tests/Slashing.t.sol index f28bf1f..d00501b 100644 --- a/tests/Slashing.t.sol +++ b/tests/Slashing.t.sol @@ -1,25 +1,32 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - -import {IStakeToken} from 'src/contracts/interfaces/IStakeToken.sol'; +import {OwnableUpgradeable} from 'openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol'; import {StakeToken} from 'src/contracts/StakeToken.sol'; +import {IERC4626StakeToken} from 'src/contracts/interfaces/IERC4626StakeToken.sol'; + import {StakeTestBase} from './utils/StakeTestBase.sol'; contract SlashingTests is StakeTestBase { - function test_slashWithWrongCaller() external { - vm.startPrank(user); + function test_slashNotByAdmin(address anyone) external { + vm.assume(anyone != admin && anyone != proxyAdmin); + + vm.startPrank(anyone); - vm.expectRevert(IStakeToken.CallerIsNotSlashingAdmin.selector); + vm.expectRevert( + abi.encodeWithSelector( + OwnableUpgradeable.OwnableUnauthorizedAccount.selector, + address(anyone) + ) + ); stakeToken.slash(user, type(uint256).max); } function test_slash_shouldRevertWithAmountZero() public { - vm.startPrank(slashingAdmin); + vm.startPrank(admin); - vm.expectRevert(IStakeToken.ZeroAmountSlashing.selector); + vm.expectRevert(IERC4626StakeToken.ZeroAmountSlashing.selector); stakeToken.slash(user, 0); } @@ -28,42 +35,53 @@ contract SlashingTests is StakeTestBase { _deposit(amount, user, user); - vm.startPrank(slashingAdmin); + vm.startPrank(admin); - vm.expectRevert(IStakeToken.ZeroFundsAvailable.selector); + vm.expectRevert(IERC4626StakeToken.ZeroFundsAvailable.selector); stakeToken.slash(someone, type(uint256).max); } - function test_slash() public { - _deposit(100 ether, user, user); + function test_slash(uint192 amountToStake, uint192 amountToSlash) public { + vm.assume(amountToStake > stakeToken.MIN_ASSETS_REMAINING()); + vm.assume(amountToSlash > 0 && amountToSlash < amountToStake); + vm.assume(amountToStake - stakeToken.MIN_ASSETS_REMAINING() >= amountToSlash); - vm.startPrank(slashingAdmin); + _deposit(amountToStake, user, user); - stakeToken.slash(someone, 20 ether); + vm.startPrank(admin); + + stakeToken.slash(someone, amountToSlash); vm.stopPrank(); - assertEq(underlying.balanceOf(someone), 20 ether); - assertEq(underlying.balanceOf(address(stakeToken)), 80 ether); + assertEq(underlying.balanceOf(someone), amountToSlash); + assertEq(underlying.balanceOf(address(stakeToken)), amountToStake - amountToSlash); - assertEq(stakeToken.getExchangeRate(), 1.25 ether); - assertEq(stakeToken.convertToAssets(stakeToken.balanceOf(user)), 80 ether); + assertEq(stakeToken.convertToAssets(stakeToken.balanceOf(user)), amountToStake - amountToSlash); } - function test_stakeAfterSlash() public { - uint256 shares = _deposit(100 ether, user, user); + function test_stakeAfterSlash(uint192 amountToStake, uint192 amountToSlash) public { + vm.assume(amountToStake > stakeToken.MIN_ASSETS_REMAINING()); + vm.assume(amountToSlash > 0 && amountToSlash < amountToStake); + vm.assume(amountToStake - amountToSlash >= stakeToken.MIN_ASSETS_REMAINING()); + vm.assume(uint256(amountToStake) * 2 - amountToSlash < type(uint192).max); + + _deposit(amountToStake, user, user); - vm.startPrank(slashingAdmin); + vm.startPrank(admin); - stakeToken.slash(someone, 20 ether); + stakeToken.slash(someone, amountToSlash); vm.stopPrank(); - _deposit(100 ether, someone, someone); + _deposit(amountToStake, user, user); - assertEq(stakeToken.balanceOf(someone), 125 ether); - assertEq(stakeToken.balanceOf(user), shares); + assertEq(underlying.balanceOf(someone), amountToSlash); + assertEq(underlying.balanceOf(address(stakeToken)), 2 * uint256(amountToStake) - amountToSlash); - assertEq(stakeToken.totalAssets(), 180 ether); + assertEq( + stakeToken.convertToAssets(stakeToken.balanceOf(user)), + 2 * uint256(amountToStake) - amountToSlash + ); } } diff --git a/tests/StakeTokenConfig.t.sol b/tests/StakeTokenConfig.t.sol index dc5706a..4391b58 100644 --- a/tests/StakeTokenConfig.t.sol +++ b/tests/StakeTokenConfig.t.sol @@ -1,8 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.0; -import 'forge-std/Test.sol'; - import {StakeTestBase} from './utils/StakeTestBase.sol'; contract StakeTokenConfigTests is StakeTestBase { @@ -23,6 +21,24 @@ contract StakeTokenConfigTests is StakeTestBase { } function test_decimals() public view { - assertEq(stakeToken.decimals(), 18); + assertEq(stakeToken.decimals(), 18 + _decimalsOffset()); + } + + function test_transferOwnership(address anyone) public { + vm.assume(anyone != address(0)); + + vm.startPrank(admin); + + stakeToken.transferOwnership(anyone); + + assertEq(stakeToken.owner(), anyone); + } + + function test_renounceOwnership() public { + vm.startPrank(admin); + + stakeToken.renounceOwnership(); + + assertEq(stakeToken.owner(), address(0)); } } diff --git a/tests/utils/StakeTestBase.sol b/tests/utils/StakeTestBase.sol index bebee29..a467ba1 100644 --- a/tests/utils/StakeTestBase.sol +++ b/tests/utils/StakeTestBase.sol @@ -5,40 +5,30 @@ import 'forge-std/Test.sol'; import {VmSafe} from 'forge-std/Vm.sol'; -import {IStakeToken} from 'src/contracts/interfaces/IStakeToken.sol'; - -import {IRewardsController} from 'src/contracts/interfaces/IRewardsController.sol'; -import {IPoolAddressesProvider} from 'src/contracts/interfaces/IPoolAddressesProvider.sol'; - import {IERC20Metadata} from 'openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol'; import {TransparentUpgradeableProxy} from 'solidity-utils/contracts/transparent-proxy/TransparentUpgradeableProxy.sol'; import {StakeToken} from 'src/contracts/StakeToken.sol'; +import {IRewardsController} from 'src/contracts/interfaces/IRewardsController.sol'; import {MockERC20Permit} from './mock/MockERC20Permit.sol'; -import {MockACLManager} from './mock/MockACLManager.sol'; -import {MockAddressProvider} from './mock/MockAddressProvider.sol'; import {MockRewardsController} from './mock/MockRewardsController.sol'; contract StakeTestBase is Test { address public admin = vm.addr(0x1000); - address public guardian = vm.addr(0x2000); - uint256 userPrivateKey = 0x3000; + uint256 public userPrivateKey = 0x3000; address public user = vm.addr(userPrivateKey); address public someone = vm.addr(0x4000); address public proxyAdmin = vm.addr(0x5000); - address public slashingAdmin = vm.addr(0x9000); IERC20Metadata public underlying; - IStakeToken public stakeToken; + StakeToken public stakeToken; - address mockAddressProvider; - address mockACLManager; - address mockRewardsContoller; + address public mockRewardsController; function setUp() public virtual { _setupProtocol(); @@ -46,11 +36,8 @@ contract StakeTestBase is Test { } function _setupStakeToken(address stakeTokenUnderlying) internal { - StakeToken stakeTokenImpl = new StakeToken( - IRewardsController(mockRewardsContoller), - IPoolAddressesProvider(mockAddressProvider) - ); - stakeToken = IStakeToken( + StakeToken stakeTokenImpl = new StakeToken(IRewardsController(mockRewardsController)); + stakeToken = StakeToken( address( new TransparentUpgradeableProxy( address(stakeTokenImpl), @@ -61,7 +48,6 @@ contract StakeTestBase is Test { 'Stake Test', 'stkTest', admin, - guardian, 15 days, 2 days ) @@ -71,10 +57,7 @@ contract StakeTestBase is Test { } function _setupProtocol() internal { - mockACLManager = address(new MockACLManager(slashingAdmin)); - - mockAddressProvider = address(new MockAddressProvider(mockACLManager)); - mockRewardsContoller = address(new MockRewardsController()); + mockRewardsController = address(new MockRewardsController()); underlying = new MockERC20Permit('MockToken', 'MTK'); } @@ -105,7 +88,7 @@ contract StakeTestBase is Test { address actor, address receiver ) internal returns (uint256) { - uint256 amountOfAssets = stakeToken.convertToAssets(amountOfShares); + uint256 amountOfAssets = stakeToken.previewMint(amountOfShares); _dealUnderlying(amountOfAssets, actor); @@ -118,4 +101,16 @@ contract StakeTestBase is Test { return assets; } + + function sharesMultiplier() internal pure returns (uint256) { + return 10 ** _decimalsOffset(); + } + + function _decimalsOffset() internal pure returns (uint256) { + return 3; + } + + function getDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a - b : b - a; + } } diff --git a/tests/utils/mock/MockACLManager.sol b/tests/utils/mock/MockACLManager.sol deleted file mode 100644 index 2d348b8..0000000 --- a/tests/utils/mock/MockACLManager.sol +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -contract MockACLManager { - address public slashingManager; - - constructor(address newSlashingManager) { - slashingManager = newSlashingManager; - } - - function hasRole(bytes32, address who) public view returns (bool) { - if (who == slashingManager) { - return true; - } - - return false; - } -} diff --git a/tests/utils/mock/MockAddressProvider.sol b/tests/utils/mock/MockAddressProvider.sol deleted file mode 100644 index cb76789..0000000 --- a/tests/utils/mock/MockAddressProvider.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -contract MockAddressProvider { - address public aclManager; - - constructor(address newAclManager) { - aclManager = newAclManager; - } - - function getACLManager() public view returns (address) { - return aclManager; - } -} diff --git a/tests/utils/mock/MockTokenForExchangeRate.sol b/tests/utils/mock/MockTokenForExchangeRate.sol deleted file mode 100644 index 1c3e2f3..0000000 --- a/tests/utils/mock/MockTokenForExchangeRate.sol +++ /dev/null @@ -1,21 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.0; - -import {IPoolAddressesProvider} from 'src/contracts/interfaces/IPoolAddressesProvider.sol'; -import {IRewardsController} from 'src/contracts/interfaces/IRewardsController.sol'; - -import {StakeToken} from 'src/contracts/StakeToken.sol'; -import {SafeCast} from 'openzeppelin-contracts/contracts/utils/math/SafeCast.sol'; - -contract MockToken is StakeToken { - using SafeCast for uint256; - - constructor( - IRewardsController rewardsController, - IPoolAddressesProvider provider - ) StakeToken(rewardsController, provider) {} - - function setExchangeRate(uint256 newExchangeRate) public { - _updateExchangeRate(newExchangeRate.toUint192()); - } -}