def test_init_ursula_stake(click_runner, configuration_file_location, funded_blockchain, stake_value, token_economics): stake_args = ('ursula', 'stake', '--config-file', configuration_file_location, '--value', stake_value.to_tokens(), '--duration', token_economics.minimum_locked_periods, '--force') result = click_runner.invoke(nucypher_cli, stake_args, input=INSECURE_DEVELOPMENT_PASSWORD, catch_exceptions=False) assert result.exit_code == 0 with open(configuration_file_location, 'r') as config_file: config_data = json.loads(config_file.read()) # Verify the stake is on-chain staking_agent = StakingEscrowAgent() stakes = list( staking_agent.get_all_stakes( staker_address=config_data['checksum_address'])) assert len(stakes) == 1 start_period, end_period, value = stakes[0] assert NU(int(value), 'NuNit') == stake_value
class StakeTracker: REFRESH_RATE = 60 tracking_addresses = set() __stakes = dict() # type: Dict[str: List[Stake]] __actions = list() # type: List[Tuple[Callable, tuple]] def __init__(self, checksum_addresses: List[str], refresh_rate: int = None, start_now: bool = False, *args, **kwargs): super().__init__(*args, **kwargs) self.log = Logger('stake-tracker') self.staking_agent = StakingEscrowAgent() self._refresh_rate = refresh_rate or self.REFRESH_RATE self._tracking_task = task.LoopingCall(self.__update) self.__current_period = None self.__stakes = dict() self.__start_time = NOT_STAKING self.__uptime_period = NOT_STAKING self.__terminal_period = NOT_STAKING self._abort_on_stake_tracking_error = True # "load-in": Read on-chain stakes for checksum_address in checksum_addresses: if not is_checksum_address(checksum_address): raise ValueError( f'{checksum_address} is not a valid EIP-55 checksum address' ) self.tracking_addresses.add(checksum_address) if start_now: self.start() # deamonize else: self.refresh(checksum_addresses=checksum_addresses) # read-once @validate_checksum_address def __getitem__(self, checksum_address: str): stakes = self.stakes(checksum_address=checksum_address) return stakes def add_action(self, func: Callable, args=()) -> None: self.__actions.append((func, args)) def clear_actions(self) -> None: self.__actions.clear() @property def current_period(self): return self.__current_period @validate_checksum_address def stakes(self, checksum_address: str) -> List[Stake]: """Return all cached stake instances from the blockchain.""" try: return self.__stakes[checksum_address] except KeyError: return NO_STAKES.bool_value(False) except TypeError: if self.__stakes in (UNKNOWN_STAKES, NO_STAKES): return NO_STAKES.bool_value(False) raise @validate_checksum_address def refresh(self, checksum_addresses: List[str] = None) -> None: """Public staking cache invalidation method""" return self.__read_stakes(checksum_addresses=checksum_addresses) def stop(self) -> None: self._tracking_task.stop() self.log.info(f"STOPPED STAKE TRACKING") def start(self, force: bool = False) -> None: """ High-level stake tracking initialization, this function aims to be safely called at any time - For example, it is okay to call this function multiple times within the same period. """ if self._tracking_task.running and not force: return # Record the start time and period self.__start_time = maya.now() self.__uptime_period = self.staking_agent.get_current_period() self.__current_period = self.__uptime_period d = self._tracking_task.start(interval=self._refresh_rate) d.addErrback(self.handle_tracking_errors) self.log.info( f"STARTED STAKE TRACKING for {len(self.tracking_addresses)} addresses" ) def _crash_gracefully(self, failure=None) -> None: """ A facility for crashing more gracefully in the event that an exception is unhandled in a different thread. """ self._crashed = failure failure.raiseException() def handle_tracking_errors(self, *args, **kwargs) -> None: failure = args[0] if self._abort_on_stake_tracking_error: self.log.critical( f"Unhandled error during node stake tracking. {failure}") reactor.callFromThread(self._crash_gracefully, failure=failure) else: self.log.warn( f"Unhandled error during stake tracking: {failure.getTraceback()}" ) def __update(self) -> None: self.log.info( f"Checking for new period. Current period is {self.__current_period}" ) onchain_period = self.staking_agent.get_current_period( ) # < -- Read from contract if self.__current_period != onchain_period: self.__current_period = onchain_period self.__read_stakes() for action, args in self.__actions: action(*args) @validate_checksum_address def __read_stakes(self, checksum_addresses: List[str] = None) -> None: """Rewrite the local staking cache by reading on-chain stakes""" if not checksum_addresses: checksum_addresses = self.tracking_addresses for checksum_address in checksum_addresses: if not is_checksum_address(checksum_address): if self._abort_on_stake_tracking_error: raise ValueError( f'{checksum_address} is not a valid EIP-55 checksum address' ) self.tracking_addresses.remove(checksum_address) # Prune existing_records = len( self.stakes(checksum_address=checksum_address)) # Candidate replacement cache values onchain_stakes, terminal_period = list(), 0 # Read from blockchain stakes_reader = self.staking_agent.get_all_stakes( staker_address=checksum_address) for onchain_index, stake_info in enumerate(stakes_reader): if not stake_info: onchain_stake = EMPTY_STAKING_SLOT else: onchain_stake = Stake.from_stake_info( checksum_address=checksum_address, stake_info=stake_info, index=onchain_index) # rack the latest terminal period if onchain_stake.end_period > terminal_period: terminal_period = onchain_stake.end_period # Store the replacement stake onchain_stakes.append(onchain_stake) # Commit the new stake and terminal values to the cache if not onchain_stakes: self.__stakes[checksum_address] = NO_STAKES.bool_value(False) else: self.__terminal_period = terminal_period self.__stakes[checksum_address] = onchain_stakes new_records = existing_records - len( self.__stakes[checksum_address]) self.log.debug( f"Updated local staking cache ({new_records} new stakes).") # Record most recent cache update self.__updated = maya.now()
class StakeHolder(BaseConfiguration): _NAME = 'stakeholder' TRANSACTION_GAS = {} class NoFundingAccount(BaseConfiguration.ConfigurationError): pass class NoStakes(BaseConfiguration.ConfigurationError): pass def __init__(self, blockchain: BlockchainInterface, sync_now: bool = True, *args, **kwargs): super().__init__(*args, **kwargs) self.log = Logger(f"stakeholder") # Blockchain and Contract connection self.blockchain = blockchain self.staking_agent = StakingEscrowAgent(blockchain=blockchain) self.token_agent = NucypherTokenAgent(blockchain=blockchain) self.economics = TokenEconomics() # Mode self.connect(blockchain=blockchain) self.__accounts = list() self.__stakers = dict() self.__transacting_powers = dict() self.__get_accounts() if sync_now: self.read_onchain_stakes() # Stakes # # Configuration # def static_payload(self) -> dict: """Values to read/write from stakeholder JSON configuration files""" payload = dict(provider_uri=self.blockchain.provider_uri, blockchain=self.blockchain.to_dict(), accounts=self.__accounts, stakers=self.__serialize_stakers()) return payload @classmethod def from_configuration_file(cls, filepath: str = None, sync_now: bool = True, **overrides) -> 'StakeHolder': filepath = filepath or cls.default_filepath() payload = cls._read_configuration_file(filepath=filepath) # Sub config blockchain_payload = payload.pop('blockchain') blockchain = BlockchainInterface.from_dict(payload=blockchain_payload) blockchain.connect(sync_now=sync_now) # TODO: Leave this here? payload.update(dict(blockchain=blockchain)) payload.update(overrides) instance = cls(filepath=filepath, **payload) return instance @validate_checksum_address def attach_transacting_power(self, checksum_address: str, password: str = None) -> None: try: transacting_power = self.__transacting_powers[checksum_address] except KeyError: transacting_power = TransactingPower(blockchain=self.blockchain, password=password, account=checksum_address) self.__transacting_powers[checksum_address] = transacting_power transacting_power.activate(password=password) def to_configuration_file(self, *args, **kwargs) -> str: filepath = super().to_configuration_file(*args, **kwargs) return filepath def connect(self, blockchain: BlockchainInterface = None) -> None: """Go Online""" if not self.staking_agent: self.staking_agent = StakingEscrowAgent(blockchain=blockchain) if not self.token_agent: self.token_agent = NucypherTokenAgent(blockchain=blockchain) self.blockchain = self.token_agent.blockchain # # Account Utilities # @property def accounts(self) -> list: return self.__accounts def __get_accounts(self) -> None: accounts = self.blockchain.client.accounts self.__accounts.extend(accounts) # # Staking Utilities # def read_onchain_stakes(self, account: str = None) -> None: if account: accounts = [account] else: accounts = self.__accounts for account in 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[account] = staker @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.values()) @property def stakes(self) -> list: payload = list() for staker in self.__stakers.values(): payload.extend(staker.stakes) return payload @property def account_balances(self) -> dict: balances = dict() for account in self.__accounts: funds = { 'ETH': self.blockchain.client.get_balance(account), 'NU': self.token_agent.get_balance(account) } balances.update({account: funds}) return balances @property 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.checksum_address: staker_funds } return balances def __serialize_stakers(self) -> list: payload = list() for staker in self.stakers: payload.append(staker.to_dict()) return payload def get_active_staker(self, address: str) -> Staker: self.read_onchain_stakes(account=address) try: return self.__stakers[address] except KeyError: raise self.NoStakes(f"{address} does not have any stakes.") def create_worker_configuration(self, staking_address: str, worker_address: str, password: str, **configuration): """Generates a worker JSON configuration file for a given staking address.""" from nucypher.config.characters import UrsulaConfiguration worker_configuration = UrsulaConfiguration.generate( checksum_address=staking_address, worker_address=worker_address, password=password, config_root=self.config_root, federated_only=False, provider_uri=self.blockchain.provider_uri, **configuration) return worker_configuration # # Actions # def set_worker(self, staker_address: str, worker_address: str, password: str = None): self.attach_transacting_power(checksum_address=staker_address, password=password) staker = self.get_active_staker(address=staker_address) receipt = self.staking_agent.set_worker( staker_address=staker.checksum_address, worker_address=worker_address) self.to_configuration_file(override=True) return receipt def initialize_stake( self, amount: NU, duration: int, checksum_address: str, password: str = None, ) -> Stake: # Existing Staker address if not is_checksum_address(checksum_address): raise ValueError( f"{checksum_address} is an invalid EIP-55 checksum address.") try: staker = self.__stakers[checksum_address] except KeyError: if checksum_address not in self.__accounts: raise ValueError( f"{checksum_address} is an unknown wallet address.") else: staker = Staker(is_me=True, checksum_address=checksum_address, blockchain=self.blockchain) # Don the transacting power for the staker's account. self.attach_transacting_power(checksum_address=staker.checksum_address, password=password) new_stake = staker.initialize_stake(amount=amount, lock_periods=duration) # Update local cache and save to disk. self.__stakers[checksum_address] = staker staker.stake_tracker.refresh( checksum_addresses=[staker.checksum_address]) self.to_configuration_file(override=True) return new_stake def divide_stake( self, address: str, index: int, value: NU, duration: int, password: str = None, ): staker = self.get_active_staker(address=address) if not staker.is_staking: raise Stake.StakingError( f"{staker.checksum_address} has no published stakes.") self.attach_transacting_power(checksum_address=staker.checksum_address, password=password) result = staker.divide_stake(stake_index=index, additional_periods=duration, target_value=value) # Save results to disk self.to_configuration_file(override=True) return result def calculate_rewards(self) -> dict: rewards = dict() for staker in self.stakers: reward = staker.calculate_reward() rewards[staker.checksum_address] = reward return rewards def collect_rewards(self, staker_address: str, password: str = None, withdraw_address: str = None, staking: bool = True, policy: bool = True) -> Dict[str, dict]: if not staking and not policy: raise ValueError( "Either staking or policy must be True in order to collect rewards" ) try: staker = self.get_active_staker(address=staker_address) except self.NoStakes: staker = Staker(is_me=True, checksum_address=staker_address, blockchain=self.blockchain) self.attach_transacting_power(checksum_address=staker.checksum_address, password=password) receipts = dict() if staking: receipts['staking_reward'] = staker.collect_staking_reward() if policy: receipts['policy_reward'] = staker.collect_policy_reward( collector_address=withdraw_address) self.to_configuration_file(override=True) return receipts