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))