class ScenarioRunner(object): def __init__( self, account: Account, chain_urls: Dict[str, List[str]], auth: str, data_path: Path, scenario_file: Path, ): from scenario_player.tasks.base import get_task_class_for_type from scenario_player.node_support import RaidenReleaseKeeper, NodeController self.task_count = 0 self.running_task_count = 0 self.auth = auth self.release_keeper = RaidenReleaseKeeper( data_path.joinpath('raiden_releases')) self.task_cache = {} self.task_storage = defaultdict(dict) self.scenario_name = os.path.basename( scenario_file.name).partition('.')[0] self.scenario = yaml.load(scenario_file) self.scenario_version = self.scenario.get('version', 1) if self.scenario_version not in SUPPORTED_SCENARIO_VERSIONS: raise ScenarioError( f'Unexpected scenario version {self.scenario_version}') self.data_path = data_path.joinpath('scenarios', self.scenario_name) self.data_path.mkdir(exist_ok=True, parents=True) log.debug('Data path', path=self.data_path) self.run_number = 0 run_number_file = self.data_path.joinpath('run_number.txt') if run_number_file.exists(): self.run_number = int(run_number_file.read_text()) + 1 run_number_file.write_text(str(self.run_number)) log.info('Run number', run_number=self.run_number) nodes = self.scenario['nodes'] node_mode = NodeMode.EXTERNAL.name if self.is_v2: node_mode = nodes.get('mode', '').upper() if not node_mode: raise ScenarioError( 'Version 2 scenarios require a "mode" in the "nodes" section.' ) try: self.node_mode = NodeMode[node_mode] except KeyError: known_modes = ', '.join(mode.name.lower() for mode in NodeMode) raise ScenarioError( f'Unknown node mode "{node_mode}". Expected one of {known_modes}', ) from None if self.is_managed: self.node_controller = NodeController( self, nodes.get('raiden_version', 'LATEST'), nodes['count'], nodes.get('default_options', {}), nodes.get('node_options', {}), ) else: if 'range' in nodes: range_config = nodes['range'] template = range_config['template'] self.raiden_nodes = [ template.format(i) for i in range(range_config['first'], range_config['last'] + 1) ] else: self.raiden_nodes = nodes['list'] self.node_commands = nodes.get('commands', {}) settings = self.scenario.get('settings') if settings is None: settings = {} self.timeout = settings.get('timeout', TIMEOUT) if self.is_managed: self.protocol = 'http' if 'protocol' in settings: log.warning( 'The "protocol" setting is not supported in "managed" node mode.' ) else: self.protocol = settings.get('protocol', 'http') self.notification_email = settings.get('notify') self.chain_name = settings.get('chain', 'any') if self.chain_name == 'any': self.chain_name = random.choice(list(chain_urls.keys())) elif self.chain_name not in chain_urls: raise ScenarioError( f'The scenario requested chain "{self.chain_name}" for which no RPC-URL is known.', ) log.info('Using chain', chain=self.chain_name) self.eth_rpc_urls = chain_urls[self.chain_name] self.client = JSONRPCClient( Web3(HTTPProvider(chain_urls[self.chain_name][0])), privkey=account.privkey, gas_price_strategy=get_gas_price_strategy( settings.get('gas_price', 'fast')), ) self.chain_id = self.client.web3.net.version balance = self.client.balance(account.address) if balance < OWN_ACCOUNT_BALANCE_MIN: raise ScenarioError( f'Insufficient balance ({balance / 10 ** 18} Eth) ' f'in account {to_checksum_address(account.address)} on chain "{self.chain_name}"', ) self.session = Session() # WTF? https://github.com/requests/requests/issues/2605 if auth: self.session.auth = tuple(auth.split(":")) self.session.mount('http', TimeOutHTTPAdapter(timeout=self.timeout)) self.session.mount('https', TimeOutHTTPAdapter(timeout=self.timeout)) self._node_to_address = None self.token_address = None scenario_config = self.scenario.get('scenario') if not scenario_config: raise ScenarioError( "Invalid scenario definition. Missing 'scenario' key.") try: (root_task_type, root_task_config), = scenario_config.items() except ValueError: # will be thrown if it's not a 1-element dict raise ScenarioError( "Invalid scenario definition. " "Exactly one root task is required below the 'scenario' key.", ) from None task_class = get_task_class_for_type(root_task_type) self.root_task = task_class(runner=self, config=root_task_config) def run_scenario(self): fund_tx = [] node_starter: gevent.Greenlet = None if self.is_managed: self.node_controller.initialize_nodes() node_addresses = self.node_controller.addresses node_balances = { address: self.client.balance(address) for address in node_addresses } low_balances = { address: balance for address, balance in node_balances.items() if balance < NODE_ACCOUNT_BALANCE_MIN } if low_balances: log.info('Funding nodes', nodes=low_balances.keys()) fund_tx = [ self.client.send_transaction( to=address, startgas=21000, value=NODE_ACCOUNT_BALANCE_FUND - balance, ) for address, balance in low_balances.items() ] node_starter = self.node_controller.start(wait=False) else: log.info("Fetching node addresses") unreachable_nodes = [ node for node, addr in self.node_to_address.items() if not addr ] if not self.node_to_address or unreachable_nodes: raise NodesUnreachableError( f"Raiden nodes unreachable: {','.join(unreachable_nodes)}", ) token_ctr = get_or_deploy_token(self) token_address = self.token_address = to_checksum_address( token_ctr.contract_address) first_node = self.get_node_baseurl(0) token_settings = self.scenario.get('token') or {} token_balance_min = token_settings.get( 'balance_min', DEFAULT_TOKEN_BALANCE_MIN, ) token_balance_fund = token_settings.get( 'balance_fund', DEFAULT_TOKEN_BALANCE_FUND, ) mint_tx = [] if self.is_managed: addresses = self.node_controller.addresses else: addresses = self.node_to_address.values() for address in addresses: balance = token_ctr.contract.functions.balanceOf(address).call() if balance < token_balance_min: mint_amount = token_balance_fund - balance startgas = GAS_LIMIT_FOR_TOKEN_CONTRACT_CALL log.debug("Minting tokens for", address=address, amount=mint_amount) mint_tx.append( token_ctr.transact('mintFor', startgas, mint_amount, address)) elif balance > token_balance_min: log.warning("Node is overfunded", address=address, balance=balance) wait_for_txs(self.client, mint_tx + fund_tx) if node_starter is not None: log.debug('Waiting for nodes to finish starting') node_starter.get(block=True) registered_tokens = set( self.session.get( API_URL_TOKENS.format(protocol=self.protocol, target_host=first_node), ).json(), ) if token_address not in registered_tokens: code, msg = self.register_token(token_address, first_node) if not 199 < code < 300: log.error("Couldn't register token with network", code=code, message=msg) raise TokenRegistrationError(msg) # Start root task root_task_greenlet = gevent.spawn(self.root_task) greenlets = [root_task_greenlet] if self.is_managed: greenlets.append(self.node_controller.start_node_monitor()) try: gevent.joinall(greenlets, raise_error=True) except BaseException: if not root_task_greenlet.dead: # Make sure we kill the tasks if a node dies root_task_greenlet.kill() raise def register_token(self, token_address, node): try: base_url = API_URL_TOKENS.format(protocol=self.protocol, target_host=node) url = "{}/{}".format(base_url, token_address) log.info("Registering token with network", url=url) resp = self.session.put(url) code = resp.status_code msg = resp.text except RequestException as ex: code = -1 msg = str(ex) return code, msg def _spawn_and_wait(self, objects, callback): tasks = {obj: gevent.spawn(callback, obj) for obj in objects} gevent.joinall(tasks.values()) return {obj: task.get() for obj, task in tasks.items()} @property def is_v2(self): return self.scenario_version == 2 @property def is_managed(self): return self.node_mode is NodeMode.MANAGED def get_node_address(self, index): if self.is_managed: return self.node_controller[index].address else: return self.node_to_address[self.raiden_nodes[index]] def get_node_baseurl(self, index): if self.is_managed: return self.node_controller[index].base_url else: return self.raiden_nodes[index] # Legacy for 'external' nodes def _get_node_addresses(self, nodes): def cb(node): log.debug("Getting node address", node=node) url = API_URL_ADDRESS.format(protocol=self.protocol, target_host=node) log.debug("Requesting", url=url) try: resp = self.session.get(url) except RequestException: log.error("Error fetching node address", url=url, node=node) return try: return resp.json().get('our_address', '') except ValueError: log.error( "Error decoding response", response=resp.text, code=resp.status_code, url=url, ) ret = self._spawn_and_wait(nodes, cb) return ret @property def node_to_address(self): if not self.raiden_nodes: return {} if self._node_to_address is None: self._node_to_address = { node: address for node, address in self._get_node_addresses( self.raiden_nodes).items() } return self._node_to_address
class ScenarioRunner: # TODO: #73 Drop support for version 1 scenario files. def __init__( self, account: Account, chain_urls: Dict[str, List[str]], auth: str, data_path: Path, scenario_file: Path, task_state_callback: Optional[Callable[ ["ScenarioRunner", Task, TaskState], None]] = None, ): from scenario_player.node_support import RaidenReleaseKeeper, NodeController self.task_count = 0 self.running_task_count = 0 self.auth = auth self.release_keeper = RaidenReleaseKeeper( data_path.joinpath("raiden_releases")) self.task_cache = {} self.task_state_callback = task_state_callback # Storage for arbitrary data tasks might need to persist self.task_storage = defaultdict(dict) self.scenario = Scenario(scenario_file) self.scenario_name = self.scenario.name self.data_path = data_path.joinpath("scenarios", self.scenario.name) self.data_path.mkdir(exist_ok=True, parents=True) log.debug("Data path", path=self.data_path) self.run_number = self.determine_run_number() self.node_mode = self.scenario.nodes.mode if self.is_managed: self.node_controller = NodeController( self, self.scenario.nodes.raiden_version, self.scenario.nodes.count, self.scenario.nodes.default_options, self.scenario.nodes.node_options, ) else: self.raiden_nodes = self.scenario.nodes self.node_commands = self.scenario.nodes.commands self.timeout = self.scenario.timeout self.protocol = self.scenario.protocol self.notification_email = self.scenario.notification_email self.chain_name, chain_urls = self.select_chain(chain_urls) self.eth_rpc_urls = chain_urls self.client = JSONRPCClient( Web3(HTTPProvider(chain_urls[0])), privkey=account.privkey, gas_price_strategy=self.scenario.gas_price_strategy, ) self.chain_id = int(self.client.web3.net.version) self.contract_manager = ContractManager(contracts_precompiled_path()) balance = self.client.balance(account.address) if balance < OWN_ACCOUNT_BALANCE_MIN: raise ScenarioError( f"Insufficient balance ({balance / 10 ** 18} Eth) " f'in account {to_checksum_address(account.address)} on chain "{self.chain_name}"' ) self.session = Session() if auth: self.session.auth = tuple(auth.split(":")) self.session.mount("http", TimeOutHTTPAdapter(timeout=self.timeout)) self.session.mount("https", TimeOutHTTPAdapter(timeout=self.timeout)) self._node_to_address = None self.token_address = None self.token_deployment_block = 0 self.token_network_address = None task_config = self.scenario.task_config task_class = self.scenario.task_class self.root_task = task_class(runner=self, config=task_config) def determine_run_number(self) -> int: """Determine the current run number. We check for a run number file, and use any number that is logged there after incrementing it. REFAC: Replace this with a property. """ run_number = 0 run_number_file = self.data_path.joinpath("run_number.txt") if run_number_file.exists(): run_number = int(run_number_file.read_text()) + 1 run_number_file.write_text(str(run_number)) log.info("Run number", run_number=run_number) return run_number def select_chain( self, chain_urls: Dict[str, List[str]]) -> Tuple[str, List[str]]: """Select a chain and return its name and RPC URL. If the currently loaded scenario's designated chain is set to 'any', we randomly select a chain from the given `chain_urls`. Otherwise, we will return `ScenarioRunner.scenario.chain_name` and whatever value may be associated with this key in `chain_urls`. :raises ScenarioError: if ScenarioRunner.scenario.chain_name is not one of `('any', 'Any', 'ANY')` and it is not a key in `chain_urls`. """ chain_name = self.scenario.chain_name if chain_name in ("any", "Any", "ANY"): chain_name = random.choice(list(chain_urls.keys())) log.info("Using chain", chain=chain_name) try: return chain_name, chain_urls[chain_name] except KeyError: raise ScenarioError( f'The scenario requested chain "{chain_name}" for which no RPC-URL is known.' ) def run_scenario(self): mint_gas = GAS_LIMIT_FOR_TOKEN_CONTRACT_CALL * 2 fund_tx, node_starter, node_addresses, node_count = self._initialize_nodes( ) ud_token_tx, udc_ctr, should_deposit_ud_token = self._initialize_udc( gas_limit=mint_gas, node_count=node_count) mint_tx = self._initialize_scenario_token( node_addresses=node_addresses, udc_ctr=udc_ctr, should_deposit_ud_token=should_deposit_ud_token, gas_limit=mint_gas, ) wait_for_txs(self.client, fund_tx | ud_token_tx | mint_tx) if node_starter is not None: log.debug("Waiting for nodes to finish starting") node_starter.get(block=True) first_node = self.get_node_baseurl(0) registered_tokens = set( self.session.get( API_URL_TOKENS.format(protocol=self.protocol, target_host=first_node)).json()) if self.token_address not in registered_tokens: for _ in range(5): code, msg = self.register_token(self.token_address, first_node) if 199 < code < 300: break gevent.sleep(1) else: log.error("Couldn't register token with network", code=code, message=msg) raise TokenRegistrationError(msg) # The nodes need some time to find the token, see # https://github.com/raiden-network/raiden/issues/3544 # FIXME: Add proper check via API log.info("Waiting till new network is found by nodes") while self.token_network_address is None: self.token_network_address = self.session.get( API_URL_TOKEN_NETWORK_ADDRESS.format( protocol=self.protocol, target_host=first_node, token_address=self.token_address, )).json() gevent.sleep(1) log.info("Received token network address", token_network_address=self.token_network_address) # Start root task root_task_greenlet = gevent.spawn(self.root_task) greenlets = {root_task_greenlet} if self.is_managed: greenlets.add(self.node_controller.start_node_monitor()) try: gevent.joinall(greenlets, raise_error=True) except BaseException: if not root_task_greenlet.dead: # Make sure we kill the tasks if a node dies root_task_greenlet.kill() raise def _initialize_scenario_token( self, node_addresses: Set[ChecksumAddress], udc_ctr: Optional[ContractProxy], should_deposit_ud_token: bool, gas_limit: int, ) -> Set[TransactionHash]: token_ctr, token_block = get_or_deploy_token(self) self.token_address = to_checksum_address(token_ctr.contract_address) self.token_deployment_block = token_block token_settings = self.scenario.get("token") or {} token_balance_min = token_settings.get("balance_min", DEFAULT_TOKEN_BALANCE_MIN) token_balance_fund = token_settings.get("balance_fund", DEFAULT_TOKEN_BALANCE_FUND) mint_tx = set() for address in node_addresses: tx = mint_token_if_balance_low( token_contract=token_ctr, target_address=address, min_balance=token_balance_min, fund_amount=token_balance_fund, gas_limit=gas_limit, mint_msg="Minting tokens for", ) if tx: mint_tx.add(tx) if not should_deposit_ud_token: continue ud_deposit_balance = udc_ctr.contract.functions.effectiveBalance( address).call() if ud_deposit_balance < DEFAULT_TOKEN_BALANCE_MIN // 2: deposit_amount = (DEFAULT_TOKEN_BALANCE_FUND // 2) - ud_deposit_balance log.debug("Depositing into UDC", address=address, amount=deposit_amount) mint_tx.add( udc_ctr.transact("deposit", gas_limit, address, DEFAULT_TOKEN_BALANCE_FUND // 2)) return mint_tx def _initialize_udc( self, gas_limit: int, node_count: int ) -> Tuple[Set[TransactionHash], Optional[ContractProxy], bool]: our_address = to_checksum_address(self.client.address) udc_settings = self.scenario.services.get("udc", {}) udc_enabled = udc_settings.get("enable") ud_token_tx = set() if not udc_enabled: return ud_token_tx, None, False udc_ctr, ud_token_ctr = get_udc_and_token(self) ud_token_address = to_checksum_address(ud_token_ctr.contract_address) udc_address = to_checksum_address(udc_ctr.contract_address) log.info("UDC enabled", contract_address=udc_address, token_address=ud_token_address) should_deposit_ud_token = udc_enabled and udc_settings.get( "token", {}).get("deposit", False) if should_deposit_ud_token: tx = mint_token_if_balance_low( token_contract=ud_token_ctr, target_address=our_address, min_balance=DEFAULT_TOKEN_BALANCE_FUND * node_count, fund_amount=DEFAULT_TOKEN_BALANCE_FUND * 10 * node_count, gas_limit=gas_limit, mint_msg="Minting UD tokens", no_action_msg="UD token balance sufficient", ) if tx: ud_token_tx.add(tx) udt_allowance = ud_token_ctr.contract.functions.allowance( our_address, udc_address).call() if udt_allowance < DEFAULT_TOKEN_BALANCE_FUND * node_count: allow_amount = (DEFAULT_TOKEN_BALANCE_FUND * 10 * node_count) - udt_allowance log.debug("Updating UD token allowance", allowance=allow_amount) ud_token_tx.add( ud_token_ctr.transact("approve", gas_limit, udc_address, allow_amount)) else: log.debug("UD token allowance sufficient", allowance=udt_allowance) return ud_token_tx, udc_ctr, should_deposit_ud_token def _initialize_nodes( self ) -> Tuple[Set[TransactionHash], gevent.Greenlet, Set[ChecksumAddress], int]: fund_tx = set() node_starter: gevent.Greenlet = None if self.is_managed: self.node_controller.initialize_nodes() node_addresses = self.node_controller.addresses node_count = len(self.node_controller) node_balances = { address: self.client.balance(address) for address in node_addresses } low_balances = { address: balance for address, balance in node_balances.items() if balance < NODE_ACCOUNT_BALANCE_MIN } if low_balances: log.info("Funding nodes", nodes=low_balances.keys()) fund_tx = { self.client.send_transaction( to=address, startgas=21_000, value=NODE_ACCOUNT_BALANCE_FUND - balance) for address, balance in low_balances.items() } node_starter = self.node_controller.start(wait=False) else: log.info("Fetching node addresses") unreachable_nodes = [ node for node, addr in self.node_to_address.items() if not addr ] if not self.node_to_address or unreachable_nodes: raise NodesUnreachableError( f"Raiden nodes unreachable: {','.join(unreachable_nodes)}") node_addresses = set(self.node_to_address.values()) node_count = len(self.node_to_address) return fund_tx, node_starter, node_addresses, node_count def task_state_changed(self, task: "Task", state: "TaskState"): if self.task_state_callback: self.task_state_callback(self, task, state) def register_token(self, token_address, node): try: base_url = API_URL_TOKENS.format(protocol=self.protocol, target_host=node) url = "{}/{}".format(base_url, token_address) log.info("Registering token with network", url=url) resp = self.session.put(url) code = resp.status_code msg = resp.text except RequestException as ex: code = -1 msg = str(ex) return code, msg @staticmethod def _spawn_and_wait(objects, callback): tasks = {obj: gevent.spawn(callback, obj) for obj in objects} gevent.joinall(set(tasks.values())) return {obj: task.get() for obj, task in tasks.items()} @property def is_managed(self): return self.node_mode is NodeMode.MANAGED def get_node_address(self, index): if self.is_managed: return self.node_controller[index].address else: return self.node_to_address[self.raiden_nodes[index]] def get_node_baseurl(self, index): if self.is_managed: return self.node_controller[index].base_url else: return self.raiden_nodes[index] # Legacy for 'external' nodes def _get_node_addresses(self, nodes): def cb(node): log.debug("Getting node address", node=node) url = API_URL_ADDRESS.format(protocol=self.protocol, target_host=node) log.debug("Requesting", url=url) try: resp = self.session.get(url) except RequestException: log.error("Error fetching node address", url=url, node=node) return None try: return to_checksum_address(resp.json().get("our_address", "")) except ValueError: log.error("Error decoding response", response=resp.text, code=resp.status_code, url=url) return None ret = self._spawn_and_wait(nodes, cb) return ret @property def node_to_address(self) -> Dict[str, ChecksumAddress]: if not self.raiden_nodes: return {} if self._node_to_address is None: self._node_to_address = { node: address for node, address in self._get_node_addresses( self.raiden_nodes).items() } return self._node_to_address
class ScenarioRunner: def __init__( self, account: Account, chain_urls: Dict[str, List[str]], auth: str, data_path: Path, scenario_file: Path, task_state_callback: Optional[ Callable[["ScenarioRunner", "Task", "TaskState"], None] ] = None, ): from scenario_player.node_support import RaidenReleaseKeeper, NodeController self.auth = auth self.release_keeper = RaidenReleaseKeeper(data_path.joinpath("raiden_releases")) self.task_count = 0 self.running_task_count = 0 self.task_cache = {} self.task_state_callback = task_state_callback # Storage for arbitrary data tasks might need to persist self.task_storage = defaultdict(dict) scenario_name = scenario_file.stem self.data_path = data_path.joinpath("scenarios", scenario_name) self.data_path.mkdir(exist_ok=True, parents=True) self.yaml = ScenarioYAML(scenario_file, self.data_path) log.debug("Data path", path=self.data_path) # Determining the run number requires :attr:`.data_path` self.run_number = self.determine_run_number() self.node_controller = NodeController(self, self.yaml.nodes) self.protocol = "http" self.gas_limit = GAS_LIMIT_FOR_TOKEN_CONTRACT_CALL * 2 self.chain_name, chain_urls = self.select_chain(chain_urls) self.eth_rpc_urls = chain_urls self.client = JSONRPCClient( Web3(HTTPProvider(chain_urls[0])), privkey=account.privkey, gas_price_strategy=self.yaml.settings.gas_price_strategy, ) self.chain_id = int(self.client.web3.net.version) self.contract_manager = ContractManager(contracts_precompiled_path()) balance = self.client.balance(account.address) if balance < OWN_ACCOUNT_BALANCE_MIN: raise ScenarioError( f"Insufficient balance ({balance / 10 ** 18} Eth) " f'in account {to_checksum_address(account.address)} on chain "{self.chain_name}"' ) self.session = Session() if auth: self.session.auth = tuple(auth.split(":")) self.session.mount("http", TimeOutHTTPAdapter(timeout=self.yaml.settings.timeout)) self.session.mount("https", TimeOutHTTPAdapter(timeout=self.yaml.settings.timeout)) self.service_session = ServiceInterface(self.yaml.spaas) # Request an RPC Client instance ID from the RPC service and assign it to the runner. assign_rpc_instance_id(self, chain_urls[0], account.privkey, self.yaml.settings.gas_price) self.token = Token(self, data_path) self.udc = None self.token_network_address = None task_config = self.yaml.scenario.root_config task_class = self.yaml.scenario.root_class self.root_task = task_class(runner=self, config=task_config) def determine_run_number(self) -> int: """Determine the current run number. We check for a run number file, and use any number that is logged there after incrementing it. REFAC: Replace this with a property. """ run_number = 0 run_number_file = self.data_path.joinpath("run_number.txt") if run_number_file.exists(): run_number = int(run_number_file.read_text()) + 1 run_number_file.write_text(str(run_number)) log.info("Run number", run_number=run_number) return run_number def select_chain(self, chain_urls: Dict[str, List[str]]) -> Tuple[str, List[str]]: """Select a chain and return its name and RPC URL. If the currently loaded scenario's designated chain is set to 'any', we randomly select a chain from the given `chain_urls`. Otherwise, we will return `ScenarioRunner.scenario.chain_name` and whatever value may be associated with this key in `chain_urls`. :raises ScenarioError: if ScenarioRunner.scenario.chain_name is not one of `('any', 'Any', 'ANY')` and it is not a key in `chain_urls`. """ chain_name = self.yaml.settings.chain if chain_name in ("any", "Any", "ANY"): chain_name = random.choice(list(chain_urls.keys())) log.info("Using chain", chain=chain_name) try: return chain_name, chain_urls[chain_name] except KeyError: raise ScenarioError( f'The scenario requested chain "{chain_name}" for which no RPC-URL is known.' ) def wait_for_token_network_discovery(self, node): """Check for token network discovery with the given `node`. By default exit the wait if the token has not been discovered after `n` seconds, where `n` is the value of :attr:`.timeout`. :raises TokenNetworkDiscoveryTimeout: If we waited a set time for the token network to be discovered, but it wasn't. """ log.info("Waiting till new network is found by nodes") node_endpoint = API_URL_TOKEN_NETWORK_ADDRESS.format( protocol=self.protocol, target_host=node, token_address=self.token.address ) started = time.monotonic() elapsed = 0 while elapsed < self.yaml.settings.timeout: try: resp = self.session.get(node_endpoint) resp.raise_for_status() except HTTPError as e: # We explicitly handle 404 Not Found responses only - anything else is none # of our business. if e.response.status_code != 404: raise # Wait before continuing, no sense in spamming the node. gevent.sleep(1) # Update our elapsed time tracker. elapsed = time.monotonic() - started continue else: # The node appears to have discovered our token network. data = resp.json() if not is_checksum_address(data): # Something's amiss about this response. Notify a human. raise TypeError(f"Unexpected response type from API: {data!r}") return data # We could not assert that our token network was registered within an # acceptable time frame. raise TokenNetworkDiscoveryTimeout def run_scenario(self): mint_gas = GAS_LIMIT_FOR_TOKEN_CONTRACT_CALL * 2 fund_tx, node_starter, node_addresses, node_count = self._initialize_nodes() ud_token_tx, udc_ctr, should_deposit_ud_token = self._initialize_udc( gas_limit=mint_gas, node_count=node_count ) mint_tx = self._initialize_scenario_token( node_addresses=node_addresses, udc_ctr=udc_ctr, should_deposit_ud_token=should_deposit_ud_token, gas_limit=mint_gas, ) wait_for_txs(self.client, fund_tx | ud_token_tx | mint_tx) if node_starter is not None: log.debug("Waiting for nodes to finish starting") node_starter.get(block=True) first_node = self.get_node_baseurl(0) registered_tokens = set( self.session.get( API_URL_TOKENS.format(protocol=self.protocol, target_host=first_node) ).json() ) if self.token.checksum_address not in registered_tokens: for _ in range(5): code, msg = self.register_token(self.token.checksum_address, first_node) if 199 < code < 300: break gevent.sleep(1) else: log.error("Couldn't register token with network", code=code, message=msg) raise TokenRegistrationError(msg) last_node = self.node_controller[-1].base_url self.token_network_address = self.wait_for_token_network_discovery(last_node) log.info( "Received token network address", token_network_address=self.token_network_address ) # Start root task root_task_greenlet = gevent.spawn(self.root_task) greenlets = {root_task_greenlet} greenlets.add(self.node_controller.start_node_monitor()) try: gevent.joinall(greenlets, raise_error=True) except BaseException: if not root_task_greenlet.dead: # Make sure we kill the tasks if a node dies root_task_greenlet.kill() raise def _initialize_scenario_token( self, node_addresses: Set[ChecksumAddress], udc_ctr: Optional[ContractProxy], should_deposit_ud_token: bool, gas_limit: int, ) -> Set[TransactionHash]: """Deploy new token to the blockchain, or load an existing one's data from disk.""" self.token.init() mint_tx = set() for address in node_addresses: tx = self.token.mint(address) if tx: mint_tx.add(tx) if not should_deposit_ud_token: continue deposit_tx = self.udc.deposit(address) if deposit_tx: mint_tx.add(deposit_tx) return mint_tx def _initialize_udc( self, gas_limit: int, node_count: int ) -> Tuple[Set[TransactionHash], Optional[ContractProxy], bool]: our_address = to_checksum_address(self.client.address) udc_settings = self.yaml.settings.services.udc udc_enabled = udc_settings.enable ud_token_tx = set() if not udc_enabled: return ud_token_tx, None, False udc_ctr, ud_token_ctr = get_udc_and_token(self) ud_token_address = to_checksum_address(ud_token_ctr.contract_address) udc_address = to_checksum_address(udc_ctr.contract_address) log.info("UDC enabled", contract_address=udc_address, token_address=ud_token_address) self.udc = UserDepositContract(self, udc_ctr, ud_token_ctr) should_deposit_ud_token = udc_enabled and udc_settings.token["deposit"] allowance_tx = self.udc.update_allowance() if allowance_tx: ud_token_tx.add(allowance_tx) if should_deposit_ud_token: tx = self.udc.mint(our_address) if tx: ud_token_tx.add(tx) return ud_token_tx, udc_ctr, should_deposit_ud_token def _initialize_nodes( self ) -> Tuple[Set[TransactionHash], gevent.Greenlet, Set[ChecksumAddress], int]: fund_tx = set() self.node_controller.initialize_nodes() node_addresses = self.node_controller.addresses node_count = len(self.node_controller) balance_per_node = {address: self.client.balance(address) for address in node_addresses} low_balances = { address: balance for address, balance in balance_per_node.items() if balance < NODE_ACCOUNT_BALANCE_MIN } if low_balances: log.info("Funding nodes", nodes=low_balances.keys()) fund_tx = set() for address, balance in low_balances.items(): params = { "client_id": self.yaml.spaas.rpc.client_id, "to": address, "value": NODE_ACCOUNT_BALANCE_FUND - balance, "startgas": 21_000, } resp = self.service_session.post("spaas://rpc/transactions", json=params) tx_hash = resp.json()["tx_hash"] fund_tx.add(tx_hash) node_starter = self.node_controller.start(wait=False) return fund_tx, node_starter, node_addresses, node_count def task_state_changed(self, task: "Task", state: "TaskState"): if self.task_state_callback: self.task_state_callback(self, task, state) def register_token(self, token_address, node): # TODO: Move this to :class:`scenario_player.utils.token.Token`. try: base_url = API_URL_TOKENS.format(protocol=self.protocol, target_host=node) url = "{}/{}".format(base_url, token_address) log.info("Registering token with network", url=url) resp = self.session.put(url) code = resp.status_code msg = resp.text except RequestException as ex: code = -1 msg = str(ex) return code, msg @staticmethod def _spawn_and_wait(objects, callback): tasks = {obj: gevent.spawn(callback, obj) for obj in objects} gevent.joinall(set(tasks.values())) return {obj: task.get() for obj, task in tasks.items()} def get_node_address(self, index): return self.node_controller[index].address def get_node_baseurl(self, index): return self.node_controller[index].base_url
class ScenarioRunner: def __init__( self, account: Account, auth: str, data_path: Path, scenario_file: Path, environment: EnvironmentConfig, success: Event, raiden_client: Optional[str], task_state_callback: Optional[ Callable[["ScenarioRunner", "Task", "TaskState"], None] ] = None, smoketest_deployment_data: DeployedContracts = None, delete_snapshots: bool = False, ) -> None: self.success = success self.smoketest_deployment_data = smoketest_deployment_data self.delete_snapshots = delete_snapshots self.data_path = data_path self.environment = environment # Set the client executable to use if raiden_client is not None: self.environment.raiden_client = raiden_client self.task_count = 0 self.running_task_count = 0 self.task_cache: Dict[str, Task] = {} self.task_state_callback = task_state_callback # Storage for arbitrary data tasks might need to persist self.task_storage: Dict[str, dict] = defaultdict(dict) self.definition = ScenarioDefinition(scenario_file, data_path, self.environment) log.debug("Local seed", seed=self.local_seed) self.run_number = determine_run_number(self.definition.scenario_dir) log.info("Run number", run_number=self.run_number) self.protocol = "http" self.session = make_session(auth, self.definition.settings, self.definition.nodes) web3 = Web3(HTTPProvider(environment.eth_rpc_endpoints[0], session=self.session)) self.chain_id = ChainID(web3.eth.chainId) self.definition.settings.eth_rpc_endpoint_iterator = environment.eth_rpc_endpoint_iterator self.definition.settings.chain_id = self.chain_id assert account.privkey, "Account not unlockable" self.client = JSONRPCClient( web3=web3, privkey=account.privkey, gas_price_strategy=faster_gas_price_strategy, block_num_confirmations=DEFAULT_NUMBER_OF_BLOCK_CONFIRMATIONS, ) assert account.address, "Account not loaded" balance = self.client.balance(account.address) if balance < OWN_ACCOUNT_BALANCE_MIN: raise ScenarioError( f"Insufficient balance ({balance / 10 ** 18} Eth) " f"in account {to_checksum_address(account.address)} " f'on chain "{self.definition.settings.chain_id}"' f" - it needs additional {(OWN_ACCOUNT_BALANCE_MIN - balance) / 10 ** 18} Eth (" f"that is {OWN_ACCOUNT_BALANCE_MIN - balance} Wei)." ) self.node_controller = NodeController( runner=self, config=self.definition.nodes, raiden_client=self.environment.raiden_client, delete_snapshots=self.delete_snapshots, ) task_config = self.definition.scenario.root_config task_class = self.definition.scenario.root_class self.root_task = task_class(runner=self, config=task_config) @property def local_seed(self) -> str: """Return a persistent random seed value. We need a unique seed per scenario player 'installation'. This is used in the node private key generation to prevent re-use of node keys between multiple users of the scenario player. The seed is stored in a file inside the ``.definition.settings.sp_root_dir``. """ assert self.definition.settings.sp_root_dir seed_file = self.definition.settings.sp_root_dir.joinpath("seed.txt") if not seed_file.exists(): seed = str(encode_hex(bytes(random.randint(0, 255) for _ in range(20)))) seed_file.write_text(seed) else: seed = seed_file.read_text().strip() return seed def ensure_token_network_discovery( self, token: CustomToken, token_network_addresses: TokenNetworkAddress ) -> None: """Ensure that all our nodes have discovered the same token network.""" for node in self.node_controller: # type: ignore node_endpoint = API_URL_TOKEN_NETWORK_ADDRESS.format( protocol=self.protocol, target_host=node.base_url, token_address=to_checksum_address(token.address), ) address = wait_for_token_network_discovery( node_endpoint, self.definition.settings, self.session ) if to_canonical_address(address) != Address(token_network_addresses): raise RuntimeError( f"Nodes diverged on the token network address, there should be " f"exactly one token network available for all nodes. Current " f"values : {to_hex(token_network_addresses)}" ) def run_scenario(self) -> None: with Janitor() as nursery: self.node_controller.set_nursery(nursery) self.node_controller.initialize_nodes() try: for node_runner in self.node_controller._node_runners: node_runner.start() except Exception: log.error("failed to start", exc_info=True) raise node_addresses = self.node_controller.addresses scenario = nursery.spawn_under_watch( self.setup_environment_and_run_main_task, node_addresses ) scenario.name = "orchestration" # Wait for either a crash in one of the Raiden nodes or for the # scenario to exit (successfully or not). greenlets = {scenario} gevent.joinall(greenlets, raise_error=True, count=1) self.success.set() def setup_environment_and_run_main_task(self, node_addresses: Set[ChecksumAddress]) -> None: """This will first make sure the on-chain state is setup properly, and then execute the scenario. The on-chain state consists of: - Deployment of the test CustomToken - For each of the Raiden nodes, make sure they have enough: - Ether to pay for the transactions. - Utility token balances in the user deposit smart contract. - Tokens to be used with the test token network. """ block_execution_started = self.client.block_number() settings = self.definition.settings udc_settings = settings.services.udc smoketesting = False if self.chain_id != CHAINNAME_TO_ID["smoketest"]: deploy = get_contracts_deployment_info( self.chain_id, RAIDEN_CONTRACT_VERSION, development_environment=self.environment.development_environment, ) else: smoketesting = True deploy = self.smoketest_deployment_data msg = "There is no deployment details for the given chain_id and contracts version pair" assert deploy, msg proxy_manager = get_proxy_manager(self.client, deploy) # Tracking pool to synchronize on all concurrent transactions pool = Pool() log.debug("Funding Raiden node's accounts with ether") self.setup_raiden_nodes_ether_balances(pool, node_addresses) if is_udc_enabled(udc_settings): ( userdeposit_proxy, user_token_proxy, ) = get_udc_and_corresponding_token_from_dependencies( udc_address=udc_settings.address, chain_id=settings.chain_id, proxy_manager=proxy_manager, development_environment=self.environment.development_environment, ) log.debug("Minting utility tokens and /scheduling/ transfers to the nodes") mint_greenlets = self.setup_mint_user_deposit_tokens_for_distribution( pool, userdeposit_proxy, user_token_proxy, node_addresses ) self.setup_raiden_nodes_with_sufficient_user_deposit_balances( pool, userdeposit_proxy, node_addresses, mint_greenlets ) # This is a blocking call. If the token has to be deployed it will # block until mined and confirmed, since that is a requirement for the # following setup calls. token_proxy = self.setup_token_contract_for_token_network(proxy_manager) if smoketesting: token_network_registry_proxy = get_token_network_registry_from_dependencies( settings=settings, proxy_manager=proxy_manager, smoketest_deployment_data=deploy, development_environment=self.environment.development_environment, ) else: token_network_registry_proxy = get_token_network_registry_from_dependencies( settings=settings, proxy_manager=proxy_manager, development_environment=self.environment.development_environment, ) self.setup_raiden_token_balances(pool, token_proxy, node_addresses) # Wait for all the transactions # - Move ether from the orcheastration account (the scenario player), # to the raiden nodes. # - Mint enough utility tokens (user deposit tokens) for the # orchestration account to transfer for the nodes. # - Mint network tokens for the nodes to use in the scenarion. # - Deposit utility tokens for the raiden nodes in the user deposit # contract. log.debug("Waiting for funding transactions to be mined") pool.join(raise_error=True) log.debug("Registering token to create the network") token_network_address = maybe_create_token_network( token_network_registry_proxy, token_proxy ) log.debug("Waiting for the REST APIs") wait_for_nodes_to_be_ready(self.node_controller._node_runners, self.session) log.info("Making sure all nodes have the same token network") self.ensure_token_network_discovery(token_proxy, token_network_address) log.info( "Setup done, running scenario", token_network_address=to_checksum_address(token_network_address), ) # Expose attributes used by the tasks self.token = token_proxy self.contract_manager = proxy_manager.contract_manager self.token_network_address = to_checksum_address(token_network_address) self.block_execution_started = block_execution_started self.root_task() def setup_raiden_nodes_ether_balances( self, pool: Pool, node_addresses: Set[ChecksumAddress] ) -> Set[Greenlet]: """ Makes sure every Raiden node has at least `NODE_ACCOUNT_BALANCE_MIN`. """ greenlets: Set[Greenlet] = set() for address in node_addresses: g = pool.spawn( eth_maybe_transfer, orchestration_client=self.client, target=to_canonical_address(address), minimum_balance=NODE_ACCOUNT_BALANCE_MIN, maximum_balance=NODE_ACCOUNT_BALANCE_FUND, ) greenlets.add(g) return greenlets def setup_mint_user_deposit_tokens_for_distribution( self, pool: Pool, userdeposit_proxy: UserDeposit, token_proxy: CustomToken, node_addresses: Set[ChecksumAddress], ) -> Set[Greenlet]: """Ensures the scenario player account has enough tokens and allowance to fund the Raiden nodes. """ settings = self.definition.settings udc_settings = settings.services.udc balance_per_node = settings.services.udc.token.balance_per_node msg = "udc is not enabled, this function should not be called" assert is_udc_enabled(udc_settings), msg node_count = len(node_addresses) required_allowance = balance_per_node * node_count allowance_greenlet = pool.spawn( userdeposit_maybe_increase_allowance, token_proxy=token_proxy, userdeposit_proxy=userdeposit_proxy, orchestrator_address=self.client.address, minimum_allowance=required_allowance, maximum_allowance=UINT256_MAX, ) mint_greenlet = pool.spawn( token_maybe_mint, token_proxy=token_proxy, target_address=to_checksum_address(self.client.address), minimum_balance=required_allowance, maximum_balance=ORCHESTRATION_MAXIMUM_BALANCE, ) return {allowance_greenlet, mint_greenlet} def setup_raiden_token_balances( self, pool: Pool, token_proxy: CustomToken, node_addresses: Set[ChecksumAddress] ) -> Set[Greenlet]: """Mint the necessary amount of tokens from `token_proxy` for every `node_addresses`. This will use the scenario player's account, therefore it doesn't have to wait for the ether transfers to finish. """ token_min_amount = self.definition.token.min_balance token_max_amount = self.definition.token.max_funding greenlets: Set[Greenlet] = set() for address in node_addresses: g = pool.spawn( token_maybe_mint, token_proxy=token_proxy, target_address=address, minimum_balance=token_min_amount, maximum_balance=token_max_amount, ) greenlets.add(g) return greenlets def setup_raiden_nodes_with_sufficient_user_deposit_balances( self, pool: Pool, userdeposit_proxy: UserDeposit, node_addresses: Set[ChecksumAddress], mint_greenlets: Set[Greenlet], ) -> Set[Greenlet]: """Makes sure every Raiden node's account has enough tokens in the user deposit contract. For these transfers to work, the approve and mint transacations have to be mined and confirmed. This is necessary because otherwise the gas estimation of the deposits fail. """ msg = "udc is not enabled, this function should not be called" assert is_udc_enabled(self.definition.settings.services.udc), msg minimum_effective_deposit = self.definition.settings.services.udc.token.balance_per_node maximum_funding = self.definition.settings.services.udc.token.max_funding log.debug("Depositing utility tokens for the nodes") greenlets: Set[Greenlet] = set() for address in node_addresses: g = pool.spawn( userdeposit_maybe_deposit, userdeposit_proxy=userdeposit_proxy, mint_greenlets=mint_greenlets, target_address=to_canonical_address(address), minimum_effective_deposit=minimum_effective_deposit, maximum_funding=maximum_funding, ) greenlets.add(g) return greenlets def setup_token_contract_for_token_network(self, proxy_manager: ProxyManager) -> CustomToken: """Ensure there is a deployed token contract and return a `CustomToken` proxy to it. This token will be used for the scenario's token network. This will either: - Use the token from the address provided in the scenario configuration. - Use a previously deployed token, with the details loaded from the disk. - Deploy a new token if neither of the above options is used. """ token_definition = self.definition.token reuse_token_from_file = token_definition.can_reuse_token if token_definition.address: token_address = to_canonical_address(token_definition.address) elif reuse_token_from_file: token_details = load_token_configuration_from_file(token_definition.token_file) token_address = to_canonical_address(token_details["address"]) else: contract_data = proxy_manager.contract_manager.get_contract(CONTRACT_CUSTOM_TOKEN) contract, receipt = self.client.deploy_single_contract( contract_name=CONTRACT_CUSTOM_TOKEN, contract=contract_data, constructor_parameters=( ORCHESTRATION_MAXIMUM_BALANCE, token_definition.decimals, token_definition.name, token_definition.symbol, ), ) token_address = to_canonical_address(contract.address) if token_definition.should_reuse_token: details = TokenDetails( { "name": token_definition.name, "address": to_checksum_address(token_address), "block": receipt["blockNumber"], } ) save_token_configuration_to_file(token_definition.token_file, details) return proxy_manager.custom_token(TokenAddress(token_address), "latest") def task_state_changed(self, task: "Task", state: "TaskState"): if self.task_state_callback: self.task_state_callback(self, task, state) def get_node_address(self, index): return self.node_controller[index].address def get_node_baseurl(self, index): return self.node_controller[index].base_url