Merge pull request #1339 from KPrasch/restake

Restaking Control
pull/1350/head
K Prasch 2019-09-19 11:46:48 -07:00 committed by GitHub
commit 314ab277b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 348 additions and 29 deletions

View File

@ -43,7 +43,8 @@ All staking-related operations done by StakeHolder are performed through the ``n
+----------------------+-------------------------------------------------------------------------------+
| ``divide`` | Create a new stake from part of an existing one |
+----------------------+-------------------------------------------------------------------------------+
| ``restake`` | Manage automatic reward re-staking |
+----------------------+-------------------------------------------------------------------------------+
**Stake Command Options**
@ -59,6 +60,18 @@ All staking-related operations done by StakeHolder are performed through the ``n
| ``--hw-wallet`` | Use a hardware wallet |
+-----------------+--------------------------------------------+
**ReStake Command Options**
+-------------------------+---------------------------------------------+
| Option | Description |
+=========================+=============================================+
| ``--enable`` | Enable re-staking |
+-------------------------+---------------------------------------------+
| ``--disable`` | Disable re-staking |
+-------------------------+---------------------------------------------+
| ``--lock-until`` | Enable re-staking lock until release period |
+-------------------------+---------------------------------------------+
Staking Overview
-----------------
@ -72,7 +85,8 @@ Most stakers on the Goerli testnet will complete the following steps:
4) Stake tokens (See Below)
5) Install another Ethereum node at the Worker instance
6) Initialize a Worker node [:ref:`ursula-config-guide`] and bond it to your Staker (``set-worker``)
7) Configure and run the Worker, and keep it online [:ref:`ursula-config-guide`]!
7) Optionally, enable re-staking
8) Configure and run the Worker, and keep it online [:ref:`ursula-config-guide`]!
Interactive Method
------------------
@ -237,6 +251,35 @@ the address to checksum format in geth console:
After this step, you're finished with the Staker, and you can proceed to :ref:`ursula-config-guide`.
Manage automatic reward re-staking
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
As your Ursula performs work, you can optionally enable the automatic addition of
all rewards to your existing stake to optimize earnings. By default this feature is disabled,
to enable it run:
.. code:: bash
(nucypher)$ nucypher stake restake --enable
To disable restaking:
.. code:: bash
(nucypher)$ nucypher stake restake --disable
Additionally, you can enable **restake locking**, an on-chain commitment to continue restaking
until a future period (`release_period`). Once enabled, the `StakingEscrow` contract will not
allow **restaking** to be disabled until the release period begins, even if you are the stake owner.
.. code:: bash
(nucypher)$ nucypher stake restake --lock-until 12345
No action is needed to release the restaking lock once the release period begins.
Collect rewards earned by the staker
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -512,7 +512,7 @@ class Staker(NucypherTokenActor):
# Calculate stake duration in periods
if expiration:
additional_periods = datetime_to_period(datetime=expiration) - current_stake.final_locked_period
additional_periods = datetime_to_period(datetime=expiration, seconds_per_period=self.economics.seconds_per_period) - current_stake.final_locked_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.final_locked_period}).")
@ -539,7 +539,8 @@ class Staker(NucypherTokenActor):
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)
lock_periods = calculate_period_duration(future_time=expiration,
seconds_per_period=self.economics.seconds_per_period)
# Value
if entire_balance and amount:
@ -566,6 +567,36 @@ class Staker(NucypherTokenActor):
return new_stake
@property
def is_restaking(self) -> bool:
restaking = self.staking_agent.is_restaking(staker_address=self.checksum_address)
return restaking
@only_me
@save_receipt
def enable_restaking(self) -> dict:
receipt = self.staking_agent.set_restaking(staker_address=self.checksum_address, value=True)
return receipt
@only_me
@save_receipt
def enable_restaking_lock(self, release_period: int):
current_period = self.staking_agent.get_current_period()
if release_period < current_period:
raise ValueError(f"Terminal restaking period must be in the future. "
f"Current period is {current_period}, got '{release_period}'.")
receipt = self.staking_agent.lock_restaking(staker_address=self.checksum_address,
release_period=release_period)
return receipt
@property
def restaking_lock_enabled(self) -> bool:
status = self.staking_agent.is_restaking_locked(staker_address=self.checksum_address)
return status
def disable_restaking(self) -> dict:
receipt = self.staking_agent.set_restaking(staker_address=self.checksum_address, value=False)
return receipt
#
# Reward and Collection
#

View File

@ -363,6 +363,42 @@ class StakingEscrowAgent(EthereumContractAgent):
sender_address=staker_address)
return receipt
@validate_checksum_address
def is_restaking(self, staker_address: str) -> bool:
staker_info = self.get_staker_info(staker_address)
restake_flag = bool(staker_info[3]) # TODO: #1348 Use constant or enum
return restake_flag
@validate_checksum_address
def is_restaking_locked(self, staker_address: str) -> bool:
return self.contract.functions.isReStakeLocked(staker_address).call()
@validate_checksum_address
def set_restaking(self, staker_address: str, value: bool) -> dict:
"""
Enable automatic restaking for a fixed duration of lock periods.
If set to True, then all staking rewards will be automatically added to locked stake.
"""
contract_function = self.contract.functions.setReStake(value)
receipt = self.blockchain.send_transaction(contract_function=contract_function,
sender_address=staker_address)
# TODO: Handle ReStakeSet event (see #1193)
return receipt
@validate_checksum_address
def lock_restaking(self, staker_address: str, release_period: int) -> dict:
contract_function = self.contract.functions.lockReStake(release_period)
receipt = self.blockchain.send_transaction(contract_function=contract_function,
sender_address=staker_address)
# TODO: Handle ReStakeLocked event (see #1193)
return receipt
@validate_checksum_address
def get_restake_unlock_period(self, staker_address: str) -> int:
staker_info = self.get_staker_info(staker_address)
restake_unlock_period = int(staker_info[4]) # TODO: #1348 Use constant or enum
return restake_unlock_period
def staking_parameters(self) -> Tuple:
parameter_signatures = (
# Period

View File

@ -78,7 +78,7 @@ class BaseContractRegistry(ABC):
return bool(self.id == other.id)
def __repr__(self) -> str:
r = f"{self.__class__.__name__}"
r = f"{self.__class__.__name__}(id={self.id[:6]})"
return r
@property

View File

@ -369,3 +369,21 @@ def confirm_deployment(emitter, deployer_interface) -> bool:
raise click.Abort()
return True
def confirm_enable_restaking_lock(emitter, staking_address: str, release_period: int) -> bool:
restaking_lock_agreement = f"""
By enabling the re-staking lock for {staking_address}, you are committing to automatically
re-stake all rewards until period a future period. You will not be able to disable re-staking until {release_period}.
"""
emitter.message(restaking_lock_agreement)
click.confirm(f"Confirm enable re-staking lock for staker {staking_address} until {release_period}?", abort=True)
return True
def confirm_enable_restaking(emitter, staking_address: str) -> bool:
restaking_lock_agreement = f"By enabling the re-staking for {staking_address}, " \
f"All staking rewards will be automatically added to your existing stake."
emitter.message(restaking_lock_agreement)
click.confirm(f"Confirm enable automatic re-staking for staker {staking_address}?", abort=True)
return True

View File

@ -19,12 +19,19 @@ along with nucypher. If not, see <https://www.gnu.org/licenses/>.
import click
from web3 import Web3
from nucypher.characters.lawful import StakeHolder
from nucypher.blockchain.eth.interfaces import BlockchainInterface, BlockchainInterfaceFactory
from nucypher.blockchain.eth.token import NU
from nucypher.blockchain.eth.utils import datetime_at_period
from nucypher.characters.lawful import StakeHolder
from nucypher.cli import painting, actions
from nucypher.cli.actions import confirm_staged_stake, get_client_password, select_stake, select_client_account
from nucypher.cli.actions import (
confirm_staged_stake,
get_client_password,
select_stake,
select_client_account,
confirm_enable_restaking_lock,
confirm_enable_restaking
)
from nucypher.cli.config import nucypher_click_config
from nucypher.cli.painting import paint_receipt_summary
from nucypher.cli.types import (
@ -53,6 +60,8 @@ from nucypher.config.characters import StakeHolderConfiguration
@click.option('--value', help="Token value of stake", type=click.INT)
@click.option('--lock-periods', help="Duration of stake in periods.", type=click.INT)
@click.option('--index', help="A specific stake index to resume", type=click.INT)
@click.option('--enable/--disable', help="Used to enable and disable re-staking", is_flag=True, default=True)
@click.option('--lock-until', help="Period to release re-staking lock", type=click.IntRange(min=0))
@nucypher_click_config
def stake(click_config,
action,
@ -80,6 +89,8 @@ def stake(click_config,
index,
policy_reward,
staking_reward,
enable,
lock_until,
) -> None:
"""
@ -88,15 +99,16 @@ def stake(click_config,
\b
Actions
-------------------------------------------------
init-stakeholder Create a new stakeholder configuration
list List active stakes for current stakeholder
accounts Show ETH and NU balances for stakeholder's accounts
sync Synchronize stake data with on-chain information
set-worker Bond a worker to a staker
detach-worker Detach worker currently bonded to a staker
init Create a new stake
divide Create a new stake from part of an existing one
collect-reward Withdraw staking reward
init-stakeholder Create a new stakeholder configuration
list List active stakes for current stakeholder
accounts Show ETH and NU balances for stakeholder's accounts
sync Synchronize stake data with on-chain information
set-worker Bond a worker to a staker
detach-worker Detach worker currently bonded to a staker
init Create a new stake
restake Manage re-staking with --enable or --disable
divide Create a new stake from part of an existing one
collect-reward Withdraw staking reward
"""
@ -290,10 +302,41 @@ def stake(click_config,
transactions=new_stake.transactions)
return # Exit
elif action == "restake":
# Authenticate
if not staking_address:
staking_address = select_stake(stakeholder=STAKEHOLDER, emitter=emitter).staker_address
password = None
if not hw_wallet and not blockchain.client.is_local:
password = get_client_password(checksum_address=staking_address)
STAKEHOLDER.assimilate(checksum_address=staking_address, password=password)
# Inner Exclusive Switch
if lock_until:
if not force:
confirm_enable_restaking_lock(emitter, staking_address=staking_address, release_period=lock_until)
receipt = STAKEHOLDER.enable_restaking_lock(release_period=lock_until)
emitter.echo(f'Successfully enabled re-staking lock for {staking_address} until {lock_until}',
color='green', verbosity=1)
elif enable:
if not force:
confirm_enable_restaking(emitter, staking_address=staking_address)
receipt = STAKEHOLDER.enable_restaking()
emitter.echo(f'Successfully enabled re-staking for {staking_address}', color='green', verbosity=1)
else:
if not force:
click.confirm(f"Confirm disable re-staking for staker {staking_address}?", abort=True)
receipt = STAKEHOLDER.disable_restaking()
emitter.echo(f'Successfully disabled re-staking for {staking_address}', color='green', verbosity=1)
paint_receipt_summary(receipt=receipt, emitter=emitter, chain_name=blockchain.client.chain_name)
return # Exit
elif action == 'divide':
"""Divide an existing stake by specifying the new target value and end period"""
if staking_address and index is not None:
if staking_address and index is not None: # 0 is valid.
current_stake = STAKEHOLDER.stakes[index]
else:
current_stake = select_stake(stakeholder=STAKEHOLDER, emitter=emitter)

View File

@ -517,12 +517,21 @@ def paint_stakers(emitter, stakers: List[str], agent) -> None:
stake = agent.owned_tokens(staker)
last_confirmed_period = agent.get_last_active_period(staker)
worker = agent.get_worker_from_staker(staker)
is_restaking = agent.is_restaking(staker)
missing_confirmations = current_period - last_confirmed_period
stake_in_nu = round(NU.from_nunits(stake), 2)
locked_tokens = round(NU.from_nunits(agent.get_locked_tokens(staker)), 2)
emitter.echo(f"{tab} {'Stake:':10} {stake_in_nu} (Locked: {locked_tokens})")
if is_restaking:
if agent.is_restaking_locked(staker):
unlock_period = agent.get_restake_unlock_period(staker)
emitter.echo(f"{tab} {'Re-staking:':10} Yes (Locked until period: {unlock_period})")
else:
emitter.echo(f"{tab} {'Re-staking:':10} Yes (Unlocked)")
else:
emitter.echo(f"{tab} {'Re-staking:':10} No")
emitter.echo(f"{tab} {'Activity:':10} ", nl=False)
if missing_confirmations == -1:
emitter.echo(f"Next period confirmed (#{last_confirmed_period})", color='green')

View File

@ -17,8 +17,10 @@ along with nucypher. If not, see <https://www.gnu.org/licenses/>.
import pytest
from eth_tester.exceptions import TransactionFailed
from nucypher.blockchain.eth.actors import Staker
from nucypher.blockchain.eth.agents import ContractAgency, StakingEscrowAgent
from nucypher.blockchain.eth.token import NU, Stake
from nucypher.crypto.powers import TransactingPower
from nucypher.utilities.sandbox.blockchain import token_airdrop
@ -90,6 +92,32 @@ def test_staker_divides_stake(staker, token_economics):
assert expected_yet_another_stake == staker.stakes[stake_index + 3], 'Third stake values are invalid'
def test_staker_manages_restaking(testerchain, test_registry, staker):
# Enable Restaking
receipt = staker.enable_restaking()
assert receipt['status'] == 1
# Enable Restaking Lock
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
current_period = staking_agent.get_current_period()
terminal_period = current_period + 2
assert not staker.restaking_lock_enabled
receipt = staker.enable_restaking_lock(release_period=terminal_period)
assert receipt['status'] == 1
assert staker.restaking_lock_enabled
with pytest.raises((TransactionFailed, ValueError)):
staker.disable_restaking()
# Wait until terminal period
testerchain.time_travel(periods=2)
receipt = staker.disable_restaking()
assert receipt['status'] == 1
assert not staker.restaking_lock_enabled
@pytest.mark.slow()
def test_staker_collects_staking_reward(testerchain,
test_registry,

View File

@ -224,6 +224,45 @@ def test_divide_stake(agency, token_economics):
assert len(stakes) == 3
@pytest.mark.slow()
def test_enable_restaking(agency, testerchain, test_registry):
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
staker_account, worker_account, *other = testerchain.unassigned_accounts
assert not staking_agent.is_restaking(staker_account)
receipt = staking_agent.set_restaking(staker_account, value=True)
assert receipt['status'] == 1
assert staking_agent.is_restaking(staker_account)
@pytest.mark.slow()
def test_lock_restaking(agency, testerchain, test_registry):
staker_account, worker_account, *other = testerchain.unassigned_accounts
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
current_period = staking_agent.get_current_period()
terminal_period = current_period + 2
assert staking_agent.is_restaking(staker_account)
assert not staking_agent.is_restaking_locked(staker_account)
receipt = staking_agent.lock_restaking(staker_account, release_period=terminal_period)
assert receipt['status'] == 1, "Transaction Rejected"
assert staking_agent.is_restaking_locked(staker_account)
testerchain.time_travel(periods=2) # Wait for re-staking lock to be released.
assert not staking_agent.is_restaking_locked(staker_account)
@pytest.mark.slow()
def test_disable_restaking(agency, testerchain, test_registry):
staker_account, worker_account, *other = testerchain.unassigned_accounts
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
assert staking_agent.is_restaking(staker_account)
receipt = staking_agent.set_restaking(staker_account, value=False)
assert receipt['status'] == 1, "Transaction Rejected"
assert not staking_agent.is_restaking(staker_account)
@pytest.mark.slow()
def test_collect_staking_reward(agency, testerchain):
token_agent, staking_agent, _policy_agent = agency

View File

@ -7,7 +7,7 @@ from nucypher.blockchain.eth.agents import (
ContractAgency
)
from nucypher.blockchain.eth.constants import STAKING_ESCROW_CONTRACT_NAME
from nucypher.blockchain.eth.registry import InMemoryContractRegistry
from nucypher.blockchain.eth.registry import InMemoryContractRegistry, LocalContractRegistry
from nucypher.cli.deploy import deploy
from nucypher.utilities.sandbox.constants import TEST_PROVIDER_URI, MOCK_REGISTRY_FILEPATH
@ -32,11 +32,12 @@ def test_nucypher_deploy_inspect_no_deployments(click_runner, testerchain):
assert result.exit_code == 0
def test_nucypher_deploy_inspect_fully_deployed(click_runner, testerchain, test_registry, agency):
def test_nucypher_deploy_inspect_fully_deployed(click_runner, testerchain, agency):
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
policy_agent = ContractAgency.get_agent(PolicyManagerAgent, registry=test_registry)
adjudicator_agent = ContractAgency.get_agent(AdjudicatorAgent, registry=test_registry)
local_registry = LocalContractRegistry(filepath=registry_filepath)
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=local_registry)
policy_agent = ContractAgency.get_agent(PolicyManagerAgent, registry=local_registry)
adjudicator_agent = ContractAgency.get_agent(AdjudicatorAgent, registry=local_registry)
status_command = ('inspect',
'--registry-infile', MOCK_REGISTRY_FILEPATH,
@ -52,11 +53,12 @@ def test_nucypher_deploy_inspect_fully_deployed(click_runner, testerchain, test_
assert adjudicator_agent.owner in result.output
def test_transfer_ownership(click_runner, testerchain, test_registry, agency):
def test_transfer_ownership(click_runner, testerchain, agency):
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
policy_agent = ContractAgency.get_agent(PolicyManagerAgent, registry=test_registry)
adjudicator_agent = ContractAgency.get_agent(AdjudicatorAgent, registry=test_registry)
local_registry = LocalContractRegistry(filepath=registry_filepath)
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=local_registry)
policy_agent = ContractAgency.get_agent(PolicyManagerAgent, registry=local_registry)
adjudicator_agent = ContractAgency.get_agent(AdjudicatorAgent, registry=local_registry)
assert staking_agent.owner == testerchain.etherbase_account
assert policy_agent.owner == testerchain.etherbase_account
@ -65,7 +67,7 @@ def test_transfer_ownership(click_runner, testerchain, test_registry, agency):
maclane = testerchain.unassigned_accounts[0]
ownership_command = ('transfer-ownership',
'--registry-infile', MOCK_REGISTRY_FILEPATH,
'--registry-infile', registry_filepath,
'--provider', TEST_PROVIDER_URI,
'--target-address', maclane,
'--poa')

View File

@ -275,6 +275,76 @@ def test_ursula_run(click_runner,
assert result.exit_code == 0
def test_stake_restake(click_runner,
manual_staker,
custom_filepath,
testerchain,
test_registry,
stakeholder_configuration_file_location):
staker = Staker(is_me=True, checksum_address=manual_staker, registry=test_registry)
assert not staker.is_restaking
restake_args = ('stake', 'restake',
'--enable',
'--config-file', stakeholder_configuration_file_location,
'--staking-address', manual_staker,
'--force',
'--debug')
result = click_runner.invoke(nucypher_cli,
restake_args,
input=INSECURE_DEVELOPMENT_PASSWORD,
catch_exceptions=False)
assert result.exit_code == 0
assert staker.is_restaking
assert "Successfully enabled" in result.output
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
current_period = staking_agent.get_current_period()
release_period = current_period + 1
lock_args = ('stake', 'restake',
'--lock-until', release_period,
'--config-file', stakeholder_configuration_file_location,
'--staking-address', manual_staker,
'--force',
'--debug')
result = click_runner.invoke(nucypher_cli,
lock_args,
input=INSECURE_DEVELOPMENT_PASSWORD,
catch_exceptions=False)
assert result.exit_code == 0
# Still staking and the lock is enabled
assert staker.is_restaking
assert staker.restaking_lock_enabled
# CLI Output includes success message
assert "Successfully enabled" in result.output
assert str(release_period) in result.output
# Wait until release period
testerchain.time_travel(periods=1)
assert not staker.restaking_lock_enabled
assert staker.is_restaking
disable_args = ('stake', 'restake',
'--disable',
'--config-file', stakeholder_configuration_file_location,
'--staking-address', manual_staker,
'--force',
'--debug')
result = click_runner.invoke(nucypher_cli,
disable_args,
input=INSECURE_DEVELOPMENT_PASSWORD,
catch_exceptions=False)
assert result.exit_code == 0
assert not staker.is_restaking
assert "Successfully disabled" in result.output
def test_collect_rewards_integration(click_runner,
testerchain,
test_registry,
@ -397,8 +467,8 @@ def test_collect_rewards_integration(click_runner,
current_period += 1
logger.debug(f">>>>>>>>>>> TEST PERIOD {current_period} <<<<<<<<<<<<<<<<")
# Half of the tokens are unlocked.
assert staker.locked_tokens() == token_economics.minimum_allowed_locked
# At least half of the tokens are unlocked (restaking was enabled for some prior periods)
assert staker.locked_tokens() >= token_economics.minimum_allowed_locked
# Since we are mocking the blockchain connection, manually consume the transacting power of the Staker.
testerchain.transacting_power = TransactingPower(account=staker_address,