From 436ae0f134255fabcd49a1d6b5b1eae4fd8c9d51 Mon Sep 17 00:00:00 2001 From: vzotova Date: Fri, 29 Jan 2021 16:30:49 +0300 Subject: [PATCH] New modification of WorkLockPoolingContract - without worklock part --- newsfragments/2544.feature.rst | 1 + .../PoolingStakingContractV2.sol | 297 ++++++++++ .../test_pooling_contract_v2.py | 526 ++++++++++++++++++ 3 files changed, 824 insertions(+) create mode 100644 newsfragments/2544.feature.rst create mode 100644 nucypher/blockchain/eth/sol/source/contracts/staking_contracts/PoolingStakingContractV2.sol create mode 100644 tests/contracts/main/staking_contracts/test_pooling_contract_v2.py diff --git a/newsfragments/2544.feature.rst b/newsfragments/2544.feature.rst new file mode 100644 index 000000000..5d2eaa77a --- /dev/null +++ b/newsfragments/2544.feature.rst @@ -0,0 +1 @@ +New preferable base pooling contract diff --git a/nucypher/blockchain/eth/sol/source/contracts/staking_contracts/PoolingStakingContractV2.sol b/nucypher/blockchain/eth/sol/source/contracts/staking_contracts/PoolingStakingContractV2.sol new file mode 100644 index 000000000..6cbee6848 --- /dev/null +++ b/nucypher/blockchain/eth/sol/source/contracts/staking_contracts/PoolingStakingContractV2.sol @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity ^0.7.0; + +import "zeppelin/ownership/Ownable.sol"; +import "zeppelin/math/SafeMath.sol"; +import "contracts/staking_contracts/AbstractStakingContract.sol"; + +/** + * @notice Contract acts as delegate for sub-stakers + **/ +contract PoolingStakingContractV2 is InitializableStakingContract, Ownable { + using SafeMath for uint256; + using Address for address payable; + using SafeERC20 for NuCypherToken; + + event TokensDeposited( + address indexed sender, + uint256 value, + uint256 depositedTokens + ); + event TokensWithdrawn( + address indexed sender, + uint256 value, + uint256 depositedTokens + ); + event ETHWithdrawn(address indexed sender, uint256 value); + event DepositSet(address indexed sender, bool value); + + struct Delegator { + uint256 depositedTokens; + uint256 withdrawnReward; + uint256 withdrawnETH; + } + + /// Defines base fraction and precision of worker fraction. Value 100 defines 1 worker fraction is 1% of reward + uint256 public constant BASIS_FRACTION = 100; + + StakingEscrow public escrow; + address public workerOwner; + + uint256 public totalDepositedTokens; + uint256 public totalWithdrawnReward; + uint256 public totalWithdrawnETH; + + uint256 workerFraction; + uint256 public workerWithdrawnReward; + + mapping(address => Delegator) public delegators; + bool public depositIsEnabled = true; + + /** + * @notice Initialize function for using with OpenZeppelin proxy + * @param _workerFraction Share of token reward that worker node owner will get. + * Use value up to BASIS_FRACTION, if _workerFraction = BASIS_FRACTION -> means 100% reward as commission + * @param _router StakingInterfaceRouter address + * @param _workerOwner Owner of worker node, only this address can withdraw worker commission + */ + function initialize( + uint256 _workerFraction, + StakingInterfaceRouter _router, + address _workerOwner + ) public initializer { + require(_workerOwner != address(0) && _workerFraction <= BASIS_FRACTION); + InitializableStakingContract.initialize(_router); + _transferOwnership(msg.sender); + escrow = _router.target().escrow(); + workerFraction = _workerFraction; + workerOwner = _workerOwner; + } + + /** + * @notice Enabled deposit + */ + function enableDeposit() external onlyOwner { + depositIsEnabled = true; + emit DepositSet(msg.sender, depositIsEnabled); + } + + /** + * @notice Disable deposit + */ + function disableDeposit() external onlyOwner { + depositIsEnabled = false; + emit DepositSet(msg.sender, depositIsEnabled); + } + + /** + * @notice Calculate worker's fraction depending on deposited tokens + */ + function getWorkerFraction() public view returns (uint256) { + return workerFraction; + } + + /** + * @notice Transfer tokens as delegator + * @param _value Amount of tokens to transfer + */ + function depositTokens(uint256 _value) external { + require(depositIsEnabled, "Deposit must be enabled"); + require(_value > 0, "Value must be not empty"); + totalDepositedTokens = totalDepositedTokens.add(_value); + Delegator storage delegator = delegators[msg.sender]; + delegator.depositedTokens = delegator.depositedTokens.add(_value); + token.safeTransferFrom(msg.sender, address(this), _value); + emit TokensDeposited(msg.sender, _value, delegator.depositedTokens); + } + + /** + * @notice Get available reward for all delegators and owner + */ + function getAvailableReward() public view returns (uint256) { + uint256 stakedTokens = escrow.getAllTokens(address(this)); + uint256 freeTokens = token.balanceOf(address(this)); + uint256 reward = stakedTokens.add(freeTokens).sub(totalDepositedTokens); + if (reward > freeTokens) { + return freeTokens; + } + return reward; + } + + /** + * @notice Get cumulative reward + */ + function getCumulativeReward() public view returns (uint256) { + return getAvailableReward().add(totalWithdrawnReward); + } + + /** + * @notice Get available reward in tokens for worker node owner + */ + function getAvailableWorkerReward() public view returns (uint256) { + uint256 reward = getCumulativeReward(); + + uint256 maxAllowableReward; + if (totalDepositedTokens != 0) { + uint256 fraction = getWorkerFraction(); + maxAllowableReward = reward.mul(fraction).div(BASIS_FRACTION); + } else { + maxAllowableReward = reward; + } + + if (maxAllowableReward > workerWithdrawnReward) { + return maxAllowableReward - workerWithdrawnReward; + } + return 0; + } + + /** + * @notice Get available reward in tokens for delegator + */ + function getAvailableReward(address _delegator) + public + view + returns (uint256) + { + if (totalDepositedTokens == 0) { + return 0; + } + + uint256 reward = getCumulativeReward(); + Delegator storage delegator = delegators[_delegator]; + uint256 fraction = getWorkerFraction(); + uint256 maxAllowableReward = reward.mul(delegator.depositedTokens).mul(BASIS_FRACTION - fraction).div( + totalDepositedTokens.mul(BASIS_FRACTION) + ); + + return + maxAllowableReward > delegator.withdrawnReward + ? maxAllowableReward - delegator.withdrawnReward + : 0; + } + + /** + * @notice Withdraw reward in tokens to worker node owner + */ + function withdrawWorkerReward() external { + require(msg.sender == workerOwner); + uint256 balance = token.balanceOf(address(this)); + uint256 availableReward = getAvailableWorkerReward(); + + if (availableReward > balance) { + availableReward = balance; + } + require( + availableReward > 0, + "There is no available reward to withdraw" + ); + workerWithdrawnReward = workerWithdrawnReward.add(availableReward); + totalWithdrawnReward = totalWithdrawnReward.add(availableReward); + + token.safeTransfer(msg.sender, availableReward); + emit TokensWithdrawn(msg.sender, availableReward, 0); + } + + /** + * @notice Withdraw reward to delegator + * @param _value Amount of tokens to withdraw + */ + function withdrawTokens(uint256 _value) public override { + uint256 balance = token.balanceOf(address(this)); + require(_value <= balance, "Not enough tokens in the contract"); + + Delegator storage delegator = delegators[msg.sender]; + uint256 availableReward = getAvailableReward(msg.sender); + + require( _value <= availableReward, "Requested amount of tokens exceeded allowed portion"); + delegator.withdrawnReward = delegator.withdrawnReward.add(_value); + totalWithdrawnReward = totalWithdrawnReward.add(_value); + + token.safeTransfer(msg.sender, _value); + emit TokensWithdrawn(msg.sender, _value, delegator.depositedTokens); + } + + /** + * @notice Withdraw reward, deposit and fee to delegator + */ + function withdrawAll() public { + uint256 balance = token.balanceOf(address(this)); + + Delegator storage delegator = delegators[msg.sender]; + uint256 availableReward = getAvailableReward(msg.sender); + uint256 value = availableReward.add(delegator.depositedTokens); + require(value <= balance, "Not enough tokens in the contract"); + + // TODO remove double reading + uint256 availableWorkerReward = getAvailableWorkerReward(); + + // potentially could be less then due reward + uint256 availableETH = getAvailableETH(msg.sender); + + // prevent losing reward for worker after calculations + uint256 workerReward = availableWorkerReward.mul(delegator.depositedTokens).div(totalDepositedTokens); + if (workerReward > 0) { + require(value.add(workerReward) <= balance, "Not enough tokens in the contract"); + token.safeTransfer(workerOwner, workerReward); + emit TokensWithdrawn(workerOwner, workerReward, 0); + } + + uint256 withdrawnToDecrease = workerWithdrawnReward.mul(delegator.depositedTokens).div(totalDepositedTokens); + + workerWithdrawnReward = workerWithdrawnReward.sub(withdrawnToDecrease); + totalWithdrawnReward = totalWithdrawnReward.sub(withdrawnToDecrease).sub(delegator.withdrawnReward); + totalDepositedTokens = totalDepositedTokens.sub(delegator.depositedTokens); + + delegator.withdrawnReward = 0; + delegator.depositedTokens = 0; + + token.safeTransfer(msg.sender, value); + emit TokensWithdrawn(msg.sender, value, 0); + + totalWithdrawnETH = totalWithdrawnETH.sub(delegator.withdrawnETH); + delegator.withdrawnETH = 0; + if (availableETH > 0) { + msg.sender.sendValue(availableETH); + emit ETHWithdrawn(msg.sender, availableETH); + } + } + + /** + * @notice Get available ether for delegator + */ + function getAvailableETH(address _delegator) public view returns (uint256) { + Delegator storage delegator = delegators[_delegator]; + uint256 balance = address(this).balance; + // ETH balance + already withdrawn + balance = balance.add(totalWithdrawnETH); + uint256 maxAllowableETH = balance.mul(delegator.depositedTokens).div(totalDepositedTokens); + + uint256 availableETH = maxAllowableETH.sub(delegator.withdrawnETH); + if (availableETH > balance) { + availableETH = balance; + } + return availableETH; + } + + /** + * @notice Withdraw available amount of ETH to delegator + */ + function withdrawETH() public override { + Delegator storage delegator = delegators[msg.sender]; + uint256 availableETH = getAvailableETH(msg.sender); + require(availableETH > 0, "There is no available ETH to withdraw"); + delegator.withdrawnETH = delegator.withdrawnETH.add(availableETH); + + totalWithdrawnETH = totalWithdrawnETH.add(availableETH); + msg.sender.sendValue(availableETH); + emit ETHWithdrawn(msg.sender, availableETH); + } + + /** + * @notice Calling fallback function is allowed only for the owner + */ + function isFallbackAllowed() public override view returns (bool) { + return msg.sender == owner(); + } +} diff --git a/tests/contracts/main/staking_contracts/test_pooling_contract_v2.py b/tests/contracts/main/staking_contracts/test_pooling_contract_v2.py new file mode 100644 index 000000000..f877f42e9 --- /dev/null +++ b/tests/contracts/main/staking_contracts/test_pooling_contract_v2.py @@ -0,0 +1,526 @@ +""" +This file is part of nucypher. + +nucypher is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +nucypher is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with nucypher. If not, see . +""" + + +import pytest +from eth_tester.exceptions import TransactionFailed +from web3 import Web3 +from web3.contract import Contract + +from nucypher.blockchain.eth.token import NU + + +WORKER_FRACTION = 10 +BASIS_FRACTION = 100 + + +@pytest.fixture() +def pooling_contract(testerchain, router, deploy_contract): + owner = testerchain.client.accounts[1] + worker_owner = testerchain.client.accounts[2] + + contract, _ = deploy_contract('PoolingStakingContractV2') + # Initialize + tx = contract.functions.initialize(WORKER_FRACTION, router.address, worker_owner).transact({'from': owner}) + testerchain.wait_for_receipt(tx) + + return contract + + +@pytest.fixture() +def pooling_contract_interface(testerchain, staking_interface, pooling_contract): + return testerchain.client.get_contract( + abi=staking_interface.abi, + address=pooling_contract.address, + ContractFactoryClass=Contract) + + +def test_staking(testerchain, token_economics, token, escrow, pooling_contract, pooling_contract_interface): + creator = testerchain.client.accounts[0] + owner = testerchain.client.accounts[1] + worker_owner = testerchain.client.accounts[2] + delegators = testerchain.client.accounts[3:6] + deposit_log = pooling_contract.events.TokensDeposited.createFilter(fromBlock='latest') + withdraw_log = pooling_contract.events.TokensWithdrawn.createFilter(fromBlock='latest') + + assert pooling_contract.functions.owner().call() == owner + assert pooling_contract.functions.workerOwner().call() == worker_owner + assert pooling_contract.functions.getWorkerFraction().call() == WORKER_FRACTION + assert pooling_contract.functions.workerWithdrawnReward().call() == 0 + assert pooling_contract.functions.totalDepositedTokens().call() == 0 + assert pooling_contract.functions.totalWithdrawnReward().call() == 0 + assert token.functions.balanceOf(pooling_contract.address).call() == 0 + assert pooling_contract.functions.getAvailableWorkerReward().call() == 0 + assert pooling_contract.functions.getAvailableReward().call() == 0 + + # Give some tokens to delegators + for index, delegator in enumerate(delegators): + tokens = token_economics.minimum_allowed_locked // (index + 1) * 2 + tx = token.functions.transfer(delegator, tokens).transact({'from': creator}) + testerchain.wait_for_receipt(tx) + + # Delegators deposit tokens to the pooling contract + total_deposited_tokens = 0 + tokens_supply = 0 + assert pooling_contract.functions.getAvailableReward().call() == 0 + for index, delegator in enumerate(delegators): + assert pooling_contract.functions.delegators(delegator).call() == [0, 0, 0] + assert pooling_contract.functions.getAvailableReward(delegator).call() == 0 + tokens = token.functions.balanceOf(delegator).call() // 2 + tx = token.functions.approve(pooling_contract.address, tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + tx = pooling_contract.functions.depositTokens(tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [tokens, 0, 0] + assert pooling_contract.functions.getAvailableReward(delegator).call() == 0 + total_deposited_tokens += tokens + tokens_supply += tokens + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == tokens_supply + + events = deposit_log.get_all_entries() + assert len(events) == index + 1 + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == tokens + assert event_args['depositedTokens'] == tokens + + assert pooling_contract.functions.getWorkerFraction().call() == WORKER_FRACTION + assert pooling_contract.functions.workerWithdrawnReward().call() == 0 + assert pooling_contract.functions.totalWithdrawnReward().call() == 0 + assert pooling_contract.functions.getAvailableWorkerReward().call() == 0 + assert pooling_contract.functions.getAvailableReward().call() == 0 + + # Disable deposit + log = pooling_contract.events.DepositSet.createFilter(fromBlock='latest') + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.disableDeposit().transact({'from': delegators[0]}) + testerchain.wait_for_receipt(tx) + tx = pooling_contract.functions.disableDeposit().transact({'from': owner}) + testerchain.wait_for_receipt(tx) + events = log.get_all_entries() + assert len(events) == 1 + event_args = events[-1]['args'] + assert event_args['sender'] == owner + assert not event_args['value'] + + delegator = delegators[0] + tokens = token.functions.balanceOf(delegator).call() + tx = token.functions.approve(pooling_contract.address, tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.depositTokens(tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + tx = token.functions.approve(pooling_contract.address, 0).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + + # Enable deposit + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.enableDeposit().transact({'from': delegators[0]}) + testerchain.wait_for_receipt(tx) + tx = pooling_contract.functions.enableDeposit().transact({'from': owner}) + testerchain.wait_for_receipt(tx) + events = log.get_all_entries() + assert len(events) == 2 + event_args = events[-1]['args'] + assert event_args['sender'] == owner + assert event_args['value'] + + # Delegators deposit tokens to the pooling contract again + for index, delegator in enumerate(delegators): + tokens = token.functions.balanceOf(delegator).call() + tx = token.functions.approve(pooling_contract.address, tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + tx = pooling_contract.functions.depositTokens(tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [2 * tokens, 0, 0] + total_deposited_tokens += tokens + tokens_supply += tokens + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == tokens_supply + + events = deposit_log.get_all_entries() + assert len(events) == len(delegators) + index + 1 + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == tokens + assert event_args['depositedTokens'] == 2 * tokens + + assert pooling_contract.functions.totalWithdrawnReward().call() == 0 + + # Only owner can deposit tokens to the staking escrow + stake = tokens_supply + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract_interface.functions.depositAsStaker(stake, 5).transact({'from': delegators[0]}) + testerchain.wait_for_receipt(tx) + + tx = pooling_contract_interface.functions.depositAsStaker(stake, 5).transact({'from': owner}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == 0 + + # Give some tokens as a reward + assert pooling_contract.functions.getAvailableReward().call() == 0 + reward = token_economics.minimum_allowed_locked + tx = token.functions.approve(escrow.address, reward).transact() + testerchain.wait_for_receipt(tx) + tx = escrow.functions.deposit(pooling_contract.address, reward, 0).transact() + testerchain.wait_for_receipt(tx) + + # Only owner can withdraw tokens from the staking escrow + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract_interface.functions.withdrawAsStaker(reward).transact({'from': delegators[0]}) + testerchain.wait_for_receipt(tx) + + withdrawn_stake = reward + stake + assert pooling_contract.functions.getAvailableReward().call() == 0 + tx = pooling_contract_interface.functions.withdrawAsStaker(withdrawn_stake).transact({'from': owner}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.getAvailableReward().call() == reward + worker_reward = reward * WORKER_FRACTION // BASIS_FRACTION + assert pooling_contract.functions.getAvailableWorkerReward().call() == worker_reward + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == withdrawn_stake + tokens_supply = withdrawn_stake + total_withdrawn_tokens = 0 + + # Each delegator can withdraw some portion of tokens + available_reward = reward + for index, delegator in enumerate(delegators): + deposited_tokens = pooling_contract.functions.delegators(delegator).call()[0] + max_portion = reward * deposited_tokens * (BASIS_FRACTION - WORKER_FRACTION) // \ + (total_deposited_tokens * BASIS_FRACTION) + + # Can't withdraw more than max allowed + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.withdrawTokens(max_portion + 1).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + + portion = max_portion // 2 + assert pooling_contract.functions.getAvailableReward(delegator).call() == max_portion + tx = pooling_contract.functions.withdrawTokens(portion).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [deposited_tokens, portion, 0] + assert pooling_contract.functions.getAvailableReward(delegator).call() == max_portion - portion + tokens_supply -= portion + total_withdrawn_tokens += portion + available_reward -= portion + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == tokens_supply + assert token.functions.balanceOf(delegator).call() == portion + assert pooling_contract.functions.totalWithdrawnReward().call() == total_withdrawn_tokens + + events = withdraw_log.get_all_entries() + assert len(events) == index + 1 + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == portion + assert event_args['depositedTokens'] == deposited_tokens + + # Node owner withdraws tokens + assert pooling_contract.functions.getAvailableWorkerReward().call() == worker_reward + assert pooling_contract.functions.getAvailableReward().call() == available_reward + + # Only node owner can call this method + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.withdrawWorkerReward().transact({'from': delegators[0]}) + testerchain.wait_for_receipt(tx) + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.withdrawWorkerReward().transact({'from': owner}) + testerchain.wait_for_receipt(tx) + + tx = pooling_contract.functions.withdrawWorkerReward().transact({'from': worker_owner}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.getWorkerFraction().call() == WORKER_FRACTION + assert pooling_contract.functions.workerWithdrawnReward().call() == worker_reward + assert pooling_contract.functions.getAvailableWorkerReward().call() == 0 + tokens_supply -= worker_reward + total_withdrawn_tokens += worker_reward + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == tokens_supply + assert token.functions.balanceOf(owner).call() == 0 + assert token.functions.balanceOf(worker_owner).call() == worker_reward + assert pooling_contract.functions.totalWithdrawnReward().call() == total_withdrawn_tokens + assert pooling_contract.functions.getAvailableReward().call() == available_reward - worker_reward + + events = withdraw_log.get_all_entries() + assert len(events) == len(delegators) + 1 + event_args = events[-1]['args'] + assert event_args['sender'] == worker_owner + assert event_args['value'] == worker_reward + assert event_args['depositedTokens'] == 0 + + # Can't withdraw more than max allowed + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.withdrawWorkerReward().transact({'from': worker_owner}) + testerchain.wait_for_receipt(tx) + + # Each delegator can withdraw rest of reward and deposit + previous_total_deposited_tokens = total_deposited_tokens + withdrawn_worker_reward = worker_reward + + # Withdraw everything from one delegator and check others rewards + delegator = delegators[0] + deposited_tokens = pooling_contract.functions.delegators(delegator).call()[0] + withdrawn_tokens = pooling_contract.functions.delegators(delegator).call()[1] + + max_portion = reward * deposited_tokens * (BASIS_FRACTION - WORKER_FRACTION) // \ + (previous_total_deposited_tokens * BASIS_FRACTION) + supposed_portion = max_portion // 2 + reward_portion = pooling_contract.functions.getAvailableReward(delegator).call() + # could be some rounding errors + assert abs(supposed_portion - reward_portion) <= 10 + + new_portion = deposited_tokens + reward_portion + previous_portion = token.functions.balanceOf(delegator).call() + tx = pooling_contract.functions.withdrawAll().transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [0, 0, 0] + tokens_supply -= new_portion + total_deposited_tokens -= deposited_tokens + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert token.functions.balanceOf(pooling_contract.address).call() == tokens_supply + assert token.functions.balanceOf(delegator).call() == previous_portion + new_portion + assert token.functions.balanceOf(worker_owner).call() == worker_reward + assert pooling_contract.functions.getAvailableReward(delegator).call() == 0 + + withdraw_to_decrease = withdrawn_worker_reward * deposited_tokens // previous_total_deposited_tokens + total_withdrawn_tokens -= withdraw_to_decrease + total_withdrawn_tokens -= withdrawn_tokens + withdrawn_worker_reward -= withdraw_to_decrease + assert pooling_contract.functions.totalWithdrawnReward().call() == total_withdrawn_tokens + assert abs(pooling_contract.functions.workerWithdrawnReward().call() - withdrawn_worker_reward) <= 1 + + events = withdraw_log.get_all_entries() + assert len(events) == len(delegators) + 2 + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == new_portion + assert event_args['depositedTokens'] == 0 + + # Check worker's reward, still zero + assert pooling_contract.functions.getAvailableWorkerReward().call() == 0 + + # Check others rewards + for delegator in delegators[1:3]: + deposited_tokens = pooling_contract.functions.delegators(delegator).call()[0] + max_portion = reward * deposited_tokens * (BASIS_FRACTION - WORKER_FRACTION) // \ + (previous_total_deposited_tokens * BASIS_FRACTION) + supposed_portion = max_portion // 2 + reward_portion = pooling_contract.functions.getAvailableReward(delegator).call() + # could be some rounding errors + assert abs(supposed_portion - reward_portion) <= 10 + + # Increase reward for delegators and worker + new_reward = token_economics.minimum_allowed_locked // 2 + tx = token.functions.transfer(pooling_contract.address, new_reward).transact({'from': creator}) + testerchain.wait_for_receipt(tx) + tokens_supply += new_reward + new_worker_reward = new_reward * WORKER_FRACTION // BASIS_FRACTION + assert new_worker_reward > 0 + assert abs(pooling_contract.functions.getAvailableWorkerReward().call() - new_worker_reward) <= 1 + new_worker_reward = pooling_contract.functions.getAvailableWorkerReward().call() + + # Withdraw everything from one delegator and check others rewards + previous_total_deposited_tokens = total_deposited_tokens + delegator = delegators[1] + deposited_tokens = pooling_contract.functions.delegators(delegator).call()[0] + withdrawn_tokens = pooling_contract.functions.delegators(delegator).call()[1] + reward_portion = pooling_contract.functions.getAvailableReward(delegator).call() + other_reward_portion = pooling_contract.functions.getAvailableReward(delegators[2]).call() + + new_portion = deposited_tokens + reward_portion + previous_portion = token.functions.balanceOf(delegator).call() + tx = pooling_contract.functions.withdrawAll().transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [0, 0, 0] + tokens_supply -= new_portion + total_deposited_tokens -= deposited_tokens + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + new_worker_transfer = new_worker_reward * deposited_tokens // previous_total_deposited_tokens + tokens_supply -= new_worker_transfer + assert token.functions.balanceOf(pooling_contract.address).call() == tokens_supply + assert token.functions.balanceOf(delegator).call() == previous_portion + new_portion + assert token.functions.balanceOf(worker_owner).call() == worker_reward + new_worker_transfer + assert pooling_contract.functions.getAvailableReward(delegator).call() == 0 + + withdraw_to_decrease = withdrawn_worker_reward * deposited_tokens // previous_total_deposited_tokens + total_withdrawn_tokens -= withdraw_to_decrease + total_withdrawn_tokens -= withdrawn_tokens + withdrawn_worker_reward -= withdraw_to_decrease + assert pooling_contract.functions.totalWithdrawnReward().call() == total_withdrawn_tokens + assert abs(pooling_contract.functions.workerWithdrawnReward().call() - withdrawn_worker_reward) <= 1 + + events = withdraw_log.get_all_entries() + assert len(events) == len(delegators) + 4 + event_args = events[-2]['args'] + assert event_args['sender'] == worker_owner + assert event_args['value'] == new_worker_transfer + assert event_args['depositedTokens'] == 0 + + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == new_portion + assert event_args['depositedTokens'] == 0 + + # Check worker's reward + new_worker_reward = new_worker_reward * (previous_total_deposited_tokens - deposited_tokens) // previous_total_deposited_tokens + assert pooling_contract.functions.getAvailableWorkerReward().call() == new_worker_reward + + # Check others rewards + assert abs(pooling_contract.functions.getAvailableReward(delegators[2]).call() - other_reward_portion) <= 10 + + # Withdraw last portion for last delegator + delegator = delegators[2] + deposited_tokens = pooling_contract.functions.delegators(delegator).call()[0] + reward_portion = pooling_contract.functions.getAvailableReward(delegator).call() + + new_portion = deposited_tokens + reward_portion + previous_portion = token.functions.balanceOf(delegator).call() + tx = pooling_contract.functions.withdrawAll().transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [0, 0, 0] + assert pooling_contract.functions.totalDepositedTokens().call() == 0 + assert token.functions.balanceOf(pooling_contract.address).call() <= 1 + assert token.functions.balanceOf(delegator).call() == previous_portion + new_portion + assert token.functions.balanceOf(worker_owner).call() == worker_reward + new_worker_transfer + new_worker_reward + assert pooling_contract.functions.getAvailableReward().call() <= 1 + + events = withdraw_log.get_all_entries() + assert len(events) == len(delegators) + 6 + event_args = events[-2]['args'] + assert event_args['sender'] == worker_owner + assert event_args['value'] == new_worker_reward + assert event_args['depositedTokens'] == 0 + + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == new_portion + assert event_args['depositedTokens'] == 0 + + +def test_fee(testerchain, token_economics, token, policy_manager, pooling_contract, pooling_contract_interface): + creator = testerchain.client.accounts[0] + owner = testerchain.client.accounts[1] + delegators = testerchain.client.accounts[2:5] + withdraw_log = pooling_contract.events.ETHWithdrawn.createFilter(fromBlock='latest') + + assert pooling_contract.functions.getWorkerFraction().call() == WORKER_FRACTION + assert pooling_contract.functions.totalDepositedTokens().call() == 0 + assert pooling_contract.functions.totalWithdrawnETH().call() == 0 + assert token.functions.balanceOf(pooling_contract.address).call() == 0 + + # Give some tokens to delegators and deposit them + for index, delegator in enumerate(delegators): + tokens = token_economics.minimum_allowed_locked // (index + 1) + tx = token.functions.transfer(delegator, tokens).transact({'from': creator}) + testerchain.wait_for_receipt(tx) + tx = token.functions.approve(pooling_contract.address, tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + tx = pooling_contract.functions.depositTokens(tokens).transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + + total_deposited_tokens = pooling_contract.functions.totalDepositedTokens().call() + assert pooling_contract.functions.totalWithdrawnETH().call() == 0 + assert testerchain.client.get_balance(pooling_contract.address) == 0 + + # Give some fees + value = Web3.toWei(1, 'ether') + tx = testerchain.client.send_transaction( + {'from': testerchain.client.coinbase, 'to': policy_manager.address, 'value': value}) + testerchain.wait_for_receipt(tx) + + # Only owner can withdraw fees from the policy manager + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract_interface.functions.withdrawPolicyFee().transact({'from': delegators[0]}) + testerchain.wait_for_receipt(tx) + + balance = testerchain.client.get_balance(owner) + tx = pooling_contract_interface.functions.withdrawPolicyFee().transact({'from': owner}) + testerchain.wait_for_receipt(tx) + assert testerchain.client.get_balance(pooling_contract.address) == value + assert testerchain.client.get_balance(owner) == balance + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + withdrawn_eth = 0 + eth_supply = value + + # Each delegator can withdraw portion of eth + for index, delegator in enumerate(delegators): + deposited_tokens = pooling_contract.functions.delegators(delegator).call()[0] + max_portion = value * deposited_tokens // total_deposited_tokens + balance = testerchain.client.get_balance(delegator) + assert pooling_contract.functions.getAvailableETH(delegator).call() == max_portion + + tx = pooling_contract.functions.withdrawETH().transact({'from': delegator, 'gas_price': 0}) + testerchain.wait_for_receipt(tx) + assert pooling_contract.functions.delegators(delegator).call() == [deposited_tokens, 0, max_portion] + eth_supply -= max_portion + withdrawn_eth += max_portion + assert pooling_contract.functions.totalDepositedTokens().call() == total_deposited_tokens + assert testerchain.client.get_balance(pooling_contract.address) == eth_supply + assert testerchain.client.get_balance(delegator) == balance + max_portion + assert pooling_contract.functions.totalWithdrawnETH().call() == withdrawn_eth + + # Can't withdraw more than max allowed + with pytest.raises((TransactionFailed, ValueError)): + tx = pooling_contract.functions.withdrawETH().transact({'from': delegator}) + testerchain.wait_for_receipt(tx) + + events = withdraw_log.get_all_entries() + assert len(events) == index + 1 + event_args = events[-1]['args'] + assert event_args['sender'] == delegator + assert event_args['value'] == max_portion + + +def test_reentrancy(testerchain, pooling_contract, token, deploy_contract): + creator = testerchain.client.accounts[0] + owner = testerchain.client.accounts[1] + + # Prepare contracts + reentrancy_contract, _ = deploy_contract('ReentrancyTest') + contract_address = reentrancy_contract.address + tx = pooling_contract.functions.transferOwnership(contract_address).transact({'from': owner}) + testerchain.wait_for_receipt(tx) + + # Transfer ETH to the contract + value = Web3.toWei(1, 'ether') + tx = testerchain.client.send_transaction( + {'from': testerchain.client.coinbase, 'to': pooling_contract.address, 'value': value}) + testerchain.wait_for_receipt(tx) + assert testerchain.client.get_balance(pooling_contract.address) == value + + # Change eth distribution, owner will be able to withdraw only half + tokens = WORKER_FRACTION + tx = token.functions.transfer(owner, tokens).transact({'from': creator}) + testerchain.wait_for_receipt(tx) + tx = token.functions.approve(pooling_contract.address, tokens).transact({'from': owner}) + testerchain.wait_for_receipt(tx) + tx = pooling_contract.functions.depositTokens(tokens).transact({'from': owner}) + testerchain.wait_for_receipt(tx) + + # Try to withdraw ETH twice + balance = testerchain.w3.eth.getBalance(contract_address) + transaction = pooling_contract.functions.withdrawETH().buildTransaction({'gas': 0}) + tx = reentrancy_contract.functions.setData(1, transaction['to'], 0, transaction['data']).transact() + testerchain.wait_for_receipt(tx) + with pytest.raises((TransactionFailed, ValueError)): + tx = testerchain.client.send_transaction({'to': contract_address}) + testerchain.wait_for_receipt(tx) + assert testerchain.w3.eth.getBalance(contract_address) == balance + + # TODO same test for withdrawAll()