Relocate staking methods to the Stake class, away from Actors; Staking API bug fixes and consistency check.

pull/866/head
Kieran Prasch 2019-04-16 03:59:45 +03:00
parent 07aa603d30
commit 13d4b5c448
No known key found for this signature in database
GPG Key ID: 199AB839D4125A62
6 changed files with 321 additions and 238 deletions

View File

@ -16,28 +16,38 @@ along with nucypher. If not, see <https://www.gnu.org/licenses/>.
"""
import json
from collections import OrderedDict
from datetime import datetime
from json import JSONDecodeError
from typing import Tuple, List, Dict, Union
import maya
from constant_sorrow import constants
from constant_sorrow.constants import CONTRACT_NOT_DEPLOYED, NO_DEPLOYER_ADDRESS
from datetime import datetime
from constant_sorrow.constants import CONTRACT_NOT_DEPLOYED, NO_DEPLOYER_ADDRESS, EMPTY_STAKING_SLOT
from twisted.internet import task, reactor
from twisted.logger import Logger
from typing import Tuple, List, Dict, Union
from nucypher.blockchain.economics import TokenEconomics
from nucypher.blockchain.eth.agents import NucypherTokenAgent, MinerAgent, PolicyAgent, MiningAdjudicatorAgent, \
from nucypher.blockchain.eth.agents import (
NucypherTokenAgent,
MinerAgent,
PolicyAgent,
MiningAdjudicatorAgent,
EthereumContractAgent
)
from nucypher.blockchain.eth.chains import Blockchain
from nucypher.blockchain.eth.deployers import NucypherTokenDeployer, MinerEscrowDeployer, PolicyManagerDeployer, \
UserEscrowProxyDeployer, UserEscrowDeployer, MiningAdjudicatorDeployer
from nucypher.blockchain.eth.deployers import (
NucypherTokenDeployer,
MinerEscrowDeployer,
PolicyManagerDeployer,
UserEscrowProxyDeployer,
UserEscrowDeployer,
MiningAdjudicatorDeployer
)
from nucypher.blockchain.eth.interfaces import BlockchainDeployerInterface
from nucypher.blockchain.eth.registry import AllocationRegistry
from nucypher.blockchain.eth.token import NU, Stake
from nucypher.blockchain.eth.utils import (datetime_to_period,
calculate_period_duration)
from nucypher.blockchain.eth.token import NU, Stake
def only_me(func):
@ -56,10 +66,7 @@ class NucypherTokenActor:
class ActorError(Exception):
pass
def __init__(self,
checksum_address: str = None,
blockchain: Blockchain = None
) -> None:
def __init__(self, checksum_address: str = None, blockchain: Blockchain = None):
"""
:param checksum_address: If not passed, we assume this is an unknown actor
"""
@ -322,6 +329,7 @@ class Miner(NucypherTokenActor):
self.__uptime_period = constants.NO_STAKES
self.__terminal_period = constants.NO_STAKES
# Read on-chain stakes
self.__read_stakes()
if self.stakes and start_staking_loop:
self.stake()
@ -329,11 +337,14 @@ class Miner(NucypherTokenActor):
#
# Staking
#
@only_me
def stake(self, confirm_now: bool = True) -> None:
"""High-level staking looping call initialization"""
# TODO #841: Check if there is an active stake in the current period: Resume staking daemon
"""
High-level staking looping call initialization, this function aims
to be safely called at any time - For example, it is okay to call
this function multiple time within the same period.
"""
# Get the last stake end period of all stakes
terminal_period = max(stake.end_period for stake in self.stakes.values())
@ -343,10 +354,15 @@ class Miner(NucypherTokenActor):
# 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 + terminal_period
self.__terminal_period = terminal_period
self.__current_period = self.__uptime_period
self.start_staking_loop()
@property
def last_active_period(self) -> int:
period = self.miner_agent.get_last_active_period(address=self.checksum_public_address)
return period
@only_me
def _confirm_period(self):
@ -355,10 +371,16 @@ class Miner(NucypherTokenActor):
if self.__current_period != period:
# check for stake expiration
# Let's see how much time has passed
delta = self.__current_period - self.last_active_period
if delta > 1:
self.log.warn(f"{delta} Missed staking confirmation(s) detected")
self.__read_stakes() # Invalidate the stake cache
# Check for stake expiration
stake_expired = self.__current_period >= self.__terminal_period
if stake_expired:
self.log.info('Stake duration expired')
self.log.info('STOPPED STAKING - Final stake ended.')
return True
self.confirm_activity()
@ -394,14 +416,14 @@ class Miner(NucypherTokenActor):
return d
@property
def is_staking(self):
def is_staking(self) -> bool:
"""Checks if this Miner currently has locked tokens."""
return bool(self.locked_tokens > 0)
@property
def locked_tokens(self):
def locked_tokens(self) -> NU:
"""Returns the amount of tokens this miner has locked."""
return self.miner_agent.get_locked_tokens(miner_address=self.checksum_public_address)
return NU(self.miner_agent.get_locked_tokens(miner_address=self.checksum_public_address), 'NU')
@property
def total_staked(self) -> NU:
@ -410,15 +432,93 @@ class Miner(NucypherTokenActor):
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:
# Validate function input
if additional_periods and expiration:
raise ValueError("Pass the number of lock periods or an expiration MayaDT; not both.")
# Select stake to divide
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("Expiration {} must be at least 1 period from now.".format(expiration))
# Check the new stake will not exceed the staking limit
if (self.total_staked + target_value) > self.economics.maximum_allowed_locked:
raise Stake.StakingError(f"Cannot divide stake - Maximum stake value exceeded with a target value of {target_value}.")
# Do it already!
modified_stake, new_stake = current_stake.divide(target_value=target_value,
additional_periods=additional_periods)
# Update stake cache
self.__read_stakes()
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:
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)
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.MinerError(f"Insufficient token balance ({self.token_agent}) for new stake initialization of {amount}")
# Write to blockchain
stake = Stake.initialize_stake(miner=self, amount=amount, lock_periods=lock_periods)
# Update local on-chain stake cache
self.__read_stakes()
return stake
#
# Staking Cache
#
def __read_stakes(self) -> None:
stakes_reader = self.miner_agent.get_all_stakes(miner_address=self.checksum_public_address)
stakes = dict()
terminal_period = 0
for index, stake_info in enumerate(stakes_reader):
stake = Stake.from_stake_info(owner_address=self.checksum_public_address,
stake_info=stake_info,
index=index,
economics=self.economics)
if not stake_info:
stake = EMPTY_STAKING_SLOT
else:
stake = Stake.from_stake_info(miner=self, stake_info=stake_info, index=index)
if stake.end_period > terminal_period:
terminal_period = stake.end_period
stakes[index] = stake
self.__terminal_period = terminal_period
self.__stakes = stakes
@property
@ -426,160 +526,8 @@ class Miner(NucypherTokenActor):
"""Return all cached stakes from the blockchain."""
return self.__stakes
@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: NU,
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 stake_index: The miner's stake index of the stake to divide
:param additional_periods: The number of periods to extend the stake by
:param target_value: The quantity of tokens in the smallest denomination to divide.
:param expiration: The new expiration date to set as an end period for stake division.
:return: Returns the blockchain transaction hash
"""
# Validate function input
if additional_periods and expiration:
raise ValueError("Pass the number of lock periods or an expiration MayaDT; not both.")
# Re-read stakes from blockchain and select stake to divide
self.__read_stakes()
current_stake = self.stakes[stake_index]
# Ensure selected stake is active
if current_stake.is_expired:
raise self.MinerError(f'Cannot dive an expired stake')
# Validate stake division parameters
if expiration:
additional_periods = datetime_to_period(datetime=expiration) - current_stake.end_period
if additional_periods <= 0:
raise self.MinerError("Expiration {} must be at least 1 period from now.".format(expiration))
if target_value >= current_stake.value:
raise self.MinerError(f"Cannot divide stake; Value ({target_value}) must be less "
f"than the existing stake value {current_stake.value}.")
#
# Generate Stakes
#
# Modified Original Stake
new_stake_1 = Stake(owner_address=self.checksum_public_address,
index=len(self.stakes)+1,
start_period=current_stake.start_period,
end_period=current_stake.end_period,
value=target_value,
economics=self.economics)
# New Derived Stake
new_stake_2 = Stake(owner_address=self.checksum_public_address,
index=len(self.stakes)+1,
start_period=current_stake.start_period,
end_period=current_stake.end_period + additional_periods,
value=current_stake.value - target_value,
economics=self.economics)
# Ensure both halves are for valid amounts
new_stake_1.validate_value()
new_stake_2.validate_value()
# Transmit the stake division transaction
tx = self.miner_agent.divide_stake(miner_address=self.checksum_public_address,
stake_index=stake_index,
target_value=int(target_value),
periods=additional_periods)
self.blockchain.wait_for_receipt(tx)
# Update stake cache
self.__read_stakes()
return tx
@only_me
def __validate_stake(self, stake: Stake) -> bool:
stake.validate_value()
stake.validate_duration()
if not self.token_balance >= stake.value:
raise self.MinerError(f"Insufficient token balance ({self.token_agent}) for new stake of {stake.value}")
else:
return True
@only_me
def initialize_stake(self,
amount: NU,
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
amount = NU(int(amount), 'NuNit')
current_period = self.miner_agent.get_current_period()
stake = Stake(owner_address=self.checksum_public_address,
start_period=current_period+1,
end_period=current_period + lock_periods,
value=amount,
economics=self.economics)
staking_transactions = OrderedDict() # type: OrderedDict # Time series of txhases
# Validate
assert self.__validate_stake(stake=stake)
# Transact
approve_txhash, initial_deposit_txhash = self.deposit(amount=int(amount), lock_periods=lock_periods)
self._transaction_cache.append((datetime.utcnow(), initial_deposit_txhash))
staking_transactions['approve'] = approve_txhash
staking_transactions['deposit'] = initial_deposit_txhash
self.__read_stakes() # update local on-chain stake cache
self.log.info("{} Initialized new stake: {} tokens for {} periods".format(self.checksum_public_address, amount, lock_periods))
return staking_transactions
def refresh_staking_cache(self):
return self.__read_stakes()
#
# Reward and Collection

View File

@ -174,6 +174,10 @@ class MinerAgent(EthereumContractAgent):
self.blockchain.wait_for_receipt(tx)
return tx
def get_last_active_period(self, address: str) -> int:
period = self.contract.functions.getLastActivePeriod(address).call()
return int(period)
def confirm_activity(self, node_address: str) -> str:
"""Miner rewarded for every confirmed period"""
@ -496,4 +500,4 @@ class MiningAdjudicatorAgent(EthereumContractAgent):
:param precomputed_data:
:return:
"""
# TODO: #931 - Challenge Agent and Actor
# TODO: #931 - Challenge Agent and Actor - "Investigator"

View File

@ -1,13 +1,12 @@
from _pydecimal import Decimal
import eth_utils
import maya
from eth_utils import currency
from nacl.hash import sha256
from typing import Union, Tuple
from nucypher.blockchain.economics import TokenEconomics
from nucypher.blockchain.eth.agents import NucypherTokenAgent
import maya
from constant_sorrow.constants import NEW_STAKE, NO_STAKING_RECEIPT
from eth_utils import currency
from twisted.logger import Logger
from nucypher.blockchain.eth.agents import MinerAgent
from nucypher.blockchain.eth.utils import datetime_at_period, datetime_to_period
@ -132,17 +131,23 @@ class Stake:
A quantity of tokens and staking duration for one stake for one miner.
"""
class StakingError(Exception):
"""Raised when a staking operation cannot be executed due to failure."""
__ID_LENGTH = 16
def __init__(self,
owner_address: str,
miner,
value: NU,
start_period: int,
end_period: int,
index: int = None,
economics: TokenEconomics = None,
index: int,
validate_now: bool = True):
self.miner = miner
owner_address = miner.checksum_public_address
self.log = Logger(f'stake-{owner_address}-{index}')
# Stake Metadata
self.owner_address = owner_address
self.index = index
@ -156,16 +161,23 @@ class Stake:
self.end_datetime = datetime_at_period(period=end_period)
self.duration_delta = self.end_datetime - self.start_datetime
self.blockchain = miner.blockchain
# Agency
self.miner_agent = miner.miner_agent
self.token_agent = miner.token_agent
# Economics
if not economics:
economics = TokenEconomics()
self.economics = economics
self.minimum_nu = NU(int(economics.minimum_allowed_locked), 'NuNit')
self.maximum_nu = NU(int(economics.maximum_allowed_locked), 'NuNit')
self.economics = miner.economics
self.minimum_nu = NU(int(self.economics.minimum_allowed_locked), 'NuNit')
self.maximum_nu = NU(int(self.economics.maximum_allowed_locked), 'NuNit')
if validate_now:
self.validate_duration()
self.transactions = NO_STAKING_RECEIPT
self.receipt = NO_STAKING_RECEIPT
def __repr__(self) -> str:
r = f'Stake(index={self.index}, value={self.value}, end_period={self.end_period})'
return r
@ -173,55 +185,46 @@ class Stake:
def __eq__(self, other) -> bool:
return bool(self.value == other.value)
@property
def is_active(self):
now = maya.now()
if now >= self.end_datetime:
return False
else:
return True
#
# Metadata
#
@property
def is_expired(self):
now = maya.now()
if now >= self.end_datetime:
def is_expired(self) -> bool:
current_period = self.miner_agent.get_current_period()
if current_period >= self.end_period:
return True
else:
return False
@property
def is_active(self) -> bool:
return not self.is_expired
@classmethod
def from_stake_info(cls,
owner_address: str,
miner,
index: int,
stake_info: Tuple[int, int, int],
economics: TokenEconomics) -> 'Stake':
stake_info: Tuple[int, int, int]
) -> 'Stake':
"""Reads staking values as they exist on the blockchain"""
start_period, end_period, value = stake_info
instance = cls(owner_address=owner_address,
instance = cls(miner=miner,
index=index,
start_period=start_period,
end_period=end_period,
value=NU(value, 'NuNit'),
economics=economics)
value=NU(value, 'NuNit'))
return instance
def to_stake_info(self) -> Tuple[int, int, int]:
"""Returns a tuple representing the blockchain record of a stake"""
return self.start_period, self.end_period, int(self.value)
@property
def id(self) -> str:
"""TODO: Unique staking ID, currently unused"""
digest_elements = list()
digest_elements.append(eth_utils.to_canonical_address(address=self.owner_address))
digest_elements.append(str(self.index).encode())
digest_elements.append(str(self.start_period).encode())
digest_elements.append(str(self.end_period).encode())
digest_elements.append(str(self.value).encode())
digest = b'|'.join(digest_elements)
stake_id = sha256(digest).hex()[:16] # type: str
return stake_id[:self.__ID_LENGTH]
#
# Duration
#
@property
def periods_remaining(self) -> int:
@ -240,6 +243,10 @@ class Stake:
result = delta.seconds
return result
#
# Validation
#
@staticmethod
def __handle_validation_failure(rulebook: Tuple[Tuple[bool, str], ...]) -> bool:
"""Validate a staking rulebook"""
@ -275,14 +282,134 @@ class Stake:
rulebook = (
(self.economics.minimum_locked_periods <= self.duration,
'Locktime ({duration}) too short; must be at least {minimum}'
'Stake duration of ({duration}) is too short; must be at least {minimum} periods.'
.format(minimum=self.economics.minimum_locked_periods, duration=self.duration)),
(self.economics.maximum_locked_periods >= self.duration,
'Locktime ({duration}) too long; must be no more than {maximum}'
'Stake duration of ({duration}) is too long; must be no more than {maximum} periods.'
.format(maximum=self.economics.maximum_locked_periods, duration=self.duration)),
)
if raise_on_fail is True:
self.__handle_validation_failure(rulebook=rulebook)
return all(rulebook)
#
# Blockchain
#
@classmethod
def __deposit(cls, miner, amount: int, lock_periods: int) -> Tuple[str, str]:
"""Public facing method for token locking."""
approve_txhash = miner.token_agent.approve_transfer(amount=amount,
target_address=miner.miner_agent.contract_address,
sender_address=miner.checksum_public_address)
deposit_txhash = miner.miner_agent.deposit_tokens(amount=amount,
lock_periods=lock_periods,
sender_address=miner.checksum_public_address)
return approve_txhash, deposit_txhash
def divide(self, target_value: NU, additional_periods: int = None) -> tuple:
"""
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.
"""
# Re-read stakes from blockchain and select stake to divide
current_stake = self
# Ensure selected stake is active
if current_stake.is_expired:
raise self.StakingError(f'Cannot divide an expired stake')
if target_value >= current_stake.value:
raise self.StakingError(f"Cannot divide stake; Target value ({target_value}) must be less "
f"than the existing stake value {current_stake.value}.")
#
# Generate SubStakes
#
# Modified Original Stake
modified_stake = Stake(miner=self.miner,
index=self.index,
start_period=current_stake.start_period,
end_period=current_stake.end_period,
value=target_value)
# New Derived Stake
end_period = current_stake.end_period + additional_periods
new_stake = Stake(miner=self.miner,
start_period=current_stake.start_period,
end_period=end_period,
value=current_stake.value - target_value,
index=NEW_STAKE)
#
# Validate
#
# Ensure both halves are for valid amounts
modified_stake.validate_value()
new_stake.validate_value()
# Detect recycled or inconsistent slot then check start period or fail
stake_info = self.miner_agent.get_stake_info(miner_address=self.owner_address, stake_index=self.index)
first_period, last_period, locked_value = stake_info
if not modified_stake.start_period == first_period:
raise self.StakingError("Inconsistent staking cache, aborting stake division. ")
#
# Transact
#
# Transmit the stake division transaction
tx = self.miner_agent.divide_stake(miner_address=self.owner_address,
stake_index=self.index,
target_value=int(target_value),
periods=additional_periods)
receipt = self.blockchain.wait_for_receipt(tx)
new_stake.receipt = receipt
return modified_stake, new_stake
@classmethod
def initialize_stake(cls, miner, amount: NU, lock_periods: int = None) -> 'Stake':
# Value
amount = NU(int(amount), 'NuNit')
# Duration
current_period = miner.miner_agent.get_current_period()
end_period = current_period + lock_periods
stake = Stake(miner=miner,
start_period=current_period+1,
end_period=end_period,
value=amount,
index=NEW_STAKE)
# Validate
stake.validate_value()
stake.validate_duration()
# Transact
approve_txhash, initial_deposit_txhash = stake.__deposit(amount=int(amount),
lock_periods=lock_periods,
miner=miner)
# Store the staking transactions on the instance
staking_transactions = dict(approve=approve_txhash, deposit=initial_deposit_txhash)
stake.transactions = staking_transactions
# Log and return Stake instance
log = Logger(f'stake-{miner.checksum_public_address}-creation')
log.info("{} Initialized new stake: {} tokens for {} periods".format(miner.checksum_public_address,
amount,
lock_periods))
return stake

View File

@ -364,13 +364,13 @@ def ursula(click_config,
click.confirm("Is this correct?", abort=True)
txhash_bytes = URSULA.divide_stake(stake_index=index,
modified_stake, new_stake = URSULA.divide_stake(stake_index=index,
target_value=value,
additional_periods=extension)
if not quiet:
click.secho('Successfully divided stake', fg='green')
click.secho(f'Transaction Hash ........... {txhash_bytes.hex()}')
click.secho(f'Transaction Hash ........... {new_stake.receipt}')
# Show the resulting stake list
painting.paint_stakes(stakes=URSULA.stakes)
@ -421,8 +421,8 @@ def ursula(click_config,
if not force:
click.confirm("Publish staged stake to the blockchain?", abort=True)
staking_transactions = URSULA.initialize_stake(amount=int(value), lock_periods=duration)
painting.paint_staking_confirmation(ursula=URSULA, transactions=staking_transactions)
stake = URSULA.initialize_stake(amount=int(value), lock_periods=duration)
painting.paint_staking_confirmation(ursula=URSULA, transactions=stake.transactions)
return
elif action == 'confirm-activity':

View File

@ -82,9 +82,8 @@ def test_miner_divides_stake(miner, token_economics):
expected_yet_another_stake = Stake(start_period=current_period + 1,
end_period=current_period + 34,
value=yet_another_stake_value,
owner_address=miner.checksum_public_address,
index=3,
economics=token_economics)
miner=miner,
index=3)
assert 4 == len(miner.stakes), 'A new stake was not added after two stake divisions'
assert expected_old_stake == miner.stakes[stake_index + 1].to_stake_info(), 'Old stake values are invalid after two stake divisions'

View File

@ -3,6 +3,7 @@ from decimal import InvalidOperation, Decimal
import pytest
from web3 import Web3
from nucypher.blockchain.economics import TokenEconomics
from nucypher.blockchain.eth.token import NU, Stake
from nucypher.utilities.sandbox.constants import INSECURE_DEVELOPMENT_PASSWORD
@ -98,21 +99,25 @@ def test_NU(token_economics):
_nan = NU(float('NaN'), 'NU')
def test_stake():
def test_stake(testerchain, three_agents):
class FakeUrsula:
token_agent, miner_agent, _policy_agent = three_agents
burner_wallet = Web3().eth.account.create(INSECURE_DEVELOPMENT_PASSWORD)
checksum_public_address = burner_wallet.address
miner_agent = None
miner_agent = miner_agent
token_agent = token_agent
blockchain = testerchain
economics = TokenEconomics()
ursula = FakeUrsula()
stake = Stake(owner_address=ursula.checksum_public_address,
stake = Stake(miner=ursula,
start_period=1,
end_period=100,
value=NU(100, 'NU'),
index=0)
assert len(stake.id) == 16
assert stake.value, 'NU' == NU(100, 'NU')
assert isinstance(stake.time_remaining(), int) # seconds