Merge pull request #3185 from KPrasch/multichain

Multichain Condition Evaluation and Fallback
pull/3203/head
LunarBytes 2023-08-23 18:54:08 +02:00 committed by GitHub
commit c8e1c9b46e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 571 additions and 137 deletions

View File

@ -0,0 +1,3 @@
- Support arbitrary multichain configuration for EVM-compatible blockchains for condition evaluation by ursula.
- Support for fallback RPC providers and multiple URI specification for a single chain ID.

View File

@ -1,6 +1,7 @@
import time
from collections import defaultdict
from decimal import Decimal
from typing import List, Optional, Tuple, Union
from typing import DefaultDict, Dict, List, Optional, Set, Tuple, Union
import maya
from eth_typing import ChecksumAddress
@ -22,7 +23,7 @@ from nucypher_core.ferveo import (
Transcript,
Validator,
)
from web3 import Web3
from web3 import HTTPProvider, Web3
from web3.types import TxReceipt
from nucypher.acumen.nicknames import Nickname
@ -53,6 +54,7 @@ from nucypher.crypto.powers import (
)
from nucypher.datastore.dkg import DKGStorage
from nucypher.network.trackers import OperatorBondedTracker
from nucypher.policy.conditions.evm import _CONDITION_CHAINS
from nucypher.policy.conditions.lingo import ConditionLingo
from nucypher.policy.payment import ContractPayment
from nucypher.utilities.emitters import StdoutEmitter
@ -175,9 +177,9 @@ class Operator(BaseActor):
# Falsy values may be passed down from the superclass
if not eth_provider_uri:
raise ValueError("ETH Provider URI is required to init a local character.")
raise ValueError("ETH Provider URI is required to init an operator.")
if not payment_method:
raise ValueError("Payment method is required to init a local character.")
raise ValueError("Payment method is required to init an operator.")
if not transacting_power:
transacting_power = TransactingPower(
@ -209,26 +211,6 @@ class Operator(BaseActor):
)
self.work_tracker = work_tracker or WorkTracker(worker=self)
#
# Multi-provider support
#
# TODO: Improve and formalize fully configurable multi-provider support
# TODO: Abstract away payment provider #3004
# TODO: #3094 Is chain ID stable and completely reliable?
# TODO: Relocate to a higher layer
eth_chain = self.application_agent.blockchain
polygon_chain = self.payment_method.agent.blockchain
# TODO: Use clients layer?
self.condition_providers = {
eth_chain.client.chain_id: eth_chain.provider,
polygon_chain.client.chain_id: polygon_chain.provider,
}
self.log.info(
f"Connected to {len(self.condition_providers)} blockchains: {self.condition_providers}"
)
def _local_operator_address(self):
return self.__operator_address
@ -303,10 +285,11 @@ class Ritualist(BaseActor):
def __init__(
self,
provider_uri: str, # this is a blockchain connection to the chain with the coordinator contract
coordinator_provider_uri: str,
network: str, # this must be the network where the coordinator lives
crypto_power: CryptoPower,
transacting_power: TransactingPower,
condition_provider_uris: Optional[Dict[int, List[str]]] = None,
publish_finalization: bool = True, # TODO: Remove this
*args,
**kwargs,
@ -318,7 +301,7 @@ class Ritualist(BaseActor):
self.coordinator_agent = ContractAgency.get_agent(
CoordinatorAgent,
registry=InMemoryContractRegistry.from_latest_publication(network=network),
provider_uri=provider_uri, # TODO: rename, this might be a polygon provider
provider_uri=coordinator_provider_uri,
)
# track active onchain rituals
@ -338,6 +321,65 @@ class Ritualist(BaseActor):
ThresholdRequestDecryptingPower
) # used for secure decryption request channel
self.condition_providers = self.connect_condition_providers(
condition_provider_uris
)
@staticmethod
def _is_permitted_condition_chain(chain_id: int) -> bool:
return int(chain_id) in [int(cid) for cid in _CONDITION_CHAINS.keys()]
@staticmethod
def _make_condition_provider(uri: str) -> HTTPProvider:
provider = HTTPProvider(endpoint_uri=uri)
return provider
def connect_condition_providers(
self, condition_provider_uris: Optional[Dict[int, List[str]]] = None
) -> DefaultDict[int, Set[HTTPProvider]]:
"""Multi-provider support"""
# If condition_provider_uris is None the node operator
# did not configure any additional condition providers.
condition_provider_uris = condition_provider_uris or dict()
# These are the chains that the Ritualist will connect to for conditions evaluation (read-only).
condition_providers = defaultdict(set)
# Now, add any additional providers that were passed in.
for chain_id, condition_provider_uris in condition_provider_uris.items():
if not self._is_permitted_condition_chain(chain_id):
# this is a safety check to prevent the Ritualist from connecting to
# chains that are not supported by ursulas on the network;
# Prevents the Ursula/Ritualist from starting up if this happens.
raise NotImplementedError(
f"Chain ID {chain_id} is not supported for condition evaluation by the Ritualist."
)
providers = set()
for uri in condition_provider_uris:
provider = self._make_condition_provider(uri)
providers.add(provider)
condition_providers[int(chain_id)] = providers
# Log the chains that the Ritualist is connected to.
humanized_chain_ids = ", ".join(
_CONDITION_CHAINS[chain_id] for chain_id in condition_providers
)
self.log.info(
f"Connected to {len(condition_providers)} blockchains for condition checking: {humanized_chain_ids}"
)
return condition_providers
def get_ritual(self, ritual_id: int) -> CoordinatorAgent.Ritual:
try:
ritual = self.ritual_tracker.rituals[ritual_id]
except KeyError:
raise self.ActorError(f"{ritual_id} is not in the local cache")
return ritual
def _resolve_validators(
self,
ritual: CoordinatorAgent.Ritual,

View File

@ -68,6 +68,7 @@ POA_CHAINS = {
42, # Kovan
77, # Sokol
100, # xDAI
10200, # gnosis/chiado,
137, # Polygon/Mainnet
80001, # "Polygon/Mumbai"
}

View File

@ -34,11 +34,18 @@ class NetworksInventory: # TODO: See #1564
pass
@classmethod
def get_ethereum_chain_id(cls, network): # TODO: Use this (where?) to make sure we're in the right chain
def get_ethereum_chain_id(cls, network):
try:
return cls.__to_ethereum_chain_id[network]
return cls.__to_chain_id_eth[network]
except KeyError:
return 1337 # TODO: what about chain id when testing?
raise cls.UnrecognizedNetwork(network)
@classmethod
def get_polygon_chain_id(cls, network):
try:
return cls.__to_chain_id_polygon[network]
except KeyError:
raise cls.UnrecognizedNetwork(network)
@classmethod
def validate_network_name(cls, network_name: str):

View File

@ -820,6 +820,7 @@ class Ursula(Teacher, Character, actors.Operator, actors.Ritualist):
client_password: Optional[str] = None,
operator_signature_from_metadata=NOT_SIGNED,
eth_provider_uri: Optional[str] = None,
condition_provider_uris: Optional[Dict[int, List[str]]] = None,
payment_method: Optional[Union[PaymentMethod, ContractPayment]] = None,
# Character
abort_on_learning_error: bool = False,
@ -869,7 +870,8 @@ class Ursula(Teacher, Character, actors.Operator, actors.Ritualist):
actors.Ritualist.__init__(
self,
domain=domain,
provider_uri=payment_method.provider,
condition_provider_uris=condition_provider_uris,
coordinator_provider_uri=payment_method.provider,
network=payment_method.network,
transacting_power=self.transacting_power,
crypto_power=self._crypto_power,

View File

@ -1,4 +1,5 @@
from copy import copy
from unittest import mock
from unittest.mock import Mock, patch
from eth_tester.exceptions import ValidationError
@ -10,7 +11,7 @@ from nucypher.config.constants import TEMPORARY_DOMAIN
from nucypher.crypto.powers import CryptoPower
from nucypher.exceptions import DevelopmentInstallationRequired
from nucypher.policy.payment import FreeReencryptions
from tests.constants import TEST_ETH_PROVIDER_URI
from tests.constants import TEST_ETH_PROVIDER_URI, TESTERCHAIN_CHAIN_ID
class Vladimir(Ursula):
@ -52,6 +53,11 @@ class Vladimir(Ursula):
bogus_payment_method.provider = Mock()
bogus_payment_method.agent = Mock()
bogus_payment_method.network = TEMPORARY_DOMAIN
bogus_payment_method.agent.blockchain.client.chain_id = TESTERCHAIN_CHAIN_ID
mock.patch(
"mock.interfaces.MockBlockchain.client.chain_id",
new_callable=mock.PropertyMock(return_value=TESTERCHAIN_CHAIN_ID),
)
vladimir = cls(is_me=True,
crypto_power=crypto_power,

View File

@ -304,7 +304,7 @@ class CharacterConfiguration(BaseConfiguration):
'Sideways Engagement' of Character classes; a reflection of input parameters.
"""
VERSION = 5 # bump when static payload scheme changes
VERSION = 6 # bump when static payload scheme changes
CHARACTER_CLASS = NotImplemented
MNEMONIC_KEYSTORE = False

View File

@ -1,13 +1,11 @@
import json
from pathlib import Path
from typing import Optional
from typing import Dict, List, Optional
from cryptography.x509 import Certificate
from eth_utils import is_checksum_address
from nucypher.blockchain.eth.networks import NetworksInventory
from nucypher.config.base import CharacterConfiguration
from nucypher.config.constants import (
NUCYPHER_ENVVAR_ALICE_ETH_PASSWORD,
@ -40,6 +38,7 @@ class UrsulaConfiguration(CharacterConfiguration):
rest_port: Optional[int] = None,
certificate: Optional[Certificate] = None,
availability_check: Optional[bool] = None,
condition_provider_uris: Optional[Dict[int, List[str]]] = None,
*args,
**kwargs,
) -> None:
@ -58,8 +57,27 @@ class UrsulaConfiguration(CharacterConfiguration):
self.rest_host = rest_host
self.certificate = certificate
self.operator_address = operator_address
self.availability_check = availability_check if availability_check is not None else self.DEFAULT_AVAILABILITY_CHECKS
super().__init__(dev_mode=dev_mode, keystore_path=keystore_path, *args, **kwargs)
self.availability_check = (
availability_check
if availability_check is not None
else self.DEFAULT_AVAILABILITY_CHECKS
)
super().__init__(
dev_mode=dev_mode, keystore_path=keystore_path, *args, **kwargs
)
self.condition_provider_uris = condition_provider_uris or dict()
self.configure_condition_provider_uris()
def configure_condition_provider_uris(self) -> None:
"""Configure default condition provider URIs for mainnet and polygon network."""
# Polygon
polygon_chain_id = NetworksInventory.get_polygon_chain_id(self.payment_network)
self.condition_provider_uris[polygon_chain_id] = [self.payment_provider]
# Ethereum
staking_chain_id = NetworksInventory.get_ethereum_chain_id(self.domain)
self.condition_provider_uris[staking_chain_id] = [self.eth_provider_uri]
@classmethod
def address_from_filepath(cls, filepath: Path) -> str:
@ -85,12 +103,13 @@ class UrsulaConfiguration(CharacterConfiguration):
rest_host=self.rest_host,
rest_port=self.rest_port,
availability_check=self.availability_check,
condition_provider_uris=self.condition_provider_uris,
# PRE Payments
# TODO: Resolve variable prefixing below (uses nested configuration fields?)
payment_method=self.payment_method,
payment_provider=self.payment_provider,
payment_network=self.payment_network
payment_network=self.payment_network,
)
return {**super().static_payload(), **payload}

View File

@ -1,13 +1,15 @@
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple
from eth_typing import ChecksumAddress
from eth_utils import to_checksum_address
from marshmallow import fields, post_load, validates_schema
from web3 import Web3
from web3 import HTTPProvider, Web3
from web3.contract.contract import ContractFunction
from web3.middleware import geth_poa_middleware
from web3.providers import BaseProvider
from web3.types import ABIFunction
from nucypher.blockchain.eth.clients import POA_CHAINS
from nucypher.policy.conditions import STANDARD_ABI_CONTRACT_TYPES, STANDARD_ABIS
from nucypher.policy.conditions.base import AccessControlCondition
from nucypher.policy.conditions.context import get_context_value, is_context_variable
@ -19,20 +21,27 @@ from nucypher.policy.conditions.exceptions import (
from nucypher.policy.conditions.lingo import ReturnValueTest
from nucypher.policy.conditions.utils import CamelCaseSchema, camel_case_to_snake
# TODO: Move this to a more appropriate location,
# but be sure to change the mocks in tests too.
# Permitted blockchains for condition evaluation
_CONDITION_CHAINS = (
1, # ethereum/mainnet
5, # ethereum/goerli
137, # polygon/mainnet
80001 # polygon/mumbai
)
from nucypher.utilities import logging
_CONDITION_CHAINS = {
1: "ethereum/mainnet",
5: "ethereum/goerli",
137: "polygon/mainnet",
80001: "polygon/mumbai",
# TODO: Permit support for these chains
# 100: "gnosis/mainnet",
# 10200: "gnosis/chiado",
}
def _resolve_abi(
w3: Web3,
method: str,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
w3: Web3,
method: str,
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
) -> ABIFunction:
"""Resolves the contract an/or function ABI from a standard contract name"""
@ -53,7 +62,9 @@ def _resolve_abi(
try:
# Extract all function ABIs from the contract's ABI.
# Will raise a ValueError if there is not exactly one match.
function_abi = w3.eth.contract(abi=contract_abi).get_function_by_name(method).abi
function_abi = (
w3.eth.contract(abi=contract_abi).get_function_by_name(method).abi
)
except ValueError as e:
raise InvalidCondition(str(e))
@ -61,7 +72,7 @@ def _resolve_abi(
def _resolve_any_context_variables(
parameters: List[Any], return_value_test: ReturnValueTest, **context
parameters: List[Any], return_value_test: ReturnValueTest, **context
):
processed_parameters = []
for p in parameters:
@ -100,23 +111,27 @@ class RPCCondition(AccessControlCondition):
ALLOWED_METHODS = (
# RPC
'eth_getBalance',
"eth_getBalance",
) # TODO other allowed methods (tDEC #64)
LOG = logging.Logger(__name__)
class Schema(CamelCaseSchema):
SKIP_VALUES = (None,)
name = fields.Str(required=False)
chain = fields.Int(required=True)
method = fields.Str(required=True)
parameters = fields.List(fields.Field, attribute='parameters', required=False)
return_value_test = fields.Nested(ReturnValueTest.ReturnValueTestSchema(), required=True)
parameters = fields.List(fields.Field, attribute="parameters", required=False)
return_value_test = fields.Nested(
ReturnValueTest.ReturnValueTestSchema(), required=True
)
@post_load
def make(self, data, **kwargs):
return RPCCondition(**data)
def __repr__(self) -> str:
r = f'{self.__class__.__name__}(function={self.method}, chain={self.chain})'
r = f"{self.__class__.__name__}(function={self.method}, chain={self.chain})"
return r
def __init__(
@ -127,7 +142,6 @@ class RPCCondition(AccessControlCondition):
name: Optional[str] = None,
parameters: Optional[List[Any]] = None,
):
# Validate input
# TODO: Additional validation (function is valid for ABI, RVT validity, standard contract name validity, etc.)
_validate_chain(chain=chain)
@ -135,6 +149,7 @@ class RPCCondition(AccessControlCondition):
# internal
self.name = name
self.chain = chain
self.provider: Optional[BaseProvider] = None # set in _configure_provider
self.method = self.validate_method(method=method)
# test
@ -153,24 +168,49 @@ class RPCCondition(AccessControlCondition):
)
return method
def _configure_provider(self, providers: Dict[int, BaseProvider]):
"""Binds the condition's contract function to a blockchian provider for evaluation"""
def _next_endpoint(
self, providers: Dict[int, Set[HTTPProvider]]
) -> Iterator[HTTPProvider]:
"""Yields the next web3 provider to try for a given chain ID"""
try:
provider = providers[self.chain]
rpc_providers = providers[self.chain]
# if there are no entries for the chain ID, there
# is no connection to that chain available.
except KeyError:
raise NoConnectionToChain(chain=self.chain)
if not rpc_providers:
raise NoConnectionToChain(chain=self.chain)
for provider in rpc_providers:
# Someday, we might make this whole function async, and then we can knock on
# each endpoint here to see if it's alive and only yield it if it is.
yield provider
def _configure_w3(self, provider: BaseProvider) -> Web3:
# Instantiate a local web3 instance
self.w3 = Web3(provider)
self.provider = provider
w3 = Web3(provider)
if self.chain in POA_CHAINS:
# inject web3 middleware to handle POA chain extra_data field.
self.w3.middleware_onion.inject(geth_poa_middleware, layer=0)
return w3
# This next block validates that the actual web3 provider is *actually*
# connected to the condition's chain ID by reading its RPC endpoint.
def _check_chain_id(self) -> None:
"""
Validates that the actual web3 provider is *actually*
connected to the condition's chain ID by reading its RPC endpoint.
"""
provider_chain = self.w3.eth.chain_id
if provider_chain != self.chain:
raise InvalidCondition(
f"This condition can only be evaluated on chain ID {self.chain} but the provider's "
f"connection is to chain ID {provider_chain}"
)
def _configure_provider(self, provider: BaseProvider):
"""Binds the condition's contract function to a blockchain provider for evaluation"""
self.w3 = self._configure_w3(provider=provider)
self._check_chain_id()
return provider
def _get_web3_py_function(self, rpc_method: str):
@ -187,19 +227,31 @@ class RPCCondition(AccessControlCondition):
rpc_result = rpc_function(*parameters) # RPC read
return rpc_result
def verify(self, providers: Dict[int, BaseProvider], **context) -> Tuple[bool, Any]:
def verify(
self, providers: Dict[int, Set[HTTPProvider]], **context
) -> Tuple[bool, Any]:
"""
Verifies the onchain condition is met by performing a
read operation and evaluating the return value test.
"""
self._configure_provider(providers=providers)
parameters, return_value_test = _resolve_any_context_variables(
self.parameters, self.return_value_test, **context
)
try:
result = self._execute_call(parameters=parameters)
except Exception as e:
raise RPCExecutionFailed(f"Contract call '{self.method}' failed: {e}")
endpoints = self._next_endpoint(providers=providers)
for provider in endpoints:
self._configure_provider(provider=provider)
parameters, return_value_test = _resolve_any_context_variables(
self.parameters, self.return_value_test, **context
)
try:
result = self._execute_call(parameters=parameters)
break
except Exception as e:
self.LOG.warn(
f"RPC call '{self.method}' failed: {e}, attempting to try next endpoint."
)
# Something went wrong. Try the next endpoint.
continue
else:
# Fuck.
raise RPCExecutionFailed(f"Contract call '{self.method}' failed.")
eval_result = return_value_test.eval(result) # test
return eval_result, result
@ -230,7 +282,7 @@ class ContractCondition(RPCCondition):
standard_contract_type: Optional[str] = None,
function_abi: Optional[ABIFunction] = None,
*args,
**kwargs
**kwargs,
):
# internal
super().__init__(*args, **kwargs)
@ -251,9 +303,11 @@ class ContractCondition(RPCCondition):
self.contract_function = self._get_unbound_contract_function()
def __repr__(self) -> str:
r = f'{self.__class__.__name__}(function={self.method}, ' \
f'contract={self.contract_address[:6]}..., ' \
f'chain={self.chain})'
r = (
f"{self.__class__.__name__}(function={self.method}, "
f"contract={self.contract_address[:6]}..., "
f"chain={self.chain})"
)
return r
def validate_method(self, method):

View File

@ -18,7 +18,6 @@
"""
import json
import os
import sys

View File

@ -18,7 +18,6 @@
"""
import json
import os
import sys

View File

@ -1,9 +1,7 @@
#!/usr/bin/env python
import json
import os
import sys
BACKUP_SUFFIX = '.old'

View File

@ -0,0 +1,63 @@
#!/usr/bin/env python
import json
import os
import sys
from nucypher.blockchain.eth.networks import NetworksInventory
BACKUP_SUFFIX = ".old"
OLD_VERSION = 4
NEW_VERSION = 6
def configuration_v4_to_v6(filepath: str):
# Read + deserialize
with open(filepath, "r") as file:
contents = file.read()
config = json.loads(contents)
try:
existing_version = config["version"]
if existing_version != OLD_VERSION:
raise RuntimeError(
f"Existing configuration is not version {OLD_VERSION}; Got version {existing_version}"
)
# Make a copy of the original file
backup_filepath = filepath + BACKUP_SUFFIX
os.rename(filepath, backup_filepath)
print(f"Backed up existing configuration to {backup_filepath}")
# Apply updates
del config["federated_only"] # deprecated
del config["checksum_address"]
config["version"] = NEW_VERSION
# Multichain support
eth_provider = config["eth_provider_uri"]
eth_chain_id = NetworksInventory.get_ethereum_chain_id(config["domain"])
polygon_provider = config["payment_provider"]
polygon_chain_id = NetworksInventory.get_polygon_chain_id(
config["payment_network"]
)
config["condition_providers"] = {
eth_chain_id: [eth_provider],
polygon_chain_id: [polygon_provider],
}
except KeyError:
raise RuntimeError(f"Invalid {OLD_VERSION} configuration file.")
# Commit updates
with open(filepath, "w") as file:
file.write(json.dumps(config, indent=4))
print(f"OK! Migrated configuration file from v{OLD_VERSION} -> v{NEW_VERSION}.")
if __name__ == "__main__":
try:
_python, filepath = sys.argv
except ValueError:
raise ValueError("Invalid command: Provide a single configuration filepath.")
configuration_v4_to_v6(filepath=filepath)

View File

@ -1,6 +1,5 @@
import pytest
import nucypher
from nucypher.blockchain.eth.agents import (
ContractAgency,
NucypherTokenAgent,
@ -19,19 +18,10 @@ from tests.constants import TEST_ETH_PROVIDER_URI, TESTERCHAIN_CHAIN_ID
@pytest.fixture()
def condition_providers(testerchain):
providers = {testerchain.client.chain_id: testerchain.provider}
providers = {testerchain.client.chain_id: {testerchain.provider}}
return providers
def mock_condition_blockchains(mocker):
"""adds testerchain's chain ID to permitted conditional chains"""
mocker.patch.object(
nucypher.policy.conditions.evm,
"_CONDITION_CHAINS",
tuple([TESTERCHAIN_CHAIN_ID]),
)
@pytest.fixture()
def compound_lingo(
erc721_evm_condition_balanceof,

View File

@ -165,7 +165,7 @@ def test_rpc_condition_evaluation_with_context_var_in_return_value_test(
invalid_balance = balance + 1
context[":balanceContextVar"] = invalid_balance
condition_result, call_result = rpc_condition.verify(
providers={testerchain.client.chain_id: testerchain.provider}, **context
providers={testerchain.client.chain_id: [testerchain.provider]}, **context
)
assert condition_result is False
assert call_result != invalid_balance

View File

@ -0,0 +1,161 @@
from collections import defaultdict
import pytest
from web3 import HTTPProvider
from nucypher.policy.conditions.evm import _CONDITION_CHAINS, RPCCondition
from nucypher.policy.conditions.lingo import ConditionLingo
from nucypher.utilities.logging import GlobalLoggerSettings
from tests.constants import TESTERCHAIN_CHAIN_ID
from tests.utils.policy import make_message_kits
GlobalLoggerSettings.start_text_file_logging()
def make_multichain_evm_conditions(bob, chain_ids):
"""This is a helper function to make a set of conditions that are valid on multiple chains."""
operands = list()
for chain_id in chain_ids:
operand = [
{
"returnValueTest": {"value": "0", "comparator": ">"},
"method": "blocktime",
"chain": chain_id,
},
{
"chain": chain_id,
"method": "eth_getBalance",
"parameters": [bob.checksum_address, "latest"],
"returnValueTest": {"comparator": ">=", "value": "10000000000000"},
},
]
operands.extend(operand)
_conditions = {
"version": ConditionLingo.VERSION,
"condition": {
"operator": "and",
"operands": operands,
},
}
return _conditions
@pytest.fixture(scope="module")
def chain_ids(module_mocker):
ids = [
TESTERCHAIN_CHAIN_ID,
TESTERCHAIN_CHAIN_ID + 1,
TESTERCHAIN_CHAIN_ID + 2,
123456789,
]
module_mocker.patch.dict(
_CONDITION_CHAINS, {cid: "fakechain/mainnet" for cid in ids}
)
return ids
@pytest.fixture(scope="module", autouse=True)
def multichain_ursulas(ursulas, chain_ids):
base_uri = "tester://multichain.{}"
base_fallback_uri = "tester://multichain.fallback.{}"
provider_uris = [base_uri.format(i) for i in range(len(chain_ids))]
fallback_provider_uris = [
base_fallback_uri.format(i) for i in range(len(chain_ids))
]
mocked_condition_providers = {
cid: {HTTPProvider(uri), HTTPProvider(furi)}
for cid, uri, furi in zip(chain_ids, provider_uris, fallback_provider_uris)
}
for ursula in ursulas:
ursula.condition_providers = mocked_condition_providers
return ursulas
@pytest.fixture(scope="module")
def conditions(bob, chain_ids):
_conditions = make_multichain_evm_conditions(bob, chain_ids)
return _conditions
@pytest.fixture(scope="module")
def monkeymodule():
from _pytest.monkeypatch import MonkeyPatch
mpatch = MonkeyPatch()
yield mpatch
mpatch.undo()
@pytest.fixture(scope="module")
def mock_rpc_condition(module_mocker, testerchain, monkeymodule):
def configure_mock(condition, provider, *args, **kwargs):
condition.provider = provider
return testerchain.w3
monkeymodule.setattr(RPCCondition, "_configure_w3", configure_mock)
configure_spy = module_mocker.spy(RPCCondition, "_configure_w3")
chain_id_check_mock = module_mocker.patch.object(RPCCondition, "_check_chain_id")
return configure_spy, chain_id_check_mock
def test_single_retrieve_with_multichain_conditions(
enacted_policy, bob, multichain_ursulas, conditions, mock_rpc_condition, mocker
):
bob.remember_node(multichain_ursulas[0])
bob.start_learning_loop()
messages, message_kits = make_message_kits(enacted_policy.public_key, conditions)
policy_info_kwargs = dict(
encrypted_treasure_map=enacted_policy.treasure_map,
alice_verifying_key=enacted_policy.publisher_verifying_key,
)
cleartexts = bob.retrieve_and_decrypt(
message_kits=message_kits,
**policy_info_kwargs,
)
assert cleartexts == messages
def test_single_decryption_request_with_faulty_rpc_endpoint(
enacted_policy, bob, multichain_ursulas, conditions, mock_rpc_condition
):
bob.remember_node(multichain_ursulas[0])
bob.start_learning_loop()
messages, message_kits = make_message_kits(enacted_policy.public_key, conditions)
policy_info_kwargs = dict(
encrypted_treasure_map=enacted_policy.treasure_map,
alice_verifying_key=enacted_policy.publisher_verifying_key,
)
calls = defaultdict(int)
original_execute_call = RPCCondition._execute_call
def faulty_execute_call(*args, **kwargs):
"""Intercept the call to the RPC endpoint and raise an exception on the second call."""
nonlocal calls
rpc_call = args[0]
calls[rpc_call.chain] += 1
if (
calls[rpc_call.chain] == 2
and "tester://multichain.0" in rpc_call.provider.endpoint_uri
):
# simulate a network error
raise ConnectionError("Something went wrong with the network")
elif calls[rpc_call.chain] == 3:
# check the provider is the fallback
this_uri = rpc_call.provider.endpoint_uri
assert "fallback" in this_uri
return original_execute_call(*args, **kwargs)
RPCCondition._execute_call = faulty_execute_call
cleartexts = bob.retrieve_and_decrypt(
message_kits=message_kits,
**policy_info_kwargs,
)
assert cleartexts == messages
RPCCondition._execute_call = original_execute_call

View File

@ -4,9 +4,10 @@ import random
import pytest
from web3 import Web3
from nucypher.blockchain.eth.actors import Operator
from nucypher.blockchain.eth.actors import Operator, Ritualist
from nucypher.blockchain.eth.agents import ContractAgency, PREApplicationAgent
from nucypher.blockchain.eth.interfaces import BlockchainInterfaceFactory
from nucypher.blockchain.eth.networks import NetworksInventory
from nucypher.blockchain.eth.signers.software import Web3Signer
from nucypher.config.constants import TEMPORARY_DOMAIN
from nucypher.crypto.powers import CryptoPower, TransactingPower
@ -18,6 +19,7 @@ from tests.constants import (
MIN_STAKE_FOR_TESTS,
MOCK_STAKING_CONTRACT_NAME,
TEST_ETH_PROVIDER_URI,
TESTERCHAIN_CHAIN_ID,
)
from tests.utils.ape import deploy_contracts as ape_deploy_contracts
from tests.utils.ape import registry_from_ape_deployments
@ -26,6 +28,30 @@ from tests.utils.blockchain import TesterBlockchain
test_logger = Logger("acceptance-test-logger")
@pytest.fixture(scope="session", autouse=True)
def mock_condition_blockchains(session_mocker):
"""adds testerchain's chain ID to permitted conditional chains"""
session_mocker.patch.dict(
"nucypher.policy.conditions.evm._CONDITION_CHAINS",
{TESTERCHAIN_CHAIN_ID: "eth-tester/pyevm"},
)
session_mocker.patch.object(
NetworksInventory, "get_polygon_chain_id", return_value=TESTERCHAIN_CHAIN_ID
)
session_mocker.patch.object(
NetworksInventory, "get_ethereum_chain_id", return_value=TESTERCHAIN_CHAIN_ID
)
@pytest.fixture(scope="module", autouse=True)
def mock_multichain_configuration(module_mocker, testerchain):
module_mocker.patch.object(
Ritualist, "_make_condition_provider", return_value=testerchain.provider
)
@pytest.fixture(scope='session', autouse=True)
def nucypher_contracts(project):
nucypher_contracts_dependency_api = project.dependencies["nucypher-contracts"]

View File

@ -3,11 +3,13 @@ from collections import defaultdict
import pytest
from eth_utils.crypto import keccak
from nucypher.blockchain.eth.actors import Ritualist
from nucypher.blockchain.eth.networks import NetworksInventory
from nucypher.crypto.powers import TransactingPower
from nucypher.network.nodes import Learner
from nucypher.network.trackers import AvailabilityTracker
from nucypher.utilities.logging import GlobalLoggerSettings
from tests.constants import MOCK_IP_ADDRESS
from tests.constants import MOCK_IP_ADDRESS, TESTERCHAIN_CHAIN_ID
# Don't re-lock accounts in the background while making commitments
LOCK_FUNCTION = TransactingPower.lock_account
@ -135,3 +137,27 @@ def mock_get_external_ip_from_url_source(session_mocker):
def disable_check_grant_requirements(session_mocker):
target = 'nucypher.characters.lawful.Alice._check_grant_requirements'
session_mocker.patch(target, return_value=MOCK_IP_ADDRESS)
@pytest.fixture(scope="session", autouse=True)
def mock_condition_blockchains(session_mocker):
"""adds testerchain's chain ID to permitted conditional chains"""
session_mocker.patch.dict(
"nucypher.policy.conditions.evm._CONDITION_CHAINS",
{TESTERCHAIN_CHAIN_ID: "eth-tester/pyevm"},
)
session_mocker.patch.object(
NetworksInventory, "get_polygon_chain_id", return_value=TESTERCHAIN_CHAIN_ID
)
session_mocker.patch.object(
NetworksInventory, "get_ethereum_chain_id", return_value=TESTERCHAIN_CHAIN_ID
)
@pytest.fixture(scope="module", autouse=True)
def mock_multichain_configuration(module_mocker, testerchain):
module_mocker.patch.object(
Ritualist, "_make_condition_provider", return_value=testerchain.provider
)

View File

@ -13,16 +13,10 @@ import pytest
from click.testing import CliRunner
from eth_account import Account
from eth_utils import to_checksum_address
from nucypher_core.ferveo import (
AggregatedTranscript,
DkgPublicKey,
Keypair,
Validator,
)
from nucypher_core.ferveo import AggregatedTranscript, DkgPublicKey, Keypair, Validator
from twisted.internet.task import Clock
from web3 import Web3
import nucypher
import tests
from nucypher.blockchain.economics import Economics
from nucypher.blockchain.eth.clients import EthereumClient
@ -42,7 +36,7 @@ from nucypher.crypto.ferveo import dkg
from nucypher.crypto.keystore import Keystore
from nucypher.network.nodes import TEACHER_NODES
from nucypher.policy.conditions.context import USER_ADDRESS_CONTEXT
from nucypher.policy.conditions.evm import ContractCondition, RPCCondition
from nucypher.policy.conditions.evm import RPCCondition
from nucypher.policy.conditions.lingo import ConditionLingo, ReturnValueTest
from nucypher.policy.conditions.time import TimeCondition
from nucypher.policy.payment import SubscriptionManagerPayment
@ -605,14 +599,6 @@ def conditions_test_data():
return data
@pytest.fixture(autouse=True)
def mock_condition_blockchains(mocker):
"""adds testerchain's chain ID to permitted conditional chains"""
mocker.patch.object(
nucypher.policy.conditions.evm, "_CONDITION_CHAINS", tuple([TESTERCHAIN_CHAIN_ID])
)
@pytest.fixture
def time_condition():
condition = TimeCondition(

View File

@ -1,3 +1,6 @@
import pytest
def test_new_ursula_announces_herself(
lonely_ursula_maker, test_registry_source_manager
):
@ -27,3 +30,18 @@ def test_node_deployer(ursulas):
deployer = ursula.get_deployer()
assert deployer.options['https_port'] == ursula.rest_information()[0].port
assert deployer.application == ursula.rest_app
def test_goerli_and_mumbai_as_conditions_providers(
lonely_ursula_maker, test_registry_source_manager
):
INVALID_CHAIN_ID = 66775827584859395569954838 # If we eventually support a chain with this ID, heaven help us.
with pytest.raises(NotImplementedError):
_ursula_who_tries_to_connect_to_an_invalid_chain = lonely_ursula_maker(
quantity=1,
domain="useless_domain",
condition_provider_uris={
INVALID_CHAIN_ID: "this is a provider URI, but it doesn't matter what we pass here because the chain_id is invalid."
},
)

View File

@ -5,6 +5,8 @@ from unittest.mock import PropertyMock
import pytest
import nucypher
from nucypher.blockchain.eth.actors import Ritualist
from nucypher.blockchain.eth.trackers.dkg import ActiveRitualTracker
from nucypher.cli.literature import (
COLLECT_NUCYPHER_PASSWORD,
@ -31,6 +33,7 @@ from tests.constants import (
MOCK_ETH_PROVIDER_URI,
MOCK_IP_ADDRESS,
YES_ENTER,
TESTERCHAIN_CHAIN_ID,
)
from tests.utils.ursula import select_test_port
@ -180,7 +183,9 @@ def test_ursula_view_configuration(custom_filepath: Path, click_runner, nominal_
assert custom_config_filepath.is_file(), 'Configuration file does not exist'
def test_run_ursula_from_config_file(custom_filepath: Path, click_runner, mock_funding_and_bonding):
def test_run_ursula_from_config_file(
custom_filepath: Path, click_runner, mock_funding_and_bonding, mocker
):
# Ensure the configuration file still exists
custom_config_filepath = custom_filepath / UrsulaConfiguration.generate_filename()

View File

@ -1,3 +1,4 @@
import json
from pathlib import Path
import pytest
@ -28,15 +29,18 @@ characters = (Alice, Bob, Ursula)
# Assemble
characters_and_configurations = list(zip(characters, configurations))
all_characters = tuple(characters, )
all_configurations = tuple(configurations, )
all_characters = tuple(
characters,
)
all_configurations = tuple(
configurations,
)
@pytest.mark.parametrize("character,configuration", characters_and_configurations)
def test_development_character_configurations(
character, configuration, test_registry_source_manager, mocker, testerchain
):
mocker.patch.object(
CharacterConfiguration, "DEFAULT_PAYMENT_NETWORK", TEMPORARY_DOMAIN
)
@ -73,7 +77,7 @@ def test_development_character_configurations(
# Node Storage
assert isinstance(thing_one.node_storage, ForgetfulNodeStorage)
assert ':memory:' in thing_one.node_storage._name
assert ":memory:" in thing_one.node_storage._name
# All development characters are unique
_characters = [thing_one, thing_two]
@ -95,15 +99,16 @@ def test_default_character_configuration_preservation(
tmpdir,
test_registry,
):
configuration_class.DEFAULT_CONFIG_ROOT = Path('/tmp')
fake_address = '0xdeadbeef'
configuration_class.DEFAULT_CONFIG_ROOT = Path("/tmp")
fake_address = "0xdeadbeef"
network = TEMPORARY_DOMAIN
expected_filename = f'{configuration_class.NAME}.{configuration_class._CONFIG_FILE_EXTENSION}'
expected_filename = (
f"{configuration_class.NAME}.{configuration_class._CONFIG_FILE_EXTENSION}"
)
generated_filename = configuration_class.generate_filename()
assert generated_filename == expected_filename
expected_filepath = Path('/', 'tmp', generated_filename)
expected_filepath = Path("/", "tmp", generated_filename)
if expected_filepath.exists():
expected_filepath.unlink()
@ -112,7 +117,9 @@ def test_default_character_configuration_preservation(
if configuration_class == UrsulaConfiguration:
# special case for rest_host & dev mode
# use keystore
keystore = Keystore.generate(password=INSECURE_DEVELOPMENT_PASSWORD, keystore_dir=tmpdir)
keystore = Keystore.generate(
password=INSECURE_DEVELOPMENT_PASSWORD, keystore_dir=tmpdir
)
keystore.signing_public_key = SecretKey.random().public_key()
character_config = configuration_class(
checksum_address=fake_address,
@ -143,12 +150,16 @@ def test_default_character_configuration_preservation(
try:
# Read
with open(character_config.filepath, 'r') as f:
contents = f.read()
with open(character_config.filepath, "r") as f:
_contents = json.loads(
f.read()
) # ensure this can be read and is valid JSON
# Restore from JSON file
restored_configuration = configuration_class.from_configuration_file()
assert character_config.serialize() == restored_configuration.serialize()
assert json.loads(character_config.serialize()) == json.loads(
restored_configuration.serialize()
)
# File still exists after reading
assert written_filepath.exists()
@ -183,7 +194,7 @@ def test_ursula_development_configuration(test_registry_source_manager, testerch
assert port == UrsulaConfiguration.DEFAULT_DEVELOPMENT_REST_PORT
assert ursula_one.certificate_filepath is CERTIFICATE_NOT_SAVED
assert isinstance(ursula_one.node_storage, ForgetfulNodeStorage)
assert ':memory:' in ursula_one.node_storage._name
assert ":memory:" in ursula_one.node_storage._name
# Alternate way to produce a character with a direct call
ursula_two = config.produce()
@ -201,19 +212,15 @@ def test_ursula_development_configuration(test_registry_source_manager, testerch
@pytest.mark.skip("See #2016")
def test_destroy_configuration(config,
test_emitter,
capsys,
mocker):
def test_destroy_configuration(config, test_emitter, capsys, mocker):
# Setup
config_class = config.__class__
config_file = config.filepath
# Isolate from filesystem and Spy on the methods we're testing here
spy_keystore_attached = mocker.spy(CharacterConfiguration, 'attach_keystore')
mock_config_destroy = mocker.patch.object(CharacterConfiguration, 'destroy')
spy_keystore_destroy = mocker.spy(Keystore, 'destroy')
mock_os_remove = mocker.patch('pathlib.Path.unlink')
spy_keystore_attached = mocker.spy(CharacterConfiguration, "attach_keystore")
mock_config_destroy = mocker.patch.object(CharacterConfiguration, "destroy")
spy_keystore_destroy = mocker.spy(Keystore, "destroy")
mock_os_remove = mocker.patch("pathlib.Path.unlink")
# Test
destroy_configuration(emitter=test_emitter, character_config=config)

View File

@ -4,7 +4,7 @@ from pathlib import Path
from typing import Iterable, Optional
from nucypher.blockchain.economics import EconomicsFactory
from nucypher.blockchain.eth.actors import Operator
from nucypher.blockchain.eth.actors import Operator, Ritualist
from nucypher.blockchain.eth.agents import (
AdjudicatorAgent,
ContractAgency,
@ -15,6 +15,7 @@ from nucypher.blockchain.eth.interfaces import (
BlockchainInterface,
BlockchainInterfaceFactory,
)
from nucypher.blockchain.eth.networks import NetworksInventory
from nucypher.blockchain.eth.registry import InMemoryContractRegistry
from nucypher.blockchain.eth.signers import KeystoreSigner
from nucypher.characters.lawful import Ursula
@ -26,6 +27,7 @@ from tests.constants import (
KEYFILE_NAME_TEMPLATE,
MOCK_KEYSTORE_PATH,
NUMBER_OF_MOCK_KEYSTORE_ACCOUNTS,
TESTERCHAIN_CHAIN_ID,
)
from tests.mock.interfaces import MockBlockchain, mock_registry_source_manager
from tests.mock.io import MockStdinWrapper
@ -245,3 +247,20 @@ def staking_providers(testerchain, test_registry, monkeymodule):
Operator.get_staking_provider_address = faked
return testerchain.stake_providers_accounts
@pytest.fixture(scope="session", autouse=True)
def mock_condition_blockchains(session_mocker):
"""adds testerchain's chain ID to permitted conditional chains"""
session_mocker.patch.dict(
"nucypher.policy.conditions.evm._CONDITION_CHAINS",
{TESTERCHAIN_CHAIN_ID: "eth-tester/pyevm"},
)
session_mocker.patch.object(
NetworksInventory, "get_polygon_chain_id", return_value=TESTERCHAIN_CHAIN_ID
)
session_mocker.patch.object(
NetworksInventory, "get_ethereum_chain_id", return_value=TESTERCHAIN_CHAIN_ID
)

View File

@ -15,7 +15,7 @@ from nucypher.blockchain.eth.registry import (
RegistrySourceManager,
)
from nucypher.config.constants import TEMPORARY_DOMAIN
from tests.constants import MOCK_ETH_PROVIDER_URI
from tests.constants import MOCK_ETH_PROVIDER_URI, TESTERCHAIN_CHAIN_ID
from tests.utils.blockchain import TesterBlockchain
@ -90,3 +90,6 @@ class MockEthereumClient(EthereumClient):
def add_middleware(self, middleware):
pass
@property
def chain_id(self) -> int:
return TESTERCHAIN_CHAIN_ID