diff --git a/squeaknode/bitcoin/bitcoin_core_bitcoin_client.py b/squeaknode/bitcoin/bitcoin_core_client.py similarity index 72% rename from squeaknode/bitcoin/bitcoin_core_bitcoin_client.py rename to squeaknode/bitcoin/bitcoin_core_client.py index 305a6b16..954b06f2 100644 --- a/squeaknode/bitcoin/bitcoin_core_bitcoin_client.py +++ b/squeaknode/bitcoin/bitcoin_core_client.py @@ -27,11 +27,12 @@ import requests from squeaknode.bitcoin.bitcoin_client import BitcoinClient from squeaknode.bitcoin.block_info import BlockInfo +from squeaknode.bitcoin.exception import BitcoinRequestError logger = logging.getLogger(__name__) -class BitcoinCoreBitcoinClient(BitcoinClient): +class BitcoinCoreClient(BitcoinClient): """Access a bitcoin daemon using RPC.""" def __init__( @@ -59,7 +60,7 @@ class BitcoinCoreBitcoinClient(BitcoinClient): def get_block_info_by_height(self, block_height: int) -> BlockInfo: block_hash = self.get_block_hash(block_height) - block_header = self.get_block_header(block_hash, False) + block_header = self.get_block_header(block_hash) return BlockInfo(block_height, block_hash, block_header) def get_block_count(self) -> int: @@ -69,16 +70,8 @@ class BitcoinCoreBitcoinClient(BitcoinClient): "jsonrpc": "2.0", "id": 0, } - response = requests.post( - self.url, - data=json.dumps(payload), - headers=self.headers, - ).json() - - logger.debug("Got response for get_block_count: {}".format(response)) - result = response["result"] - if result is None: - raise Exception("Unable to get block count from bitcoin node.") + json_response = self.make_request(payload) + result = json_response["result"] block_count = int(result) logger.debug("Got block_count: {}".format(block_count)) return block_count @@ -90,32 +83,39 @@ class BitcoinCoreBitcoinClient(BitcoinClient): "jsonrpc": "2.0", "id": 0, } - response = requests.post( - self.url, - data=json.dumps(payload), - headers=self.headers, - ).json() - - logger.debug("Got response for get_block_hash: {}".format(response)) - result = response["result"] + json_response = self.make_request(payload) + result = json_response["result"] block_hash = result logger.debug("Got block_hash: {}".format(block_hash)) return bytes.fromhex(block_hash) - def get_block_header(self, block_hash: bytes, verbose: bool) -> bytes: + def get_block_header(self, block_hash: bytes, verbose: bool = False) -> bytes: payload = { "method": "getblockheader", "params": [block_hash.hex(), verbose], "jsonrpc": "2.0", "id": 0, } - response = requests.post( - self.url, - data=json.dumps(payload), - headers=self.headers, - ).json() - - logger.debug("Got response for get_block_header: {}".format(response)) - result = response["result"] + json_response = self.make_request(payload) + result = json_response["result"] logger.debug("Got block_header: {}".format(result)) return bytes.fromhex(result) + + def make_request(self, payload: dict) -> dict: + try: + response = requests.post( + self.url, + data=json.dumps(payload), + headers=self.headers, + ) + response.raise_for_status() + except requests.exceptions.HTTPError as errh: + raise BitcoinRequestError(errh) + except requests.exceptions.ConnectionError as errc: + raise BitcoinRequestError(errc) + except requests.exceptions.Timeout as errt: + raise BitcoinRequestError(errt) + except requests.exceptions.RequestException as err: + raise BitcoinRequestError(err) + + return response.json() diff --git a/squeaknode/bitcoin/exception.py b/squeaknode/bitcoin/exception.py new file mode 100644 index 00000000..8e6bf514 --- /dev/null +++ b/squeaknode/bitcoin/exception.py @@ -0,0 +1,37 @@ +# MIT License +# +# Copyright (c) 2020 Jonathan Zernik +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +class BitcoinError(Exception): + """Base class for other bitcoin exceptions.""" + + +class BitcoinRequestError(BitcoinError): + """Error that is raised when the bitcoin connection fails.""" + + def __init__(self, err): + self.err = err + + def __repr__(self): + return 'BitcoinRequestError(%r)' % ( + self.err, + ) diff --git a/squeaknode/node/squeak_node.py b/squeaknode/node/squeak_node.py index 28af14c7..d90d8943 100644 --- a/squeaknode/node/squeak_node.py +++ b/squeaknode/node/squeak_node.py @@ -28,7 +28,7 @@ from squeaknode.admin.squeak_admin_server_handler import SqueakAdminServerHandle from squeaknode.admin.squeak_admin_server_servicer import SqueakAdminServerServicer from squeaknode.admin.webapp.app import SqueakAdminWebServer from squeaknode.bitcoin.bitcoin_block_subscription_client import BitcoinBlockSubscriptionClient -from squeaknode.bitcoin.bitcoin_core_bitcoin_client import BitcoinCoreBitcoinClient +from squeaknode.bitcoin.bitcoin_core_client import BitcoinCoreClient from squeaknode.config.config import SqueaknodeConfig from squeaknode.core.squeak_core import SqueakCore from squeaknode.db.db_engine import get_connection_string @@ -131,7 +131,7 @@ class SqueakNode: def initialize_bitcoin_client(self): # load the bitcoin client - self.bitcoin_client = BitcoinCoreBitcoinClient( + self.bitcoin_client = BitcoinCoreClient( self.config.bitcoin.rpc_host, self.config.bitcoin.rpc_port, self.config.bitcoin.rpc_user, diff --git a/tests/bitcoin/test_bitcoin_core_client.py b/tests/bitcoin/test_bitcoin_core_client.py new file mode 100644 index 00000000..f1fefc41 --- /dev/null +++ b/tests/bitcoin/test_bitcoin_core_client.py @@ -0,0 +1,240 @@ +# MIT License +# +# Copyright (c) 2020 Jonathan Zernik +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +import mock +import pytest +from requests import HTTPError +from requests.exceptions import ConnectionError +from requests.exceptions import RequestException +from requests.exceptions import Timeout + +from squeaknode.bitcoin.bitcoin_core_client import BitcoinCoreClient +from squeaknode.bitcoin.block_info import BlockInfo +from squeaknode.bitcoin.exception import BitcoinRequestError + + +@pytest.fixture +def bitcoin_core_client(): + yield BitcoinCoreClient( + host="fake_bitcoin_host", + port=5678, + rpc_user="fake_user", + rpc_password="fake_pass", + use_ssl=False, + ssl_cert="fake_ssl_cert", + ) + + +@pytest.fixture +def block_count(): + yield 555 + + +@pytest.fixture +def block_hash_str(): + yield '00000000edade40797e3c4bf27edeb65733d1884beaa8c502a89d50a54111e1c' + + +@pytest.fixture +def block_hash(block_hash_str): + yield bytes.fromhex(block_hash_str) + + +@pytest.fixture +def block_header_str(): + yield '0100000079c30d2c23727a1e9f5feda4e7feb8ea0bda2ab98e23e7f6a9cf594f00000000b0de897e42fa7a3b5c3a6bfb8e797acf4ffbc16169394b03ad93296524ed633dcfef6e49ffff001d36d19a6c' + + +@pytest.fixture +def block_header(block_header_str): + yield bytes.fromhex(block_header_str) + + +@pytest.fixture +def block_info(block_count, block_hash, block_header): + yield BlockInfo( + block_height=block_count, + block_hash=block_hash, + block_header=block_header, + ) + + +class MockResponse: + + def json(self): + return {} + + @property + def status_code(self): + return 200 + + def raise_for_status(self): + pass + + +class MockGetCountResponse(MockResponse): + + def __init__(self, block_count): + self.block_count = block_count + + def json(self): + return {'result': '{}'.format(self.block_count)} + + +class MockGetBlockHashResponse(MockResponse): + + def __init__(self, block_hash_str): + self.block_hash_str = block_hash_str + + def json(self): + return {'result': '{}'.format(self.block_hash_str)} + + +class MockGetBlockHeaderResponse(MockResponse): + + def __init__(self, block_header_str): + self.block_header_str = block_header_str + + def json(self): + return {'result': '{}'.format(self.block_header_str)} + + +class MockEmptyResponse(MockResponse): + def json(self): + return {} + + +class MockInvalidStatusResponse(MockResponse): + + def raise_for_status(self): + raise HTTPError("Some http error", response=self) + + +@pytest.fixture +def mock_get_count_response(block_count): + yield MockGetCountResponse(block_count) + + +@pytest.fixture +def mock_get_block_hash_response(block_hash_str): + yield MockGetBlockHashResponse(block_hash_str) + + +@pytest.fixture +def mock_get_block_header_response(block_header_str): + yield MockGetBlockHeaderResponse(block_header_str) + + +@pytest.fixture +def mock_empty_response(): + yield MockEmptyResponse() + + +@pytest.fixture +def mock_invalid_status_response(): + yield MockInvalidStatusResponse() + + +def test_get_block_count(bitcoin_core_client, mock_get_count_response, block_count): + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.return_value = mock_get_count_response + retrieved_block_count = bitcoin_core_client.get_block_count() + + assert retrieved_block_count == block_count + + +def test_get_block_count_invalid_status(bitcoin_core_client, mock_invalid_status_response): + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.return_value = mock_invalid_status_response + + with pytest.raises(BitcoinRequestError): + bitcoin_core_client.get_block_count() + + +def test_get_block_count_connection_error(bitcoin_core_client, mock_invalid_status_response): + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.side_effect = ConnectionError() + + with pytest.raises(BitcoinRequestError): + bitcoin_core_client.get_block_count() + + +def test_get_block_count_timeout_error(bitcoin_core_client, mock_invalid_status_response): + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.side_effect = Timeout() + + with pytest.raises(BitcoinRequestError): + bitcoin_core_client.get_block_count() + + +def test_get_block_count_request_exception(bitcoin_core_client, mock_invalid_status_response): + # TODO: assert mocks called with correct args. + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.side_effect = RequestException() + + with pytest.raises(BitcoinRequestError): + bitcoin_core_client.get_block_count() + + +def test_get_block_hash(bitcoin_core_client, mock_get_block_hash_response, block_count, block_hash): + # TODO: assert mocks called with correct args. + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.return_value = mock_get_block_hash_response + retrieved_block_hash = bitcoin_core_client.get_block_hash(block_count) + + assert retrieved_block_hash == block_hash + + +def test_get_block_header(bitcoin_core_client, mock_get_block_header_response, block_hash, block_header): + # TODO: assert mocks called with correct args. + with mock.patch('squeaknode.bitcoin.bitcoin_core_client.requests.post', autospec=True) as mock_post: + mock_post.return_value = mock_get_block_header_response + retrieved_block_header = bitcoin_core_client.get_block_header( + block_hash) + + assert retrieved_block_header == block_header + + +def test_get_block_info_by_height(bitcoin_core_client, block_count, block_hash, block_header): + # TODO: assert mocks called with correct args. + with mock.patch.object(bitcoin_core_client, 'get_block_hash', autospec=True) as mock_get_block_hash, \ + mock.patch.object(bitcoin_core_client, 'get_block_header', autospec=True) as mock_get_block_header: + mock_get_block_hash.return_value = block_hash + mock_get_block_header.return_value = block_header + retrieved_block_info = bitcoin_core_client.get_block_info_by_height( + block_count) + + assert retrieved_block_info == BlockInfo( + block_height=block_count, + block_hash=block_hash, + block_header=block_header, + ) + + +def test_get_best_block_info(bitcoin_core_client, block_count, block_info): + # TODO: assert mocks called with correct args. + with mock.patch.object(bitcoin_core_client, 'get_block_count', autospec=True) as mock_get_block_count, \ + mock.patch.object(bitcoin_core_client, 'get_block_info_by_height', autospec=True) as mock_get_block_info_by_height: + mock_get_block_count.return_value = block_count + mock_get_block_info_by_height.return_value = block_info + retrieved_block_info = bitcoin_core_client.get_best_block_info() + + assert retrieved_block_info == block_info