from random import shuffle
import socket
from time import sleep, time
from typing import Optional
import bitcoin
from bitcoin import SelectParams
from bitcoin.base58 import Base58ChecksumError, InvalidBase58Error
from bitcoin.core import CTransaction, b2lx, b2x, script, x
from bitcoin.core.serialize import Hash, SerializationError, SerializationTruncationError
from bitcoin.messages import (
MSG_TX,
MsgSerializable,
msg_getdata,
msg_inv,
msg_ping,
msg_pong,
msg_reject,
msg_tx,
msg_verack,
msg_version,
)
from bitcoin.net import CInv
from bitcoin.wallet import CBitcoinAddress, CBitcoinAddressError
from clove.constants import (
CLOVE_API_URL,
NODE_COMMUNICATION_TIMEOUT,
REJECT_TIMEOUT,
TRANSACTION_BROADCASTING_MAX_ATTEMPTS,
)
from clove.exceptions import (
ConnectionProblem,
ImpossibleDeserialization,
TransactionRejected,
UnexpectedResponseFromNode,
)
from clove.network.base import BaseNetwork
from clove.network.bitcoin.contract import BitcoinContract
from clove.network.bitcoin.transaction import BitcoinAtomicSwapTransaction
from clove.network.bitcoin.wallet import BitcoinWallet
from clove.utils.bitcoin import auto_switch_params
from clove.utils.external_source import clove_req_json
from clove.utils.logging import logger
from clove.utils.network import generate_params_object
[docs]class BitcoinBaseNetwork(BaseNetwork):
name = None
'''Network name'''
symbols = ()
'''Tuple with network symbols'''
seeds = ()
'''Tuple with seed nodes addresses'''
nodes = ()
'''Tuple with nodes addresses or IPs'''
port = None
'''Port number used by network nodes'''
connection = None
'''Connection object'''
protocol_version = None
'''Protocol version number used by network nodes'''
blacklist_nodes = {}
'''List of dead nodes'''
message_start = b''
'''Message prefix'''
base58_prefixes = {}
'''Dictionary of different prefixes'''
bitcoin_based = True
'''Flag for bitcoin-based networks'''
[docs] @classmethod
def switch_params(cls):
'''
Method for changing network parameteres.
Note:
This method is used by the `auto_switch_params` decorator.
'''
if cls.name == 'bitcoin':
SelectParams('mainnet')
elif cls.name == 'test-bitcoin':
SelectParams('testnet')
else:
SelectParams(
name=cls.name,
generic_params_object=generate_params_object(
name=cls.name,
message_start=cls.message_start,
base58_prefixes=cls.base58_prefixes,
)
)
[docs] def publish(self, raw_transaction: str) -> Optional[str]:
'''
Method for publishing transactions.
Args:
raw_transaction (str): hex string containing signed transaction
Returns:
str, None: transaction address or None if transaction wasn't published
Example:
>>> raw_transaction = '010000000184a38a4e8743964249665fb241fbd3...35b'
>>> network.publish(raw_transaction)
70eefae0106b787e592e12914e4040efd8181dd299fa314d8f66da6a95cd1cfe
'''
for attempt in range(1, TRANSACTION_BROADCASTING_MAX_ATTEMPTS + 1):
transaction_address = self.broadcast_transaction(raw_transaction)
if transaction_address is None:
logger.warning('Transaction broadcast attempt no. %s failed. Retrying...', attempt)
continue
logger.info('Transaction broadcast is successful. End of broadcasting process.')
return transaction_address
logger.warning(
'%s attempts to broadcast transaction failed. Broadcasting process terminates!',
TRANSACTION_BROADCASTING_MAX_ATTEMPTS
)
[docs] @staticmethod
def get_nodes(seed: str) -> list:
'''
Extracting nodes from seed node.
Args:
seed (str): seed node address
Returns:
list: list of IPs of network nodes
Example:
>>> network.get_nodes('seed.testnet.bitcoin.sprovoost.nl')
['46.101.230.222',
'47.97.79.7',
'159.89.205.190',
'163.44.175.226',
'13.57.215.93',
'18.191.17.25',
'46.4.61.78',
'104.248.185.143',
'149.28.176.234',
'71.13.92.62',
'114.215.66.15',
'178.128.244.253',
'46.23.46.139',
'54.37.192.136',
'167.114.64.228',
'159.89.128.32',
'144.76.136.19',
'18.212.60.14',
'54.149.194.238',
'52.53.159.115',
'18.218.226.83']
'''
logger.debug('Getting nodes from seed node %s', seed)
try:
hostname, alias, nodes = socket.gethostbyname_ex(seed)
except (socket.herror, socket.gaierror):
return []
logger.debug('Got %s nodes', len(nodes))
return nodes
[docs] @auto_switch_params()
def capture_messages(self, expected_message_types: list, timeout: int=20, buf_size: int=1024,
ignore_empty: bool=False) -> list:
'''
Method for receiving messages from network nodes.
Args:
expected_message_types (list): list of message types to search for
timeout (int): timeout for waiting for messages
buf_size (int): buffer size that is going to be used for receiving messages
ignore_empty (bool): flag for ignoring errors if the message that we're looking for wasn't found.
Eg. this flag can be set to True when looking for reject messages because the absence of the
reject message is not an error.
Returns:
list, None: list of received messages or None if none or not all expected message types were found
Example:
>>> from bitcoin.messages import msg_verack, msg_version
>>> network.capture_messages([msg_version, msg_verack])
'''
deadline = time() + timeout
found = []
partial_message = None
while expected_message_types and time() < deadline:
try:
received_data = self.connection.recv(buf_size)
if partial_message:
received_data = partial_message + received_data
except socket.timeout:
continue
if not received_data:
sleep(0.1)
continue
for raw_message in self.split_message(received_data):
try:
message = MsgSerializable.from_bytes(raw_message)
except (SerializationError, SerializationTruncationError, ValueError):
partial_message = raw_message
continue
partial_message = None
if not message:
# unknown message type, skipping
continue
msg_type = type(message)
if msg_type is msg_ping:
logger.debug('Got ping, sending pong.')
self.send_pong(message)
elif msg_type is msg_version:
logger.debug('Saving version')
self.protocol_version = message
if msg_type in expected_message_types:
found.append(message)
expected_message_types.remove(msg_type)
logger.debug('Found %s, %s more to catch', msg_type.command.upper(), len(expected_message_types))
if not expected_message_types:
return found
if not ignore_empty:
logger.error('Not all messages could be captured')
[docs] @auto_switch_params()
def create_connection(self, node: str, timeout=2) -> Optional[socket.socket]:
'''
Establish connection to a given node.
Args:
node (str): node domain or IP address
timeout (int): number of seconds to wait before raising timeout
Returns:
socket.socket: socket connection
Example:
>>> network.create_connection('104.248.185.143')
<socket.socket fd=11, family=AddressFamily.AF_INET, type=2049, proto=6, laddr=('10.93.5.21', 36086), raddr=('104.248.185.143', 18333)> # noqa: E501
'''
try:
self.connection = socket.create_connection(
address=(node, self.port),
timeout=timeout
)
except (socket.timeout, ConnectionRefusedError, OSError):
logger.debug('[%s] Could not establish connection to this node', node)
return
logger.debug('[%s] Connection established, sending version packet', node)
if self.send_version():
return self.connection
[docs] @auto_switch_params()
def connect(self) -> Optional[str]:
'''
Connects to some node from the network.
Returns:
str, None: node IP address or domain, None if doesn't connect to any node
Example:
>>> network.connect()
'198.251.83.19'
>>> network.connection
<socket.socket fd=12, family=AddressFamily.AF_INET, type=2049, proto=6, laddr=('10.93.5.21', 54300), raddr=('198.251.83.19', 18333)> # noqa: E501
'''
if self.connection and self.send_ping():
# already connected
return self.get_current_node()
if self.nodes:
# fake seed node to enter the seed nodes loop
self.seeds = (None, )
random_seeds = list(self.seeds)
shuffle(random_seeds)
for seed in random_seeds:
if seed is None:
# get hardcoded nodes
nodes = self.nodes
else:
# get nodes from seed node
nodes = self.get_nodes(seed)
nodes = self.filter_blacklisted_nodes(nodes)
for node in nodes:
if not self.create_connection(node):
self.terminate(node)
continue
messages = self.capture_messages([msg_version, msg_verack])
if not messages:
logger.debug('[%s] Failed to get version or version acknowledge message from node', node)
self.terminate(node)
continue
logger.debug('[%s] Got version, sending version acknowledge message', node)
if not self.send_verack():
self.terminate(node)
continue
return node
[docs] def filter_blacklisted_nodes(self, nodes: list, max_tries_number=3) -> list:
'''
Filtering out dead nodes.
Args:
nodes (list): list of nodes to filter
max_tries_number (int): maximum number of attempts to connect to a node (above this value node
is being filtered out)
Returns:
list: list of nodes without dead nodes
'''
return sorted(
[node for node in nodes if self.blacklist_nodes.get(node, 0) <= max_tries_number],
key=lambda node: self.blacklist_nodes.get(node, 0)
)
[docs] def terminate(self, node: str=None):
'''
Closing connection to a given node and increasing number of failed connection attempts to this node.
Args:
node (str): node's IP address or domain. If the node was not given then this method will just
close the existing connection.
Returns:
None
'''
if node:
self.update_blacklist(node)
if self.connection:
self.connection.close()
self.connection = None
[docs] def update_blacklist(self, node):
'''
Increasing number of failed connection attempts to a give node.
Args:
node (str): node's IP address or domain
Returns:
None
'''
try:
self.blacklist_nodes[node] += 1
except KeyError:
logger.warning('Unable to update blacklist')
self.blacklist_nodes[node] = 1
[docs] @auto_switch_params()
def version_packet(self) -> bitcoin.messages.msg_version:
'''
Creating version package for a current network.
Returns:
bitcoin.messages.msg_version
'''
packet = msg_version(170002)
packet.addrFrom.ip, packet.addrFrom.port = self.connection.getsockname()
packet.addrTo.ip, packet.addrTo.port = self.connection.getpeername()
return packet
[docs] @classmethod
@auto_switch_params()
def split_message(cls, received_data: bytes) -> list:
'''
Splits received data based on start message prefix.
Args:
received_data (bytes): portion of received data
Returns:
list: list of messages that were found
'''
return [
bitcoin.params.MESSAGE_START + m for m in received_data.split(bitcoin.params.MESSAGE_START) if m
]
[docs] @auto_switch_params()
def send_message(self, msg: object, timeout: int=2) -> bool:
'''
Sends given message to the connected node.
Args:
msg (obj): bitcoin message object eg bitcoin.messages.msg_version or bitcoin.messages.msg_tx
timeout (int): timeout for communication with connected node
Returns:
bool: True if the message was sent, False otherwise.
Example:
>>> from bitcoin.messages import msg_ping
>>> network.send_message(msg_ping())
True
'''
try:
self.connection.settimeout(timeout)
self.connection.send(msg.to_bytes())
except (socket.timeout, ConnectionRefusedError, OSError) as e:
logger.debug('Failed to send %s message', msg.command.decode())
logger.debug(e)
return False
return True
[docs] @auto_switch_params()
def send_ping(self, timeout: int=1) -> bool:
'''
Sends ping message and tries to capture pong message.
Args:
timeout (int): timeout for communication with connected node
Returns:
bool: True if the message was sent, False otherwise.
Example:
>>> network.send_ping()
True
'''
if not self.send_message(msg_ping(), timeout):
return False
if self.capture_messages([msg_pong, ]):
return True
return False
[docs] @auto_switch_params()
def send_pong(self, ping: msg_ping, timeout: int=1) -> bool:
'''
Sends pong message in response to ping message.
Args:
ping (bitcoin.messages.msg_ping): ping message that where received
timeout (int): timeout for communication with connected node
Returns:
bool: True if the message was sent, False otherwise.
Example:
>>> network.send_pong(ping_message)
True
'''
return self.send_message(
msg_pong(self.protocol_version.nVersion, ping.nonce), timeout
)
[docs] @auto_switch_params()
def send_verack(self, timeout: int=2) -> bool:
'''
Sending version acknowledge message.
Args:
timeout (int): timeout for communication with connected node
Returns:
bool: True if the message was sent, False otherwise.
Example:
>>> network.send_verack()
True
'''
return self.send_message(
msg_verack(self.protocol_version.nVersion), timeout
)
[docs] def send_version(self, timeout: int=2) -> bool:
'''
Sending version message.
Args:
timeout (int): timeout for communication with connected node
Returns:
bool: True if the message was sent, False otherwise.
Example:
>>> network.send_version()
True
'''
return self.send_message(
self.version_packet(), timeout
)
[docs] @auto_switch_params()
def broadcast_transaction(self, raw_transaction: str) -> Optional[str]:
'''
Sends given transaction to connected node.
Args:
raw_transaction (str): hex string containing signed transaction
Returns:
str, None: transaction address if transaction was sent, None otherwise.
'''
deserialized_transaction = self.deserialize_raw_transaction(raw_transaction)
serialized_transaction = deserialized_transaction.serialize()
get_data = self.send_inventory(serialized_transaction)
if not get_data:
logger.debug(
ConnectionProblem('Clove could not get connected with any of the nodes for too long.')
)
self.reset_connection()
return
node = self.get_current_node()
if all(el.hash != Hash(serialized_transaction) for el in get_data.inv):
logger.debug(UnexpectedResponseFromNode('Node did not ask for our transaction', node))
self.reset_connection()
return
message = msg_tx()
message.tx = deserialized_transaction
if not self.send_message(message, 20):
return
logger.info('[%s] Looking for reject message.', node)
messages = self.capture_messages([msg_reject, ], timeout=REJECT_TIMEOUT, buf_size=8192, ignore_empty=True)
if messages:
logger.debug(TransactionRejected(messages[0], node))
self.reset_connection()
return
logger.info('[%s] Reject message not found.', node)
transaction_address = b2lx(deserialized_transaction.GetHash())
logger.info('[%s] Transaction %s has just been sent.', node, transaction_address)
return transaction_address
[docs] @auto_switch_params()
def send_inventory(self, serialized_transaction) -> Optional[msg_getdata]:
'''
Sends inventory message with given serialized transaction to connected node.
Returns:
msg_getdata, None: get data request or None if something went wrong
Note:
This method is used by `broadcast_transaction` to inform connected node about existence
of the new transaction.
'''
message = msg_inv()
inventory = CInv()
inventory.type = MSG_TX
hash_transaction = Hash(serialized_transaction)
inventory.hash = hash_transaction
message.inv.append(inventory)
timeout = time() + NODE_COMMUNICATION_TIMEOUT
while time() < timeout:
node = self.connect()
if node is None:
self.reset_connection()
continue
if not self.send_message(message):
self.terminate(node)
continue
messages = self.capture_messages([msg_getdata, ])
if not messages:
self.terminate(node)
continue
logger.info('[%s] Node responded correctly.', node)
return messages[0]
[docs] def reset_connection(self):
'''Disconnection from node if connected and clearing the blacklisted nodes.'''
if self.connection:
self.connection.close()
self.connection = None
self.blacklist_nodes = {}
[docs] @classmethod
@auto_switch_params()
def get_new_wallet(cls) -> BitcoinWallet:
'''
Generates a new wallet.
Example:
>>> wallet = network.get_new_wallet()
>>> wallet.address
'mrkLKxgzfQk4dgFrfaoVYjeTkXxCfKyz1q'
'''
return cls.get_wallet()
[docs] @classmethod
def get_current_fee_per_kb(cls) -> Optional[float]:
"""
Getting current network fee from Clove API
Returns:
float, None: current fee per kb or None if something went wrong
Example:
>>> network.get_current_fee_per_kb()
0.01006814
"""
network = cls.symbols[0].upper()
if cls.testnet:
network += '-TESTNET'
resp = clove_req_json(f'{CLOVE_API_URL}/fee/{network}')
if not resp:
logger.debug('Could not get current fee for %s network', network)
return
return resp['fee']
[docs] def get_current_node(self) -> Optional[str]:
'''
Getting address of connected node.
Returns:
str, None: IP for connected node or None if there is no connection established.
Example:
>>> network.get_current_node()
'157.97.106.250'
'''
if self.connection:
return self.connection.getpeername()[0]
[docs] @auto_switch_params()
def atomic_swap(
self,
sender_address: str,
recipient_address: str,
value: float,
solvable_utxo: list=None,
secret_hash: str=None,
) -> Optional[BitcoinAtomicSwapTransaction]:
'''
Creates atomic swap unsigned transaction object.
Args:
sender_address (str): wallet address of the sender
recipient_address (str): wallet address of the recipient
value (float): amount to swap
solvable_utxo (list): optional list of UTXO objects. If None then it will try to find UTXO automatically
by using the `get_utxo` method.
secret_hash (str): optional secret hash to be used in transaction. If None then the new hash
will be generated.
Returns:
BitcoinAtomicSwapTransaction, None: unsigned Atomic Swap transaction object or None if something went wrong
Example:
>>> from clove.network import BitcoinTestNet
>>> network = BitcoinTestNet()
>>> network.atomic_swap('msJ2ucZ2NDhpVzsiNE5mGUFzqFDggjBVTM', 'mmJtKA92Mxqfi3XdyGReza69GjhkwAcBN1', 2.4)
<clove.network.bitcoin.transaction.BitcoinAtomicSwapTransaction at 0x7f989439d630>
'''
if not solvable_utxo:
solvable_utxo = self.get_utxo(sender_address, value)
if not solvable_utxo:
logger.error(f'Cannot get UTXO for address {sender_address}')
return
transaction = BitcoinAtomicSwapTransaction(
self, sender_address, recipient_address, value, solvable_utxo, secret_hash
)
transaction.create_unsigned_transaction()
return transaction
[docs] @auto_switch_params()
def audit_contract(
self,
contract: str,
raw_transaction: Optional[str]=None,
transaction_address: Optional[str]=None,
) -> BitcoinContract:
'''
Getting details about an Atomic Swap contract.
Args:
contract (str): contract definition (hex string)
raw_transaction (str): hex string with raw transaction
transaction_address (str): hex string with transaction address which created an Atomic Swap
Returns:
BitcoinContract: contract object
Example:
>>> from clove.network import Litecoin
>>> network = Litecoin()
>>> network.audit_contract(
contract='63a61450314a793bf317665ecdc54c2e843bb106aeee158876a91485c0522f6e23beb11cc3d066cd20ed732648a4e66704926db75bb17576a914621f617c765c3caa5ce1bb67f6a3e51382b8da296888ac', # noqa: E501
transaction_address='09a60dc3fafe6ba058b2a140457df1c3b446602595d47deed641cb635ffd25aa'
)
<clove.network.bitcoin.contract.BitcoinContract at 0x7f98870db978>
'''
return BitcoinContract(self, contract, raw_transaction, transaction_address)
[docs] @classmethod
@auto_switch_params()
def get_wallet(cls, private_key: str=None, encrypted_private_key: bytes=None, password: str=None) ->BitcoinWallet:
'''
Returns wallet object.
Args:
private_key (str): hex string with wallet's secret key
encrypted_private_key (bytes): private key encrypted with password
password (str): password for decrypting an encrypted private key
Returns:
BitcoinWallet: wallet object
Example:
>>> from clove.network import BitcoinTestNet
>>> network = BitcoinTestNet()
>>> wallet = network.get_wallet('cV8FDJu3JhLED2D7q5L7FwLCus69TajYGEnTWEbNqhzzV9WxMBE7')
>>> wallet.address
'mtUXc6UJTiwb5FdoEJ2hzR8R1yUrcj3hcn'
Note:
If private_key has not been provided then a new wallet will be generated just like via `get_new_wallet()`.
'''
return BitcoinWallet(private_key, encrypted_private_key, password)
[docs] @classmethod
@auto_switch_params()
def is_valid_address(cls, address: str) -> bool:
'''
Checks if wallet address is valid for this network.
Args:
address (str): wallet address to check
Returns:
bool: True if address is valued, False otherwise
Example:
>>> from clove.network import Bitcoin
>>> network = Bitcoin()
>>> network.is_valid_address('13iNsKgMfVJQaYVFqp5ojuudxKkVCMtkoa')
True
>>> network.is_valid_address('msJ2ucZ2NDhpVzsiNE5mGUFzqFDggjBVTM')
False
'''
try:
CBitcoinAddress(address)
except (CBitcoinAddressError, Base58ChecksumError, InvalidBase58Error):
return False
return True
[docs] @staticmethod
def deserialize_raw_transaction(raw_transaction: str) -> CTransaction:
'''
Checking if transaction can be deserialized (is not corrupted).
Args:
raw_transaction (str): transaction to check
Returns:
CTransaction: transaction object
Raises:
ImpossibleDeserialization: when transaction is corrupted
Example:
>>> from clove.network import Litecoin
>>> network = Litecoin()
>>> network.deserialize_raw_transaction('0100000001aa25fd5f63cb41d6ee7dd495256046b4c3f17d4540a1b258a06bfefac30da60900000000fdff0047304402201c8869d359b5599ecffd51a96f0a8799392c98c4e15242762ba455e37b1f5d6302203f2974e9afc8d641f9363167df48e5a845a8deba1381bf5a1b549ac04718a1ac01410459cdb91eb7298bc2578dc4e7ac2109ac3cfd9dc9818795c5583e720d2114d540724bf26b4541f683ff51968db627a04eecd1f5cff615b6350dad5fb595f8adf420c480afb333623864901c968022a07dd93fe3c06f5684ea728b8113e17fa91bd9514c5163a61450314a793bf317665ecdc54c2e843bb106aeee158876a91485c0522f6e23beb11cc3d066cd20ed732648a4e66704926db75bb17576a914621f617c765c3caa5ce1bb67f6a3e51382b8da296888ac00000000015a7b0100000000001976a91485c0522f6e23beb11cc3d066cd20ed732648a4e688ac00000000') # noqa: E501
CTransaction((CTxIn(COutPoint(lx('09a60dc3fafe6ba058b2a140457df1c3b446602595d47deed641cb635ffd25aa'), 0), CScript([x('304402201c8869d359b5599ecffd51a96f0a8799392c98c4e15242762ba455e37b1f5d6302203f2974e9afc8d641f9363167df48e5a845a8deba1381bf5a1b549ac04718a1ac01'), x('0459cdb91eb7298bc2578dc4e7ac2109ac3cfd9dc9818795c5583e720d2114d540724bf26b4541f683ff51968db627a04eecd1f5cff615b6350dad5fb595f8adf4'), x('c480afb333623864901c968022a07dd93fe3c06f5684ea728b8113e17fa91bd9'), 1, x('63a61450314a793bf317665ecdc54c2e843bb106aeee158876a91485c0522f6e23beb11cc3d066cd20ed732648a4e66704926db75bb17576a914621f617c765c3caa5ce1bb67f6a3e51382b8da296888ac')]), 0x0),), (CTxOut(0.00097114*COIN, CScript([OP_DUP, OP_HASH160, x('85c0522f6e23beb11cc3d066cd20ed732648a4e6'), OP_EQUALVERIFY, OP_CHECKSIG])),), 0, 1, CTxWitness()) # noqa: E501
'''
try:
return CTransaction.deserialize(x(raw_transaction))
except Exception:
raise ImpossibleDeserialization()
[docs]class NoAPI(object):
'''Empty class for networks that does not have block explorer with API.'''
API = False
[docs] @classmethod
def get_latest_block(cls):
raise NotImplementedError
[docs] @staticmethod
def get_transaction(tx_address: str) -> dict:
raise NotImplementedError
[docs] @classmethod
def get_utxo(cls, address, amount):
raise NotImplementedError
[docs] @staticmethod
def get_balance(wallet_address: str) -> float:
raise NotImplementedError