|
|
@ -29,7 +29,7 @@ import sys |
|
|
|
import traceback |
|
|
|
import asyncio |
|
|
|
import socket |
|
|
|
from typing import Tuple, Union, List, TYPE_CHECKING, Optional, Set, NamedTuple |
|
|
|
from typing import Tuple, Union, List, TYPE_CHECKING, Optional, Set, NamedTuple, Any |
|
|
|
from collections import defaultdict |
|
|
|
from ipaddress import IPv4Network, IPv6Network, ip_address, IPv6Address, IPv4Address |
|
|
|
import itertools |
|
|
@ -44,16 +44,19 @@ from aiorpcx.jsonrpc import JSONRPC, CodeMessageError |
|
|
|
from aiorpcx.rawsocket import RSClient |
|
|
|
import certifi |
|
|
|
|
|
|
|
from .util import ignore_exceptions, log_exceptions, bfh, SilentTaskGroup, MySocksProxy |
|
|
|
from .util import (ignore_exceptions, log_exceptions, bfh, SilentTaskGroup, MySocksProxy, |
|
|
|
is_integer, is_non_negative_integer, is_hash256_str, is_hex_str, |
|
|
|
is_real_number) |
|
|
|
from . import util |
|
|
|
from . import x509 |
|
|
|
from . import pem |
|
|
|
from . import version |
|
|
|
from . import blockchain |
|
|
|
from .blockchain import Blockchain |
|
|
|
from .blockchain import Blockchain, HEADER_SIZE |
|
|
|
from . import constants |
|
|
|
from .i18n import _ |
|
|
|
from .logging import Logger |
|
|
|
from .transaction import Transaction |
|
|
|
|
|
|
|
if TYPE_CHECKING: |
|
|
|
from .network import Network |
|
|
@ -82,6 +85,45 @@ class NetworkTimeout: |
|
|
|
RELAXED = 20 |
|
|
|
MOST_RELAXED = 60 |
|
|
|
|
|
|
|
|
|
|
|
def assert_non_negative_integer(val: Any) -> None: |
|
|
|
if not is_non_negative_integer(val): |
|
|
|
raise RequestCorrupted(f'{val!r} should be a non-negative integer') |
|
|
|
|
|
|
|
|
|
|
|
def assert_integer(val: Any) -> None: |
|
|
|
if not is_integer(val): |
|
|
|
raise RequestCorrupted(f'{val!r} should be an integer') |
|
|
|
|
|
|
|
|
|
|
|
def assert_real_number(val: Any, *, as_str: bool = False) -> None: |
|
|
|
if not is_real_number(val, as_str=as_str): |
|
|
|
raise RequestCorrupted(f'{val!r} should be a number') |
|
|
|
|
|
|
|
|
|
|
|
def assert_hash256_str(val: Any) -> None: |
|
|
|
if not is_hash256_str(val): |
|
|
|
raise RequestCorrupted(f'{val!r} should be a hash256 str') |
|
|
|
|
|
|
|
|
|
|
|
def assert_hex_str(val: Any) -> None: |
|
|
|
if not is_hex_str(val): |
|
|
|
raise RequestCorrupted(f'{val!r} should be a hex str') |
|
|
|
|
|
|
|
|
|
|
|
def assert_dict_contains_field(d: Any, *, field_name: str) -> Any: |
|
|
|
if not isinstance(d, dict): |
|
|
|
raise RequestCorrupted(f'{d!r} should be a dict') |
|
|
|
if field_name not in d: |
|
|
|
raise RequestCorrupted(f'required field {field_name!r} missing from dict') |
|
|
|
return d[field_name] |
|
|
|
|
|
|
|
|
|
|
|
def assert_list_or_tuple(val: Any) -> None: |
|
|
|
if not isinstance(val, (list, tuple)): |
|
|
|
raise RequestCorrupted(f'{val!r} should be a list or tuple') |
|
|
|
|
|
|
|
|
|
|
|
class NotificationSession(RPCSession): |
|
|
|
|
|
|
|
def __init__(self, *args, **kwargs): |
|
|
@ -187,7 +229,7 @@ class RequestTimedOut(GracefulDisconnect): |
|
|
|
return _("Network request timed out.") |
|
|
|
|
|
|
|
|
|
|
|
class RequestCorrupted(GracefulDisconnect): pass |
|
|
|
class RequestCorrupted(Exception): pass |
|
|
|
|
|
|
|
class ErrorParsingSSLCert(Exception): pass |
|
|
|
class ErrorGettingSSLCertFromServer(Exception): pass |
|
|
@ -529,6 +571,8 @@ class Interface(Logger): |
|
|
|
return blockchain.deserialize_header(bytes.fromhex(res), height) |
|
|
|
|
|
|
|
async def request_chunk(self, height: int, tip=None, *, can_return_early=False): |
|
|
|
if not is_non_negative_integer(height): |
|
|
|
raise Exception(f"{repr(height)} is not a block height") |
|
|
|
index = height // 2016 |
|
|
|
if can_return_early and index in self._requested_chunks: |
|
|
|
return |
|
|
@ -542,6 +586,16 @@ class Interface(Logger): |
|
|
|
res = await self.session.send_request('blockchain.block.headers', [index * 2016, size]) |
|
|
|
finally: |
|
|
|
self._requested_chunks.discard(index) |
|
|
|
assert_dict_contains_field(res, field_name='count') |
|
|
|
assert_dict_contains_field(res, field_name='hex') |
|
|
|
assert_dict_contains_field(res, field_name='max') |
|
|
|
assert_non_negative_integer(res['count']) |
|
|
|
assert_non_negative_integer(res['max']) |
|
|
|
assert_hex_str(res['hex']) |
|
|
|
if len(res['hex']) != HEADER_SIZE * 2 * res['count']: |
|
|
|
raise RequestCorrupted('inconsistent chunk hex and count') |
|
|
|
if res['count'] != size: |
|
|
|
raise RequestCorrupted(f"expected {size} headers but only got {res['count']}") |
|
|
|
conn = self.blockchain.connect_chunk(index, res['hex']) |
|
|
|
if not conn: |
|
|
|
return conn, 0 |
|
|
@ -819,6 +873,108 @@ class Interface(Logger): |
|
|
|
self._ipaddr_bucket = do_bucket() |
|
|
|
return self._ipaddr_bucket |
|
|
|
|
|
|
|
async def get_merkle_for_transaction(self, tx_hash: str, tx_height: int) -> dict: |
|
|
|
if not is_hash256_str(tx_hash): |
|
|
|
raise Exception(f"{repr(tx_hash)} is not a txid") |
|
|
|
if not is_non_negative_integer(tx_height): |
|
|
|
raise Exception(f"{repr(tx_height)} is not a block height") |
|
|
|
# do request |
|
|
|
res = await self.session.send_request('blockchain.transaction.get_merkle', [tx_hash, tx_height]) |
|
|
|
# check response |
|
|
|
block_height = assert_dict_contains_field(res, field_name='block_height') |
|
|
|
merkle = assert_dict_contains_field(res, field_name='merkle') |
|
|
|
pos = assert_dict_contains_field(res, field_name='pos') |
|
|
|
# note: tx_height was just a hint to the server, don't enforce the response to match it |
|
|
|
assert_non_negative_integer(block_height) |
|
|
|
assert_non_negative_integer(pos) |
|
|
|
assert_list_or_tuple(merkle) |
|
|
|
for item in merkle: |
|
|
|
assert_hash256_str(item) |
|
|
|
return res |
|
|
|
|
|
|
|
async def get_transaction(self, tx_hash: str, *, timeout=None) -> str: |
|
|
|
if not is_hash256_str(tx_hash): |
|
|
|
raise Exception(f"{repr(tx_hash)} is not a txid") |
|
|
|
raw = await self.session.send_request('blockchain.transaction.get', [tx_hash], timeout=timeout) |
|
|
|
# validate response |
|
|
|
tx = Transaction(raw) |
|
|
|
try: |
|
|
|
tx.deserialize() # see if raises |
|
|
|
except Exception as e: |
|
|
|
raise RequestCorrupted(f"cannot deserialize received transaction (txid {tx_hash})") from e |
|
|
|
if tx.txid() != tx_hash: |
|
|
|
raise RequestCorrupted(f"received tx does not match expected txid {tx_hash} (got {tx.txid()})") |
|
|
|
return raw |
|
|
|
|
|
|
|
async def get_history_for_scripthash(self, sh: str) -> List[dict]: |
|
|
|
if not is_hash256_str(sh): |
|
|
|
raise Exception(f"{repr(sh)} is not a scripthash") |
|
|
|
# do request |
|
|
|
res = await self.session.send_request('blockchain.scripthash.get_history', [sh]) |
|
|
|
# check response |
|
|
|
assert_list_or_tuple(res) |
|
|
|
for tx_item in res: |
|
|
|
assert_dict_contains_field(tx_item, field_name='height') |
|
|
|
assert_dict_contains_field(tx_item, field_name='tx_hash') |
|
|
|
assert_integer(tx_item['height']) |
|
|
|
assert_hash256_str(tx_item['tx_hash']) |
|
|
|
if tx_item['height'] in (-1, 0): |
|
|
|
assert_dict_contains_field(tx_item, field_name='fee') |
|
|
|
assert_non_negative_integer(tx_item['fee']) |
|
|
|
return res |
|
|
|
|
|
|
|
async def listunspent_for_scripthash(self, sh: str) -> List[dict]: |
|
|
|
if not is_hash256_str(sh): |
|
|
|
raise Exception(f"{repr(sh)} is not a scripthash") |
|
|
|
# do request |
|
|
|
res = await self.session.send_request('blockchain.scripthash.listunspent', [sh]) |
|
|
|
# check response |
|
|
|
assert_list_or_tuple(res) |
|
|
|
for utxo_item in res: |
|
|
|
assert_dict_contains_field(utxo_item, field_name='tx_pos') |
|
|
|
assert_dict_contains_field(utxo_item, field_name='value') |
|
|
|
assert_dict_contains_field(utxo_item, field_name='tx_hash') |
|
|
|
assert_dict_contains_field(utxo_item, field_name='height') |
|
|
|
assert_non_negative_integer(utxo_item['tx_pos']) |
|
|
|
assert_non_negative_integer(utxo_item['value']) |
|
|
|
assert_non_negative_integer(utxo_item['height']) |
|
|
|
assert_hash256_str(utxo_item['tx_hash']) |
|
|
|
return res |
|
|
|
|
|
|
|
async def get_balance_for_scripthash(self, sh: str) -> dict: |
|
|
|
if not is_hash256_str(sh): |
|
|
|
raise Exception(f"{repr(sh)} is not a scripthash") |
|
|
|
# do request |
|
|
|
res = await self.session.send_request('blockchain.scripthash.get_balance', [sh]) |
|
|
|
# check response |
|
|
|
assert_dict_contains_field(res, field_name='confirmed') |
|
|
|
assert_dict_contains_field(res, field_name='unconfirmed') |
|
|
|
assert_non_negative_integer(res['confirmed']) |
|
|
|
assert_non_negative_integer(res['unconfirmed']) |
|
|
|
return res |
|
|
|
|
|
|
|
async def get_txid_from_txpos(self, tx_height: int, tx_pos: int, merkle: bool): |
|
|
|
if not is_non_negative_integer(tx_height): |
|
|
|
raise Exception(f"{repr(tx_height)} is not a block height") |
|
|
|
if not is_non_negative_integer(tx_pos): |
|
|
|
raise Exception(f"{repr(tx_pos)} should be non-negative integer") |
|
|
|
# do request |
|
|
|
res = await self.session.send_request( |
|
|
|
'blockchain.transaction.id_from_pos', |
|
|
|
[tx_height, tx_pos, merkle], |
|
|
|
) |
|
|
|
# check response |
|
|
|
if merkle: |
|
|
|
assert_dict_contains_field(res, field_name='tx_hash') |
|
|
|
assert_dict_contains_field(res, field_name='merkle') |
|
|
|
assert_hash256_str(res['tx_hash']) |
|
|
|
assert_list_or_tuple(res['merkle']) |
|
|
|
for node_hash in res['merkle']: |
|
|
|
assert_hash256_str(node_hash) |
|
|
|
else: |
|
|
|
assert_hash256_str(res) |
|
|
|
return res |
|
|
|
|
|
|
|
|
|
|
|
def _assert_header_does_not_check_against_any_chain(header: dict) -> None: |
|
|
|
chain_bad = blockchain.check_header(header) if 'mock' not in header else header['mock']['check'](header) |
|
|
|