Merge pull request #2384 from vzotova/remove-sub-stakes

Method to remove unused sub-stakes in StakingEscrow
pull/2409/head
K Prasch 2020-10-21 09:34:49 -07:00 committed by GitHub
commit e531035ae8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 327 additions and 15 deletions

View File

@ -83,6 +83,8 @@ All staking-related operations done by Staker are performed through the ``nucyph
+----------------------+-------------------------------------------------------------------------------+
| ``merge`` | Merge two stakes into one |
+----------------------+-------------------------------------------------------------------------------+
| ``remove-unused`` | Remove unused stake |
+----------------------+-------------------------------------------------------------------------------+
**Stake Command Options**
@ -488,6 +490,18 @@ This can help to decrease gas consumption in some operations. To merge two stake
(nucypher)$ nucypher stake merge --hw-wallet
Remove unused sub-stake
***********************
Merging or editing sub-stakes can lead to 'unused', inactive sub-stakes remaining on-chain.
These unused sub-stakes add unnecessary gas costs to daily operations.
To remove unused sub-stake:
.. code:: bash
(nucypher)$ nucypher stake remove-unused --hw-wallet
Collect rewards earned by the staker
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -1275,6 +1275,32 @@ class Staker(NucypherTokenActor):
receipt = self._set_snapshots(value=False)
return receipt
@only_me
@save_receipt
def remove_unused_stake(self, stake: Stake) -> TxReceipt:
self._ensure_stake_exists(stake)
# Read on-chain stake and validate
stake.sync()
if not stake.status().is_child(Stake.Status.INACTIVE):
raise ValueError(f"Stake with index {stake.index} is still active")
receipt = self._remove_unused_stake(stake_index=stake.index)
# Update staking cache element
self.refresh_stakes()
return receipt
@only_me
@save_receipt
def _remove_unused_stake(self, stake_index: int) -> TxReceipt:
# TODO #1497 #1358
# if self.is_contract:
# else:
receipt = self.staking_agent.remove_unused_stake(staker_address=self.checksum_address,
stake_index=stake_index)
return receipt
def non_withdrawable_stake(self) -> NU:
staked_amount: NuNits = self.staking_agent.non_withdrawable_stake(staker_address=self.checksum_address)
return NU.from_nunits(staked_amount)

View File

@ -711,6 +711,13 @@ class StakingEscrowAgent(EthereumContractAgent):
# TODO: Handle SnapshotSet event (see #1193)
return receipt
@contract_api(TRANSACTION)
def remove_unused_stake(self, staker_address: ChecksumAddress, stake_index: int) -> TxReceipt:
contract_function: ContractFunction = self.contract.functions.removeUnusedSubStake(stake_index)
receipt: TxReceipt = self.blockchain.send_transaction(contract_function=contract_function,
sender_address=staker_address)
return receipt
@contract_api(CONTRACT_CALL)
def staking_parameters(self) -> StakingEscrowParameters:
parameter_signatures = (

View File

@ -45,7 +45,7 @@ interface WorkLockInterface {
/**
* @notice Contract holds and locks stakers tokens.
* Each staker that locks their tokens will receive some compensation
* @dev |v5.4.4|
* @dev |v5.5.1|
*/
contract StakingEscrow is Issuer, IERC900History {
@ -1048,6 +1048,30 @@ contract StakingEscrow is Issuer, IERC900History {
}
}
/**
* @notice Remove unused sub-stake to decrease gas cost for several methods
*/
function removeUnusedSubStake(uint16 _index) external onlyStaker {
StakerInfo storage info = stakerInfo[msg.sender];
uint256 lastIndex = info.subStakes.length - 1;
SubStakeInfo storage subStake = info.subStakes[_index];
require(subStake.lastPeriod != 0 &&
(info.currentCommittedPeriod == 0 ||
subStake.lastPeriod < info.currentCommittedPeriod) &&
(info.nextCommittedPeriod == 0 ||
subStake.lastPeriod < info.nextCommittedPeriod));
if (_index != lastIndex) {
SubStakeInfo storage lastSubStake = info.subStakes[lastIndex];
subStake.firstPeriod = lastSubStake.firstPeriod;
subStake.lastPeriod = lastSubStake.lastPeriod;
subStake.periods = lastSubStake.periods;
subStake.lockedValue = lastSubStake.lockedValue;
}
info.subStakes.pop();
}
/**
* @notice Withdraw available amount of tokens to staker
* @param _value Amount of tokens to withdraw

View File

@ -73,7 +73,7 @@ from nucypher.cli.literature import (
INSUFFICIENT_BALANCE_TO_CREATE, PROMPT_STAKE_CREATE_VALUE, PROMPT_STAKE_CREATE_LOCK_PERIODS,
ONLY_DISPLAYING_MERGEABLE_STAKES_NOTE, CONFIRM_MERGE, SUCCESSFUL_STAKES_MERGE, SUCCESSFUL_ENABLE_SNAPSHOTS,
SUCCESSFUL_DISABLE_SNAPSHOTS, CONFIRM_ENABLE_SNAPSHOTS,
CONFIRM_STAKE_USE_UNLOCKED)
CONFIRM_STAKE_USE_UNLOCKED, CONFIRM_REMOVE_SUBSTAKE, SUCCESSFUL_STAKE_REMOVAL)
from nucypher.cli.options import (
group_options,
option_config_file,
@ -1068,6 +1068,61 @@ def merge(general_config: GroupGeneralConfig,
paint_stakes(emitter=emitter, staker=STAKEHOLDER)
@stake.command()
@group_transacting_staker_options
@option_config_file
@option_force
@group_general_config
@click.option('--index', help="Index of unused stake to remove", type=click.INT)
def remove_unused(general_config: GroupGeneralConfig,
transacting_staker_options: TransactingStakerOptions,
config_file, force, index):
"""Remove unused stake."""
# Setup
emitter = setup_emitter(general_config)
STAKEHOLDER = transacting_staker_options.create_character(emitter, config_file)
action_period = STAKEHOLDER.staking_agent.get_current_period()
blockchain = transacting_staker_options.get_blockchain()
client_account, staking_address = select_client_account_for_staking(
emitter=emitter,
stakeholder=STAKEHOLDER,
staking_address=transacting_staker_options.staker_options.staking_address,
individual_allocation=STAKEHOLDER.individual_allocation,
force=force)
# Handle stake update and selection
if index is not None: # 0 is valid.
current_stake = STAKEHOLDER.stakes[index]
else:
current_stake = select_stake(staker=STAKEHOLDER, emitter=emitter, stakes_status=Stake.Status.INACTIVE)
if not force:
click.confirm(CONFIRM_REMOVE_SUBSTAKE.format(stake_index=current_stake.index), abort=True)
# Authenticate
password = get_password(stakeholder=STAKEHOLDER,
blockchain=blockchain,
client_account=client_account,
hw_wallet=transacting_staker_options.hw_wallet)
STAKEHOLDER.assimilate(password=password)
# Non-interactive: Consistency check to prevent the above agreement from going stale.
last_second_current_period = STAKEHOLDER.staking_agent.get_current_period()
if action_period != last_second_current_period:
emitter.echo(PERIOD_ADVANCED_WARNING, color='red')
raise click.Abort
# Execute
receipt = STAKEHOLDER.remove_unused_stake(stake=current_stake)
# Report
emitter.echo(SUCCESSFUL_STAKE_REMOVAL, color='green', verbosity=1)
paint_receipt_summary(emitter=emitter, receipt=receipt, chain_name=blockchain.client.chain_name)
paint_stakes(emitter=emitter, staker=STAKEHOLDER)
@stake.command('collect-reward')
@group_transacting_staker_options
@option_config_file

View File

@ -271,6 +271,10 @@ CONFIRM_MERGE = "Publish merging of {stake_index_1} and {stake_index_2} stakes?"
SUCCESSFUL_STAKES_MERGE = 'Successfully Merged Stakes'
CONFIRM_REMOVE_SUBSTAKE = "Publish removal of {stake_index} stake?"
SUCCESSFUL_STAKE_REMOVAL = 'Successfully Removed Stake'
#
# Rewards
#

View File

@ -199,7 +199,7 @@ def test_staker_increases_stake(staker, token_economics):
assert stake.value == origin_stake.value + balance
def test_staker_merges_stakes(agency, staker, token_economics):
def test_staker_merges_stakes(agency, staker):
stake_index_1 = 0
stake_index_2 = 3
origin_stake_1 = staker.stakes[stake_index_1]
@ -224,6 +224,19 @@ def test_staker_merges_stakes(agency, staker, token_economics):
staker.merge_stakes(stake_1=staker.stakes[1], stake_2=stake)
def test_remove_unused_stake(agency, staker):
stake_index = 3
staker.refresh_stakes()
original_stakes = list(staker.stakes)
unused_stake = original_stakes[stake_index]
assert unused_stake.final_locked_period == 1
staker.remove_unused_stake(stake=unused_stake)
stakes = staker.stakes
assert stakes == original_stakes[:-1]
def test_staker_manages_restaking(testerchain, test_registry, staker):
# Enable Restaking
receipt = staker.enable_restaking()

View File

@ -138,7 +138,6 @@ def test_get_swarm(agency, blockchain_ursulas):
assert is_address(staker_addr)
@pytest.mark.usefixtures("blockchain_ursulas")
def test_sample_stakers(agency):
_token_agent, staking_agent, _policy_agent = agency
@ -264,16 +263,6 @@ def test_deposit_and_increase(agency, testerchain, test_registry, token_economic
assert staking_agent.get_locked_tokens(staker_account, 1) == locked_tokens + amount
def test_disable_restaking(agency, testerchain, test_registry):
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
staker_account, worker_account, *other = testerchain.unassigned_accounts
assert staking_agent.is_restaking(staker_account)
receipt = staking_agent.set_restaking(staker_account, value=False)
assert receipt['status'] == 1
assert not staking_agent.is_restaking(staker_account)
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)
@ -438,6 +427,33 @@ def test_merge(agency, testerchain, test_registry, token_economics):
assert staking_agent.get_locked_tokens(staker_account, 0) == current_locked_tokens
def test_remove_unused_stake(agency, testerchain, test_registry):
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=test_registry)
staker_account = testerchain.unassigned_accounts[0]
testerchain.time_travel(periods=1)
staking_agent.mint(staker_address=staker_account)
current_period = staking_agent.get_current_period()
original_stakes = list(staking_agent.get_all_stakes(staker_address=staker_account))
assert original_stakes[2].last_period == current_period - 1
current_locked_tokens = staking_agent.get_locked_tokens(staker_account, 0)
next_locked_tokens = staking_agent.get_locked_tokens(staker_account, 1)
receipt = staking_agent.remove_unused_stake(staker_address=staker_account, stake_index=2)
assert receipt['status'] == 1
# Ensure stake was extended by one period.
stakes = list(staking_agent.get_all_stakes(staker_address=staker_account))
assert len(stakes) == len(original_stakes) - 1
assert stakes[0] == original_stakes[0]
assert stakes[1] == original_stakes[1]
assert stakes[2] == original_stakes[4]
assert stakes[3] == original_stakes[3]
assert staking_agent.get_locked_tokens(staker_account, 1) == next_locked_tokens
assert staking_agent.get_locked_tokens(staker_account, 0) == current_locked_tokens
def test_batch_deposit(testerchain,
agency,
token_economics,
@ -448,7 +464,6 @@ def test_batch_deposit(testerchain,
amount = token_economics.minimum_allowed_locked
lock_periods = token_economics.minimum_locked_periods
current_period = staking_agent.get_current_period()
stakers = [get_random_checksum_address() for _ in range(4)]

View File

@ -286,6 +286,33 @@ def test_merge_stakes(click_runner,
assert stakes[selection_2].last_period == 1
def test_remove_unused(click_runner,
stakeholder_configuration_file_location,
token_economics,
testerchain,
agency_local_registry,
manual_staker,
stake_value):
staking_agent = ContractAgency.get_agent(StakingEscrowAgent, registry=agency_local_registry)
original_stakes = list(staking_agent.get_all_stakes(staker_address=manual_staker))
selection = 2
assert original_stakes[selection].last_period == 1
stake_args = ('stake', 'remove-unused',
'--config-file', stakeholder_configuration_file_location,
'--staking-address', manual_staker,
'--index', selection,
'--force')
user_input = f'0\n' + f'{INSECURE_DEVELOPMENT_PASSWORD}\n' + YES_ENTER
result = click_runner.invoke(nucypher_cli, stake_args, input=user_input, catch_exceptions=False)
assert result.exit_code == 0
stakes = list(staking_agent.get_all_stakes(staker_address=manual_staker))
assert len(stakes) == len(original_stakes) - 1
def test_stake_bond_worker(click_runner,
testerchain,
agency_local_registry,

View File

@ -1467,3 +1467,130 @@ def test_snapshots(testerchain, token, escrow_contract):
assert last_balance_staker1 + deposit_staker2 == escrow.functions.totalStakedAt(now).call()
assert balance_staker1 == escrow.functions.totalStakedForAt(staker1, now - 1).call()
assert balance_staker1 + deposit_staker2 == escrow.functions.totalStakedAt(now - 1).call()
def test_remove_unused_sub_stakes(testerchain, token, escrow_contract, token_economics):
escrow = escrow_contract(token_economics.maximum_allowed_locked, disable_reward=True)
creator = testerchain.client.accounts[0]
staker = testerchain.client.accounts[1]
# GIVe staker some tokens
stake = 10 * token_economics.minimum_allowed_locked
tx = token.functions.transfer(staker, stake).transact({'from': creator})
testerchain.wait_for_receipt(tx)
tx = token.functions.approve(escrow.address, stake).transact({'from': staker})
testerchain.wait_for_receipt(tx)
# Prepare sub-stakes
initial_period = escrow.functions.getCurrentPeriod().call()
sub_stake = token_economics.minimum_allowed_locked
duration = token_economics.minimum_locked_periods
for i in range(3):
tx = escrow.functions.deposit(staker, sub_stake, duration).transact({'from': staker})
testerchain.wait_for_receipt(tx)
for i in range(2):
tx = escrow.functions.deposit(staker, sub_stake, duration + 1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
testerchain.time_travel(hours=1)
assert escrow.functions.getLockedTokens(staker, 1).call() == 5 * sub_stake
tx = escrow.functions.mergeStake(1, 0).transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.mergeStake(1, 2).transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.mergeStake(3, 4).transact({'from': staker})
testerchain.wait_for_receipt(tx)
assert escrow.functions.getLockedTokens(staker, 1).call() == 5 * sub_stake
assert escrow.functions.getSubStakesLength(staker).call() == 5
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 1, 1, 0, sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 1).call() == [initial_period + 1, 0, duration, 3 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 2).call() == [initial_period + 1, 1, 0, sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 3).call() == [initial_period + 1, initial_period + 1, 0, sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 4).call() == [initial_period + 2, 0, duration + 1, 2 * sub_stake]
# Can't remove active sub-stakes
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(4).transact({'from': staker})
testerchain.wait_for_receipt(tx)
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(5).transact({'from': staker})
testerchain.wait_for_receipt(tx)
# Remove first unused sub-stake
tx = escrow.functions.removeUnusedSubStake(0).transact({'from': staker})
testerchain.wait_for_receipt(tx)
assert escrow.functions.getLockedTokens(staker, 1).call() == 5 * sub_stake
assert escrow.functions.getSubStakesLength(staker).call() == 4
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 2, 0, duration + 1, 2 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 1).call() == [initial_period + 1, 0, duration, 3 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 2).call() == [initial_period + 1, 1, 0, sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 3).call() == [initial_period + 1, initial_period + 1, 0, sub_stake]
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(0).transact({'from': staker})
testerchain.wait_for_receipt(tx)
# Remove unused sub-stake in the middle
tx = escrow.functions.removeUnusedSubStake(2).transact({'from': staker})
testerchain.wait_for_receipt(tx)
assert escrow.functions.getLockedTokens(staker, 1).call() == 5 * sub_stake
assert escrow.functions.getSubStakesLength(staker).call() == 3
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 2, 0, duration + 1, 2 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 1).call() == [initial_period + 1, 0, duration, 3 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 2).call() == [initial_period + 1, initial_period + 1, 0, sub_stake]
# Remove last sub-stake
tx = escrow.functions.removeUnusedSubStake(2).transact({'from': staker})
testerchain.wait_for_receipt(tx)
assert escrow.functions.getLockedTokens(staker, 1).call() == 5 * sub_stake
assert escrow.functions.getSubStakesLength(staker).call() == 2
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 2, 0, duration + 1, 2 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 1).call() == [initial_period + 1, 0, duration, 3 * sub_stake]
# Prepare other case: when sub-stake is unlocked but still active
tx = escrow.functions.initialize(0, creator).transact({'from': creator})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.setWindDown(True).transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.bondWorker(staker).transact({'from': staker})
testerchain.wait_for_receipt(tx)
for i in range(duration):
tx = escrow.functions.commitToNextPeriod().transact({'from': staker})
testerchain.wait_for_receipt(tx)
testerchain.time_travel(hours=1)
current_period = escrow.functions.getCurrentPeriod().call()
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 2, 0, 1, 2 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 1).call() == [initial_period + 1, current_period, 0, 3 * sub_stake]
# Can't remove active sub-stakes
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.mint().transact({'from': staker})
testerchain.wait_for_receipt(tx)
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.commitToNextPeriod().transact({'from': staker})
testerchain.wait_for_receipt(tx)
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
testerchain.time_travel(hours=1)
current_period = escrow.functions.getCurrentPeriod().call()
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 2, current_period, 0, 2 * sub_stake]
assert escrow.functions.getSubStakeInfo(staker, 1).call() == [initial_period + 1, current_period - 1, 0, 3 * sub_stake]
with pytest.raises((TransactionFailed, ValueError)):
tx = escrow.functions.removeUnusedSubStake(1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.mint().transact({'from': staker})
testerchain.wait_for_receipt(tx)
tx = escrow.functions.removeUnusedSubStake(1).transact({'from': staker})
testerchain.wait_for_receipt(tx)
assert escrow.functions.getSubStakesLength(staker).call() == 1
assert escrow.functions.getSubStakeInfo(staker, 0).call() == [initial_period + 2, current_period, 0, 2 * sub_stake]