diff --git a/contracts/mock/staking/AbstractStakingMock.sol b/contracts/mock/staking/AbstractStakingMock.sol new file mode 100644 index 00000000..86950665 --- /dev/null +++ b/contracts/mock/staking/AbstractStakingMock.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {AbstractStaking} from "../../staking/AbstractStaking.sol"; + +contract AbstractStakingMock is AbstractStaking, Multicall { + function __AbstractStakingMock_init( + address sharesToken_, + address rewardsToken_, + uint256 rate_, + uint256 stakingStartTime_ + ) external initializer { + __AbstractStaking_init(sharesToken_, rewardsToken_, rate_, stakingStartTime_); + } + + function mockInit( + address sharesToken_, + address rewardsToken_, + uint256 rate_, + uint256 stakingStartTime_ + ) external { + __AbstractStaking_init(sharesToken_, rewardsToken_, rate_, stakingStartTime_); + } + + function setStakingStartTime(uint256 stakingStartTime_) external { + _setStakingStartTime(stakingStartTime_); + } + + function setRate(uint256 newRate_) external { + _setRate(newRate_); + } + + function userShares(address user_) external view returns (uint256) { + return userDistribution(user_).shares; + } + + function userOwedValue(address user_) external view returns (uint256) { + return userDistribution(user_).owedValue; + } +} + +contract StakersFactory is Multicall { + Staker[] public stakers; + + function createStaker() public { + Staker staker_ = new Staker(); + stakers.push(staker_); + } + + function stake( + address stakingContract_, + address staker_, + address token_, + uint256 amount_ + ) external { + Staker(staker_).stake(stakingContract_, token_, amount_); + } + + function unstake(address stakingContract_, address staker_, uint256 amount_) external { + Staker(staker_).unstake(stakingContract_, amount_); + } +} + +contract Staker { + function stake(address stakingContract_, address token_, uint256 amount_) external { + IERC20(token_).approve(stakingContract_, amount_); + AbstractStakingMock(stakingContract_).stake(amount_); + } + + function unstake(address stakingContract_, uint256 amount_) external { + AbstractStakingMock(stakingContract_).unstake(amount_); + } +} diff --git a/contracts/mock/staking/AbstractValueDistributorMock.sol b/contracts/mock/staking/AbstractValueDistributorMock.sol new file mode 100644 index 00000000..106a189d --- /dev/null +++ b/contracts/mock/staking/AbstractValueDistributorMock.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {Multicall} from "@openzeppelin/contracts/utils/Multicall.sol"; + +import {AbstractValueDistributor} from "../../staking/AbstractValueDistributor.sol"; +import {DECIMAL} from "../../utils/Globals.sol"; + +contract AbstractValueDistributorMock is AbstractValueDistributor, Multicall { + function addShares(address user_, uint256 amount_) external { + _addShares(user_, amount_); + } + + function removeShares(address user_, uint256 amount_) external { + _removeShares(user_, amount_); + } + + function distributeValue(address user_, uint256 amount_) external { + _distributeValue(user_, amount_); + } + + function userShares(address user_) external view returns (uint256) { + return userDistribution(user_).shares; + } + + function userOwedValue(address user_) external view returns (uint256) { + return userDistribution(user_).owedValue; + } + + function _getValueToDistribute( + uint256 timeUpTo_, + uint256 timeLastUpdate_ + ) internal view virtual override returns (uint256) { + return DECIMAL * (timeUpTo_ - timeLastUpdate_); + } +} diff --git a/contracts/package.json b/contracts/package.json index d3ce930b..b0da0c4c 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@solarity/solidity-lib", - "version": "2.6.13", + "version": "2.6.14", "license": "MIT", "author": "Distributed Lab", "readme": "README.md", diff --git a/contracts/staking/AbstractStaking.sol b/contracts/staking/AbstractStaking.sol new file mode 100644 index 00000000..3a4e6040 --- /dev/null +++ b/contracts/staking/AbstractStaking.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +import {AbstractValueDistributor} from "./AbstractValueDistributor.sol"; + +/** + * @notice The AbstractStaking module + * + * Contract module for staking tokens and earning rewards based on shares. + */ +abstract contract AbstractStaking is AbstractValueDistributor, Initializable { + using SafeERC20 for IERC20; + + address private _sharesToken; + address private _rewardsToken; + + /** + * @dev The rate of rewards distribution per second. + * + * It determines the rate at which rewards are earned and distributed + * to stakers based on their shares. + * + * Note: Ensure that the `_rate` value is set correctly to match + * the decimal precision of the `_rewardsToken` to ensure accurate rewards distribution. + */ + uint256 private _rate; + + uint256 private _stakingStartTime; + + /** + * @dev Throws if the staking has not started yet. + */ + modifier stakingStarted() { + _checkStakingStarted(); + _; + } + + /** + * @notice Initializes the contract setting the values provided as shares token, rewards token, reward rate and staking start time. + * + * Warning: when shares and rewards tokens are the same, users may accidentally withdraw + * other users' shares as a reward if the rewards token balance is improperly handled. + * + * @param sharesToken_ The address of the shares token. + * @param rewardsToken_ The address of the rewards token. + * @param rate_ The reward rate. + * @param stakingStartTime_ The staking start time + */ + function __AbstractStaking_init( + address sharesToken_, + address rewardsToken_, + uint256 rate_, + uint256 stakingStartTime_ + ) internal onlyInitializing { + require(sharesToken_ != address(0), "Staking: zero address cannot be the Shares Token"); + require(rewardsToken_ != address(0), "Staking: zero address cannot be the Rewards Token"); + + _sharesToken = sharesToken_; + _rewardsToken = rewardsToken_; + _setRate(rate_); + _setStakingStartTime(stakingStartTime_); + } + + /** + * @notice Stakes the specified amount of tokens. + * @param amount_ The amount of tokens to stake. + */ + function stake(uint256 amount_) public stakingStarted { + _addShares(msg.sender, amount_); + } + + /** + * @notice Unstakes the specified amount of tokens. + * @param amount_ The amount of tokens to unstake. + */ + function unstake(uint256 amount_) public stakingStarted { + _removeShares(msg.sender, amount_); + } + + /** + * @notice Claims the specified amount of rewards. + * @param amount_ The amount of rewards to claim. + */ + function claim(uint256 amount_) public stakingStarted { + _distributeValue(msg.sender, amount_); + } + + /** + * @notice Withdraws all the staked tokens together with rewards. + * + * Note: All the rewards are claimed after the shares are removed. + * + * @return shares_ The amount of shares being withdrawn. + * @return owedValue_ The total value of the rewards owed to the user. + */ + function withdraw() public stakingStarted returns (uint256 shares_, uint256 owedValue_) { + shares_ = userDistribution(msg.sender).shares; + owedValue_ = getOwedValue(msg.sender); + + unstake(shares_); + claim(owedValue_); + } + + /** + * @notice Returns the shares token. + * @return The address of the shares token contract. + */ + function sharesToken() public view returns (address) { + return _sharesToken; + } + + /** + * @notice Returns the rewards token. + * @return The address of the rewards token contract. + */ + function rewardsToken() public view returns (address) { + return _rewardsToken; + } + + /** + * @notice Returns the staking start time. + * @return The timestamp when staking starts. + */ + function stakingStartTime() public view returns (uint256) { + return _stakingStartTime; + } + + /** + * @notice Returns the rate of rewards distribution. + * @return The rate of rewards distribution per second. + */ + function rate() public view returns (uint256) { + return _rate; + } + + /** + * @notice Sets the staking start time. + * @param stakingStartTime_ The timestamp when staking will start. + */ + function _setStakingStartTime(uint256 stakingStartTime_) internal { + _stakingStartTime = stakingStartTime_; + } + + /** + * @notice Sets the rate of rewards distribution per second. + * @param newRate_ The new rate of rewards distribution. + */ + function _setRate(uint256 newRate_) internal { + _update(address(0)); + + _rate = newRate_; + } + + /** + * @notice Hook function that is called after shares have been added to a user's distribution. + * @param user_ The address of the user. + * @param amount_ The amount of shares added. + */ + function _afterAddShares(address user_, uint256 amount_) internal virtual override { + IERC20(_sharesToken).safeTransferFrom(user_, address(this), amount_); + } + + /** + * @notice Hook function that is called after shares have been removed from a user's distribution. + * @param user_ The address of the user. + * @param amount_ The amount of shares removed. + */ + function _afterRemoveShares(address user_, uint256 amount_) internal virtual override { + IERC20(_sharesToken).safeTransfer(user_, amount_); + } + + /** + * @notice Hook function that is called after value has been distributed to a user. + * @param user_ The address of the user. + * @param amount_ The amount of value distributed. + */ + function _afterDistributeValue(address user_, uint256 amount_) internal virtual override { + IERC20(_rewardsToken).safeTransfer(user_, amount_); + } + + /** + * @dev Throws if the staking has not started yet. + */ + function _checkStakingStarted() internal view { + require(block.timestamp >= _stakingStartTime, "Staking: staking has not started yet"); + } + + /** + * @notice Gets the value to be distributed for a given time period. + * @param timeUpTo_ The end timestamp of the period. + * @param timeLastUpdate_ The start timestamp of the period. + * @return The value to be distributed for the period. + */ + function _getValueToDistribute( + uint256 timeUpTo_, + uint256 timeLastUpdate_ + ) internal view virtual override returns (uint256) { + return _rate * (timeUpTo_ - timeLastUpdate_); + } +} diff --git a/contracts/staking/AbstractValueDistributor.sol b/contracts/staking/AbstractValueDistributor.sol new file mode 100644 index 00000000..2909e90b --- /dev/null +++ b/contracts/staking/AbstractValueDistributor.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {PRECISION} from "../utils/Globals.sol"; + +/** + * @notice The AbstractValueDistributor module + * + * Contract module for distributing value among users based on their shares. + * + * The algorithm ensures that the distribution is proportional to the shares + * held by each user and takes into account changes in the cumulative sum over time. + * + * This contract can be used as a base contract for implementing various distribution mechanisms, + * such as token staking, profit sharing, or dividend distribution. + * + * It includes hooks for performing additional logic + * when shares are added or removed, or when value is distributed. + */ +abstract contract AbstractValueDistributor { + struct UserDistribution { + uint256 shares; + uint256 cumulativeSum; + uint256 owedValue; + } + + uint256 private _totalShares; + uint256 private _cumulativeSum; + uint256 private _updatedAt; + + mapping(address => UserDistribution) private _userDistributions; + + event SharesAdded(address user, uint256 amount); + event SharesRemoved(address user, uint256 amount); + event ValueDistributed(address user, uint256 amount); + + /** + * @notice Returns the total number of shares. + * @return The total number of shares. + */ + function totalShares() public view returns (uint256) { + return _totalShares; + } + + /** + * @notice Returns the cumulative sum of value that has been distributed. + * @return The cumulative sum of value that has been distributed. + */ + function cumulativeSum() public view returns (uint256) { + return _cumulativeSum; + } + + /** + * @notice Returns the timestamp of the last update. + * @return The timestamp of the last update. + */ + function updatedAt() public view returns (uint256) { + return _updatedAt; + } + + /** + * @notice Returns the distribution details for a specific user. + * @param user_ The address of the user. + * @return The distribution details including user's shares, cumulative sum and value owed. + */ + function userDistribution(address user_) public view returns (UserDistribution memory) { + return _userDistributions[user_]; + } + + /** + * @notice Gets the amount of value owed to a specific user. + * @param user_ The address of the user. + * @return The total owed value to the user. + */ + function getOwedValue(address user_) public view returns (uint256) { + UserDistribution storage userDist = _userDistributions[user_]; + + return + (userDist.shares * + (_getFutureCumulativeSum(block.timestamp) - userDist.cumulativeSum)) / + PRECISION + + userDist.owedValue; + } + + /** + * @notice Adds shares to a user's distribution. + * @param user_ The address of the user. + * @param amount_ The amount of shares to add. + */ + function _addShares(address user_, uint256 amount_) internal virtual { + require(user_ != address(0), "ValueDistributor: zero address is not allowed"); + require(amount_ > 0, "ValueDistributor: amount has to be more than 0"); + + _update(user_); + + _totalShares += amount_; + _userDistributions[user_].shares += amount_; + + emit SharesAdded(user_, amount_); + + _afterAddShares(user_, amount_); + } + + /** + * @notice Removes shares from a user's distribution. + * @param user_ The address of the user. + * @param amount_ The amount of shares to remove. + */ + function _removeShares(address user_, uint256 amount_) internal virtual { + require(amount_ > 0, "ValueDistributor: amount has to be more than 0"); + require( + amount_ <= _userDistributions[user_].shares, + "ValueDistributor: insufficient amount" + ); + + _update(user_); + + _totalShares -= amount_; + _userDistributions[user_].shares -= amount_; + + emit SharesRemoved(user_, amount_); + + _afterRemoveShares(user_, amount_); + } + + /** + * @notice Distributes value to a specific user. + * @param user_ The address of the user. + * @param amount_ The amount of value to distribute. + */ + function _distributeValue(address user_, uint256 amount_) internal virtual { + _update(user_); + + require(amount_ > 0, "ValueDistributor: amount has to be more than 0"); + require( + amount_ <= _userDistributions[user_].owedValue, + "ValueDistributor: insufficient amount" + ); + + _userDistributions[user_].owedValue -= amount_; + + emit ValueDistributed(user_, amount_); + + _afterDistributeValue(user_, amount_); + } + + /** + * @notice Hook function that is called after shares have been added to a user's distribution. + * + * This function can be used to perform any additional logic that is required, + * such as transferring tokens. + * + * @param user_ The address of the user. + * @param amount_ The amount of shares added. + */ + function _afterAddShares(address user_, uint256 amount_) internal virtual {} + + /** + * @notice Hook function that is called after shares have been removed from a user's distribution. + * + * This function can be used to perform any additional logic that is required, + * such as transferring tokens. + * + * @param user_ The address of the user. + * @param amount_ The amount of shares removed. + */ + function _afterRemoveShares(address user_, uint256 amount_) internal virtual {} + + /** + * @notice Hook function that is called after value has been distributed to a user. + * + * This function can be used to perform any additional logic that is required, + * such as transferring tokens. + * + * @param user_ The address of the user. + * @param amount_ The amount of value distributed. + */ + function _afterDistributeValue(address user_, uint256 amount_) internal virtual {} + + /** + * @notice Updates the cumulative sum of tokens that have been distributed. + * + * This function should be called whenever user shares are modified or value distribution occurs. + * + * @param user_ The address of the user. + */ + function _update(address user_) internal { + _cumulativeSum = _getFutureCumulativeSum(block.timestamp); + _updatedAt = block.timestamp; + + if (user_ != address(0)) { + UserDistribution storage userDist = _userDistributions[user_]; + + userDist.owedValue += + (userDist.shares * (_cumulativeSum - userDist.cumulativeSum)) / + PRECISION; + userDist.cumulativeSum = _cumulativeSum; + } + } + + /** + * @notice Gets the value to be distributed for a given time period. + * + * Note: It will usually be required to override this function to provide custom distribution mechanics. + * + * @param timeUpTo_ The end timestamp of the period. + * @param timeLastUpdate_ The start timestamp of the period. + * @return The value to be distributed for the period. + */ + function _getValueToDistribute( + uint256 timeUpTo_, + uint256 timeLastUpdate_ + ) internal view virtual returns (uint256); + + /** + * @notice Gets the expected cumulative sum of value per token staked distributed at a given timestamp. + * @param timeUpTo_ The timestamp up to which to calculate the value distribution. + * @return The future cumulative sum of value per token staked that has been distributed. + */ + function _getFutureCumulativeSum(uint256 timeUpTo_) internal view returns (uint256) { + if (_totalShares == 0) { + return _cumulativeSum; + } + + uint256 value_ = _getValueToDistribute(timeUpTo_, _updatedAt); + + return _cumulativeSum + (value_ * PRECISION) / _totalShares; + } +} diff --git a/package-lock.json b/package-lock.json index 996cbecc..2b9afe8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solarity/solidity-lib", - "version": "2.6.13", + "version": "2.6.14", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@solarity/solidity-lib", - "version": "2.6.13", + "version": "2.6.14", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 00466310..6e0004c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solarity/solidity-lib", - "version": "2.6.13", + "version": "2.6.14", "license": "MIT", "author": "Distributed Lab", "description": "Solidity Library by Distributed Lab", diff --git a/test/staking/AbstractStaking.test.ts b/test/staking/AbstractStaking.test.ts new file mode 100644 index 00000000..a1d82d3e --- /dev/null +++ b/test/staking/AbstractStaking.test.ts @@ -0,0 +1,658 @@ +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { Reverter } from "@/test/helpers/reverter"; + +import { AbstractStakingMock, ERC20Mock } from "@ethers-v6"; +import { wei } from "@/scripts/utils/utils"; +import { ZERO_ADDR } from "@/scripts/utils/constants"; + +describe("AbstractStaking", () => { + const reverter = new Reverter(); + + let FIRST: SignerWithAddress; + let SECOND: SignerWithAddress; + let THIRD: SignerWithAddress; + + let sharesToken: ERC20Mock; + let rewardsToken: ERC20Mock; + + let sharesDecimals: number; + let rewardsDecimals: number; + + let stakingStartTime: bigint; + let rate: bigint; + + let abstractStaking: AbstractStakingMock; + + const mintAndApproveTokens = async (user: SignerWithAddress, token: ERC20Mock, amount: bigint) => { + await token.mint(user, amount); + await token.connect(user).approve(abstractStaking, amount); + }; + + const performStakingManipulations = async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(200, sharesDecimals)); + await mintAndApproveTokens(THIRD, sharesToken, wei(200, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractStaking.connect(SECOND).stake(wei(200, sharesDecimals)); + + await abstractStaking.connect(THIRD).stake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractStaking.connect(FIRST).unstake(wei(100, sharesDecimals)); + + await abstractStaking.connect(THIRD).stake(wei(100, sharesDecimals)); + + await abstractStaking.connect(SECOND).unstake(wei(200, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractStaking.connect(THIRD).unstake(wei(200, sharesDecimals)); + }; + + const checkManipulationRewards = async () => { + const firstExpectedReward = wei(3, rewardsDecimals) + wei(1, rewardsDecimals) / 12n; + const secondExpectedReward = wei(3, rewardsDecimals) + wei(1, rewardsDecimals) / 3n; + const thirdExpectedReward = wei(3, rewardsDecimals) + wei(7, rewardsDecimals) / 12n; + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractStaking.getOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractStaking.getOwedValue(THIRD)).to.equal(thirdExpectedReward); + + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractStaking.userOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractStaking.userOwedValue(THIRD)).to.equal(thirdExpectedReward); + }; + + const performStakingManipulations2 = async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(400, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(THIRD, sharesToken, wei(400, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(200, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractStaking.connect(SECOND).stake(wei(100, sharesDecimals)); + + await abstractStaking.connect(THIRD).stake(wei(300, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractStaking.connect(FIRST).stake(wei(200, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractStaking.connect(SECOND).unstake(wei(100, sharesDecimals)); + + await abstractStaking.connect(THIRD).stake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractStaking.connect(FIRST).unstake(wei(300, sharesDecimals)); + + await abstractStaking.connect(THIRD).unstake(wei(400, sharesDecimals)); + }; + + const checkManipulationRewards2 = async () => { + const firstExpectedReward = wei(7, rewardsDecimals) + wei(2, rewardsDecimals) / 3n + wei(1, rewardsDecimals) / 7n; + const secondExpectedReward = wei(233, rewardsDecimals) / 168n; + const thirdExpectedReward = wei(5, rewardsDecimals) + wei(3, rewardsDecimals) / 8n + wei(3, rewardsDecimals) / 7n; + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractStaking.getOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractStaking.getOwedValue(THIRD)).to.equal(thirdExpectedReward); + + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractStaking.userOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractStaking.userOwedValue(THIRD)).to.equal(thirdExpectedReward); + }; + + before("setup", async () => { + [FIRST, SECOND, THIRD] = await ethers.getSigners(); + + const AbstractStakingMock = await ethers.getContractFactory("AbstractStakingMock"); + const ERC20Mock = await ethers.getContractFactory("ERC20Mock"); + + abstractStaking = await AbstractStakingMock.deploy(); + sharesToken = await ERC20Mock.deploy("SharesMock", "SMock", 18); + rewardsToken = await ERC20Mock.deploy("RewardsMock", "RMock", 18); + + sharesDecimals = Number(await sharesToken.decimals()); + rewardsDecimals = Number(await rewardsToken.decimals()); + + await rewardsToken.mint(await abstractStaking.getAddress(), wei(100, rewardsDecimals)); + + stakingStartTime = 3n; + rate = wei(1, rewardsDecimals); + + await abstractStaking.__AbstractStakingMock_init(sharesToken, rewardsToken, rate, stakingStartTime); + + await reverter.snapshot(); + }); + + afterEach(reverter.revert); + + describe("AbstractStaking initialization", () => { + it("should not initialize twice", async () => { + await expect(abstractStaking.mockInit(sharesToken, rewardsToken, rate, stakingStartTime)).to.be.revertedWith( + "Initializable: contract is not initializing", + ); + await expect( + abstractStaking.__AbstractStakingMock_init(sharesToken, rewardsToken, rate, stakingStartTime), + ).to.be.revertedWith("Initializable: contract is already initialized"); + }); + + it("should set the initial values correctly", async () => { + expect(await abstractStaking.sharesToken()).to.equal(await sharesToken.getAddress()); + expect(await abstractStaking.rewardsToken()).to.equal(await rewardsToken.getAddress()); + expect(await abstractStaking.rate()).to.equal(rate); + expect(await abstractStaking.stakingStartTime()).to.equal(stakingStartTime); + }); + + it("should not allow to set 0 as a Shares Token or Rewards Token", async () => { + const AbstractStakingMock = await ethers.getContractFactory("AbstractStakingMock"); + let abstractStaking = await AbstractStakingMock.deploy(); + + await expect( + abstractStaking.__AbstractStakingMock_init(ZERO_ADDR, rewardsToken, rate, stakingStartTime), + ).to.be.revertedWith("Staking: zero address cannot be the Shares Token"); + + await expect( + abstractStaking.__AbstractStakingMock_init(sharesToken, ZERO_ADDR, rate, stakingStartTime), + ).to.be.revertedWith("Staking: zero address cannot be the Rewards Token"); + }); + }); + + describe("timestamps", () => { + it("should not allow to stake, unstake, withdraw tokens or claim rewards before the start of the staking", async () => { + await abstractStaking.setStakingStartTime(1638474321); + + const revertMessage = "Staking: staking has not started yet"; + + await expect(abstractStaking.stake(wei(100, sharesDecimals))).to.be.revertedWith(revertMessage); + await expect(abstractStaking.unstake(wei(100, sharesDecimals))).to.be.revertedWith(revertMessage); + await expect(abstractStaking.withdraw()).to.be.revertedWith(revertMessage); + await expect(abstractStaking.claim(wei(100, sharesDecimals))).to.be.revertedWith(revertMessage); + }); + + it("should work as expected if the staking start time is set to the timestamp in the past", async () => { + await time.setNextBlockTimestamp((await time.latest()) + 20); + + await abstractStaking.setStakingStartTime(2); + + expect(await abstractStaking.stakingStartTime()).to.equal(2); + + await performStakingManipulations(); + + await checkManipulationRewards(); + }); + + it("should update values correctly if more than one transaction which updates the key values is sent within one block", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(400, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await abstractStaking.multicall([ + abstractStaking.interface.encodeFunctionData("stake", [wei(100, sharesDecimals)]), + abstractStaking.interface.encodeFunctionData("stake", [wei(100, sharesDecimals)]), + ]); + + expect(await abstractStaking.userShares(FIRST)).to.equal(wei(400, sharesDecimals)); + expect(await abstractStaking.cumulativeSum()).to.equal(wei(1.5, 23)); + }); + + it("should work as expected if more than one transaction which updates the key values is sent within one block", async () => { + const StakersFactory = await ethers.getContractFactory("StakersFactory"); + const stakersFactory = await StakersFactory.deploy(); + + await stakersFactory.createStaker(); + await stakersFactory.createStaker(); + + const staker1 = await stakersFactory.stakers(0); + const staker2 = await stakersFactory.stakers(1); + + await sharesToken.mint(staker1, wei(500, sharesDecimals)); + await sharesToken.mint(staker2, wei(500, sharesDecimals)); + await mintAndApproveTokens(THIRD, sharesToken, wei(100, sharesDecimals)); + + await stakersFactory.stake(abstractStaking, staker1, sharesToken, wei(100, sharesDecimals)); + + await stakersFactory.multicall([ + stakersFactory.interface.encodeFunctionData("stake", [ + await abstractStaking.getAddress(), + staker1, + await sharesToken.getAddress(), + wei(200, sharesDecimals), + ]), + stakersFactory.interface.encodeFunctionData("stake", [ + await abstractStaking.getAddress(), + staker2, + await sharesToken.getAddress(), + wei(200, sharesDecimals), + ]), + ]); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await stakersFactory.unstake(abstractStaking, staker2, wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 4); + + await abstractStaking.connect(THIRD).stake(wei(100, sharesDecimals)); + + await stakersFactory.multicall([ + stakersFactory.interface.encodeFunctionData("unstake", [ + await abstractStaking.getAddress(), + staker1, + wei(300, sharesDecimals), + ]), + stakersFactory.interface.encodeFunctionData("unstake", [ + await abstractStaking.getAddress(), + staker2, + wei(100, sharesDecimals), + ]), + ]); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractStaking.connect(THIRD).unstake(wei(100, sharesDecimals)); + + const firstExpectedReward = wei(6, rewardsDecimals) + wei(2, rewardsDecimals) / 5n; + const secondExpectedReward = wei(2, rewardsDecimals) + wei(2, rewardsDecimals) / 5n; + const thirdExpectedReward = wei(3, rewardsDecimals) + wei(1, rewardsDecimals) / 5n; + + expect(await abstractStaking.getOwedValue(staker1)).to.equal(firstExpectedReward); + expect(await abstractStaking.getOwedValue(staker2)).to.equal(secondExpectedReward); + expect(await abstractStaking.getOwedValue(THIRD)).to.equal(thirdExpectedReward); + + expect(await abstractStaking.userOwedValue(staker1)).to.equal(firstExpectedReward); + expect(await abstractStaking.userOwedValue(staker2)).to.equal(secondExpectedReward); + expect(await abstractStaking.userOwedValue(THIRD)).to.equal(thirdExpectedReward); + }); + }); + + describe("stake()", () => { + it("should add shares after staking correctly", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(300, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + await abstractStaking.connect(SECOND).stake(wei(300, sharesDecimals)); + + expect(await abstractStaking.totalShares()).to.equal(wei(400, sharesDecimals)); + expect(await abstractStaking.userShares(FIRST)).to.equal(wei(100, sharesDecimals)); + expect(await abstractStaking.userShares(SECOND)).to.equal(wei(300, sharesDecimals)); + }); + + it("should transfer tokens correctly on stake", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(50, sharesDecimals)); + + expect(await sharesToken.balanceOf(FIRST)).to.equal(wei(50, sharesDecimals)); + expect(await sharesToken.balanceOf(abstractStaking)).to.equal(wei(50, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(50, sharesDecimals)); + + expect(await sharesToken.balanceOf(FIRST)).to.equal(0); + expect(await sharesToken.balanceOf(abstractStaking)).to.equal(wei(100, sharesDecimals)); + }); + + it("should not allow to stake 0 tokens", async () => { + await expect(abstractStaking.connect(FIRST).stake(0)).to.be.revertedWith( + "ValueDistributor: amount has to be more than 0", + ); + }); + }); + + describe("unstake()", () => { + it("should remove shares after unstaking correctly", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(300, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + await abstractStaking.connect(SECOND).stake(wei(300, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(50, sharesDecimals)); + await abstractStaking.connect(SECOND).unstake(wei(200, sharesDecimals)); + + expect(await abstractStaking.totalShares()).to.equal(wei(150, sharesDecimals)); + expect(await abstractStaking.userShares(FIRST)).to.equal(wei(50, sharesDecimals)); + expect(await abstractStaking.userShares(SECOND)).to.equal(wei(100, sharesDecimals)); + }); + + it("should handle unstaking the whole amount staked correctly", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(200, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + await abstractStaking.connect(SECOND).stake(wei(200, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(100, sharesDecimals)); + await abstractStaking.connect(SECOND).unstake(wei(200, sharesDecimals)); + + const cumulativeSum = await abstractStaking.cumulativeSum(); + + expect(await abstractStaking.totalShares()).to.equal(0); + expect(await abstractStaking.userShares(FIRST)).to.equal(0); + expect(await abstractStaking.userShares(SECOND)).to.equal(0); + + await sharesToken.connect(FIRST).approve(abstractStaking, wei(50, sharesDecimals)); + await sharesToken.connect(SECOND).approve(abstractStaking, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(50, sharesDecimals)); + + expect(await abstractStaking.cumulativeSum()).to.equal(cumulativeSum); + + await abstractStaking.connect(SECOND).stake(wei(100, sharesDecimals)); + + expect(await abstractStaking.totalShares()).to.equal(wei(150, sharesDecimals)); + expect(await abstractStaking.userShares(FIRST)).to.equal(wei(50, sharesDecimals)); + expect(await abstractStaking.userShares(SECOND)).to.equal(wei(100, sharesDecimals)); + }); + + it("should transfer tokens correctly on unstake", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(50, sharesDecimals)); + + expect(await sharesToken.balanceOf(FIRST)).to.equal(wei(50, sharesDecimals)); + expect(await sharesToken.balanceOf(abstractStaking)).to.equal(wei(50, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(50, sharesDecimals)); + + expect(await sharesToken.balanceOf(FIRST)).to.equal(wei(100, sharesDecimals)); + expect(await sharesToken.balanceOf(abstractStaking)).to.equal(0); + }); + + it("should not allow to unstake 0 tokens", async () => { + await expect(abstractStaking.connect(FIRST).unstake(0)).to.be.revertedWith( + "ValueDistributor: amount has to be more than 0", + ); + }); + + it("should not allow to unstake more than it was staked", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + await expect(abstractStaking.connect(FIRST).unstake(wei(150, sharesDecimals))).to.be.revertedWith( + "ValueDistributor: insufficient amount", + ); + }); + }); + + describe("withdraw()", () => { + it("should withdraw tokens correctly", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).withdraw(); + + expect(await abstractStaking.totalShares()).to.equal(0); + expect(await abstractStaking.userShares(FIRST)).to.equal(0); + }); + + it("should transfer tokens correctly on withdraw", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).withdraw(); + + expect(await sharesToken.balanceOf(abstractStaking)).to.equal(0); + expect(await sharesToken.balanceOf(FIRST)).to.equal(wei(100, sharesDecimals)); + }); + + it("should claim all the rewards earned after the withdrawal", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 30); + + await abstractStaking.connect(FIRST).withdraw(); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(0); + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(0); + }); + }); + + describe("claim()", () => { + it("should calculate the rewards earned for a user correctly", async () => { + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 30); + + await abstractStaking.connect(FIRST).unstake(wei(100, sharesDecimals)); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(wei(30, rewardsDecimals)); + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(wei(30, rewardsDecimals)); + }); + + it("should calculate the reward earned for multiple users correctly", async () => { + await performStakingManipulations(); + + await checkManipulationRewards(); + }); + + it("should calculate the reward earned for multiple users correctly", async () => { + await performStakingManipulations2(); + + await checkManipulationRewards2(); + }); + + it("should claim all the rewards correctly", async () => { + await performStakingManipulations(); + + await abstractStaking.connect(FIRST).claim(await abstractStaking.getOwedValue(FIRST)); + await abstractStaking.connect(SECOND).claim(await abstractStaking.getOwedValue(SECOND)); + await abstractStaking.connect(THIRD).claim(await abstractStaking.getOwedValue(THIRD)); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(0); + expect(await abstractStaking.getOwedValue(SECOND)).to.equal(0); + expect(await abstractStaking.getOwedValue(THIRD)).to.equal(0); + + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(0); + expect(await abstractStaking.userOwedValue(SECOND)).to.equal(0); + expect(await abstractStaking.userOwedValue(THIRD)).to.equal(0); + }); + + it("should correctly claim rewards partially", async () => { + await performStakingManipulations(); + + await abstractStaking.connect(FIRST).claim((await abstractStaking.getOwedValue(FIRST)) - wei(1, rewardsDecimals)); + await abstractStaking + .connect(SECOND) + .claim((await abstractStaking.getOwedValue(SECOND)) - wei(2, rewardsDecimals)); + await abstractStaking.connect(THIRD).claim((await abstractStaking.getOwedValue(THIRD)) - wei(3, rewardsDecimals)); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(wei(1, rewardsDecimals)); + expect(await abstractStaking.getOwedValue(SECOND)).to.equal(wei(2, rewardsDecimals)); + expect(await abstractStaking.getOwedValue(THIRD)).to.equal(wei(3, rewardsDecimals)); + + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(wei(1, rewardsDecimals)); + expect(await abstractStaking.userOwedValue(SECOND)).to.equal(wei(2, rewardsDecimals)); + expect(await abstractStaking.userOwedValue(THIRD)).to.equal(wei(3, rewardsDecimals)); + }); + + it("should allow to claim rewards in several rounds correctly", async () => { + await performStakingManipulations2(); + + await abstractStaking.connect(FIRST).claim((await abstractStaking.getOwedValue(FIRST)) - wei(3)); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(wei(3, rewardsDecimals)); + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(wei(3, rewardsDecimals)); + + await abstractStaking.connect(FIRST).claim((await abstractStaking.getOwedValue(FIRST)) - wei(2)); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(wei(2, rewardsDecimals)); + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(wei(2, rewardsDecimals)); + + await abstractStaking.connect(FIRST).claim(wei(2, rewardsDecimals)); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(0); + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(0); + }); + + it("should transfer tokens correctly on the claim", async () => { + await performStakingManipulations(); + + const initialRewardsBalance = await rewardsToken.balanceOf(abstractStaking); + + const firstOwed = await abstractStaking.getOwedValue(FIRST); + const secondOwed = await abstractStaking.getOwedValue(SECOND); + const thirdOwed = await abstractStaking.getOwedValue(THIRD); + + await abstractStaking.connect(FIRST).claim(firstOwed); + await abstractStaking.connect(SECOND).claim(secondOwed); + + await abstractStaking.connect(THIRD).claim(thirdOwed); + + expect(await rewardsToken.balanceOf(abstractStaking)).to.equal( + initialRewardsBalance - (firstOwed + secondOwed + thirdOwed), + ); + expect(await rewardsToken.balanceOf(FIRST)).to.equal(firstOwed); + expect(await rewardsToken.balanceOf(SECOND)).to.equal(secondOwed); + expect(await rewardsToken.balanceOf(THIRD)).to.equal(thirdOwed); + }); + + it("should not allow to claim 0 rewards", async () => { + await expect(abstractStaking.connect(FIRST).claim(0)).to.be.revertedWith( + "ValueDistributor: amount has to be more than 0", + ); + }); + + it("should not allow to claim more rewards than earned", async () => { + await performStakingManipulations(); + + await expect(abstractStaking.connect(FIRST).claim(wei(4, rewardsDecimals))).to.be.revertedWith( + "ValueDistributor: insufficient amount", + ); + }); + }); + + describe("rate", () => { + it("should accept 0 as a rate and calculate owed values according to this rate correctly", async () => { + const AbstractStakingMock = await ethers.getContractFactory("AbstractStakingMock"); + abstractStaking = await AbstractStakingMock.deploy(); + + await abstractStaking.__AbstractStakingMock_init(sharesToken, rewardsToken, 0, stakingStartTime); + + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(100, sharesDecimals)); + + await time.setNextBlockTimestamp((await time.latest()) + 20); + + await abstractStaking.connect(FIRST).unstake(wei(100, sharesDecimals)); + + expect(await abstractStaking.rate()).to.equal(0); + + expect(await abstractStaking.getOwedValue(FIRST)).to.equal(0); + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(0); + expect(await abstractStaking.cumulativeSum()).to.equal(0); + }); + + it("should calculate owed value properly after the rate is changed to 0", async () => { + const AbstractStakingMock = await ethers.getContractFactory("AbstractStakingMock"); + abstractStaking = await AbstractStakingMock.deploy(); + + await abstractStaking.__AbstractStakingMock_init(sharesToken, rewardsToken, rate, stakingStartTime); + + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(300, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(50, sharesDecimals)); + await abstractStaking.connect(SECOND).stake(wei(150, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(50, sharesDecimals)); + await abstractStaking.connect(SECOND).unstake(wei(100, sharesDecimals)); + + await abstractStaking.setRate(0); + + expect(await abstractStaking.rate()).to.equal(0); + + await abstractStaking.connect(SECOND).unstake(wei(50, sharesDecimals)); + + let firstOwedValue = await abstractStaking.getOwedValue(FIRST); + let secondOwedValue = await abstractStaking.getOwedValue(SECOND); + + await performStakingManipulations(); + + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(firstOwedValue); + expect(await abstractStaking.userOwedValue(SECOND)).to.equal(secondOwedValue); + }); + + it("should work as expected after updating the rate", async () => { + const AbstractStakingMock = await ethers.getContractFactory("AbstractStakingMock"); + abstractStaking = await AbstractStakingMock.deploy(); + + await abstractStaking.__AbstractStakingMock_init(sharesToken, rewardsToken, rate, stakingStartTime); + + await mintAndApproveTokens(FIRST, sharesToken, wei(100, sharesDecimals)); + await mintAndApproveTokens(SECOND, sharesToken, wei(300, sharesDecimals)); + + await abstractStaking.connect(FIRST).stake(wei(50, sharesDecimals)); + await abstractStaking.connect(SECOND).stake(wei(150, sharesDecimals)); + + await abstractStaking.connect(FIRST).unstake(wei(50, sharesDecimals)); + await abstractStaking.connect(SECOND).unstake(wei(100, sharesDecimals)); + + const prevCumulativeSum = await abstractStaking.cumulativeSum(); + + let firstOwedValue = await abstractStaking.getOwedValue(FIRST); + let secondOwedValue = await abstractStaking.getOwedValue(SECOND); + + await abstractStaking.setRate(wei(2, rewardsDecimals)); + + const expectedCumulativeSum = prevCumulativeSum + wei(rate, 25) / (await abstractStaking.totalShares()); + + expect(await abstractStaking.rate()).to.equal(wei(2, rewardsDecimals)); + expect(await abstractStaking.cumulativeSum()).to.equal(expectedCumulativeSum); + expect(await abstractStaking.updatedAt()).to.equal(await time.latest()); + + expect(await abstractStaking.userOwedValue(FIRST)).to.equal(firstOwedValue); + expect(await abstractStaking.userOwedValue(SECOND)).to.equal(secondOwedValue); + }); + }); + + describe("should handle staking manipulations with 6-decimal values", () => { + it("should handle the whole staling process using 6-decimal values", async () => { + const AbstractStakingMock = await ethers.getContractFactory("AbstractStakingMock"); + const ERC20Mock = await ethers.getContractFactory("ERC20Mock"); + + abstractStaking = await AbstractStakingMock.deploy(); + sharesToken = await ERC20Mock.deploy("SharesMock", "SMock", 6); + rewardsToken = await ERC20Mock.deploy("RewardsMock", "RMock", 6); + + sharesDecimals = Number(await sharesToken.decimals()); + rewardsDecimals = Number(await rewardsToken.decimals()); + + await rewardsToken.mint(await abstractStaking.getAddress(), wei(100, rewardsDecimals)); + + await abstractStaking.__AbstractStakingMock_init(sharesToken, rewardsToken, wei(1, rewardsDecimals), 3n); + + await performStakingManipulations2(); + + await checkManipulationRewards2(); + }); + }); +}); diff --git a/test/staking/AbstractValueDistributor.test.ts b/test/staking/AbstractValueDistributor.test.ts new file mode 100644 index 00000000..06bb67e9 --- /dev/null +++ b/test/staking/AbstractValueDistributor.test.ts @@ -0,0 +1,341 @@ +import { ethers } from "hardhat"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; +import { expect } from "chai"; +import { Reverter } from "@/test/helpers/reverter"; +import { wei } from "@/scripts/utils/utils"; + +import { AbstractValueDistributorMock } from "@ethers-v6"; +import { ZERO_ADDR } from "@/scripts/utils/constants"; + +describe("AbstractValueDistributor", () => { + const reverter = new Reverter(); + + let FIRST: SignerWithAddress; + let SECOND: SignerWithAddress; + let THIRD: SignerWithAddress; + let FOURTH: SignerWithAddress; + + let abstractValueDistributor: AbstractValueDistributorMock; + + const addSharesToAllUsers = async (shares: Array) => { + await abstractValueDistributor.addShares(FIRST, shares[0]); + await abstractValueDistributor.addShares(SECOND, shares[1]); + await abstractValueDistributor.addShares(THIRD, shares[2]); + await abstractValueDistributor.addShares(FOURTH, shares[3]); + }; + + const removeSharesFromAllUsers = async (shares: Array) => { + await abstractValueDistributor.removeShares(FIRST, shares[0]); + await abstractValueDistributor.removeShares(SECOND, shares[1]); + await abstractValueDistributor.removeShares(THIRD, shares[2]); + await abstractValueDistributor.removeShares(FOURTH, shares[3]); + }; + + const checkAllShares = async (shares: Array) => { + expect(await abstractValueDistributor.userShares(FIRST)).to.equal(shares[0]); + expect(await abstractValueDistributor.userShares(SECOND)).to.equal(shares[1]); + expect(await abstractValueDistributor.userShares(THIRD)).to.equal(shares[2]); + expect(await abstractValueDistributor.userShares(FOURTH)).to.equal(shares[3]); + }; + + const performSharesManipulations = async () => { + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractValueDistributor.addShares(FIRST, 100); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractValueDistributor.addShares(SECOND, 200); + + await abstractValueDistributor.addShares(THIRD, 100); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractValueDistributor.removeShares(FIRST, 100); + + await abstractValueDistributor.addShares(THIRD, 100); + + await abstractValueDistributor.removeShares(SECOND, 200); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractValueDistributor.removeShares(THIRD, 200); + }; + + before("setup", async () => { + [FIRST, SECOND, THIRD, FOURTH] = await ethers.getSigners(); + + const abstractValueDistributorMock = await ethers.getContractFactory("AbstractValueDistributorMock"); + abstractValueDistributor = await abstractValueDistributorMock.deploy(); + + await reverter.snapshot(); + }); + + afterEach(reverter.revert); + + describe("addShares()", () => { + it("should add shares correctly", async () => { + await addSharesToAllUsers([1, 2, 3, 4]); + await abstractValueDistributor.addShares(FIRST, 5); + + await checkAllShares([6, 2, 3, 4]); + expect(await abstractValueDistributor.totalShares()).to.equal(15); + }); + + it("should not allow to add 0 shares", async () => { + await expect(abstractValueDistributor.addShares(SECOND, 0)).to.be.revertedWith( + "ValueDistributor: amount has to be more than 0", + ); + }); + + it("should not allow zero address to add shares", async () => { + await expect(abstractValueDistributor.addShares(ZERO_ADDR, 2)).to.be.revertedWith( + "ValueDistributor: zero address is not allowed", + ); + }); + }); + + describe("removeShares()", () => { + it("should correctly remove shares partially", async () => { + await addSharesToAllUsers([3, 2, 3, 4]); + + await removeSharesFromAllUsers([1, 1, 1, 2]); + await abstractValueDistributor.removeShares(THIRD, 1); + + await checkAllShares([2, 1, 1, 2]); + expect(await abstractValueDistributor.totalShares()).to.equal(6); + }); + + it("should handle removing all the shares correctly", async () => { + await addSharesToAllUsers([2, 1, 1, 2]); + + await removeSharesFromAllUsers([2, 1, 1, 2]); + + await checkAllShares([0, 0, 0, 0]); + + expect(await abstractValueDistributor.totalShares()).to.equal(0); + + const cumulativeSum = await abstractValueDistributor.cumulativeSum(); + + await abstractValueDistributor.addShares(FIRST, 2); + + expect(await abstractValueDistributor.cumulativeSum()).to.equal(cumulativeSum); + expect(await abstractValueDistributor.totalShares()).to.equal(2); + expect(await abstractValueDistributor.userShares(FIRST)).to.equal(2); + }); + + it("should not allow to remove 0 shares", async () => { + await expect(abstractValueDistributor.removeShares(SECOND, 0)).to.be.revertedWith( + "ValueDistributor: amount has to be more than 0", + ); + }); + + it("should not allow zero address to remove shares", async () => { + await expect(abstractValueDistributor.removeShares(ZERO_ADDR, 2)).to.be.revertedWith( + "ValueDistributor: insufficient amount", + ); + }); + + it("should not allow to remove more shares than it was added", async () => { + await expect(abstractValueDistributor.removeShares(SECOND, 1)).to.be.revertedWith( + "ValueDistributor: insufficient amount", + ); + }); + }); + + describe("distributeValue()", () => { + it("should calculate the value owed to a user correctly", async () => { + await abstractValueDistributor.addShares(FIRST, 100); + + await time.setNextBlockTimestamp((await time.latest()) + 30); + + await abstractValueDistributor.removeShares(FIRST, 100); + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(wei(30)); + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(wei(30)); + }); + + it("should calculate the value owed to multiple users correctly", async () => { + await performSharesManipulations(); + + const firstExpectedReward = wei(3) + wei(1) / 12n; + const secondExpectedReward = wei(3) + wei(1) / 3n; + const thirdExpectedReward = wei(3) + wei(7) / 12n; + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractValueDistributor.getOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractValueDistributor.getOwedValue(THIRD)).to.equal(thirdExpectedReward); + + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractValueDistributor.userOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractValueDistributor.userOwedValue(THIRD)).to.equal(thirdExpectedReward); + }); + + it("should calculate the value owed to multiple users correctly", async () => { + await abstractValueDistributor.addShares(FIRST, 200); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractValueDistributor.addShares(SECOND, 100); + + await abstractValueDistributor.addShares(THIRD, 300); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractValueDistributor.addShares(FIRST, 200); + + await abstractValueDistributor.removeShares(FIRST, 100); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractValueDistributor.removeShares(SECOND, 100); + + await abstractValueDistributor.addShares(THIRD, 100); + + await time.setNextBlockTimestamp((await time.latest()) + 2); + + await abstractValueDistributor.removeShares(FIRST, 300); + + await abstractValueDistributor.removeShares(THIRD, 400); + + const firstExpectedReward = wei(7) + wei(2) / 3n + wei(1) / 7n; + const secondExpectedReward = wei(233) / 168n; + const thirdExpectedReward = wei(5) + wei(3) / 8n + wei(3) / 7n; + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractValueDistributor.getOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractValueDistributor.getOwedValue(THIRD)).to.equal(thirdExpectedReward); + + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractValueDistributor.userOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractValueDistributor.userOwedValue(THIRD)).to.equal(thirdExpectedReward); + }); + + it("should distribute all the owed values correctly", async () => { + await performSharesManipulations(); + + await abstractValueDistributor.distributeValue(FIRST, await abstractValueDistributor.getOwedValue(FIRST)); + await abstractValueDistributor.distributeValue(SECOND, await abstractValueDistributor.getOwedValue(SECOND)); + await abstractValueDistributor.distributeValue(THIRD, await abstractValueDistributor.getOwedValue(THIRD)); + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(0); + expect(await abstractValueDistributor.getOwedValue(SECOND)).to.equal(0); + expect(await abstractValueDistributor.getOwedValue(THIRD)).to.equal(0); + + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(0); + expect(await abstractValueDistributor.userOwedValue(SECOND)).to.equal(0); + expect(await abstractValueDistributor.userOwedValue(THIRD)).to.equal(0); + }); + + it("should correctly distribute owed values partially", async () => { + await performSharesManipulations(); + + await abstractValueDistributor.distributeValue( + FIRST, + (await abstractValueDistributor.getOwedValue(FIRST)) - wei(1), + ); + await abstractValueDistributor.distributeValue( + SECOND, + (await abstractValueDistributor.getOwedValue(SECOND)) - wei(2), + ); + await abstractValueDistributor.distributeValue( + THIRD, + (await abstractValueDistributor.getOwedValue(THIRD)) - wei(3), + ); + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(wei(1)); + expect(await abstractValueDistributor.getOwedValue(SECOND)).to.equal(wei(2)); + expect(await abstractValueDistributor.getOwedValue(THIRD)).to.equal(wei(3)); + + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(wei(1)); + expect(await abstractValueDistributor.userOwedValue(SECOND)).to.equal(wei(2)); + expect(await abstractValueDistributor.userOwedValue(THIRD)).to.equal(wei(3)); + }); + + it("should allow to distribute values in several rounds correctly", async () => { + await performSharesManipulations(); + + await abstractValueDistributor.distributeValue( + FIRST, + (await abstractValueDistributor.getOwedValue(FIRST)) - wei(3), + ); + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(wei(3)); + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(wei(3)); + + await abstractValueDistributor.distributeValue( + FIRST, + (await abstractValueDistributor.getOwedValue(FIRST)) - wei(2), + ); + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(wei(2)); + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(wei(2)); + + await abstractValueDistributor.distributeValue(FIRST, wei(2)); + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(0); + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(wei(0)); + }); + + it("should not allow to distribute 0 values", async () => { + await expect(abstractValueDistributor.distributeValue(FIRST, 0)).to.be.revertedWith( + "ValueDistributor: amount has to be more than 0", + ); + }); + + it("should not allow zero address to distribute values", async () => { + await expect(abstractValueDistributor.distributeValue(ZERO_ADDR, 2)).to.be.revertedWith( + "ValueDistributor: insufficient amount", + ); + }); + + it("should not allow to distribute more values than owed", async () => { + await performSharesManipulations(); + + await expect(abstractValueDistributor.distributeValue(FIRST, wei(4))).to.be.revertedWith( + "ValueDistributor: insufficient amount", + ); + }); + }); + + describe("same block transactions", () => { + it("should work as expected if more than one transaction which updates the key values is sent within one block", async () => { + await abstractValueDistributor.addShares(FIRST, 100); + + await abstractValueDistributor.multicall([ + abstractValueDistributor.interface.encodeFunctionData("addShares", [await FIRST.getAddress(), 200]), + abstractValueDistributor.interface.encodeFunctionData("addShares", [await SECOND.getAddress(), 200]), + ]); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractValueDistributor.removeShares(SECOND, 100); + + await time.setNextBlockTimestamp((await time.latest()) + 4); + + await abstractValueDistributor.addShares(THIRD, 100); + + await abstractValueDistributor.multicall([ + abstractValueDistributor.interface.encodeFunctionData("removeShares", [await FIRST.getAddress(), 300]), + abstractValueDistributor.interface.encodeFunctionData("removeShares", [await SECOND.getAddress(), 100]), + ]); + + await time.setNextBlockTimestamp((await time.latest()) + 3); + + await abstractValueDistributor.removeShares(THIRD, 100); + + const firstExpectedReward = wei(6) + wei(2) / 5n; + const secondExpectedReward = wei(2) + wei(2) / 5n; + const thirdExpectedReward = wei(3) + wei(1) / 5n; + + expect(await abstractValueDistributor.getOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractValueDistributor.getOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractValueDistributor.getOwedValue(THIRD)).to.equal(thirdExpectedReward); + + expect(await abstractValueDistributor.userOwedValue(FIRST)).to.equal(firstExpectedReward); + expect(await abstractValueDistributor.userOwedValue(SECOND)).to.equal(secondExpectedReward); + expect(await abstractValueDistributor.userOwedValue(THIRD)).to.equal(thirdExpectedReward); + }); + }); +});