diff --git a/.openzeppelin/sepolia.json b/.openzeppelin/sepolia.json index e987c391..f615f32f 100644 --- a/.openzeppelin/sepolia.json +++ b/.openzeppelin/sepolia.json @@ -10,6 +10,11 @@ "address": "0x2441Ce5eB269505f30F6F434D21E039438aaC342", "txHash": "0x842a721a0030bf9f531b294ea1d97911960f3b9b12c6eb8f36aceaba93c77cdf", "kind": "uups" + }, + { + "address": "0xF778D88620e395EA7e8F6808fA18703ee733Ee9F", + "txHash": "0x9610332710957bb58436a2c2a9e0ea24f4720549bd43ea02538900fe9ea39465", + "kind": "uups" } ], "impls": { @@ -370,6 +375,269 @@ } } } + }, + "9d23a2e905b3966754acabf541b93cd7fff74e38b2360efa3ef3023a67075323": { + "address": "0xd302BB5ac9e1B0c6F179501eC3e2326118D24A8a", + "txHash": "0x9989032d9dccc2b6e6bcbfd1dbf34c899da11fc87c3e7e7b2c17cddd9695dc0c", + "layout": { + "solcVersion": "0.8.9", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "openzeppelin-contracts-upgradeable-4.9/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "openzeppelin-contracts-upgradeable-4.9/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_balances", + "offset": 0, + "slot": "51", + "type": "t_mapping(t_address,t_uint256)", + "contract": "ERC20Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/ERC20Upgradeable.sol:40" + }, + { + "label": "_allowances", + "offset": 0, + "slot": "52", + "type": "t_mapping(t_address,t_mapping(t_address,t_uint256))", + "contract": "ERC20Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/ERC20Upgradeable.sol:42" + }, + { + "label": "_totalSupply", + "offset": 0, + "slot": "53", + "type": "t_uint256", + "contract": "ERC20Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/ERC20Upgradeable.sol:44" + }, + { + "label": "_name", + "offset": 0, + "slot": "54", + "type": "t_string_storage", + "contract": "ERC20Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/ERC20Upgradeable.sol:46" + }, + { + "label": "_symbol", + "offset": 0, + "slot": "55", + "type": "t_string_storage", + "contract": "ERC20Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/ERC20Upgradeable.sol:47" + }, + { + "label": "__gap", + "offset": 0, + "slot": "56", + "type": "t_array(t_uint256)45_storage", + "contract": "ERC20Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/ERC20Upgradeable.sol:376" + }, + { + "label": "_asset", + "offset": 0, + "slot": "101", + "type": "t_contract(IERC20Upgradeable)3233", + "contract": "ERC4626Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/extensions/ERC4626Upgradeable.sol:54" + }, + { + "label": "_underlyingDecimals", + "offset": 20, + "slot": "101", + "type": "t_uint8", + "contract": "ERC4626Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/extensions/ERC4626Upgradeable.sol:55" + }, + { + "label": "__gap", + "offset": 0, + "slot": "102", + "type": "t_array(t_uint256)49_storage", + "contract": "ERC4626Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/token/ERC20/extensions/ERC4626Upgradeable.sol:267" + }, + { + "label": "__gap", + "offset": 0, + "slot": "151", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC165Upgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/utils/introspection/ERC165Upgradeable.sol:41" + }, + { + "label": "_roles", + "offset": 0, + "slot": "201", + "type": "t_mapping(t_bytes32,t_struct(RoleData)1331_storage)", + "contract": "AccessControlUpgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/access/AccessControlUpgradeable.sol:62" + }, + { + "label": "__gap", + "offset": 0, + "slot": "202", + "type": "t_array(t_uint256)49_storage", + "contract": "AccessControlUpgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/access/AccessControlUpgradeable.sol:260" + }, + { + "label": "__gap", + "offset": 0, + "slot": "251", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "301", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "openzeppelin-contracts-upgradeable-4.9/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "_treasury", + "offset": 0, + "slot": "351", + "type": "t_address", + "contract": "GeneralFortaStakingVault", + "src": "contracts/components/staking/GeneralFortaStakingVault.sol:28" + }, + { + "label": "_withdrawalDelay", + "offset": 20, + "slot": "351", + "type": "t_uint64", + "contract": "GeneralFortaStakingVault", + "src": "contracts/components/staking/GeneralFortaStakingVault.sol:29" + }, + { + "label": "_depositTimes", + "offset": 0, + "slot": "352", + "type": "t_mapping(t_address,t_uint256)", + "contract": "GeneralFortaStakingVault", + "src": "contracts/components/staking/GeneralFortaStakingVault.sol:31" + }, + { + "label": "__gap", + "offset": 0, + "slot": "353", + "type": "t_array(t_uint256)48_storage", + "contract": "GeneralFortaStakingVault", + "src": "contracts/components/staking/GeneralFortaStakingVault.sol:145" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)45_storage": { + "label": "uint256[45]", + "numberOfBytes": "1440" + }, + "t_array(t_uint256)48_storage": { + "label": "uint256[48]", + "numberOfBytes": "1536" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_contract(IERC20Upgradeable)3233": { + "label": "contract IERC20Upgradeable", + "numberOfBytes": "20" + }, + "t_mapping(t_address,t_bool)": { + "label": "mapping(address => bool)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_mapping(t_address,t_uint256))": { + "label": "mapping(address => mapping(address => uint256))", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_mapping(t_bytes32,t_struct(RoleData)1331_storage)": { + "label": "mapping(bytes32 => struct AccessControlUpgradeable.RoleData)", + "numberOfBytes": "32" + }, + "t_string_storage": { + "label": "string", + "numberOfBytes": "32" + }, + "t_struct(RoleData)1331_storage": { + "label": "struct AccessControlUpgradeable.RoleData", + "members": [ + { + "label": "members", + "type": "t_mapping(t_address,t_bool)", + "offset": 0, + "slot": "0" + }, + { + "label": "adminRole", + "type": "t_bytes32", + "offset": 0, + "slot": "1" + } + ], + "numberOfBytes": "64" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint64": { + "label": "uint64", + "numberOfBytes": "8" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": {} + } } } } diff --git a/contracts/components/staking/GeneralFortaStakingVault.sol b/contracts/components/staking/GeneralFortaStakingVault.sol new file mode 100644 index 00000000..283751e5 --- /dev/null +++ b/contracts/components/staking/GeneralFortaStakingVault.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: UNLICENSED +// See Forta Network License: https://github.com/forta-network/forta-contracts/blob/master/LICENSE.md + +pragma solidity ^0.8.9; + +import "openzeppelin-contracts-4.9/utils/Multicall.sol"; +import "openzeppelin-contracts-4.9/token/ERC20/IERC20.sol"; +import "openzeppelin-contracts-4.9/token/ERC20/utils/SafeERC20.sol"; + +import "openzeppelin-contracts-upgradeable-4.9/proxy/utils/UUPSUpgradeable.sol"; +import "openzeppelin-contracts-upgradeable-4.9/token/ERC20/IERC20Upgradeable.sol"; +import "openzeppelin-contracts-upgradeable-4.9/access/AccessControlUpgradeable.sol"; +import "openzeppelin-contracts-upgradeable-4.9/token/ERC20/extensions/ERC4626Upgradeable.sol"; + +import "../../errors/GeneralErrors.sol"; +import "../utils/IVersioned.sol"; + +contract GeneralFortaStakingVault is ERC4626Upgradeable, AccessControlUpgradeable, UUPSUpgradeable, Multicall, IVersioned { + using SafeERC20 for IERC20; + + bytes32 public constant SLASHER_ROLE = keccak256("SLASHER_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + + uint256 public constant MIN_WITHDRAWAL_DELAY = 1 days; + uint256 public constant MAX_WITHDRAWAL_DELAY = 90 days; + + // treasury for slashing + address private _treasury; + uint64 private _withdrawalDelay; + // depositor => deposit timestamp + mapping(address => uint256) private _depositTimes; + + event Slashed(address indexed by, uint256 indexed value); + event DelaySet(uint256 newWithdrawalDelay); + event TreasurySet(address newTreasury); + + error WithdrawalNotReady(); + + string public constant version = "0.1.0"; + + /** + * @notice Initializer method, access point to initialize inheritance tree. + * @param __admin Granted DEFAULT_ADMIN_ROLE. + * @param __asset Asset to stake (FORT). + * @param __treasury address where the slashed tokens go to. + * @param __withdrawalDelay minimum delay between depositing/minting and withdraw/redeem (in seconds). + */ + function initialize(address __admin, address __asset, address __treasury, uint64 __withdrawalDelay) public initializer { + if (__admin == address(0)) revert ZeroAddress("__admin"); + if (__asset == address(0)) revert ZeroAddress("__asset"); + if (__treasury == address(0)) revert ZeroAddress("__treasury"); + if(__withdrawalDelay == 0) revert ZeroAmount("__withdrawalDelay"); + + __ERC20_init("General FORT Staking Vault", "vFORTGeneral"); + __ERC4626_init(IERC20Upgradeable(__asset)); + __AccessControl_init(); + __UUPSUpgradeable_init(); + _grantRole(DEFAULT_ADMIN_ROLE, __admin); + _treasury = __treasury; + _withdrawalDelay = __withdrawalDelay; + } + + /** + * @notice Slash an amount of the vault's underlying asset, and transfer it to the treasury. + * Restricted to the `SLASHER_ROLE`. + * @dev This will alter the relationship between shares and assets. + * Emits a Slashed event. + * @param stakeValue amount of staked token to be slashed. + */ + function slash( + uint256 stakeValue + ) external onlyRole(SLASHER_ROLE) { + if (stakeValue == 0) revert ZeroAmount("stakeValue"); + SafeERC20.safeTransfer(IERC20(asset()), _treasury, stakeValue); + emit Slashed(_msgSender(), stakeValue); + } + + /// Returns treasury address (slashed tokens destination) + function treasury() public view returns (address) { + return _treasury; + } + + /// Returns withdrawal delay needed to wait before exiting vault (in seconds) + function withdrawalDelay() public view returns (uint64) { + return _withdrawalDelay; + } + + /** + * @notice Sets withdrawal delay. Restricted to DEFAULT_ADMIN_ROLE + * @param newDelay in seconds. + */ + function setDelay(uint64 newDelay) external onlyRole(DEFAULT_ADMIN_ROLE) { + // TODO: Uncomment for PROD + // if (newDelay < MIN_WITHDRAWAL_DELAY) revert AmountTooSmall(newDelay, MIN_WITHDRAWAL_DELAY); + // if (newDelay > MAX_WITHDRAWAL_DELAY) revert AmountTooLarge(newDelay, MAX_WITHDRAWAL_DELAY); + _withdrawalDelay = newDelay; + emit DelaySet(newDelay); + } + + /** + * @notice Sets destination of slashed tokens. Restricted to DEFAULT_ADMIN_ROLE + * @param newTreasury address. + */ + function setTreasury(address newTreasury) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (newTreasury == address(0)) revert ZeroAddress("newTreasury"); + _treasury = newTreasury; + emit TreasurySet(newTreasury); + } + + // Access control for the upgrade process + function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(UPGRADER_ROLE) { + } + + /** + * @inheritdoc ERC4626Upgradeable + * @dev Modified to track user deposits' timestamp for withdrawal delay + */ + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { + _depositTimes[caller] = block.timestamp; + super._deposit(caller, receiver, assets, shares); + } + + /** + * @inheritdoc ERC4626Upgradeable + * @dev Modified to check user deposits' timestamp for lapse of their withdrawal delay + */ + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal override { + if(_depositTimes[caller] + _withdrawalDelay > block.timestamp) revert WithdrawalNotReady(); + super._withdraw(caller, receiver, owner, assets, shares); + } + + /** + * 50 + * - 1 _treasury + _withdrawalDelay + * - 1 _depositTimes + * -------------------------- + * 48 __gap + */ + uint256[48] private __gap; +} \ No newline at end of file diff --git a/package.json b/package.json index 617372c1..750a8951 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "@ensdomains/ens-contracts": "^0.0.7", "@gnosis.pm/safe-ethers-lib": "^1.1.0", "@openzeppelin/contracts": "4.7.0", - "@openzeppelin/contracts-upgradeable": "4.7.0" + "@openzeppelin/contracts-upgradeable": "4.7.0", + "openzeppelin-contracts-4.9": "npm:@openzeppelin/contracts@4.9.0", + "openzeppelin-contracts-upgradeable-4.9": "npm:@openzeppelin/contracts-upgradeable@4.9.0" }, "devDependencies": { "@ethersproject/experimental": "^5.4.0", diff --git a/releases/1.2.13/ethereum/config/deploy.json b/releases/1.2.13/ethereum/config/deploy.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/releases/1.2.13/ethereum/config/deploy.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/releases/1.2.13/ethereum/output/deployed.json b/releases/1.2.13/ethereum/output/deployed.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/releases/1.2.13/ethereum/output/deployed.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/releases/1.2.13/index.yml b/releases/1.2.13/index.yml new file mode 100644 index 00000000..d12e5364 --- /dev/null +++ b/releases/1.2.13/index.yml @@ -0,0 +1,8 @@ +title: General FORT Staking Vault deployment +network: ethereum +deploy: deploy-and-prepare-upgrade 1.2.13 +verify: verify-deployed +finish: propose-admin +description: | + ## Deployed new General FORT Staking Vault on Ethereum mainnet. + diff --git a/releases/1.2.13/sepolia/config/deploy.json b/releases/1.2.13/sepolia/config/deploy.json new file mode 100644 index 00000000..739a61e8 --- /dev/null +++ b/releases/1.2.13/sepolia/config/deploy.json @@ -0,0 +1,13 @@ +{ + "GeneralFortaStakingVault": { + "impl": { + "init-args": ["0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24", "deployment.forta", "0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24", "180"], + "opts": { + "unsafe-allow": [ + "delegatecall" + ], + "constructor-args": [] + } + } + } +} \ No newline at end of file diff --git a/releases/1.2.13/sepolia/output/deployed.json b/releases/1.2.13/sepolia/output/deployed.json new file mode 100644 index 00000000..a92381e2 --- /dev/null +++ b/releases/1.2.13/sepolia/output/deployed.json @@ -0,0 +1,19 @@ +{ + "general-forta-staking-vault-deploy-tx": "0x9610332710957bb58436a2c2a9e0ea24f4720549bd43ea02538900fe9ea39465", + "general-forta-staking-vault": { + "address": "0xF778D88620e395EA7e8F6808fA18703ee733Ee9F", + "impl": { + "address": "0xd302BB5ac9e1B0c6F179501eC3e2326118D24A8a", + "constructor-args": [], + "init-args": [ + "0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24", + "0x95d9a757ad9C25999ffE93f3067221F04ce1Cc79", + "0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24", + "180" + ], + "name": "GeneralFortaStakingVault", + "timeout": 1200000, + "version": "0.1.0" + } + } +} \ No newline at end of file diff --git a/releases/deployments/11155111.json b/releases/deployments/11155111.json index 8f882cb0..073de0c5 100644 --- a/releases/deployments/11155111.json +++ b/releases/deployments/11155111.json @@ -12,5 +12,21 @@ "version": "0.2.0" } }, - "forta-deploy-tx": "0xe6630a9172a655398a5df198ab4534a7e108d590ea3974243e030a42a6f177ec" + "general-forta-staking-vault": { + "address": "0xF778D88620e395EA7e8F6808fA18703ee733Ee9F", + "impl": { + "address": "0xd302BB5ac9e1B0c6F179501eC3e2326118D24A8a", + "constructor-args": [], + "init-args": [ + "0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24", + "0x95d9a757ad9C25999ffE93f3067221F04ce1Cc79", + "0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24", + "180" + ], + "name": "GeneralFortaStakingVault", + "timeout": 1200000, + "version": "0.1.0" + } + }, + "general-forta-staking-vault-deploy-tx": "0x9610332710957bb58436a2c2a9e0ea24f4720549bd43ea02538900fe9ea39465" } \ No newline at end of file diff --git a/releases/deployments/multisigs.json b/releases/deployments/multisigs.json index 500ec3b6..43670be7 100644 --- a/releases/deployments/multisigs.json +++ b/releases/deployments/multisigs.json @@ -1,7 +1,7 @@ { "mumbai": "0x19AD705930B6695812c921f08b16F7DfAF59A536", "polygon": "0x30ceaeC1d8Ed347B91d45077721c309242db3D6d", - "sepolia": "0x0000000000000000000000000000000000000000", + "sepolia": "0x4e22284F3aDC3c023c9D8bb4Fbb27fbb96ef6d5e", "basesepolia": "0x4e22284F3aDC3c023c9D8bb4Fbb27fbb96ef6d5e", "local": "0x233BAc002bF01DA9FEb9DE57Ff7De5B3820C1a24" } \ No newline at end of file diff --git a/scripts/deployments/platform.js b/scripts/deployments/platform.js index 0ba5cc11..39412727 100644 --- a/scripts/deployments/platform.js +++ b/scripts/deployments/platform.js @@ -69,6 +69,21 @@ async function migrate(config = {}) { DEBUG(`[${Object.keys(contracts).length}] forta: ${contracts.token.address}`); + const tenDaysInSeconds = 60 * 60 * 24 * 10; + contracts.generalStaking = await contractHelpers.tryFetchProxy( + hre, + 'GeneralFortaStakingVault', + 'uups', + [deployer.address, contracts.token.address, deployEnv.TREASURY(chainId, deployer), tenDaysInSeconds], + { + constructorArgs: [], + unsafeAllow: ['delegatecall'], + }, + CACHE + ); + + DEBUG(`[${Object.keys(contracts).length}] forta: ${contracts.generalStaking.address}`); + if (config.childChain || chainId === 31337) { contracts.access = await contractHelpers.tryFetchProxy( hre, diff --git a/test/components/staking.general.vault.test.js b/test/components/staking.general.vault.test.js new file mode 100644 index 00000000..f4863f91 --- /dev/null +++ b/test/components/staking.general.vault.test.js @@ -0,0 +1,567 @@ +const hre = require('hardhat'); +const helpers = require('@nomicfoundation/hardhat-network-helpers'); +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { prepare } = require('../fixture'); + +const oneThousandTokens = ethers.utils.parseUnits('1000'); +const tenThousandTokens = ethers.utils.parseUnits('10000'); + +async function mineDaysWorthOfBlocks(amountOfDays) { + const twelveSecBlockTime = 12; + const elevenDaysInBlocks = (60 * 60 * 24 * amountOfDays) / twelveSecBlockTime; + await hre.network.provider.send("hardhat_mine", ["0x" + elevenDaysInBlocks.toString(16), "0x" + twelveSecBlockTime.toString(16)]); +} + +describe('General Forta Staking Vault on Ethereum', function () { + prepare({ mainnet: true }); + + beforeEach(async function () { + await this.token.connect(this.accounts.minter).mint(this.accounts.user1.address, oneThousandTokens); + await this.token.connect(this.accounts.minter).mint(this.accounts.user2.address, oneThousandTokens); + await this.token.connect(this.accounts.minter).mint(this.accounts.user3.address, oneThousandTokens); + // `admin` serves as the account transferring tokens into the vault as rewards + await this.token.connect(this.accounts.minter).mint(this.accounts.admin.address, tenThousandTokens); + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal(oneThousandTokens); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal(oneThousandTokens); + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal(oneThousandTokens); + + await this.token.connect(this.accounts.user1).approve(this.generalStaking.address, ethers.constants.MaxUint256); + await this.token.connect(this.accounts.user2).approve(this.generalStaking.address, ethers.constants.MaxUint256); + await this.token.connect(this.accounts.user3).approve(this.generalStaking.address, ethers.constants.MaxUint256); + // `slasher` approves the vault balance of tokens, so that the slashing functionality is executable + await this.token.connect(this.accounts.slasher).approve(this.generalStaking.address, ethers.constants.MaxUint256); + }); + + describe('Access Control', async function () { + it('roles check', async function () { + expect(await this.generalStaking.hasRole(this.roles.ADMIN, this.accounts.admin.address)); + expect(await this.generalStaking.hasRole(this.roles.SLASHER, this.accounts.slasher.address)); + }); + + it('only ADMIN can set treasury address', async function () { + await expect(this.generalStaking.connect(this.accounts.user1).setTreasury(this.accounts.user1.address)).to.be.reverted; + + await expect(this.generalStaking.connect(this.accounts.admin).setTreasury(this.accounts.other.address)).to.not.be.reverted; + }); + + it('only ADMIN can set withdrawal delay', async function () { + const twoDaysInSecs = 60 * 60 * 24 * 2; + await expect(this.generalStaking.connect(this.accounts.user1).setDelay(twoDaysInSecs)).to.be.reverted; + + await expect(this.generalStaking.connect(this.accounts.admin).setDelay(twoDaysInSecs)).to.not.be.reverted; + }) + }); + + describe('Staking', async function () { + it('single depositor stakes and receives vault shares', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + // two hundred, since `user1` is the only depositor right now + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + expect(await this.generalStaking.totalSupply()).to.equal(twoHundredTokens); + expect(await this.generalStaking.totalAssets()).to.equal(twoHundredTokens); + }); + + it('multiple depositors stake and receive vault shares', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + const oneHundredFiftyTokens = ethers.utils.parseUnits('150'); + const threeHundredFifteenTokens = ethers.utils.parseUnits('315'); + + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user2).deposit(oneHundredFiftyTokens, this.accounts.user2.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user3).deposit(threeHundredFifteenTokens, this.accounts.user3.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal((oneThousandTokens.sub(oneHundredFiftyTokens))); + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal((oneThousandTokens.sub(threeHundredFifteenTokens))); + + // 1:1 exchange rate between vault's assets and shares + // because vault has not earned additional assets as rewards + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(oneHundredFiftyTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(threeHundredFifteenTokens); + + const totalTokensDeposited = twoHundredTokens.add(oneHundredFiftyTokens.add(threeHundredFifteenTokens)); + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited); + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited); + }); + + it('multiple depositors stake, receive vault shares, and redeem their shares for vault underlying assets after withdrawal delay', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + const oneHundredFiftyTokens = ethers.utils.parseUnits('150'); + const threeHundredFifteenTokens = ethers.utils.parseUnits('315'); + + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user2).deposit(oneHundredFiftyTokens, this.accounts.user2.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user3).deposit(threeHundredFifteenTokens, this.accounts.user3.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal((oneThousandTokens.sub(oneHundredFiftyTokens))); + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal((oneThousandTokens.sub(threeHundredFifteenTokens))); + + // 1:1 exchange rate between vault's assets and shares + // because vault has not earned additional assets as rewards + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(oneHundredFiftyTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(threeHundredFifteenTokens); + + const totalTokensDeposited = twoHundredTokens.add(oneHundredFiftyTokens.add(threeHundredFifteenTokens)); + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited); + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited); + + + + const user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.revertedWith('WithdrawalNotReady()'); + + const user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + await expect(this.generalStaking.connect(this.accounts.user2).redeem(user2MaxRedeemShares, this.accounts.user2.address, this.accounts.user2.address)).to.be.revertedWith('WithdrawalNotReady()'); + + const user3MaxRedeemShares = await this.generalStaking.connect(this.accounts.user3).maxRedeem(this.accounts.user3.address); + await expect(this.generalStaking.connect(this.accounts.user3).redeem(user3MaxRedeemShares, this.accounts.user3.address, this.accounts.user3.address)).to.be.revertedWith('WithdrawalNotReady()'); + + + // Mine eleven day's worth of blocks + await mineDaysWorthOfBlocks(11); + + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.not.reverted; + + // Post `user1` redemption checks + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens)); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited.sub(twoHundredTokens)); + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited.sub(twoHundredTokens)); + + + await expect(this.generalStaking.connect(this.accounts.user2).redeem(user2MaxRedeemShares, this.accounts.user2.address, this.accounts.user2.address)).to.be.not.reverted; + + // Post `user2` redemption checks + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal((oneThousandTokens)); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + // Only `user3` remains staked in the vault + expect(await this.generalStaking.totalSupply()).to.equal(threeHundredFifteenTokens); + expect(await this.generalStaking.totalAssets()).to.equal(threeHundredFifteenTokens); + + + await expect(this.generalStaking.connect(this.accounts.user3).redeem(user3MaxRedeemShares, this.accounts.user3.address, this.accounts.user3.address)).to.be.not.reverted; + + // Post `user3` redemption checks + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + // No stakers remaining + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + }); + }); + + describe('Rewards', async function () { + it('single depositor stakes, receives vault shares, and redeems for assets plus rewards after withdrawal delay', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + // two hundred, since `user1` is the only depositor right now + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + expect(await this.generalStaking.totalSupply()).to.equal(twoHundredTokens); + expect(await this.generalStaking.totalAssets()).to.equal(twoHundredTokens); + + const fiftyTokensRewarded = ethers.utils.parseUnits('50'); + // reward `50` tokens to vault + await this.token.connect(this.accounts.admin).transfer(this.generalStaking.address, fiftyTokensRewarded); + + expect(await this.token.balanceOf(this.accounts.admin.address)).to.equal((oneThousandTokens.sub(fiftyTokensRewarded))); + // Since it was a `transfer` instead of `mint`/`deposit`, vault shares do not increase + expect(await this.generalStaking.totalSupply()).to.equal(twoHundredTokens); + // However, total underlying assets do increase + expect(await this.generalStaking.totalAssets()).to.equal(twoHundredTokens.add(fiftyTokensRewarded)); + + const user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.revertedWith('WithdrawalNotReady()'); + + // Mine eleven day's worth of blocks + await mineDaysWorthOfBlocks(11); + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.not.reverted; + + // Post `user1` redemption checks + expect(await this.token.balanceOf(this.accounts.user1.address)).to.be.above(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + + // No stakers remaining + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + }); + + it('multiple depositors stake, receive vault rewards, and redeem for assets plus rewards after withdrawal delay', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + const oneHundredFiftyTokens = ethers.utils.parseUnits('150'); + const threeHundredFifteenTokens = ethers.utils.parseUnits('315'); + + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user2).deposit(oneHundredFiftyTokens, this.accounts.user2.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user3).deposit(threeHundredFifteenTokens, this.accounts.user3.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal((oneThousandTokens.sub(oneHundredFiftyTokens))); + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal((oneThousandTokens.sub(threeHundredFifteenTokens))); + + // 1:1 exchange rate between vault's assets and shares + // because vault has not earned additional assets as rewards + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(oneHundredFiftyTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(threeHundredFifteenTokens); + + const totalTokensDeposited = twoHundredTokens.add(oneHundredFiftyTokens.add(threeHundredFifteenTokens)); + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited); + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited); + + + const fiveHundredTokensRewarded = ethers.utils.parseUnits('500'); + // reward `500` tokens to vault + await this.token.connect(this.accounts.admin).transfer(this.generalStaking.address, fiveHundredTokensRewarded); + + + expect(await this.token.balanceOf(this.accounts.admin.address)).to.equal((oneThousandTokens.sub(fiveHundredTokensRewarded))); + // Since it was a `transfer` instead of `mint`/`deposit`, vault shares do not increase + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited); + // However, total underlying assets do increase + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited.add(fiveHundredTokensRewarded)); + + const user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + const user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + const user3MaxRedeemShares = await this.generalStaking.connect(this.accounts.user3).maxRedeem(this.accounts.user3.address); + + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.revertedWith('WithdrawalNotReady()'); + await expect(this.generalStaking.connect(this.accounts.user2).redeem(user2MaxRedeemShares, this.accounts.user2.address, this.accounts.user2.address)).to.be.revertedWith('WithdrawalNotReady()'); + await expect(this.generalStaking.connect(this.accounts.user3).redeem(user3MaxRedeemShares, this.accounts.user3.address, this.accounts.user3.address)).to.be.revertedWith('WithdrawalNotReady()'); + + + // Mine eleven day's worth of blocks + await mineDaysWorthOfBlocks(11); + + + const user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); + const user1TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user1.address); + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.not.reverted; + + const user1TokenBalancePlusRewards = user1TokenBalanceBeforeRedemption.add(user1PreviewRedeemReturnedAssets); + // Post `user1` redemption checks + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal(user1TokenBalancePlusRewards); + // Confirm redeemed assets are greater than the one thousand that was started with + expect(user1TokenBalancePlusRewards).to.be.above(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + + + + const user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); + const user2TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user2.address); + + await expect(this.generalStaking.connect(this.accounts.user2).redeem(user2MaxRedeemShares, this.accounts.user2.address, this.accounts.user2.address)).to.be.not.reverted; + + const user2TokenBalancePlusRewards = user2TokenBalanceBeforeRedemption.add(user2PreviewRedeemReturnedAssets); + // Post `user2` redemption checks + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal(user2TokenBalancePlusRewards); + // Confirm redeemed assets are greater than the one thousand that was started with + expect(user2TokenBalancePlusRewards).to.be.above(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + + + + const user3PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user3).previewRedeem(user3MaxRedeemShares); + const user3TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user3.address); + + await expect(this.generalStaking.connect(this.accounts.user3).redeem(user3MaxRedeemShares, this.accounts.user3.address, this.accounts.user3.address)).to.be.not.reverted; + + const user3TokenBalancePlusRewards = user3TokenBalanceBeforeRedemption.add(user3PreviewRedeemReturnedAssets); + // Post `user3` redemption checks + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal(user3TokenBalancePlusRewards); + // Confirm redeemed assets are greater than the one thousand that was started with + expect(user3TokenBalancePlusRewards).to.be.above(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + // No stakers remaining + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + }); + }); + + describe('Slashing', async function () { + it('single depositor stakes, receives vault shares, vault gets slashed, and redeems for less assets than deposited after withdrawal delay', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + // two hundred, since `user1` is the only depositor right now + expect(await this.generalStaking.totalSupply()).to.equal(twoHundredTokens); + expect(await this.generalStaking.totalAssets()).to.equal(twoHundredTokens); + + + const fiftyTokensSlashed = ethers.utils.parseUnits('50'); + // slash `50` tokens from vault + await this.generalStaking.connect(this.accounts.slasher).slash(fiftyTokensSlashed); + + // Though vault was slashed, total supply of shares remains unchanged + expect(await this.generalStaking.totalSupply()).to.equal(twoHundredTokens); + // However, total underlying assets do decrease + expect(await this.generalStaking.totalAssets()).to.equal(twoHundredTokens.sub(fiftyTokensSlashed)); + + const user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + const user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); + const user1TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user1.address); + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.revertedWith('WithdrawalNotReady()'); + + // Mine eleven day's worth of blocks + await mineDaysWorthOfBlocks(11); + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.not.reverted; + + const user1TokenBalance = user1TokenBalanceBeforeRedemption.add(user1PreviewRedeemReturnedAssets); + // Post `user1` redemption checks + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal(user1TokenBalance); + // Confirm redeemed assets are lesser than the one thousand that was started with + expect(user1TokenBalance).to.be.below(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + // No stakers remaining + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + }); + + it('multiple depositors stake, receive vault shares, vault gets slashed, and redeem for less than depositor after withdrawal delay', async function () { + const twoHundredTokens = ethers.utils.parseUnits('200'); + const oneHundredFiftyTokens = ethers.utils.parseUnits('150'); + const threeHundredFifteenTokens = ethers.utils.parseUnits('315'); + + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + expect(await this.generalStaking.totalAssets()).to.equal(ethers.constants.Zero); + + await expect(this.generalStaking.connect(this.accounts.user1).deposit(twoHundredTokens, this.accounts.user1.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user2).deposit(oneHundredFiftyTokens, this.accounts.user2.address)).to.be.not.reverted; + await expect(this.generalStaking.connect(this.accounts.user3).deposit(threeHundredFifteenTokens, this.accounts.user3.address)).to.be.not.reverted; + + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal((oneThousandTokens.sub(twoHundredTokens))); + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal((oneThousandTokens.sub(oneHundredFiftyTokens))); + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal((oneThousandTokens.sub(threeHundredFifteenTokens))); + + // 1:1 exchange rate between vault's assets and shares + // because vault has not earned additional assets as rewards + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(twoHundredTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(oneHundredFiftyTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(threeHundredFifteenTokens); + + const totalTokensDeposited = twoHundredTokens.add(oneHundredFiftyTokens.add(threeHundredFifteenTokens)); + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited); + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited); + + + const oneHundredFiftyTokensSlashed = ethers.utils.parseUnits('150'); + // slash `150` tokens from vault + await this.generalStaking.connect(this.accounts.slasher).slash(oneHundredFiftyTokensSlashed); + + + expect(await this.generalStaking.totalSupply()).to.equal(totalTokensDeposited); + // However, total underlying assets do decrease + expect(await this.generalStaking.totalAssets()).to.equal(totalTokensDeposited.sub(oneHundredFiftyTokensSlashed)); + + const user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + const user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + const user3MaxRedeemShares = await this.generalStaking.connect(this.accounts.user3).maxRedeem(this.accounts.user3.address); + + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.revertedWith('WithdrawalNotReady()'); + await expect(this.generalStaking.connect(this.accounts.user2).redeem(user2MaxRedeemShares, this.accounts.user2.address, this.accounts.user2.address)).to.be.revertedWith('WithdrawalNotReady()'); + await expect(this.generalStaking.connect(this.accounts.user3).redeem(user3MaxRedeemShares, this.accounts.user3.address, this.accounts.user3.address)).to.be.revertedWith('WithdrawalNotReady()'); + + + // Mine eleven day's worth of blocks + await mineDaysWorthOfBlocks(11); + + + const user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); + const user1TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user1.address); + + await expect(this.generalStaking.connect(this.accounts.user1).redeem(user1MaxRedeemShares, this.accounts.user1.address, this.accounts.user1.address)).to.be.not.reverted; + + const user1TokenBalance = user1TokenBalanceBeforeRedemption.add(user1PreviewRedeemReturnedAssets); + // Post `user1` redemption checks + expect(await this.token.balanceOf(this.accounts.user1.address)).to.equal(user1TokenBalance); + // Confirm redeemed assets are lesser than the one thousand that was started with + expect(user1TokenBalance).to.be.below(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user1.address)).to.equal(ethers.constants.Zero); + + + + const user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); + const user2TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user2.address); + + await expect(this.generalStaking.connect(this.accounts.user2).redeem(user2MaxRedeemShares, this.accounts.user2.address, this.accounts.user2.address)).to.be.not.reverted; + + const user2TokenBalance = user2TokenBalanceBeforeRedemption.add(user2PreviewRedeemReturnedAssets); + // Post `user2` redemption checks + expect(await this.token.balanceOf(this.accounts.user2.address)).to.equal(user2TokenBalance); + // Confirm redeemed assets are lesser than the one thousand that was started with + expect(user2TokenBalance).to.be.below(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user2.address)).to.equal(ethers.constants.Zero); + + + + const user3PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user3).previewRedeem(user3MaxRedeemShares); + const user3TokenBalanceBeforeRedemption = await this.token.balanceOf(this.accounts.user3.address); + + await expect(this.generalStaking.connect(this.accounts.user3).redeem(user3MaxRedeemShares, this.accounts.user3.address, this.accounts.user3.address)).to.be.not.reverted; + + const user3TokenBalance = user3TokenBalanceBeforeRedemption.add(user3PreviewRedeemReturnedAssets); + // Post `user3` redemption checks + expect(await this.token.balanceOf(this.accounts.user3.address)).to.equal(user3TokenBalance); + // Confirm redeemed assets are lesser than the one thousand that was started with + expect(user3TokenBalance).to.be.below(oneThousandTokens); + expect(await this.generalStaking.balanceOf(this.accounts.user3.address)).to.equal(ethers.constants.Zero); + + // No stakers remaining + expect(await this.generalStaking.totalSupply()).to.equal(ethers.constants.Zero); + }) + }); + + // Not a test, per se, but used to check changes + // in FORT-vFORT exchange rate after various actions. + it.skip('lifecycle - deposits, rewards transfers, and slashing', async function () { + await expect(this.generalStaking.connect(this.accounts.user1).deposit(ethers.utils.parseUnits('200'), this.accounts.user1.address)).to.be.not.reverted; + + let user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + let user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 200.000000000000000000 redeemable FORT + let user1vFortBalance = await this.generalStaking.connect(this.accounts.user1).balanceOf(this.accounts.user1.address); // 200.000000000000000000 vFORT + + + await this.token.connect(this.accounts.admin).transfer(this.generalStaking.address, ethers.utils.parseUnits('500')); + + + user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 699.999999999999999997 redeemable FORT + + + await expect(this.generalStaking.connect(this.accounts.user2).deposit(ethers.utils.parseUnits('200'), this.accounts.user2.address)).to.be.not.reverted; + + + user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 699.999999999999999997 redeemable FORT + user1vFortBalance = await this.generalStaking.connect(this.accounts.user1).balanceOf(this.accounts.user1.address); // 200.000000000000000000 vFORT + + let user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + let user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); // 199.999999999999999999 redeemable FORT + let user2vFortBalance = await this.generalStaking.connect(this.accounts.user2).balanceOf(this.accounts.user2.address); // 57.142857142857142857 vFORT + + + await this.token.connect(this.accounts.admin).transfer(this.generalStaking.address, ethers.utils.parseUnits('500')); + + + user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 1,088.888888888888888886 redeemable FORT + user1vFortBalance = await this.generalStaking.connect(this.accounts.user1).balanceOf(this.accounts.user1.address); // 200.000000000000000000 vFORT + + user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); // 311.111111111111111109 redeemable FORT + user2vFortBalance = await this.generalStaking.connect(this.accounts.user2).balanceOf(this.accounts.user2.address); // 57.142857142857142857 vFORT + + + await expect(this.generalStaking.connect(this.accounts.user3).deposit(ethers.utils.parseUnits('200'), this.accounts.user3.address)).to.be.not.reverted; + + + user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 1,088.888888888888888886 redeemable FORT + user1vFortBalance = await this.generalStaking.connect(this.accounts.user1).balanceOf(this.accounts.user1.address); // 200.000000000000000000 vFORT + + user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); // 311.111111111111111109 redeemable FORT + user2vFortBalance = await this.generalStaking.connect(this.accounts.user2).balanceOf(this.accounts.user2.address); // 57.142857142857142857 vFORT + + let user3MaxRedeemShares = await this.generalStaking.connect(this.accounts.user3).maxRedeem(this.accounts.user3.address); + let user3PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user3).previewRedeem(user3MaxRedeemShares); // 199.999999999999999998 redeemable FORT + let user3vFortBalance = await this.generalStaking.connect(this.accounts.user3).balanceOf(this.accounts.user3.address); // 36.734693877551020408 vFORT + + + await this.token.connect(this.accounts.admin).transfer(this.generalStaking.address, ethers.utils.parseUnits('500')); + + + user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 1,429.166666666666666663 redeemable FORT + user1vFortBalance = await this.generalStaking.connect(this.accounts.user1).balanceOf(this.accounts.user1.address); // 200.000000000000000000 vFORT + + user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); // 408.333333333333333331 redeemable FORT + user2vFortBalance = await this.generalStaking.connect(this.accounts.user2).balanceOf(this.accounts.user2.address); // 57.142857142857142857 vFORT + + user3MaxRedeemShares = await this.generalStaking.connect(this.accounts.user3).maxRedeem(this.accounts.user3.address); + user3PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user3).previewRedeem(user3MaxRedeemShares); // 262.499999999999999998 redeemable FORT + user3vFortBalance = await this.generalStaking.connect(this.accounts.user3).balanceOf(this.accounts.user3.address); // 36.734693877551020408 vFORT + + + const threeHundredFiftyTokensSlashed = ethers.utils.parseUnits('350'); + await this.generalStaking.connect(this.accounts.slasher).slash(threeHundredFiftyTokensSlashed); + + + user1MaxRedeemShares = await this.generalStaking.connect(this.accounts.user1).maxRedeem(this.accounts.user1.address); + user1PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user1).previewRedeem(user1MaxRedeemShares); // 1,190.972222222222222220 redeemable FORT + user1vFortBalance = await this.generalStaking.connect(this.accounts.user1).balanceOf(this.accounts.user1.address); // 200.000000000000000000 vFORT + console.log(`\nuser1PreviewRedeemReturnedAssets - after second reward: ${user1PreviewRedeemReturnedAssets}`); + console.log(`\nuser1vFortBalance - after second : ${user1vFortBalance}`); + + user2MaxRedeemShares = await this.generalStaking.connect(this.accounts.user2).maxRedeem(this.accounts.user2.address); + user2PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user2).previewRedeem(user2MaxRedeemShares); // 340.277777777777777776 redeemable FORT + user2vFortBalance = await this.generalStaking.connect(this.accounts.user2).balanceOf(this.accounts.user2.address); // 57.142857142857142857 vFORT + console.log(`\nuser2PreviewRedeemReturnedAssets - after second reward: ${user2PreviewRedeemReturnedAssets}`); + console.log(`\nuser2vFortBalance - after second : ${user2vFortBalance}`); + + user3MaxRedeemShares = await this.generalStaking.connect(this.accounts.user3).maxRedeem(this.accounts.user3.address); + user3PreviewRedeemReturnedAssets = await this.generalStaking.connect(this.accounts.user3).previewRedeem(user3MaxRedeemShares); // 218.749999999999999998 redeemable FORT + user3vFortBalance = await this.generalStaking.connect(this.accounts.user3).balanceOf(this.accounts.user3.address); // 36.734693877551020408 vFORT + console.log(`\nuser3PreviewRedeemReturnedAssets - after second : ${user3PreviewRedeemReturnedAssets}`); + console.log(`\nuser3vFortBalance - after second : ${user3vFortBalance}`); + }); +}); \ No newline at end of file diff --git a/test/fixture.js b/test/fixture.js index 338716a1..a8f5babd 100644 --- a/test/fixture.js +++ b/test/fixture.js @@ -9,7 +9,7 @@ function prepare(config = {}) { // list signers this.accounts = await ethers.getSigners(); this.accounts.getAccount = (name) => this.accounts[name] || (this.accounts[name] = this.accounts.shift()); - ['admin', 'manager', 'minter', 'treasure', 'user1', 'user2', 'user3', 'other'].map((name) => this.accounts.getAccount(name)); + ['admin', 'manager', 'minter', 'treasure', 'user1', 'user2', 'user3', 'other', 'slasher'].map((name) => this.accounts.getAccount(name)); // migrate await migrate( @@ -50,6 +50,7 @@ function prepare(config = {}) { this.access.connect(this.accounts.admin).grantRole(this.roles.MIGRATION_EXECUTOR, this.accounts.manager.address), this.token.connect(this.accounts.admin).grantRole(this.roles.MINTER, this.accounts.minter.address), this.otherToken.connect(this.accounts.admin).grantRole(this.roles.MINTER, this.accounts.minter.address), + this.generalStaking.connect(this.accounts.admin).grantRole(this.roles.SLASHER, this.accounts.slasher.address), ].map((txPromise) => txPromise.then((tx) => tx.wait()).catch(() => {})) ); diff --git a/yarn.lock b/yarn.lock index 69003682..cb7c4c87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9166,6 +9166,16 @@ open@^7.4.2: is-docker "^2.0.0" is-wsl "^2.1.1" +"openzeppelin-contracts-4.9@npm:@openzeppelin/contracts@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.0.tgz#683f33b6598970051bc5f0806fd8660da9e018dd" + integrity sha512-DUP74AFGKlic2sQb/CmgrN2aUPMFGxRrmCTUxLHsiU2RzwWqVuMPZBxiAyvlff6Pea77uylAX6B5x9W6evEbhA== + +"openzeppelin-contracts-upgradeable-4.9@npm:@openzeppelin/contracts-upgradeable@4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.0.tgz#70aaef469c8ac5bb0ff781480f3d321cbf7be3a8" + integrity sha512-+6i2j6vr2fdudTqkBvG+UOosankukxYzg3WN1nqU7ijjQ5A4osWaD3ip6CEz6YvDoSdZgcFVZoiGr7zRlUUoZw== + optionator@^0.8.1, optionator@^0.8.2: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"