def test_sampling_distribution(testerchain, token, deploy_contract): # # SETUP # max_allowed_locked_tokens = 5 * 10 ** 8 _staking_coefficient = 2 * 10 ** 7 staking_escrow_contract, _ = deploy_contract( contract_name=STAKING_ESCROW_CONTRACT_NAME, _token=token.address, _hoursPerPeriod=1, _miningCoefficient=4 * _staking_coefficient, _lockedPeriodsCoefficient=4, _rewardedPeriods=4, _minLockedPeriods=2, _minAllowableLockedTokens=100, _maxAllowableLockedTokens=max_allowed_locked_tokens, _minWorkerPeriods=1, _isTestContract=False ) staking_agent = StakingEscrowAgent(registry=None, contract=staking_escrow_contract) policy_manager, _ = deploy_contract( 'PolicyManagerForStakingEscrowMock', token.address, staking_escrow_contract.address ) tx = staking_escrow_contract.functions.setPolicyManager(policy_manager.address).transact() testerchain.wait_for_receipt(tx) # Travel to the start of the next period to prevent problems with unexpected overflow first period testerchain.time_travel(hours=1) creator = testerchain.etherbase_account # Give Escrow tokens for reward and initialize contract tx = token.functions.approve(staking_escrow_contract.address, 10 ** 9).transact({'from': creator}) testerchain.wait_for_receipt(tx) tx = staking_escrow_contract.functions.initialize(10 ** 9).transact({'from': creator}) testerchain.wait_for_receipt(tx) stakers = testerchain.stakers_accounts amount = token.functions.balanceOf(creator).call() // len(stakers) # Airdrop for staker in stakers: tx = token.functions.transfer(staker, amount).transact({'from': creator}) testerchain.wait_for_receipt(tx) all_locked_tokens = len(stakers) * amount for staker in stakers: balance = token.functions.balanceOf(staker).call() tx = token.functions.approve(staking_escrow_contract.address, balance).transact({'from': staker}) testerchain.wait_for_receipt(tx) staking_agent.deposit_tokens(amount=balance, lock_periods=10, sender_address=staker, staker_address=staker) staking_agent.set_worker(staker_address=staker, worker_address=staker) staking_agent.confirm_activity(staker) # Wait next period and check all locked tokens testerchain.time_travel(hours=1) # # Test sampling distribution # ERROR_TOLERANCE = 0.05 # With this tolerance, all sampling ratios should between 5% and 15% (expected is 10%) SAMPLES = 1000 quantity = 3 counter = Counter() sampled, failed = 0, 0 while sampled < SAMPLES: try: addresses = set(staking_agent.sample(quantity=quantity, additional_ursulas=1, duration=1)) addresses.discard(NULL_ADDRESS) except staking_agent.NotEnoughStakers: failed += 1 continue else: sampled += 1 counter.update(addresses) total_times = sum(counter.values()) expected = amount / all_locked_tokens for staker in stakers: times = counter[staker] sampled_ratio = times / total_times abs_error = abs(expected - sampled_ratio) assert abs_error < ERROR_TOLERANCE
class Staker(NucypherTokenActor): """ Baseclass for staking-related operations on the blockchain. """ class StakerError(NucypherTokenActor.ActorError): pass def __init__(self, is_me: bool, economics: TokenEconomics = None, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.log = Logger("staker") self.stake_tracker = StakeTracker( checksum_addresses=[self.checksum_address]) self.staking_agent = StakingEscrowAgent(blockchain=self.blockchain) self.economics = economics or TokenEconomics() self.is_me = is_me @property def stakes(self) -> List[Stake]: stakes = self.stake_tracker.stakes( checksum_address=self.checksum_address) return stakes @property def is_staking(self) -> bool: """Checks if this Staker currently has active stakes / locked tokens.""" return bool(self.stakes) def locked_tokens(self, periods: int = 0) -> NU: """Returns the amount of tokens this staker has locked for a given duration in periods.""" raw_value = self.staking_agent.get_locked_tokens( staker_address=self.checksum_address, periods=periods) value = NU.from_nunits(raw_value) return value @property def current_stake(self) -> NU: """ The total number of staked tokens, either locked or unlocked in the current period. """ if self.stakes: return NU(sum(int(stake.value) for stake in self.stakes), 'NuNit') else: return NU.ZERO() @only_me def divide_stake(self, stake_index: int, target_value: NU, additional_periods: int = None, expiration: maya.MayaDT = None) -> tuple: # Calculate duration in periods if additional_periods and expiration: raise ValueError( "Pass the number of lock periods or an expiration MayaDT; not both." ) # Select stake to divide from local cache try: current_stake = self.stakes[stake_index] except KeyError: if len(self.stakes): message = f"Cannot divide stake - No stake exists with index {stake_index}." else: message = "Cannot divide stake - There are no active stakes." raise Stake.StakingError(message) # Calculate stake duration in periods if expiration: additional_periods = datetime_to_period( datetime=expiration) - current_stake.end_period if additional_periods <= 0: raise Stake.StakingError( f"New expiration {expiration} must be at least 1 period from the " f"current stake's end period ({current_stake.end_period})." ) # Do it already! modified_stake, new_stake = current_stake.divide( target_value=target_value, additional_periods=additional_periods) # Update staking cache element self.stake_tracker.refresh(checksum_addresses=[self.checksum_address]) return modified_stake, new_stake @only_me def initialize_stake(self, amount: NU, lock_periods: int = None, expiration: maya.MayaDT = None, entire_balance: bool = False) -> Stake: """Create a new stake.""" # Duration if lock_periods and expiration: raise ValueError( "Pass the number of lock periods or an expiration MayaDT; not both." ) if expiration: lock_periods = calculate_period_duration(future_time=expiration) # Value if entire_balance and amount: raise ValueError("Specify an amount or entire balance, not both") if entire_balance: amount = self.token_balance if not self.token_balance >= amount: raise self.StakerError( f"Insufficient token balance ({self.token_agent}) " f"for new stake initialization of {amount}") # Ensure the new stake will not exceed the staking limit if (self.current_stake + amount) > self.economics.maximum_allowed_locked: raise Stake.StakingError( f"Cannot divide stake - " f"Maximum stake value exceeded with a target value of {amount}." ) # Write to blockchain new_stake = Stake.initialize_stake(staker=self, amount=amount, lock_periods=lock_periods) # Update stake tracker cache element self.stake_tracker.refresh(checksum_addresses=[self.checksum_address]) return new_stake # # Reward and Collection # @only_me @save_receipt def set_worker(self, worker_address: str) -> str: # TODO: Set a Worker for this staker, not just in StakingEscrow receipt = self.staking_agent.set_worker( staker_address=self.checksum_address, worker_address=worker_address) return receipt @only_me @save_receipt def mint(self) -> Tuple[str, str]: """Computes and transfers tokens to the staker's account""" receipt = self.staking_agent.mint(staker_address=self.checksum_address) return receipt def calculate_reward(self) -> int: staking_reward = self.staking_agent.calculate_staking_reward( staker_address=self.checksum_address) return staking_reward @only_me @save_receipt def collect_policy_reward(self, collector_address=None, policy_agent: PolicyAgent = None): """Collect rewarded ETH""" policy_agent = policy_agent if policy_agent is not None else PolicyAgent( blockchain=self.blockchain) withdraw_address = collector_address or self.checksum_address receipt = policy_agent.collect_policy_reward( collector_address=withdraw_address, staker_address=self.checksum_address) return receipt @only_me @save_receipt def collect_staking_reward(self) -> str: """Withdraw tokens rewarded for staking.""" receipt = self.staking_agent.collect_staking_reward( staker_address=self.checksum_address) return receipt @only_me @save_receipt def withdraw(self, amount: NU) -> str: """Withdraw tokens (assuming they're unlocked)""" receipt = self.staking_agent.withdraw( staker_address=self.checksum_address, amount=int(amount)) return receipt
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