class Miner(NucypherTokenActor): """ Ursula baseclass for blockchain operations, practically carrying a pickaxe. """ __current_period_sample_rate = 10 class MinerError(NucypherTokenActor.ActorError): pass def __init__(self, is_me: bool, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.log = Logger("miner") self.is_me = is_me if is_me: self.token_agent = NucypherTokenAgent(blockchain=self.blockchain) # Staking Loop self.__current_period = None self._abort_on_staking_error = True self._staking_task = task.LoopingCall(self._confirm_period) else: self.token_agent = constants.STRANGER_MINER # Everyone! self.miner_agent = MinerAgent(blockchain=self.blockchain) # # Staking # @only_me def stake(self, confirm_now=False, resume: bool = False, expiration: maya.MayaDT = None, lock_periods: int = None, *args, **kwargs) -> None: """High-level staking daemon loop""" if lock_periods and expiration: raise ValueError("Pass the number of lock periods or an expiration MayaDT; not both.") if expiration: lock_periods = datetime_to_period(expiration) if resume is False: _staking_receipts = self.initialize_stake(expiration=expiration, lock_periods=lock_periods, *args, **kwargs) # TODO: Check if this period has already been confirmed # TODO: Check if there is an active stake in the current period: Resume staking daemon # TODO: Validation and Sanity checks if confirm_now: self.confirm_activity() # record start time and periods self.__start_time = maya.now() self.__uptime_period = self.miner_agent.get_current_period() self.__terminal_period = self.__uptime_period + lock_periods self.__current_period = self.__uptime_period self.start_staking_loop() # # Daemon # @only_me def _confirm_period(self): period = self.miner_agent.get_current_period() self.log.info("Checking for new period. Current period is {}".format(self.__current_period)) # TODO: set to debug? if self.__current_period != period: # check for stake expiration stake_expired = self.__current_period >= self.__terminal_period if stake_expired: self.log.info('Stake duration expired') return True self.confirm_activity() self.__current_period = period self.log.info("Confirmed activity for period {}".format(self.__current_period)) @only_me def _crash_gracefully(self, failure=None): """ A facility for crashing more gracefully in the event that an exception is unhandled in a different thread, especially inside a loop like the learning loop. """ self._crashed = failure failure.raiseException() @only_me def handle_staking_errors(self, *args, **kwargs): failure = args[0] if self._abort_on_staking_error: self.log.critical("Unhandled error during node staking. Attempting graceful crash.") reactor.callFromThread(self._crash_gracefully, failure=failure) else: self.log.warn("Unhandled error during node learning: {}".format(failure.getTraceback())) @only_me def start_staking_loop(self, now=True): if self._staking_task.running: return False else: d = self._staking_task.start(interval=self.__current_period_sample_rate, now=now) d.addErrback(self.handle_staking_errors) self.log.info("Started staking loop") return d @property def is_staking(self): """Checks if this Miner currently has locked tokens.""" return bool(self.locked_tokens > 0) @property def locked_tokens(self): """Returns the amount of tokens this miner has locked.""" return self.miner_agent.get_locked_tokens(miner_address=self.checksum_public_address) @property def stakes(self) -> Tuple[list]: """Read all live stake data from the blockchain and return it as a tuple""" stakes_reader = self.miner_agent.get_all_stakes(miner_address=self.checksum_public_address) return tuple(stakes_reader) @only_me def deposit(self, amount: int, lock_periods: int) -> Tuple[str, str]: """Public facing method for token locking.""" approve_txhash = self.token_agent.approve_transfer(amount=amount, target_address=self.miner_agent.contract_address, sender_address=self.checksum_public_address) deposit_txhash = self.miner_agent.deposit_tokens(amount=amount, lock_periods=lock_periods, sender_address=self.checksum_public_address) return approve_txhash, deposit_txhash @only_me def divide_stake(self, stake_index: int, target_value: int, additional_periods: int = None, expiration: maya.MayaDT = None) -> dict: """ Modifies the unlocking schedule and value of already locked tokens. This actor requires that is_me is True, and that the expiration datetime is after the existing locking schedule of this miner, or an exception will be raised. :param target_value: The quantity of tokens in the smallest denomination. :param expiration: The new expiration date to set. :return: Returns the blockchain transaction hash """ if additional_periods and expiration: raise ValueError("Pass the number of lock periods or an expiration MayaDT; not both.") _first_period, last_period, locked_value = self.miner_agent.get_stake_info( miner_address=self.checksum_public_address, stake_index=stake_index) if expiration: additional_periods = datetime_to_period(datetime=expiration) - last_period if additional_periods <= 0: raise self.MinerError("Expiration {} must be at least 1 period from now.".format(expiration)) if target_value >= locked_value: raise self.MinerError("Cannot divide stake; Value must be less than the specified stake value.") # Ensure both halves are for valid amounts validate_stake_amount(amount=target_value) validate_stake_amount(amount=locked_value - target_value) tx = self.miner_agent.divide_stake(miner_address=self.checksum_public_address, stake_index=stake_index, target_value=target_value, periods=additional_periods) self.blockchain.wait_for_receipt(tx) return tx @only_me def __validate_stake(self, amount: int, lock_periods: int) -> bool: assert validate_stake_amount(amount=amount) # TODO: remove assertions..? assert validate_locktime(lock_periods=lock_periods) if not self.token_balance >= amount: raise self.MinerError("Insufficient miner token balance ({balance})".format(balance=self.token_balance)) else: return True @only_me def initialize_stake(self, amount: int, lock_periods: int = None, expiration: maya.MayaDT = None, entire_balance: bool = False) -> dict: """ High level staking method for Miners. :param amount: Amount of tokens to stake denominated in the smallest unit. :param lock_periods: Duration of stake in periods. :param expiration: A MayaDT object representing the time the stake expires; used to calculate lock_periods. :param entire_balance: If True, stake the entire balance of this node, or the maximum possible. """ if lock_periods and expiration: raise ValueError("Pass the number of lock periods or an expiration MayaDT; not both.") if entire_balance and amount: raise self.MinerError("Specify an amount or entire balance, not both") if expiration: lock_periods = calculate_period_duration(future_time=expiration) if entire_balance is True: amount = self.token_balance staking_transactions = OrderedDict() # type: OrderedDict # Time series of txhases # Validate assert self.__validate_stake(amount=amount, lock_periods=lock_periods) # Transact approve_txhash, initial_deposit_txhash = self.deposit(amount=amount, lock_periods=lock_periods) self._transaction_cache.append((datetime.utcnow(), initial_deposit_txhash)) self.log.info("{} Initialized new stake: {} tokens for {} periods".format(self.checksum_public_address, amount, lock_periods)) return staking_transactions # # Reward and Collection # @only_me def confirm_activity(self) -> str: """Miner rewarded for every confirmed period""" txhash = self.miner_agent.confirm_activity(node_address=self.checksum_public_address) self._transaction_cache.append((datetime.utcnow(), txhash)) return txhash @only_me def mint(self) -> Tuple[str, str]: """Computes and transfers tokens to the miner's account""" mint_txhash = self.miner_agent.mint(node_address=self.checksum_public_address) self._transaction_cache.append((datetime.utcnow(), mint_txhash)) return mint_txhash @only_me def collect_policy_reward(self, policy_manager): """Collect rewarded ETH""" policy_reward_txhash = policy_manager.collect_policy_reward(collector_address=self.checksum_public_address) self._transaction_cache.append((datetime.utcnow(), policy_reward_txhash)) return policy_reward_txhash @only_me def collect_staking_reward(self, collector_address: str) -> str: """Withdraw tokens rewarded for staking.""" collection_txhash = self.miner_agent.collect_staking_reward(collector_address=collector_address) self._transaction_cache.append((datetime.utcnow(), collection_txhash)) return collection_txhash
class UserEscrowDeployer(ContractDeployer): agency = UserEscrowAgent contract_name = agency.registry_contract_name _upgradeable = True __linker_deployer = LibraryLinkerDeployer __allocation_registry = AllocationRegistry def __init__(self, allocation_registry: AllocationRegistry = None, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.token_agent = NucypherTokenAgent(blockchain=self.blockchain) self.staking_agent = StakingEscrowAgent(blockchain=self.blockchain) self.policy_agent = PolicyAgent(blockchain=self.blockchain) self.__beneficiary_address = NO_BENEFICIARY self.__allocation_registry = allocation_registry or self.__allocation_registry( ) def make_agent(self) -> EthereumContractAgent: if self.__beneficiary_address is NO_BENEFICIARY: raise self.ContractDeploymentError( "No beneficiary assigned to {}".format(self.contract.address)) agent = self.agency(blockchain=self.blockchain, beneficiary=self.__beneficiary_address, allocation_registry=self.__allocation_registry) return agent @property def allocation_registry(self): return self.__allocation_registry def assign_beneficiary(self, beneficiary_address: str) -> dict: """Relinquish ownership of a UserEscrow deployment to the beneficiary""" if not is_checksum_address(beneficiary_address): raise self.ContractDeploymentError( "{} is not a valid checksum address.".format( beneficiary_address)) # TODO: #413, #842 - Gas Management payload = { 'from': self.deployer_address, 'gas': 500_000, 'gasPrice': self.blockchain.client.gas_price } transfer_owner_function = self.contract.functions.transferOwnership( beneficiary_address) transfer_owner_receipt = self.blockchain.send_transaction( transaction_function=transfer_owner_function, payload=payload, sender_address=self.deployer_address) self.__beneficiary_address = beneficiary_address return transfer_owner_receipt def initial_deposit(self, value: int, duration: int) -> dict: """Allocate an amount of tokens with lock time, and transfer ownership to the beneficiary""" # Approve allocation_receipts = dict() approve_receipt = self.token_agent.approve_transfer( amount=value, target_address=self.contract.address, sender_address=self.deployer_address) allocation_receipts['approve'] = approve_receipt # Deposit # TODO: #413, #842 - Gas Management args = { 'from': self.deployer_address, 'gasPrice': self.blockchain.client.gas_price, 'gas': 200_000 } deposit_function = self.contract.functions.initialDeposit( value, duration) deposit_receipt = self.blockchain.send_transaction( transaction_function=deposit_function, sender_address=self.deployer_address, payload=args) # TODO: Do something with allocation_receipts. Perhaps it should be returned instead of only the last receipt. allocation_receipts['initial_deposit'] = deposit_receipt return deposit_receipt def enroll_principal_contract(self): if self.__beneficiary_address is NO_BENEFICIARY: raise self.ContractDeploymentError( "No beneficiary assigned to {}".format(self.contract.address)) self.__allocation_registry.enroll( beneficiary_address=self.__beneficiary_address, contract_address=self.contract.address, contract_abi=self.contract.abi) def deliver(self, value: int, duration: int, beneficiary_address: str) -> dict: """ Transfer allocated tokens and hand-off the contract to the beneficiary. Encapsulates three operations: - Initial Deposit - Transfer Ownership - Enroll in Allocation Registry """ deposit_txhash = self.initial_deposit(value=value, duration=duration) assign_txhash = self.assign_beneficiary( beneficiary_address=beneficiary_address) self.enroll_principal_contract() return dict(deposit_txhash=deposit_txhash, assign_txhash=assign_txhash) def deploy(self, gas_limit: int = None) -> dict: """Deploy a new instance of UserEscrow to the blockchain.""" self.check_deployment_readiness() deployment_transactions = dict() linker_contract = self.blockchain.get_contract_by_name( name=self.__linker_deployer.contract_name) args = (self.contract_name, linker_contract.address, self.token_agent.contract_address) user_escrow_contract, deploy_txhash = self.blockchain.deploy_contract( *args, gas_limit=gas_limit, enroll=False) deployment_transactions['deploy_user_escrow'] = deploy_txhash self._contract = user_escrow_contract return deployment_transactions
class UserEscrowDeployer(ContractDeployer): agency = UserEscrowAgent _contract_name = agency.registry_contract_name __linker_deployer = LibraryLinkerDeployer __allocation_registry = AllocationRegistry def __init__(self, allocation_registry: AllocationRegistry = None, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.token_agent = NucypherTokenAgent(blockchain=self.blockchain) self.miner_agent = MinerAgent(blockchain=self.blockchain) self.policy_agent = PolicyAgent(blockchain=self.blockchain) self.__beneficiary_address = NO_BENEFICIARY self.__allocation_registry = allocation_registry or self.__allocation_registry( ) def make_agent(self) -> EthereumContractAgent: if self.__beneficiary_address is NO_BENEFICIARY: raise self.ContractDeploymentError( "No beneficiary assigned to {}".format(self.contract.address)) agent = self.agency(blockchain=self.blockchain, beneficiary=self.__beneficiary_address, allocation_registry=self.__allocation_registry) return agent @property def allocation_registry(self): return self.__allocation_registry def assign_beneficiary(self, beneficiary_address: str) -> str: """Relinquish ownership of a UserEscrow deployment to the beneficiary""" if not is_checksum_address(beneficiary_address): raise self.ContractDeploymentError( "{} is not a valid checksum address.".format( beneficiary_address)) txhash = self.contract.functions.transferOwnership( beneficiary_address).transact({'from': self.deployer_address}) self.blockchain.wait_for_receipt(txhash) self.__beneficiary_address = beneficiary_address return txhash def initial_deposit(self, value: int, duration: int) -> dict: """Allocate an amount of tokens with lock time, and transfer ownership to the beneficiary""" # Approve allocation_transactions = dict() approve_txhash = self.token_agent.approve_transfer( amount=value, target_address=self.contract.address, sender_address=self.deployer_address) allocation_transactions['approve'] = approve_txhash self.blockchain.wait_for_receipt(approve_txhash) # Deposit txhash = self.contract.functions.initialDeposit( value, duration).transact({'from': self.deployer_address}) allocation_transactions['initial_deposit'] = txhash self.blockchain.wait_for_receipt(txhash) return txhash def enroll_principal_contract(self): if self.__beneficiary_address is NO_BENEFICIARY: raise self.ContractDeploymentError( "No beneficiary assigned to {}".format(self.contract.address)) self.__allocation_registry.enroll( beneficiary_address=self.__beneficiary_address, contract_address=self.contract.address, contract_abi=self.contract.abi) def deliver(self, value: int, duration: int, beneficiary_address: str) -> dict: """ Transfer allocated tokens and hand-off the contract to the beneficiary. Encapsulates three operations: - Initial Deposit - Transfer Ownership - Enroll in Allocation Registry """ deposit_txhash = self.initial_deposit(value=value, duration=duration) assign_txhash = self.assign_beneficiary( beneficiary_address=beneficiary_address) self.enroll_principal_contract() return dict(deposit_txhash=deposit_txhash, assign_txhash=assign_txhash) def deploy(self) -> dict: """Deploy a new instance of UserEscrow to the blockchain.""" self.check_deployment_readiness() deployment_transactions = dict() linker_contract = self.blockchain.interface.get_contract_by_name( name=self.__linker_deployer._contract_name) args = (self._contract_name, linker_contract.address, self.token_agent.contract_address) user_escrow_contract, deploy_txhash = self.blockchain.interface.deploy_contract( *args, enroll=False) deployment_transactions['deploy_user_escrow'] = deploy_txhash self._contract = user_escrow_contract return deployment_transactions