diff --git a/nucypher/blockchain/eth/actors.py b/nucypher/blockchain/eth/actors.py index 19ab12815..4c839029e 100644 --- a/nucypher/blockchain/eth/actors.py +++ b/nucypher/blockchain/eth/actors.py @@ -28,8 +28,8 @@ from constant_sorrow.constants import ( CONTRACT_NOT_DEPLOYED, NO_DEPLOYER_ADDRESS, WORKER_NOT_RUNNING, - WORKER_NOT_RUNNING, NO_WORKER_ASSIGNED, + NO_FUNDING_ACCOUNT ) from eth_tester.exceptions import TransactionFailed from eth_utils import keccak @@ -665,12 +665,17 @@ class Investigator(NucypherTokenActor): class StakeHolder(BaseConfiguration): _NAME = 'stakeholder' + TRANSACTION_GAS = {} + + class NoFundingAccount(BaseConfiguration.ConfigurationError): + pass def __init__(self, blockchain: BlockchainInterface = None, staking_agent: StakingEscrowAgent = None, - master_address: str = None, - trezor: bool = False, + funding_account: str = NO_FUNDING_ACCOUNT, + offline_mode: bool = False, + sync_now: bool = True, *args, **kwargs): super().__init__(*args, **kwargs) @@ -678,23 +683,80 @@ class StakeHolder(BaseConfiguration): # Blockchain and Contract connection if not staking_agent: staking_agent = StakingEscrowAgent(blockchain=blockchain) + self.staking_agent = staking_agent self.blockchain = staking_agent.blockchain + self.token_agent = NucypherTokenAgent(blockchain=self.blockchain) - # Default State - self.trezor = trezor - self.master_account = master_address - self.__accounts = list() - self.__stakers = dict() + # Mode + self.offline_mode = offline_mode # Setup + self.__funding_account = funding_account + self.__accounts = list() + self.__stakers = dict() self.__get_accounts() - self.sync() # Stakes + + if sync_now: + self.read_stakes() # Stakes + + def static_payload(self) -> dict: + """Values to read/write from stakeholder JSON configuration files""" + payload = dict(accounts=self.__accounts, + stakers=self.__serialize_stake_info()) + return payload + + # + # Account Utilities + # @property - def accounts(self): + def accounts(self) -> list: return self.__accounts + def __get_accounts(self) -> None: + accounts = self.blockchain.interface.accounts + self.__accounts.extend(accounts) + + def set_funding_account(self, address: str): + self.__funding_account = address + + @property + def funding_account(self) -> str: + if self.__funding_account is NO_FUNDING_ACCOUNT: + raise self.NoFundingAccount + return self.__funding_account + + @property + def funding_tokens(self) -> NU: + nunits = self.token_agent.get_balance(address=self.funding_account) + return NU.from_nunits(nunits) + + @property + def funding_eth(self) -> Decimal: + ethers = self.blockchain.interface.client.w3.getBalance(self.funding_account) + return ethers + + # + # Staking Utilities + # + + def read_stakes(self) -> None: + for account in self.__accounts: + stakes = list(self.staking_agent.get_all_stakes(staker_address=account)) + if stakes: + staker = Staker(is_me=True, checksum_address=account, blockchain=self.blockchain) + self.__stakers[staker] = staker.stakes + + @property + def total_stake(self) -> NU: + total = sum(staker.locked_tokens() for staker in self.stakers) + return total + + @property + def stakers(self) -> List[Staker]: + return list(self.__stakers.keys()) + @property def stakes(self) -> list: payload = list() @@ -703,79 +765,110 @@ class StakeHolder(BaseConfiguration): return payload @property - def stakers(self) -> List[Staker]: - return list(self.__stakers.keys()) + def staker_balances(self) -> dict: + balances = dict() + for staker in self.stakers: + staker_funds = {'ETH': staker.eth_balance, 'NU': staker.token_balance} + balances = {staker.checksum_address: staker_funds} + return balances def __serialize_stake_info(self) -> list: payload = list() for staker in self.stakers: stake_info = [stake.to_stake_info() for stake in staker.stakes] - worker_address = staker.worker_address - staker_payload = {'staker': staker.checksum_public_address, - 'worker': worker_address or NO_WORKER_ASSIGNED, + worker_address = staker.worker_address or NO_WORKER_ASSIGNED + staker_funds = {'ETH': int(staker.eth_balance), 'NU': int(staker.token_balance)} + staker_payload = {'staker': staker.checksum_address, + 'balances': staker_funds, + 'worker': worker_address, 'stakes': stake_info} payload.append(staker_payload) return payload - def static_payload(self) -> dict: - payload = dict(trezor=self.trezor, - accounts=self.__accounts, - stakers=self.__serialize_stake_info()) - return payload - - def sync(self) -> None: - for account in self.__accounts: - stakes = list(self.staking_agent.get_all_stakes(staker_address=account)) - if stakes: - staker = Staker(is_me=True, checksum_address=account, blockchain=self.blockchain) - self.__stakers[staker] = staker.stakes - - def get_balances(self, address: str) -> dict: - staker = self.get_staker(address=address) - balances = {'ETH': staker.eth_balance, - 'NU': staker.token_balance} - return balances - - def __get_accounts(self) -> None: - - try: - accounts = self.blockchain.interface.accounts[1:] - except IndexError: - raise self.ConfigurationError(f"A minimum of two accounts are " - f"required to create a {self.__class__.__name__}") - else: - for account in accounts: - self.__accounts.append(account) - - if not self.master_account: - self.master_account = accounts[0] - - def get_staker(self, address: str) -> Staker: + def get_active_staker(self, address: str) -> Staker: + self.read_stakes() try: staker = self.__stakers[address] except KeyError: - if address not in self.__accounts: - raise RuntimeError(f"Unknown or locked account {address}") - staker = Staker(is_me=True, checksum_address=address) + raise self.ConfigurationError(f"{address} does not have any stakes.") return staker - def set_worker(self, index: int, worker_address: str): - stake = self.__stakers[index] - result = self.staking_agent.set_worker(staker_address=stake.owner_address, worker_address=worker_address) + def __create_staker(self, password: str) -> Staker: + if self.device: + raise NotImplementedError + else: + new_account = self.blockchain.interface.client.new_account(password=password) + self.__accounts.append(new_account) + staker = Staker(is_me=True, checksum_address=new_account) + return staker + + def create_worker_configuration(self, staker_address: str, **configuration) -> str: + from nucypher.config.characters import UrsulaConfiguration + staker = self.get_active_staker(address=staker_address) + ursula_configuration = UrsulaConfiguration.generate(checksum_address=staker.checksum_address, + config_root=self.config_root, + federated_only=False, + **configuration) + filepath = ursula_configuration.to_configuration_file() + return filepath + + # + # Actions + # + + def set_worker(self, staker_address: str, worker_address: str): + staker = self.get_active_staker(address=staker_address) + result = self.staking_agent.set_worker(staker_address=staker.checksum_address, + worker_address=worker_address) return result - def initialize_stake(self, address: str, amount: NU, duration: int): - staker = self.get_staker(address=address) - new_stake = Stake.initialize_stake(staker=staker, amount=amount, lock_periods=duration) + def initialize_stake(self, + password: str, + amount: NU, + duration: int, + ) -> Stake: + staker = self.__create_staker(password=password) + if self.funding_tokens < amount: + delta = amount - staker.token_balance + raise self.ConfigurationError(f"{self.funding_account} Insufficient NU (need {delta} more).") + if not self.funding_eth: + raise self.ConfigurationError(f"{self.funding_account} has no ETH") + _result = self.token_agent.transfer(amount=int(amount), + sender_address=self.funding_account, + target_address=staker.checksum_address) + new_stake = staker.initialize_stake(amount=amount, lock_periods=duration) return new_stake - def divide_stake(self, address: str, index: int, value: int): - staker = self.get_staker(address=address) + def divide_stake(self, + address: str, + index: int, + value: NU, + duration: int): + staker = self.get_active_staker(address=address) if not staker.is_staking: - raise Stake.StakingError(f"{staker.checksum_public_address} has no published stakes.") - try: - stake = staker.stakes[index] - except IndexError: - raise - result = stake.divide(target_value=value) + raise Stake.StakingError(f"{staker.checksum_address} has no published stakes.") + result = staker.divide_stake(stake_index=index, + additional_periods=duration, + target_value=value) return result + + def sweep(self) -> dict: + """Collect all rewards from all staking accounts""" + receipts = dict() + for staker in self.stakers: + receipt = self.collect_rewards(staker_address=staker.checksum_address) + receipts[staker.checksum_address] = receipt + return receipts + + def collect_rewards(self, + staker_address: str, + staking: bool = True, + policy: bool = True) -> dict: + if not staking and not policy: + raise ValueError("Either staking or policy must be True in order to collect rewards") + staker = self.get_active_staker(address=staker_address) + if staking: + receipt = staker.collect_staking_reward() + if policy: + receipt = staker.collect_policy_reward(collector_address=self.funding_account) + return receipt diff --git a/tests/blockchain/eth/entities/actors/test_stakeholder.py b/tests/blockchain/eth/entities/actors/test_stakeholder.py index 5223c2b29..b81060e93 100644 --- a/tests/blockchain/eth/entities/actors/test_stakeholder.py +++ b/tests/blockchain/eth/entities/actors/test_stakeholder.py @@ -1,48 +1,70 @@ import json import os +import pytest + from nucypher.blockchain.eth.actors import StakeHolder -def test_software_stakeholder(testerchain, agency, blockchain_ursulas): - +@pytest.fixture(scope='session') +def stakeholder_config_file_location(): path = os.path.join('/', 'tmp', 'nucypher-test-stakeholder.json') + return path + +@pytest.fixture(scope='module') +def staking_software_stakeholder(testerchain, + agency, + blockchain_ursulas, + stakeholder_config_file_location): + + # Setup + path = stakeholder_config_file_location if os.path.exists(path): os.remove(path) # Create stakeholder from on-chain values given accounts over a web3 provider stakeholder = StakeHolder(blockchain=testerchain, trezor=False) + # Teardown + yield stakeholder + if os.path.exists(path): + os.remove(path) + + +def test_software_stakeholder_configuration(testerchain, + staking_software_stakeholder, + stakeholder_config_file_location): + + stakeholder = staking_software_stakeholder + path = stakeholder_config_file_location + + # Check attributes can be successfully read + assert stakeholder.total_stake assert stakeholder.trezor is False - assert len(stakeholder.stakes) - assert len(stakeholder.accounts) + assert stakeholder.stakes + assert stakeholder.accounts - # Save to file - try: + # Save the stakeholder JSON config + stakeholder.to_configuration_file(filepath=path) + with open(stakeholder.filepath, 'r') as file: - # Save the stakeholder JSN config - stakeholder.to_configuration_file(filepath=path) - with open(path, 'r') as file: + # Ensure file contents are serializable + contents = file.read() + first_config_contents = json.loads(contents) - # Ensure file contents are serializable - contents = file.read() - deserialized_contents = json.loads(contents) + # Destroy this stake holder, leaving only the configuration file behind + del stakeholder - del stakeholder + # Restore StakeHolder instance from JSON config + the_same_stakeholder = StakeHolder.from_configuration_file(filepath=path) - # Restore StakeHolder instance from JSON config - stakeholder = StakeHolder.from_configuration_file(filepath=path) + # Save the JSON config again + stakeholder.to_configuration_file(filepath=path, override=True) + with open(the_same_stakeholder.filepath, 'r') as file: + contents = file.read() + second_config_contents = json.loads(contents) - # Save the JSON config again - stakeholder.to_configuration_file(filepath=path, override=True) - with open(path, 'r') as file: - contents = file.read() - deserialized_contents_2 = json.loads(contents) - - # Ensure the stakeholder was accurately restored from JSON config - assert deserialized_contents == deserialized_contents_2 - - finally: - if os.path.exists(path): - os.remove(path) + # Ensure the stakeholder was accurately restored from JSON config + assert first_config_contents == second_config_contents + assert stakeholder == the_same_stakeholder