def __init__(self, *, framer=None, loop=None): self.framer = framer or self.default_framer() self.loop = loop or asyncio.get_event_loop() self.logger = logging.getLogger(self.__class__.__name__) self.transport = None # Set when a connection is made self._address = None self._proxy_address = None # For logger.debug messages self.verbosity = 0 # Cleared when the send socket is full self._can_send = Event() self._can_send.set() self._pm_task = None self._task_group = TaskGroup(self.loop) # Force-close a connection if a send doesn't succeed in this time self.max_send_delay = 60 # Statistics. The RPC object also keeps its own statistics. self.start_time = time.perf_counter() self.errors = 0 self.send_count = 0 self.send_size = 0 self.last_send = self.start_time self.recv_count = 0 self.recv_size = 0 self.last_recv = self.start_time # Bandwidth usage per hour before throttling starts self.bw_limit = 2000000 self.bw_time = self.start_time self.bw_charge = 0 # Concurrency control self.max_concurrent = 6 self._concurrency = Concurrency(self.max_concurrent)
def __init__(self, config=None): self.config = config or {} self.db: BaseDatabase = self.config.get('db') or self.database_class( os.path.join(self.path, "blockchain.db") ) self.db.ledger = self self.headers: BaseHeaders = self.config.get('headers') or self.headers_class( os.path.join(self.path, "headers") ) self.network = self.config.get('network') or self.network_class(self) self.network.on_header.listen(self.receive_header) self.network.on_status.listen(self.process_status_update) self.accounts = [] self.fee_per_byte: int = self.config.get('fee_per_byte', self.default_fee_per_byte) self._on_transaction_controller = StreamController() self.on_transaction = self._on_transaction_controller.stream self.on_transaction.listen( lambda e: log.info( '(%s) on_transaction: address=%s, height=%s, is_verified=%s, tx.id=%s', self.get_id(), e.address, e.tx.height, e.tx.is_verified, e.tx.id ) ) self._on_address_controller = StreamController() self.on_address = self._on_address_controller.stream self.on_address.listen( lambda e: log.info('(%s) on_address: %s', self.get_id(), e.addresses) ) self._on_header_controller = StreamController() self.on_header = self._on_header_controller.stream self.on_header.listen( lambda change: log.info( '%s: added %s header blocks, final height %s', self.get_id(), change, self.headers.height ) ) self._on_ready_controller = StreamController() self.on_ready = self._on_ready_controller.stream self._tx_cache = pylru.lrucache(100000) self._update_tasks = TaskGroup() self._utxo_reservation_lock = asyncio.Lock() self._header_processing_lock = asyncio.Lock() self._address_update_locks: Dict[str, asyncio.Lock] = {} self.coin_selection_strategy = None self._known_addresses_out_of_sync = set()
def __init__(self, env, db): self.logger = class_logger(__name__, self.__class__.__name__) # Initialise the Peer class Peer.DEFAULT_PORTS = env.coin.PEER_DEFAULT_PORTS self.env = env self.db = db # Our clearnet and Tor Peers, if any sclass = env.coin.SESSIONCLS self.myselves = [ Peer(ident.host, sclass.server_features(env), 'env') for ident in env.identities ] self.server_version_args = sclass.server_version_args() # Peers have one entry per hostname. Once connected, the # ip_addr property is either None, an onion peer, or the # IP address that was connected to. Adding a peer will evict # any other peers with the same host name or IP address. self.peers: typing.Set[Peer] = set() self.permit_onion_peer_time = time.time() self.proxy = None self.group = TaskGroup()
class BaseLedger(metaclass=LedgerRegistry): name: str symbol: str network_name: str database_class = BaseDatabase account_class = baseaccount.BaseAccount network_class = basenetwork.BaseNetwork transaction_class = basetransaction.BaseTransaction headers_class: Type[BaseHeaders] pubkey_address_prefix: bytes script_address_prefix: bytes extended_public_key_prefix: bytes extended_private_key_prefix: bytes default_fee_per_byte = 10 def __init__(self, config=None): self.config = config or {} self.db: BaseDatabase = self.config.get('db') or self.database_class( os.path.join(self.path, "blockchain.db")) self.db.ledger = self self.headers: BaseHeaders = self.config.get( 'headers') or self.headers_class(os.path.join( self.path, "headers")) self.network = self.config.get('network') or self.network_class(self) self.network.on_header.listen(self.receive_header) self.network.on_status.listen(self.process_status_update) self.accounts = [] self.fee_per_byte: int = self.config.get('fee_per_byte', self.default_fee_per_byte) self._on_transaction_controller = StreamController() self.on_transaction = self._on_transaction_controller.stream self.on_transaction.listen(lambda e: log.info( '(%s) on_transaction: address=%s, height=%s, is_verified=%s, tx.id=%s', self.get_id(), e.address, e.tx.height, e.tx.is_verified, e.tx.id)) self._on_address_controller = StreamController() self.on_address = self._on_address_controller.stream self.on_address.listen(lambda e: log.info('(%s) on_address: %s', self.get_id(), e.addresses)) self._on_header_controller = StreamController() self.on_header = self._on_header_controller.stream self.on_header.listen(lambda change: log.info( '%s: added %s header blocks, final height %s', self.get_id(), change, self.headers.height)) self._tx_cache = pylru.lrucache(100000) self._update_tasks = TaskGroup() self._utxo_reservation_lock = asyncio.Lock() self._header_processing_lock = asyncio.Lock() self._address_update_locks: Dict[str, asyncio.Lock] = {} self.coin_selection_strategy = None self._known_addresses_out_of_sync = set() @classmethod def get_id(cls): return '{}_{}'.format(cls.symbol.lower(), cls.network_name.lower()) @classmethod def hash160_to_address(cls, h160): raw_address = cls.pubkey_address_prefix + h160 return Base58.encode( bytearray(raw_address + double_sha256(raw_address)[0:4])) @staticmethod def address_to_hash160(address): return Base58.decode(address)[1:21] @classmethod def is_valid_address(cls, address): decoded = Base58.decode_check(address) return decoded[0] == cls.pubkey_address_prefix[0] @classmethod def public_key_to_address(cls, public_key): return cls.hash160_to_address(hash160(public_key)) @staticmethod def private_key_to_wif(private_key): return b'\x1c' + private_key + b'\x01' @property def path(self): return os.path.join(self.config['data_path'], self.get_id()) def add_account(self, account: baseaccount.BaseAccount): self.accounts.append(account) async def _get_account_and_address_info_for_address(self, address): match = await self.db.get_address(address=address) if match: for account in self.accounts: if match['account'] == account.public_key.address: return account, match async def get_private_key_for_address(self, address) -> Optional[PrivateKey]: match = await self._get_account_and_address_info_for_address(address) if match: account, address_info = match return account.get_private_key(address_info['chain'], address_info['position']) return None async def get_public_key_for_address(self, address) -> Optional[PubKey]: match = await self._get_account_and_address_info_for_address(address) if match: account, address_info = match return account.get_public_key(address_info['chain'], address_info['position']) return None async def get_account_for_address(self, address): match = await self._get_account_and_address_info_for_address(address) if match: return match[0] async def get_effective_amount_estimators( self, funding_accounts: Iterable[baseaccount.BaseAccount]): estimators = [] for account in funding_accounts: utxos = await account.get_utxos() for utxo in utxos: estimators.append(utxo.get_estimator(self)) return estimators async def get_addresses(self, **constraints): self.constraint_account_or_all(constraints) addresses = await self.db.get_addresses(**constraints) for address in addresses: public_key = await self.get_public_key_for_address( address['address']) address['public_key'] = public_key.extended_key_string() return addresses def get_address_count(self, **constraints): self.constraint_account_or_all(constraints) return self.db.get_address_count(**constraints) async def get_spendable_utxos(self, amount: int, funding_accounts): async with self._utxo_reservation_lock: txos = await self.get_effective_amount_estimators(funding_accounts) fee = self.transaction_class.output_class.pay_pubkey_hash( COIN, NULL_HASH32).get_fee(self) selector = CoinSelector(amount, fee) spendables = selector.select(txos, self.coin_selection_strategy) if spendables: await self.reserve_outputs(s.txo for s in spendables) return spendables def reserve_outputs(self, txos): return self.db.reserve_outputs(txos) def release_outputs(self, txos): return self.db.release_outputs(txos) def release_tx(self, tx): return self.release_outputs([txi.txo_ref.txo for txi in tx.inputs]) def constraint_account_or_all(self, constraints): if 'accounts' in constraints: return account = constraints.pop('account', None) if account: constraints['accounts'] = [account] else: constraints['accounts'] = self.accounts def get_utxos(self, **constraints): self.constraint_account_or_all(constraints) return self.db.get_utxos(**constraints) def get_utxo_count(self, **constraints): self.constraint_account_or_all(constraints) return self.db.get_utxo_count(**constraints) def get_transactions(self, **constraints): self.constraint_account_or_all(constraints) return self.db.get_transactions(**constraints) def get_transaction_count(self, **constraints): self.constraint_account_or_all(constraints) return self.db.get_transaction_count(**constraints) async def get_local_status_and_history(self, address, history=None): if not history: address_details = await self.db.get_address(address=address) history = address_details['history'] or '' parts = history.split(':')[:-1] return (hexlify(sha256(history.encode())).decode() if history else None, list(zip(parts[0::2], map(int, parts[1::2])))) @staticmethod def get_root_of_merkle_tree(branches, branch_positions, working_branch): for i, branch in enumerate(branches): other_branch = unhexlify(branch)[::-1] other_branch_on_left = bool((branch_positions >> i) & 1) if other_branch_on_left: combined = other_branch + working_branch else: combined = working_branch + other_branch working_branch = double_sha256(combined) return hexlify(working_branch[::-1]) async def start(self): if not os.path.exists(self.path): os.mkdir(self.path) await asyncio.wait([self.db.open(), self.headers.open()]) first_connection = self.network.on_connected.first asyncio.ensure_future(self.network.start()) await first_connection await self.join_network() self.network.on_connected.listen(self.join_network) async def join_network(self, *_): log.info("Subscribing and updating accounts.") async with self._header_processing_lock: await self.update_headers() await self.subscribe_accounts() await self._update_tasks.done.wait() async def stop(self): self._update_tasks.cancel() await self._update_tasks.done.wait() await self.network.stop() await self.db.close() await self.headers.close() async def update_headers(self, height=None, headers=None, subscription_update=False): rewound = 0 while True: if height is None or height > len(self.headers): # sometimes header subscription updates are for a header in the future # which can't be connected, so we do a normal header sync instead height = len(self.headers) headers = None subscription_update = False if not headers: header_response = await self.network.retriable_call( self.network.get_headers, height, 2001) headers = header_response['hex'] if not headers: # Nothing to do, network thinks we're already at the latest height. return added = await self.headers.connect(height, unhexlify(headers)) if added > 0: height += added self._on_header_controller.add( BlockHeightEvent(self.headers.height, added)) if rewound > 0: # we started rewinding blocks and apparently found # a new chain rewound = 0 await self.db.rewind_blockchain(height) if subscription_update: # subscription updates are for latest header already # so we don't need to check if there are newer / more # on another loop of update_headers(), just return instead return elif added == 0: # we had headers to connect but none got connected, probably a reorganization height -= 1 rewound += 1 log.warning( "Blockchain Reorganization: attempting rewind to height %s from starting height %s", height, height + rewound) else: raise IndexError( "headers.connect() returned negative number ({})".format( added)) if height < 0: raise IndexError( "Blockchain reorganization rewound all the way back to genesis hash. " "Something is very wrong. Maybe you are on the wrong blockchain?" ) if rewound >= 100: raise IndexError( "Blockchain reorganization dropped {} headers. This is highly unusual. " "Will not continue to attempt reorganizing. Please, delete the ledger " "synchronization directory inside your wallet directory (folder: '{}') and " "restart the program to synchronize from scratch.".format( rewound, self.get_id())) headers = None # ready to download some more headers # if we made it this far and this was a subscription_update # it means something went wrong and now we're doing a more # robust sync, turn off subscription update shortcut subscription_update = False async def receive_header(self, response): async with self._header_processing_lock: header = response[0] await self.update_headers(height=header['height'], headers=header['hex'], subscription_update=True) async def subscribe_accounts(self): if self.network.is_connected and self.accounts: await asyncio.wait( [self.subscribe_account(a) for a in self.accounts]) async def subscribe_account(self, account: baseaccount.BaseAccount): for address_manager in account.address_managers.values(): await self.subscribe_addresses( address_manager, await address_manager.get_addresses()) await account.ensure_address_gap() async def announce_addresses(self, address_manager: baseaccount.AddressManager, addresses: List[str]): await self.subscribe_addresses(address_manager, addresses) await self._on_address_controller.add( AddressesGeneratedEvent(address_manager, addresses)) async def subscribe_addresses(self, address_manager: baseaccount.AddressManager, addresses: List[str]): if self.network.is_connected and addresses: await asyncio.wait([ self.subscribe_address(address_manager, address) for address in addresses ]) async def subscribe_address(self, address_manager: baseaccount.AddressManager, address: str): remote_status = await self.network.subscribe_address(address) self._update_tasks.add( self.update_history(address, remote_status, address_manager)) def process_status_update(self, update): address, remote_status = update self._update_tasks.add(self.update_history(address, remote_status)) async def update_history( self, address, remote_status, address_manager: baseaccount.AddressManager = None): async with self._address_update_locks.setdefault( address, asyncio.Lock()): self._known_addresses_out_of_sync.discard(address) local_status, local_history = await self.get_local_status_and_history( address) if local_status == remote_status: return True remote_history = await self.network.retriable_call( self.network.get_history, address) remote_history = list( map(itemgetter('tx_hash', 'height'), remote_history)) we_need = set(remote_history) - set(local_history) if not we_need: return True cache_tasks: List[asyncio.Future[BaseTransaction]] = [] synced_history = StringIO() for i, (txid, remote_height) in enumerate(remote_history): if i < len(local_history) and local_history[i] == ( txid, remote_height) and not cache_tasks: synced_history.write(f'{txid}:{remote_height}:') else: check_local = (txid, remote_height) not in we_need cache_tasks.append( asyncio.ensure_future( self.cache_transaction(txid, remote_height, check_local=check_local))) synced_txs = [] for task in cache_tasks: tx = await task check_db_for_txos = [] for txi in tx.inputs: if txi.txo_ref.txo is not None: continue cache_item = self._tx_cache.get(txi.txo_ref.tx_ref.id) if cache_item is not None: if cache_item.tx is None: await cache_item.has_tx.wait() assert cache_item.tx is not None txi.txo_ref = cache_item.tx.outputs[ txi.txo_ref.position].ref else: check_db_for_txos.append(txi.txo_ref.id) referenced_txos = {} if not check_db_for_txos else { txo.id: txo for txo in await self.db.get_txos( txoid__in=check_db_for_txos, no_tx=True) } for txi in tx.inputs: if txi.txo_ref.txo is not None: continue referenced_txo = referenced_txos.get(txi.txo_ref.id) if referenced_txo is not None: txi.txo_ref = referenced_txo.ref synced_history.write(f'{tx.id}:{tx.height}:') synced_txs.append(tx) await self.db.save_transaction_io_batch( synced_txs, address, self.address_to_hash160(address), synced_history.getvalue()) await asyncio.wait([ self._on_transaction_controller.add( TransactionEvent(address, tx)) for tx in synced_txs ]) if address_manager is None: address_manager = await self.get_address_manager_for_address( address) if address_manager is not None: await address_manager.ensure_address_gap() local_status, local_history = \ await self.get_local_status_and_history(address, synced_history.getvalue()) if local_status != remote_status: if local_history == remote_history: return True log.warning( "Wallet is out of sync after syncing. Remote: %s with %d items, local: %s with %d items", remote_status, len(remote_history), local_status, len(local_history)) log.warning("local: %s", local_history) log.warning("remote: %s", remote_history) self._known_addresses_out_of_sync.add(address) return False else: return True async def cache_transaction(self, txid, remote_height, check_local=True): cache_item = self._tx_cache.get(txid) if cache_item is None: cache_item = self._tx_cache[txid] = TransactionCacheItem() elif cache_item.tx is not None and \ cache_item.tx.height >= remote_height and \ (cache_item.tx.is_verified or remote_height < 1): return cache_item.tx # cached tx is already up-to-date async with cache_item.lock: tx = cache_item.tx if tx is None and check_local: # check local db tx = cache_item.tx = await self.db.get_transaction(txid=txid) if tx is None: # fetch from network _raw = await self.network.retriable_call( self.network.get_transaction, txid, remote_height) if _raw: tx = self.transaction_class(unhexlify(_raw)) cache_item.tx = tx # make sure it's saved before caching it if tx is None: raise ValueError( f'Transaction {txid} was not in database and not on network.' ) await self.maybe_verify_transaction(tx, remote_height) return tx async def maybe_verify_transaction(self, tx, remote_height): tx.height = remote_height if 0 < remote_height < len(self.headers): merkle = await self.network.retriable_call(self.network.get_merkle, tx.id, remote_height) merkle_root = self.get_root_of_merkle_tree(merkle['merkle'], merkle['pos'], tx.hash) header = self.headers[remote_height] tx.position = merkle['pos'] tx.is_verified = merkle_root == header['merkle_root'] async def get_address_manager_for_address( self, address) -> Optional[baseaccount.AddressManager]: details = await self.db.get_address(address=address) for account in self.accounts: if account.id == details['account']: return account.address_managers[details['chain']] return None def broadcast(self, tx): # broadcast cant be a retriable call yet return self.network.broadcast(hexlify(tx.raw).decode()) async def wait(self, tx: basetransaction.BaseTransaction, height=-1, timeout=None): addresses = set() for txi in tx.inputs: if txi.txo_ref.txo is not None: addresses.add( self.hash160_to_address( txi.txo_ref.txo.script.values['pubkey_hash'])) for txo in tx.outputs: addresses.add( self.hash160_to_address(txo.script.values['pubkey_hash'])) records = await self.db.get_addresses(cols=('address', ), address__in=addresses) _, pending = await asyncio.wait([ self.on_transaction.where( partial( lambda a, e: a == e.address and e.tx.height >= height and e .tx.id == tx.id, address_record['address'])) for address_record in records ], timeout=timeout) if pending: raise asyncio.TimeoutError('Timed out waiting for transaction.')
async def test_cancel_sets_it_done(self): group = TaskGroup() group.cancel() self.assertTrue(group.done.is_set())
class SessionBase(asyncio.Protocol): """Base class of networking sessions. There is no client / server distinction other than who initiated the connection. To initiate a connection to a remote server pass host, port and proxy to the constructor, and then call create_connection(). Each successful call should have a corresponding call to close(). Alternatively if used in a with statement, the connection is made on entry to the block, and closed on exit from the block. """ max_errors = 10 def __init__(self, *, framer=None, loop=None): self.framer = framer or self.default_framer() self.loop = loop or asyncio.get_event_loop() self.logger = logging.getLogger(self.__class__.__name__) self.transport = None # Set when a connection is made self._address = None self._proxy_address = None # For logger.debug messages self.verbosity = 0 # Cleared when the send socket is full self._can_send = Event() self._can_send.set() self._pm_task = None self._task_group = TaskGroup(self.loop) # Force-close a connection if a send doesn't succeed in this time self.max_send_delay = 60 # Statistics. The RPC object also keeps its own statistics. self.start_time = time.perf_counter() self.errors = 0 self.send_count = 0 self.send_size = 0 self.last_send = self.start_time self.recv_count = 0 self.recv_size = 0 self.last_recv = self.start_time # Bandwidth usage per hour before throttling starts self.bw_limit = 2000000 self.bw_time = self.start_time self.bw_charge = 0 # Concurrency control self.max_concurrent = 6 self._concurrency = Concurrency(self.max_concurrent) async def _update_concurrency(self): # A non-positive value means not to limit concurrency if self.bw_limit <= 0: return now = time.perf_counter() # Reduce the recorded usage in proportion to the elapsed time refund = (now - self.bw_time) * (self.bw_limit / 3600) self.bw_charge = max(0, self.bw_charge - int(refund)) self.bw_time = now # Reduce concurrency allocation by 1 for each whole bw_limit used throttle = int(self.bw_charge / self.bw_limit) target = max(1, self.max_concurrent - throttle) current = self._concurrency.max_concurrent if target != current: self.logger.info(f'changing task concurrency from {current} ' f'to {target}') await self._concurrency.set_max_concurrent(target) def _using_bandwidth(self, size): """Called when sending or receiving size bytes.""" self.bw_charge += size async def _limited_wait(self, secs): try: await asyncio.wait_for(self._can_send.wait(), secs) except asyncio.TimeoutError: self.abort() raise asyncio.TimeoutError(f'task timed out after {secs}s') async def _send_message(self, message): if not self._can_send.is_set(): await self._limited_wait(self.max_send_delay) if not self.is_closing(): framed_message = self.framer.frame(message) self.send_size += len(framed_message) self._using_bandwidth(len(framed_message)) self.send_count += 1 self.last_send = time.perf_counter() if self.verbosity >= 4: self.logger.debug(f'Sending framed message {framed_message}') self.transport.write(framed_message) def _bump_errors(self): self.errors += 1 if self.errors >= self.max_errors: # Don't await self.close() because that is self-cancelling self._close() def _close(self): if self.transport: self.transport.close() # asyncio framework def data_received(self, framed_message): """Called by asyncio when a message comes in.""" if self.verbosity >= 4: self.logger.debug(f'Received framed message {framed_message}') self.recv_size += len(framed_message) self._using_bandwidth(len(framed_message)) self.framer.received_bytes(framed_message) def pause_writing(self): """Transport calls when the send buffer is full.""" if not self.is_closing(): self._can_send.clear() self.transport.pause_reading() def resume_writing(self): """Transport calls when the send buffer has room.""" if not self._can_send.is_set(): self._can_send.set() self.transport.resume_reading() def connection_made(self, transport): """Called by asyncio when a connection is established. Derived classes overriding this method must call this first.""" self.transport = transport # This would throw if called on a closed SSL transport. Fixed # in asyncio in Python 3.6.1 and 3.5.4 peer_address = transport.get_extra_info('peername') # If the Socks proxy was used then _address is already set to # the remote address if self._address: self._proxy_address = peer_address else: self._address = peer_address self._pm_task = self.loop.create_task(self._receive_messages()) def connection_lost(self, exc): """Called by asyncio when the connection closes. Tear down things done in connection_made.""" self._address = None self.transport = None self._task_group.cancel() if self._pm_task: self._pm_task.cancel() # Release waiting tasks self._can_send.set() # External API def default_framer(self): """Return a default framer.""" raise NotImplementedError def peer_address(self): """Returns the peer's address (Python networking address), or None if no connection or an error. This is the result of socket.getpeername() when the connection was made. """ return self._address def peer_address_str(self): """Returns the peer's IP address and port as a human-readable string.""" if not self._address: return 'unknown' ip_addr_str, port = self._address[:2] if ':' in ip_addr_str: return f'[{ip_addr_str}]:{port}' else: return f'{ip_addr_str}:{port}' def is_closing(self): """Return True if the connection is closing.""" return not self.transport or self.transport.is_closing() def abort(self): """Forcefully close the connection.""" if self.transport: self.transport.abort() # TODO: replace with synchronous_close async def close(self, *, force_after=30): """Close the connection and return when closed.""" self._close() if self._pm_task: with suppress(CancelledError): await asyncio.wait([self._pm_task], timeout=force_after) self.abort() await self._pm_task def synchronous_close(self): self._close() if self._pm_task and not self._pm_task.done(): self._pm_task.cancel()
class PeerManager: """Looks after the DB of peer network servers. Attempts to maintain a connection with up to 8 peers. Issues a 'peers.subscribe' RPC to them and tells them our data. """ def __init__(self, env, db): self.logger = class_logger(__name__, self.__class__.__name__) # Initialise the Peer class Peer.DEFAULT_PORTS = env.coin.PEER_DEFAULT_PORTS self.env = env self.db = db # Our clearnet and Tor Peers, if any sclass = env.coin.SESSIONCLS self.myselves = [ Peer(ident.host, sclass.server_features(env), 'env') for ident in env.identities ] self.server_version_args = sclass.server_version_args() # Peers have one entry per hostname. Once connected, the # ip_addr property is either None, an onion peer, or the # IP address that was connected to. Adding a peer will evict # any other peers with the same host name or IP address. self.peers: typing.Set[Peer] = set() self.permit_onion_peer_time = time.time() self.proxy = None self.group = TaskGroup() def _my_clearnet_peer(self): """Returns the clearnet peer representing this server, if any.""" clearnet = [peer for peer in self.myselves if not peer.is_tor] return clearnet[0] if clearnet else None def _set_peer_statuses(self): """Set peer statuses.""" cutoff = time.time() - STALE_SECS for peer in self.peers: if peer.bad: peer.status = PEER_BAD elif peer.last_good > cutoff: peer.status = PEER_GOOD elif peer.last_good: peer.status = PEER_STALE else: peer.status = PEER_NEVER def _features_to_register(self, peer, remote_peers): """If we should register ourselves to the remote peer, which has reported the given list of known peers, return the clearnet identity features to register, otherwise None. """ # Announce ourself if not present. Don't if disabled, we # are a non-public IP address, or to ourselves. if not self.env.peer_announce or peer in self.myselves: return None my = self._my_clearnet_peer() if not my or not my.is_public: return None # Register if no matches, or ports have changed for peer in my.matches(remote_peers): if peer.tcp_port == my.tcp_port and peer.ssl_port == my.ssl_port: return None return my.features def _permit_new_onion_peer(self): """Accept a new onion peer only once per random time interval.""" now = time.time() if now < self.permit_onion_peer_time: return False self.permit_onion_peer_time = now + random.randrange(0, 1200) return True async def _import_peers(self): """Import hard-coded peers from a file or the coin defaults.""" imported_peers = self.myselves.copy() # Add the hard-coded ones unless only reporting ourself if self.env.peer_discovery != self.env.PD_SELF: imported_peers.extend( Peer.from_real_name(real_name, 'coins.py') for real_name in self.env.coin.PEERS) await self._note_peers(imported_peers, limit=None) async def _detect_proxy(self): """Detect a proxy if we don't have one and some time has passed since the last attempt. If found self.proxy is set to a SOCKSProxy instance, otherwise None. """ host = self.env.tor_proxy_host if self.env.tor_proxy_port is None: ports = [9050, 9150, 1080] else: ports = [self.env.tor_proxy_port] while True: self.logger.info(f'trying to detect proxy on "{host}" ' f'ports {ports}') proxy = await SOCKSProxy.auto_detect_host(host, ports, None) if proxy: self.proxy = proxy self.logger.info(f'detected {proxy}') return self.logger.info('no proxy detected, will try later') await sleep(900) async def _note_peers(self, peers, limit=2, check_ports=False, source=None): """Add a limited number of peers that are not already present.""" new_peers = [] for peer in peers: if not peer.is_public or (peer.is_tor and not self.proxy): continue matches = peer.matches(self.peers) if not matches: new_peers.append(peer) elif check_ports: for match in matches: if match.check_ports(peer): self.logger.info(f'ports changed for {peer}') match.retry_event.set() if new_peers: source = source or new_peers[0].source if limit: random.shuffle(new_peers) use_peers = new_peers[:limit] else: use_peers = new_peers for peer in use_peers: self.logger.info(f'accepted new peer {peer} from {source}') peer.retry_event = Event() self.peers.add(peer) await self.group.add(self._monitor_peer(peer)) async def _monitor_peer(self, peer): # Stop monitoring if we were dropped (a duplicate peer) while peer in self.peers: if await self._should_drop_peer(peer): self.peers.discard(peer) break # Figure out how long to sleep before retrying. Retry a # good connection when it is about to turn stale, otherwise # exponentially back off retries. if peer.try_count == 0: pause = STALE_SECS - WAKEUP_SECS * 2 else: pause = WAKEUP_SECS * 2**peer.try_count pending, done = await asyncio.wait([peer.retry_event.wait()], timeout=pause) if done: peer.retry_event.clear() async def _should_drop_peer(self, peer): peer.try_count += 1 is_good = False for kind, port in peer.connection_port_pairs(): peer.last_try = time.time() kwargs = {} if kind == 'SSL': kwargs['ssl'] = ssl.SSLContext(ssl.PROTOCOL_TLS) host = self.env.cs_host(for_rpc=False) if isinstance(host, list): host = host[0] if self.env.force_proxy or peer.is_tor: if not self.proxy: return kwargs['proxy'] = self.proxy kwargs['resolve'] = not peer.is_tor elif host: # Use our listening Host/IP for outgoing non-proxy # connections so our peers see the correct source. kwargs['local_addr'] = (host, None) peer_text = f'[{peer}:{port} {kind}]' try: async with Connector(PeerSession, peer.host, port, **kwargs) as session: await asyncio.wait_for(self._verify_peer(session, peer), 120 if peer.is_tor else 30) is_good = True break except BadPeerError as e: self.logger.error(f'{peer_text} marking bad: ({e})') peer.mark_bad() break except RPCError as e: self.logger.error(f'{peer_text} RPC error: {e.message} ' f'({e.code})') except (OSError, SOCKSError, ConnectionError, asyncio.TimeoutError) as e: self.logger.info(f'{peer_text} {e}') if is_good: now = time.time() elapsed = now - peer.last_try self.logger.info(f'{peer_text} verified in {elapsed:.1f}s') peer.try_count = 0 peer.last_good = now peer.source = 'peer' # At most 2 matches if we're a host name, potentially # several if we're an IP address (several instances # can share a NAT). matches = peer.matches(self.peers) for match in matches: if match.ip_address: if len(matches) > 1: self.peers.remove(match) # Force the peer's monitoring task to exit match.retry_event.set() elif peer.host in match.features['hosts']: match.update_features_from_peer(peer) else: # Forget the peer if long-term unreachable if peer.last_good and not peer.bad: try_limit = 10 else: try_limit = 3 if peer.try_count >= try_limit: desc = 'bad' if peer.bad else 'unreachable' self.logger.info(f'forgetting {desc} peer: {peer}') return True return False async def _verify_peer(self, session, peer): if not peer.is_tor: address = session.peer_address() if address: peer.ip_addr = address[0] # server.version goes first message = 'server.version' result = await session.send_request(message, self.server_version_args) assert_good(message, result, list) # Protocol version 1.1 returns a pair with the version first if len(result) != 2 or not all(isinstance(x, str) for x in result): raise BadPeerError(f'bad server.version result: {result}') server_version, protocol_version = result peer.server_version = server_version peer.features['server_version'] = server_version ptuple = protocol_tuple(protocol_version) await asyncio.wait([ self._send_headers_subscribe(session, peer, ptuple), self._send_server_features(session, peer), self._send_peers_subscribe(session, peer) ]) async def _send_headers_subscribe(self, session, peer, ptuple): message = 'blockchain.headers.subscribe' result = await session.send_request(message) assert_good(message, result, dict) our_height = self.db.db_height if ptuple < (1, 3): their_height = result.get('block_height') else: their_height = result.get('height') if not isinstance(their_height, int): raise BadPeerError(f'invalid height {their_height}') if abs(our_height - their_height) > 5: raise BadPeerError(f'bad height {their_height:,d} ' f'(ours: {our_height:,d})') # Check prior header too in case of hard fork. check_height = min(our_height, their_height) raw_header = await self.db.raw_header(check_height) if ptuple >= (1, 4): ours = raw_header.hex() message = 'blockchain.block.header' theirs = await session.send_request(message, [check_height]) assert_good(message, theirs, str) if ours != theirs: raise BadPeerError(f'our header {ours} and ' f'theirs {theirs} differ') else: ours = self.env.coin.electrum_header(raw_header, check_height) ours = ours.get('prev_block_hash') message = 'blockchain.block.get_header' theirs = await session.send_request(message, [check_height]) assert_good(message, theirs, dict) theirs = theirs.get('prev_block_hash') if ours != theirs: raise BadPeerError(f'our header hash {ours} and ' f'theirs {theirs} differ') async def _send_server_features(self, session, peer): message = 'server.features' features = await session.send_request(message) assert_good(message, features, dict) hosts = [host.lower() for host in features.get('hosts', {})] if self.env.coin.GENESIS_HASH != features.get('genesis_hash'): raise BadPeerError('incorrect genesis hash') elif peer.host.lower() in hosts: peer.update_features(features) else: raise BadPeerError(f'not listed in own hosts list {hosts}') async def _send_peers_subscribe(self, session, peer): message = 'server.peers.subscribe' raw_peers = await session.send_request(message) assert_good(message, raw_peers, list) # Check the peers list we got from a remote peer. # Each is expected to be of the form: # [ip_addr, hostname, ['v1.0', 't51001', 's51002']] # Call add_peer if the remote doesn't appear to know about us. try: real_names = [' '.join([u[1]] + u[2]) for u in raw_peers] peers = [ Peer.from_real_name(real_name, str(peer)) for real_name in real_names ] except Exception: raise BadPeerError('bad server.peers.subscribe response') await self._note_peers(peers) features = self._features_to_register(peer, peers) if not features: return self.logger.info(f'registering ourself with {peer}') # We only care to wait for the response await session.send_request('server.add_peer', [features]) # # External interface # async def discover_peers(self): """Perform peer maintenance. This includes 1) Forgetting unreachable peers. 2) Verifying connectivity of new peers. 3) Retrying old peers at regular intervals. """ if self.env.peer_discovery != self.env.PD_ON: self.logger.info('peer discovery is disabled') return self.logger.info(f'beginning peer discovery. Force use of ' f'proxy: {self.env.force_proxy}') self.group.add(self._detect_proxy()) self.group.add(self._import_peers()) def info(self) -> typing.Dict[str, int]: """The number of peers.""" self._set_peer_statuses() counter = Counter(peer.status for peer in self.peers) return { 'bad': counter[PEER_BAD], 'good': counter[PEER_GOOD], 'never': counter[PEER_NEVER], 'stale': counter[PEER_STALE], 'total': len(self.peers), } async def add_localRPC_peer(self, real_name): """Add a peer passed by the admin over LocalRPC.""" await self._note_peers([Peer.from_real_name(real_name, 'RPC')]) async def on_add_peer(self, features, source_info): """Add a peer (but only if the peer resolves to the source).""" if not source_info: self.logger.info('ignored add_peer request: no source info') return False source = source_info[0] peers = Peer.peers_from_features(features, source) if not peers: self.logger.info('ignored add_peer request: no peers given') return False # Just look at the first peer, require it peer = peers[0] host = peer.host if peer.is_tor: permit = self._permit_new_onion_peer() reason = 'rate limiting' else: getaddrinfo = asyncio.get_event_loop().getaddrinfo try: infos = await getaddrinfo(host, 80, type=socket.SOCK_STREAM) except socket.gaierror: permit = False reason = 'address resolution failure' else: permit = any(source == info[-1][0] for info in infos) reason = 'source-destination mismatch' if permit: self.logger.info(f'accepted add_peer request from {source} ' f'for {host}') await self._note_peers([peer], check_ports=True) else: self.logger.warning(f'rejected add_peer request from {source} ' f'for {host} ({reason})') return permit def on_peers_subscribe(self, is_tor): """Returns the server peers as a list of (ip, host, details) tuples. We return all peers we've connected to in the last day. Additionally, if we don't have onion routing, we return a few hard-coded onion servers. """ cutoff = time.time() - STALE_SECS recent = [ peer for peer in self.peers if peer.last_good > cutoff and not peer.bad and peer.is_public ] onion_peers = [] # Always report ourselves if valid (even if not public) peers = { myself for myself in self.myselves if myself.last_good > cutoff } # Bucket the clearnet peers and select up to two from each buckets = defaultdict(list) for peer in recent: if peer.is_tor: onion_peers.append(peer) else: buckets[peer.bucket()].append(peer) for bucket_peers in buckets.values(): random.shuffle(bucket_peers) peers.update(bucket_peers[:2]) # Add up to 20% onion peers (but up to 10 is OK anyway) random.shuffle(onion_peers) max_onion = 50 if is_tor else max(10, len(peers) // 4) peers.update(onion_peers[:max_onion]) return [peer.to_tuple() for peer in peers] def proxy_peername(self): """Return the peername of the proxy, if there is a proxy, otherwise None.""" return self.proxy.peername if self.proxy else None def rpc_data(self): """Peer data for the peers RPC method.""" self._set_peer_statuses() descs = ['good', 'stale', 'never', 'bad'] def peer_data(peer): data = peer.serialize() data['status'] = descs[peer.status] return data def peer_key(peer): return (peer.bad, -peer.last_good) return [peer_data(peer) for peer in sorted(self.peers, key=peer_key)]