diff --git a/docs/source/references/environment_variables.rst b/docs/source/references/environment_variables.rst
index fe353cf06..e072e6be5 100644
--- a/docs/source/references/environment_variables.rst
+++ b/docs/source/references/environment_variables.rst
@@ -25,7 +25,8 @@ General
* `NUCYPHER_STAKING_PROVIDERS_PAGINATION_SIZE_LIGHT_NODE`
Default pagination size for the maximum number of active staking providers to retrieve from PREApplication in
one contract call when a light node provider is being used.
-
+* `NUCYPHER_STAKING_PROVIDER_ETH_PASSWORD`
+ Password for a staking provider's Keystore.
Alice
-----
diff --git a/nucypher/blockchain/eth/agents.py b/nucypher/blockchain/eth/agents.py
index 003efe61c..66be1d17a 100644
--- a/nucypher/blockchain/eth/agents.py
+++ b/nucypher/blockchain/eth/agents.py
@@ -19,7 +19,7 @@ import random
import sys
from bisect import bisect_right
from itertools import accumulate
-from typing import Dict, Iterable, List, Tuple, Type, Union, Any, Optional, cast, Iterator, NamedTuple
+from typing import Dict, Iterable, List, Tuple, Type, Union, Any, Optional, cast, NamedTuple
from constant_sorrow.constants import ( # type: ignore
CONTRACT_CALL,
@@ -64,8 +64,7 @@ from nucypher.types import (
StakingProviderInfo,
PeriodDelta,
StakingEscrowParameters,
- PolicyInfo,
- ArrangementInfo, TuNits
+ TuNits
)
from nucypher.utilities.logging import Logger # type: ignore
@@ -1124,9 +1123,9 @@ class PREApplicationAgent(EthereumContractAgent):
return receipt
@contract_api(TRANSACTION)
- def bond_operator(self, provider: ChecksumAddress, operator: ChecksumAddress, transacting_power: TransactingPower) -> TxReceipt:
+ def bond_operator(self, staking_provider: ChecksumAddress, operator: ChecksumAddress, transacting_power: TransactingPower) -> TxReceipt:
"""For use by threshold operator accounts only."""
- contract_function: ContractFunction = self.contract.functions.bondOperator(provider, operator)
+ contract_function: ContractFunction = self.contract.functions.bondOperator(staking_provider, operator)
receipt = self.blockchain.send_transaction(contract_function=contract_function,
transacting_power=transacting_power)
return receipt
diff --git a/nucypher/cli/commands/bond.py b/nucypher/cli/commands/bond.py
new file mode 100644
index 000000000..4406fe07c
--- /dev/null
+++ b/nucypher/cli/commands/bond.py
@@ -0,0 +1,197 @@
+"""
+ This file is part of nucypher.
+
+ nucypher is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ nucypher is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with nucypher. If not, see .
+"""
+
+from typing import Tuple, Union
+
+import click
+import maya
+from eth_typing import ChecksumAddress
+
+from nucypher.blockchain.eth.agents import ContractAgency, PREApplicationAgent
+from nucypher.blockchain.eth.constants import NULL_ADDRESS
+from nucypher.blockchain.eth.signers import Signer
+from nucypher.cli.actions.auth import get_client_password
+from nucypher.cli.actions.select import select_network
+from nucypher.cli.literature import (
+ STAKING_PROVIDER_UNAUTHORIZED,
+ BONDING_TIME,
+ ALREADY_BONDED,
+ UNEXPECTED_HUMAN_OPERATOR,
+ BONDING,
+ CONFIRM_BONDING,
+ NOT_BONDED,
+ CONFIRM_UNBONDING,
+ UNBONDING
+)
+from nucypher.cli.options import (
+ option_registry_filepath,
+ option_signer_uri,
+ option_provider_uri,
+ option_network,
+ option_staking_provider,
+ option_operator_address,
+ option_force
+)
+from nucypher.cli.painting.transactions import paint_receipt_summary
+from nucypher.cli.utils import connect_to_blockchain, get_registry
+from nucypher.config.constants import NUCYPHER_ENVVAR_STAKING_PROVIDER_ETH_PASSWORD
+from nucypher.control.emitters import StdoutEmitter
+from nucypher.crypto.powers import TransactingPower
+
+
+def is_authorized(emitter, staking_provider: ChecksumAddress, agent: PREApplicationAgent) -> None:
+ _authorized = agent.is_authorized(staking_provider=staking_provider)
+ if not _authorized:
+ emitter.message(STAKING_PROVIDER_UNAUTHORIZED.format(provider=staking_provider), color='red')
+ raise click.Abort()
+
+
+def is_bonded(agent, staking_provider: ChecksumAddress, return_address: bool = False) -> Union[bool, Tuple[bool, ChecksumAddress]]:
+ onchain_operator = agent.get_operator_from_staking_provider(staking_provider=staking_provider)
+ result = onchain_operator != NULL_ADDRESS
+ if not return_address:
+ return result
+ return result, onchain_operator
+
+
+def check_bonding_requirements(emitter, agent: PREApplicationAgent, staking_provider: ChecksumAddress) -> None:
+ blockchain = agent.blockchain
+ now = blockchain.get_blocktime()
+ commencement = agent.get_staking_provider_info(staking_provider=staking_provider).operator_start_timestamp
+ min_seconds = agent.get_min_operator_seconds()
+ termination = (commencement + min_seconds)
+ if now < termination:
+ emitter.error(BONDING_TIME.format(date=maya.MayaDT(termination)))
+ raise click.Abort()
+
+
+@click.command('bond')
+@option_registry_filepath
+@option_provider_uri(required=True)
+@option_signer_uri
+@option_operator_address
+@option_staking_provider
+@option_network(required=True)
+@option_force
+def bond(registry_filepath, provider_uri, signer_uri, operator_address, staking_provider, network, force):
+ """
+ Bond an operator to a staking provider.
+ The staking provider must be authorized to use the PREApplication.
+ """
+
+ #
+ # Setup
+ #
+
+ emitter = StdoutEmitter()
+ connect_to_blockchain(provider_uri=provider_uri, emitter=emitter)
+ if not signer_uri:
+ emitter.message('--signer is required', color='red')
+ raise click.Abort()
+ if not network:
+ network = select_network(emitter=emitter)
+
+ signer = Signer.from_signer_uri(signer_uri)
+ transacting_power = TransactingPower(account=staking_provider, signer=signer)
+ registry = get_registry(network=network, registry_filepath=registry_filepath)
+ agent = ContractAgency.get_agent(PREApplicationAgent, registry=registry)
+
+ #
+ # Checks
+ #
+
+ # Check for authorization
+ is_authorized(emitter=emitter, agent=agent, staking_provider=staking_provider)
+
+ # Check bonding
+ if is_bonded(agent=agent, staking_provider=staking_provider, return_address=False):
+ # operator is already set - check timing
+ check_bonding_requirements(emitter=emitter, agent=agent, staking_provider=staking_provider)
+
+ # Check for pre-existing staking providers for this operator
+ onchain_staking_provider = agent.get_staking_provider_from_operator(operator_address=operator_address)
+ if onchain_staking_provider != NULL_ADDRESS:
+ emitter.message(ALREADY_BONDED.format(provider=onchain_staking_provider, operator=operator_address), color='red')
+ raise click.Abort() # dont steal bananas
+
+ # Check that operator is not human
+ if staking_provider != operator_address:
+ # if the operator has a beneficiary it is the staking provider.
+ beneficiary = agent.get_beneficiary(staking_provider=operator_address)
+ if beneficiary != NULL_ADDRESS:
+ emitter.message(UNEXPECTED_HUMAN_OPERATOR, color='red')
+ raise click.Abort()
+
+ #
+ # Bond
+ #
+
+ if not force:
+ click.confirm(CONFIRM_BONDING.format(provider=staking_provider, operator=operator_address), abort=True)
+ transacting_power.unlock(password=get_client_password(checksum_address=staking_provider, envvar=NUCYPHER_ENVVAR_STAKING_PROVIDER_ETH_PASSWORD))
+ emitter.echo(BONDING.format(operator=operator_address))
+ receipt = agent.bond_operator(operator=operator_address, transacting_power=transacting_power, staking_provider=staking_provider)
+ paint_receipt_summary(receipt=receipt, emitter=emitter)
+
+
+@click.command('unbond')
+@option_registry_filepath
+@option_provider_uri(required=True)
+@option_signer_uri
+@option_staking_provider
+@option_network()
+@option_force
+def unbond(registry_filepath, provider_uri, signer_uri, staking_provider, network, force):
+ """Unbonds an operator from an authorized staking provider."""
+
+ #
+ # Setup
+ #
+
+ emitter = StdoutEmitter()
+ if not signer_uri:
+ emitter.message('--signer is required', color='red')
+ raise click.Abort()
+ if not network:
+ network = select_network(emitter=emitter)
+
+ connect_to_blockchain(provider_uri=provider_uri, emitter=emitter)
+ registry = get_registry(network=network, registry_filepath=registry_filepath)
+ agent = ContractAgency.get_agent(PREApplicationAgent, registry=registry)
+ signer = Signer.from_signer_uri(signer_uri)
+ transacting_power = TransactingPower(account=staking_provider, signer=signer)
+
+ #
+ # Check
+ #
+
+ bonded, onchain_operator_address = is_bonded(agent=agent, staking_provider=staking_provider, return_address=True)
+ if not bonded:
+ emitter.message(NOT_BONDED.format(provider=staking_provider), color='red')
+ raise click.Abort()
+ check_bonding_requirements(emitter=emitter, agent=agent, staking_provider=staking_provider)
+
+ #
+ # Unbond
+ #
+
+ if not force:
+ click.confirm(CONFIRM_UNBONDING.format(provider=staking_provider, operator=onchain_operator_address), abort=True)
+ transacting_power.unlock(password=get_client_password(checksum_address=staking_provider, envvar=NUCYPHER_ENVVAR_STAKING_PROVIDER_ETH_PASSWORD))
+ emitter.echo(UNBONDING.format(operator=onchain_operator_address))
+ receipt = agent.bond_operator(operator=NULL_ADDRESS, transacting_power=transacting_power, staking_provider=staking_provider)
+ paint_receipt_summary(receipt=receipt, emitter=emitter)
diff --git a/nucypher/cli/literature.py b/nucypher/cli/literature.py
index 1c4d7575b..643d39bf2 100644
--- a/nucypher/cli/literature.py
+++ b/nucypher/cli/literature.py
@@ -594,3 +594,26 @@ PORTER_CORS_ALLOWED_ORIGINS = "CORS Allow Origins: {allow_origins}"
PORTER_BOTH_TLS_KEY_AND_CERTIFICATION_MUST_BE_PROVIDED = "Both --tls-key-filepath and --tls-certificate-filepath must be provided to launch porter with TLS; only one specified"
PORTER_BASIC_AUTH_REQUIRES_HTTPS = "Basic authentication can only be used with HTTPS. --tls-key-filepath and --tls-certificate-filepath must also be provided"
+
+
+#
+# PREApplication
+#
+
+STAKING_PROVIDER_UNAUTHORIZED = '{provider} is not authorized.'
+
+CONFIRM_BONDING = 'Are you sure you want to bond staking provider {provider} to operator {operator}?'
+
+BONDING_TIME = 'Bonding/Unbonding not permitted until {date}.'
+
+ALREADY_BONDED = '{operator} is already bonded to {provider}'
+
+BONDING = 'Bonding operator {operator}'
+
+UNEXPECTED_HUMAN_OPERATOR = 'Operation not permitted'
+
+UNBONDING = 'Unbonding operator {operator}'
+
+CONFIRM_UNBONDING = 'Are you sure you want to unbond {operator} from {provider}?'
+
+NOT_BONDED = '{provider} is not bonded to any operator'
diff --git a/nucypher/cli/main.py b/nucypher/cli/main.py
index b5e70d5b0..22386fffc 100644
--- a/nucypher/cli/main.py
+++ b/nucypher/cli/main.py
@@ -26,7 +26,8 @@ from nucypher.cli.commands import (
ursula,
cloudworkers,
contacts,
- porter
+ porter,
+ bond,
)
from nucypher.cli.painting.help import echo_version, echo_config_root_path, echo_logging_root_path
@@ -75,8 +76,12 @@ ENTRY_POINTS = (
ursula.ursula, # Untrusted Re-Encryption Proxy
stake.stake, # Stake Management
+ # PRE Application
+ bond.bond,
+ bond.unbond,
+
# Utility Commands
- status.status, # Network Status
+ status.status, # Network Status
cloudworkers.cloudworkers, # Remote Operator node management
contacts.contacts, # Character "card" management
porter.porter
diff --git a/nucypher/cli/options.py b/nucypher/cli/options.py
index c6c3742ea..de562b430 100644
--- a/nucypher/cli/options.py
+++ b/nucypher/cli/options.py
@@ -55,6 +55,7 @@ option_hw_wallet = click.option('--hw-wallet/--no-hw-wallet')
option_light = click.option('--light', help="Indicate that node is light", is_flag=True, default=None)
option_lonely = click.option('--lonely', help="Do not connect to seednodes", is_flag=True)
option_min_stake = click.option('--min-stake', help="The minimum stake the teacher must have to be locally accepted.", type=STAKED_TOKENS_RANGE, default=MIN_AUTHORIZATION)
+option_operator_address = click.option('--operator-address', help="Address to bond as an operator", type=EIP55_CHECKSUM_ADDRESS, required=True)
option_parameters = click.option('--parameters', help="Filepath to a JSON file containing additional parameters", type=EXISTING_READABLE_FILE)
option_participant_address = click.option('--participant-address', help="Participant's checksum address.", type=EIP55_CHECKSUM_ADDRESS)
option_payment_provider = click.option('--payment-provider', help="Connection URL for payment method", type=click.STRING, required=False)
@@ -65,6 +66,7 @@ option_registry_filepath = click.option('--registry-filepath', help="Custom cont
option_shares = click.option('--shares', '-n', help="N-Total shares", type=click.INT)
option_signer_uri = click.option('--signer', 'signer_uri', '-S', default=None, type=str)
option_staking_address = click.option('--staking-address', help="Address of a NuCypher staker", type=EIP55_CHECKSUM_ADDRESS)
+option_staking_provider = click.option('--staking-provider', help="Staking provider ethereum address", type=EIP55_CHECKSUM_ADDRESS, required=True)
option_teacher_uri = click.option('--teacher', 'teacher_uri', help="An Ursula URI to start learning from (seednode)", type=click.STRING)
option_threshold = click.option('--threshold', '-m', help="M-Threshold KFrags", type=click.INT)
option_treasure_map = click.option('--treasure-map', 'treasure_map', help="Encrypted treasure map as base64 for retrieval", type=click.STRING, required=True)
diff --git a/nucypher/config/constants.py b/nucypher/config/constants.py
index bebb5ce79..4b5fa52d4 100644
--- a/nucypher/config/constants.py
+++ b/nucypher/config/constants.py
@@ -30,6 +30,7 @@ import nucypher
NUCYPHER_ENVVAR_KEYSTORE_PASSWORD = "NUCYPHER_KEYSTORE_PASSWORD"
NUCYPHER_ENVVAR_OPERATOR_ADDRESS = "NUCYPHER_OPERATOR_ADDRESS"
NUCYPHER_ENVVAR_OPERATOR_ETH_PASSWORD = "NUCYPHER_WORKER_ETH_PASSWORD"
+NUCYPHER_ENVVAR_STAKING_PROVIDER_ETH_PASSWORD = "NUCYPHER_STAKING_PROVIDER_ETH_PASSWORD"
NUCYPHER_ENVVAR_ALICE_ETH_PASSWORD = "NUCYPHER_ALICE_ETH_PASSWORD"
NUCYPHER_ENVVAR_BOB_ETH_PASSWORD = "NUCYPHER_BOB_ETH_PASSWORD"
NUCYPHER_ENVVAR_PROVIDER_URI = "NUCYPHER_PROVIDER_URI"
diff --git a/tests/acceptance/blockchain/actors/test_operator.py b/tests/acceptance/blockchain/actors/test_operator.py
index 4ce0c1316..a144e0913 100644
--- a/tests/acceptance/blockchain/actors/test_operator.py
+++ b/tests/acceptance/blockchain/actors/test_operator.py
@@ -212,7 +212,7 @@ def test_ursula_operator_confirmation(ursula_decentralized_test_config,
# now lets visit stake.nucypher.network and bond this operator
tpower = TransactingPower(account=staking_provider, signer=Web3Signer(testerchain.client))
- application_agent.bond_operator(provider=staking_provider,
+ application_agent.bond_operator(staking_provider=staking_provider,
operator=operator_address,
transacting_power=tpower)
@@ -251,7 +251,7 @@ def test_ursula_operator_confirmation_autopilot(mocker,
# now lets bond this worker
tpower = TransactingPower(account=staking_provider2, signer=Web3Signer(testerchain.client))
- application_agent.bond_operator(provider=staking_provider2,
+ application_agent.bond_operator(staking_provider=staking_provider2,
operator=operator2,
transacting_power=tpower)
diff --git a/tests/acceptance/blockchain/agents/test_pre_application_agent.py b/tests/acceptance/blockchain/agents/test_pre_application_agent.py
index 4121b2007..f9f62de54 100644
--- a/tests/acceptance/blockchain/agents/test_pre_application_agent.py
+++ b/tests/acceptance/blockchain/agents/test_pre_application_agent.py
@@ -69,7 +69,7 @@ def test_staking_providers_and_operators_relationships(testerchain,
tpower = TransactingPower(account=staking_provider_account, signer=Web3Signer(testerchain.client))
_txhash = application_agent.bond_operator(transacting_power=tpower,
- provider=staking_provider_account,
+ staking_provider=staking_provider_account,
operator=operator_account)
# We can check the staker-worker relation from both sides
diff --git a/tests/acceptance/blockchain/agents/test_sampling_distribution.py b/tests/acceptance/blockchain/agents/test_sampling_distribution.py
index 6ae79d38e..bbe455841 100644
--- a/tests/acceptance/blockchain/agents/test_sampling_distribution.py
+++ b/tests/acceptance/blockchain/agents/test_sampling_distribution.py
@@ -52,7 +52,7 @@ def test_sampling_distribution(testerchain, test_registry, threshold_staking, ap
power = TransactingPower(account=provider_address, signer=Web3Signer(testerchain.client))
# We assume that the staking provider knows in advance the account of her operator
- application_agent.bond_operator(provider=provider_address,
+ application_agent.bond_operator(staking_provider=provider_address,
operator=operator_address,
transacting_power=power)
diff --git a/tests/acceptance/cli/test_staking_provider_bonding_cli.py b/tests/acceptance/cli/test_staking_provider_bonding_cli.py
new file mode 100644
index 000000000..06c3b8b80
--- /dev/null
+++ b/tests/acceptance/cli/test_staking_provider_bonding_cli.py
@@ -0,0 +1,136 @@
+"""
+ This file is part of nucypher.
+
+ nucypher is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ nucypher is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with nucypher. If not, see .
+"""
+
+import pytest
+from eth_typing import ChecksumAddress
+
+from nucypher.cli.commands.bond import bond, unbond
+from nucypher.config.constants import TEMPORARY_DOMAIN
+from tests.constants import TEST_PROVIDER_URI, INSECURE_DEVELOPMENT_PASSWORD
+
+
+@pytest.fixture(scope='module')
+def operator_address(testerchain):
+ return testerchain.unassigned_accounts.pop(1)
+
+
+@pytest.fixture(scope='module')
+@pytest.mark.usefixtures('test_registry_source_manager', 'agency')
+def staking_provider_address(testerchain):
+ return testerchain.unassigned_accounts.pop(1)
+
+
+def test_nucypher_bond_help(click_runner, testerchain):
+ command = '--help'
+ result = click_runner.invoke(bond, command, catch_exceptions=False)
+ assert result.exit_code == 0
+
+
+@pytest.fixture(scope='module')
+def authorized_staking_provider(testerchain, threshold_staking, staking_provider_address, application_economics):
+ # initialize threshold stake
+ tx = threshold_staking.functions.setRoles(staking_provider_address).transact()
+ testerchain.wait_for_receipt(tx)
+ tx = threshold_staking.functions.setStakes(staking_provider_address, application_economics.min_authorization, 0, 0).transact()
+ testerchain.wait_for_receipt(tx)
+ return staking_provider_address
+
+
+def exec_bond(click_runner, operator_address: ChecksumAddress, staking_provider_address: ChecksumAddress):
+ command = ('--operator-address', operator_address,
+ '--staking-provider', staking_provider_address,
+ '--provider', TEST_PROVIDER_URI,
+ '--network', TEMPORARY_DOMAIN,
+ '--signer', TEST_PROVIDER_URI,
+ '--force')
+ result = click_runner.invoke(bond,
+ command,
+ catch_exceptions=False,
+ env=dict(NUCYPHER_STAKING_PROVIDER_ETH_PASSWORD=INSECURE_DEVELOPMENT_PASSWORD))
+ return result
+
+
+def exec_unbond(click_runner, staking_provider_address: ChecksumAddress):
+ command = ('--staking-provider', staking_provider_address,
+ '--provider', TEST_PROVIDER_URI,
+ '--network', TEMPORARY_DOMAIN,
+ '--signer', TEST_PROVIDER_URI,
+ '--force')
+ result = click_runner.invoke(unbond,
+ command,
+ catch_exceptions=False,
+ env=dict(NUCYPHER_STAKING_PROVIDER_ETH_PASSWORD=INSECURE_DEVELOPMENT_PASSWORD))
+ return result
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'agency')
+def test_nucypher_bond_unauthorized(click_runner, testerchain, operator_address, staking_provider_address):
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 1
+ error_message = f'{staking_provider_address} is not authorized'
+ assert error_message in result.output
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'agency', 'test_registry')
+def test_nucypher_bond(click_runner, testerchain, operator_address, authorized_staking_provider):
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=authorized_staking_provider
+ )
+ assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'agency')
+def test_nucypher_rebond_too_soon(click_runner, testerchain, operator_address, staking_provider_address):
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 1
+ error_message = 'Bonding/Unbonding not permitted until '
+ assert error_message in result.output
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'agency')
+def test_nucypher_rebond_operator(click_runner,
+ testerchain,
+ operator_address,
+ staking_provider_address,
+ application_economics):
+ testerchain.time_travel(seconds=application_economics.min_operator_seconds)
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=testerchain.unassigned_accounts[-1],
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'agency')
+def test_nucypher_unbond_operator(click_runner,
+ testerchain,
+ staking_provider_address,
+ application_economics):
+ testerchain.time_travel(seconds=application_economics.min_operator_seconds)
+ result = exec_unbond(click_runner=click_runner, staking_provider_address=staking_provider_address)
+ assert result.exit_code == 0
diff --git a/tests/acceptance/cli/ursula/test_local_keystore_integration.py b/tests/acceptance/cli/ursula/test_local_keystore_integration.py
index 0084f35d3..f4350963d 100644
--- a/tests/acceptance/cli/ursula/test_local_keystore_integration.py
+++ b/tests/acceptance/cli/ursula/test_local_keystore_integration.py
@@ -72,7 +72,7 @@ def mock_funded_account_password_keystore(tmp_path_factory, testerchain, thresho
provider_power.unlock(password=INSECURE_DEVELOPMENT_PASSWORD)
pre_application_agent = ContractAgency.get_agent(PREApplicationAgent, registry=test_registry)
- pre_application_agent.bond_operator(provider=provider_address,
+ pre_application_agent.bond_operator(staking_provider=provider_address,
operator=account.address,
transacting_power=provider_power)
diff --git a/tests/fixtures.py b/tests/fixtures.py
index da115467f..4ee4be0e1 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -632,7 +632,7 @@ def staking_providers(testerchain, agency, test_registry, threshold_staking):
testerchain.wait_for_receipt(tx)
# We assume that the staking provider knows in advance the account of her operator
- pre_application_agent.bond_operator(provider=provider_address,
+ pre_application_agent.bond_operator(staking_provider=provider_address,
operator=operator_address,
transacting_power=provider_power)
diff --git a/tests/integration/cli/test_bonding_cli_functionality.py b/tests/integration/cli/test_bonding_cli_functionality.py
new file mode 100644
index 000000000..4ff66456f
--- /dev/null
+++ b/tests/integration/cli/test_bonding_cli_functionality.py
@@ -0,0 +1,216 @@
+"""
+ This file is part of nucypher.
+
+ nucypher is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ nucypher is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with nucypher. If not, see .
+"""
+
+import maya
+import pytest
+from eth_typing import ChecksumAddress
+
+from nucypher.blockchain.eth.constants import NULL_ADDRESS
+from nucypher.cli.commands.bond import unbond, bond
+from nucypher.cli.literature import UNEXPECTED_HUMAN_OPERATOR, BONDING_TIME, ALREADY_BONDED
+from nucypher.config.constants import (
+ TEMPORARY_DOMAIN,
+ NUCYPHER_ENVVAR_STAKING_PROVIDER_ETH_PASSWORD
+)
+from nucypher.crypto.powers import TransactingPower
+from nucypher.types import StakingProviderInfo
+from tests.constants import TEST_PROVIDER_URI, INSECURE_DEVELOPMENT_PASSWORD
+
+cli_env = {NUCYPHER_ENVVAR_STAKING_PROVIDER_ETH_PASSWORD: INSECURE_DEVELOPMENT_PASSWORD}
+
+
+@pytest.fixture(scope='module', autouse=True)
+def mock_transacting_power(module_mocker):
+ module_mocker.patch.object(TransactingPower, 'unlock')
+
+
+@pytest.fixture(scope='module')
+def operator_address(mock_testerchain):
+ return mock_testerchain.unassigned_accounts[1]
+
+
+@pytest.fixture(scope='module')
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency')
+def staking_provider_address(mock_testerchain):
+ return mock_testerchain.unassigned_accounts[2]
+
+
+def test_nucypher_bond_help(click_runner, mock_testerchain):
+ command = '--help'
+ result = click_runner.invoke(bond, command, catch_exceptions=False)
+ assert result.exit_code == 0
+
+
+def exec_bond(click_runner, operator_address: ChecksumAddress, staking_provider_address: ChecksumAddress):
+ command = ('--operator-address', operator_address,
+ '--staking-provider', staking_provider_address,
+ '--provider', TEST_PROVIDER_URI,
+ '--network', TEMPORARY_DOMAIN,
+ '--signer', TEST_PROVIDER_URI,
+ '--force' # non-interactive only
+ )
+ result = click_runner.invoke(bond, command, catch_exceptions=False, env=cli_env)
+ return result
+
+
+def exec_unbond(click_runner, staking_provider_address: ChecksumAddress):
+ command = ('--staking-provider', staking_provider_address,
+ '--provider', TEST_PROVIDER_URI,
+ '--network', TEMPORARY_DOMAIN,
+ '--signer', TEST_PROVIDER_URI,
+ '--force' # non-interactive only
+ )
+ result = click_runner.invoke(unbond, command, catch_exceptions=False, env=cli_env)
+ return result
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency', 'patch_keystore')
+def test_nucypher_bond_unauthorized(click_runner, mock_testerchain, operator_address, staking_provider_address, mock_application_agent):
+
+ mock_application_agent.is_authorized.return_value = False
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=NULL_ADDRESS,
+ operator_confirmed=False,
+ operator_start_timestamp=1
+ )
+
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 1
+ error_message = f'{staking_provider_address} is not authorized'
+ assert error_message in result.output
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency', 'test_registry')
+def test_nucypher_unexpected_beneficiary(click_runner, mock_testerchain, operator_address, staking_provider_address, mock_application_agent):
+
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=NULL_ADDRESS,
+ operator_confirmed=False,
+ operator_start_timestamp=1
+ )
+ mock_application_agent.get_beneficiary.return_value = mock_testerchain.unassigned_accounts[-1]
+ mock_application_agent.get_staking_provider_from_operator.return_value = NULL_ADDRESS
+
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+
+ assert result.exit_code == 1
+ assert UNEXPECTED_HUMAN_OPERATOR in result.output
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency', 'test_registry')
+def test_nucypher_bond(click_runner, mock_testerchain, operator_address, staking_provider_address, mock_application_agent):
+
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=NULL_ADDRESS,
+ operator_confirmed=False,
+ operator_start_timestamp=1
+ )
+ mock_application_agent.get_beneficiary.return_value = NULL_ADDRESS
+ mock_application_agent.get_staking_provider_from_operator.return_value = NULL_ADDRESS
+
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+
+ assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency')
+def test_nucypher_unbond_operator(click_runner, mock_testerchain, staking_provider_address, mock_application_agent, operator_address):
+
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=operator_address,
+ operator_confirmed=False,
+ operator_start_timestamp=1
+ )
+
+ mock_application_agent.get_staking_provider_from_operator.return_value = staking_provider_address
+
+ result = exec_unbond(click_runner=click_runner, staking_provider_address=staking_provider_address)
+ assert result.exit_code == 0
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency')
+def test_nucypher_rebond_too_soon(click_runner, mock_testerchain, operator_address, staking_provider_address, mock_application_agent):
+
+ min_authorized_seconds = 5
+ now = mock_testerchain.get_blocktime()
+ operator_start_timestamp = now
+ termination = operator_start_timestamp + min_authorized_seconds
+
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=operator_address,
+ operator_confirmed=False,
+ operator_start_timestamp=operator_start_timestamp
+ )
+ mock_application_agent.get_min_operator_seconds.return_value = min_authorized_seconds
+
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 1
+ error_message = BONDING_TIME.format(date=maya.MayaDT(termination))
+ assert error_message in result.output
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency')
+def test_nucypher_bond_already_claimed_operator(click_runner, mock_testerchain, operator_address, staking_provider_address, mock_application_agent):
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=NULL_ADDRESS,
+ operator_confirmed=False,
+ operator_start_timestamp=1
+ )
+ mock_application_agent.get_beneficiary.return_value = NULL_ADDRESS
+ mock_application_agent.get_operator_from_staking_provider.return_value = NULL_ADDRESS
+ mock_application_agent.get_staking_provider_from_operator.return_value = mock_testerchain.unassigned_accounts[4]
+
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 1
+
+
+@pytest.mark.usefixtures('test_registry_source_manager', 'mock_contract_agency')
+def test_nucypher_rebond_operator(click_runner, mock_testerchain, operator_address, staking_provider_address, mock_application_agent):
+ mock_application_agent.get_staking_provider_info.return_value = StakingProviderInfo(
+ operator=mock_testerchain.unassigned_accounts[-1],
+ operator_confirmed=False,
+ operator_start_timestamp=1
+ )
+ mock_application_agent.get_beneficiary.return_value = NULL_ADDRESS
+ mock_application_agent.get_staking_provider_from_operator.return_value = NULL_ADDRESS
+
+ result = exec_bond(
+ click_runner=click_runner,
+ operator_address=operator_address,
+ staking_provider_address=staking_provider_address
+ )
+ assert result.exit_code == 0
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 948cc14c0..328d9dfa5 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -24,7 +24,7 @@ from nucypher.blockchain.eth.agents import (
AdjudicatorAgent,
ContractAgency,
NucypherTokenAgent,
- StakingEscrowAgent
+ StakingEscrowAgent, PREApplicationAgent
)
from nucypher.blockchain.eth.interfaces import BlockchainInterface
from nucypher.blockchain.eth.registry import InMemoryContractRegistry
@@ -74,6 +74,17 @@ def mock_staking_agent(mock_testerchain, application_economics, mock_contract_ag
mock_agent.reset()
+@pytest.fixture(scope='function', autouse=True)
+def mock_application_agent(mock_testerchain, application_economics, mock_contract_agency, mocker):
+ mock_agent = mock_contract_agency.get_agent(PREApplicationAgent)
+
+ # Handle the special case of commit_to_next_period, which returns a txhash due to the fire_and_forget option
+ mock_agent.confirm_operator_address = mocker.Mock(return_value=MockContractAgent.FAKE_TX_HASH)
+
+ yield mock_agent
+ mock_agent.reset()
+
+
@pytest.fixture(scope='function', autouse=True)
def mock_adjudicator_agent(mock_testerchain, application_economics, mock_contract_agency):
mock_agent = mock_contract_agency.get_agent(AdjudicatorAgent)
@@ -235,7 +246,7 @@ def bob_blockchain_test_config(mock_testerchain, test_registry):
def ursula_decentralized_test_config(mock_testerchain, test_registry):
config = make_ursula_test_configuration(federated=False,
provider_uri=MOCK_PROVIDER_URI, # L1
- payment_provider=MOCK_PROVIDER_URI, # L2
+ payment_provider=MOCK_PROVIDER_URI, # L1/L2
test_registry=test_registry,
rest_port=MOCK_URSULA_STARTING_PORT,
checksum_address=mock_testerchain.ursula_account(index=0))