From 6e4970ad5cef7526e46c01004f71330a92d8bd68 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Wed, 22 May 2024 06:20:04 +0300 Subject: [PATCH] Fulcrum protocol (#145) * Adds Fulcrum protocol to the network services * Blockheight retrieval of endpoints is parallel threaded * Add tests for FULCRUMPROTOCOL * Refactor handshake requirements for efficient IO * Allow non-ssl tcp connections too * Add fulcrum protocol to docs * Add FulcrumProtocolAPI to network docs --- bitcash/network/APIs/FulcrumProtocolAPI.py | 309 ++++++++++++++ bitcash/network/services.py | 34 +- docs/guide/network.rst | 6 +- docs/guide/node.rst | 17 +- tests/network/APIs/test_FulcrumProtolAPI.py | 430 ++++++++++++++++++++ tests/network/test_services.py | 60 ++- tests/samples.py | 10 +- 7 files changed, 842 insertions(+), 24 deletions(-) create mode 100644 bitcash/network/APIs/FulcrumProtocolAPI.py create mode 100644 tests/network/APIs/test_FulcrumProtolAPI.py diff --git a/bitcash/network/APIs/FulcrumProtocolAPI.py b/bitcash/network/APIs/FulcrumProtocolAPI.py new file mode 100644 index 00000000..009fb168 --- /dev/null +++ b/bitcash/network/APIs/FulcrumProtocolAPI.py @@ -0,0 +1,309 @@ +from functools import wraps +import json +import socket +import ssl +from decimal import Decimal +import typing +from requests.exceptions import ConnectTimeout, ContentDecodingError, SSLError +from typing import Any, Union + +from bitcash.exceptions import InvalidEndpointURLProvided +from bitcash.network.APIs import BaseAPI +from bitcash.network.meta import Unspent +from bitcash.network.transaction import Transaction, TxPart +from bitcash.cashaddress import Address + + +context = ssl.create_default_context() +FULCRUM_PROTOCOL = "1.5.0" + +BCH_TO_SAT_MULTIPLIER = 100000000 +# TODO: Refactor constant above into a 'constants.py' file + + +def handshake(hostname: str, port: int) -> Union[socket.socket, ssl.SSLSocket]: + """ + Perform handshake with the host and establish protocol + """ + # make socket connection + try: + sock = socket.create_connection((hostname, port)) + ssock = context.wrap_socket(sock, server_hostname=hostname) + except ssl.SSLError: + ssock = socket.create_connection((hostname, port)) + + # send a server.version to establish protocol + _ = send_json_rpc_payload(ssock, "server.version", ["Bitcash", FULCRUM_PROTOCOL]) + # if no errors, then handshake complete + + return ssock + + +def send_json_rpc_payload( + sock: Union[socket.socket, ssl.SSLSocket], + method: str, + params: list[Any], + *args, + **kwargs, +) -> Any: + """ + Function to send a json rpc 2.0 payload over a given socket instance, and return the + parsed result. + """ + payload = { + "method": method, + "params": params, + "jsonrpc": "2.0", + "id": "bitcash", + } + payload_bytes = json.dumps(payload).encode() + b"\n" + sock.sendall(payload_bytes) # will raise ssl.SSLZeroReturnError if SSL closes + data = b"" + while True: + data += sock.recv(4096) + # if sock timed out and data is b"" + # or the message completed and has endline char + if not data or data.endswith(b"\n"): + break + if data == b"": + raise ConnectTimeout("TLS/SSL connection has been closed (EOF)") + return_json = json.loads(data.decode(), parse_float=Decimal) + if return_json["jsonrpc"] != "2.0" or return_json["id"] != "bitcash": + raise ContentDecodingError( + f"Returned json {return_json} is not valid json rpc 2.0" + ) + + if "error" in return_json: + raise RuntimeError(f"Error in retruned json: {return_json['error']}") + + return return_json["result"] + + +def check_stale_sock(fn): + @wraps(fn) + def wrapper(self, *args, **kwargs): + try: + result = fn(self, *args, **kwargs) + except ConnectTimeout: + self.sock = handshake(self.hostname, self.port) + result = fn(self, *args, **kwargs) + return result + + return wrapper + + +class FulcrumProtocolAPI(BaseAPI): + """Fulcrum Protocol API + Documentation at: https://electrum-cash-protocol.readthedocs.io/en/latest/index.html + + :param network_endpoint: The url for the network endpoint + """ + + # Default endpoints to use for this interface + DEFAULT_ENDPOINTS = { + "mainnet": [ + "bch.imaginary.cash:50002", + "electron.jochen-hoenicke.de:51002", + ], + "testnet": [ + "testnet.imaginary.cash:50002", + "testnet.bitcoincash.network:60002", + ], + "regtest": [], + } + + def __init__(self, network_endpoint: str): + try: + assert isinstance(network_endpoint, str) + except AssertionError: + raise InvalidEndpointURLProvided( + f"Provided endpoint '{network_endpoint}' is not a valid URL" + f" for a Electrum Cash Protocol endpoint" + ) + + if network_endpoint.count(":") != 1: + raise InvalidEndpointURLProvided( + f"Provided endpoint '{network_endpoint}' doesn't have hostname and " + f"port separated by ':'" + ) + + self.hostname, port = network_endpoint.split(":") + self.port = int(port) + + self.sock = handshake(self.hostname, self.port) + + @classmethod + def get_default_endpoints(cls, network: str): + return cls.DEFAULT_ENDPOINTS[network] + + @check_stale_sock + def get_blockheight(self, *args, **kwargs): + result = send_json_rpc_payload( + self.sock, "blockchain.headers.get_tip", [], *args, **kwargs + ) + return result["height"] + + @check_stale_sock + def get_balance(self, address, *args, **kwargs): + result = send_json_rpc_payload( + self.sock, "blockchain.address.get_balance", [address], *args, **kwargs + ) + return result["confirmed"] + result["unconfirmed"] + + @check_stale_sock + def get_transactions(self, address, *args, **kwargs): + result = send_json_rpc_payload( + self.sock, "blockchain.address.get_history", [address], *args, **kwargs + ) + transactions = [(tx["tx_hash"], tx["height"]) for tx in result] + # sort by block height + transactions.sort(key=lambda x: x[1]) + transactions = [_[0] for _ in transactions][::-1] + return transactions + + @check_stale_sock + def get_transaction(self, txid, *args, **kwargs): + result = self.get_raw_transaction(txid, *args, **kwargs) + blockheight = self.get_blockheight() + + confirmations = result.get("confirmations", 0) + if confirmations == 0: + tx_blockheight = None + else: + tx_blockheight = blockheight - result["confirmations"] + 1 + + tx_data = {"vin": [], "vout": []} + + for vx in ["vin", "vout"]: + for txout in result[vx]: + if vx == "vin": + txout = self._get_raw_tx_out(txout["txid"], txout["vout"]) + addr = None + if ( + "addresses" in txout["scriptPubKey"] + and txout["scriptPubKey"]["addresses"] is not None + ): + addr = txout["scriptPubKey"]["addresses"][0] + + category_id = None + nft_capability = None + nft_commitment = None + token_amount = None + if "tokenData" in txout: + token_data = txout["tokenData"] + category_id = token_data["category"] + token_amount = int(token_data["amount"]) or None + if "nft" in token_data: + nft_capability = token_data["nft"]["capability"] + nft_commitment = ( + bytes.fromhex(token_data["nft"]["commitment"]) or None + ) + # convert to Decimal again as json doesn't convert 0 value + # that happens in OP_RETRUN + part = TxPart( + addr, + int( + ( + Decimal(txout["value"]) * BCH_TO_SAT_MULTIPLIER + ).to_integral_value() + ), + category_id, + nft_capability, + nft_commitment, + token_amount, + asm=txout["scriptPubKey"]["asm"], + ) + tx_data[vx].append(part) + + value_in = sum([x.amount for x in tx_data["vin"]]) + value_out = sum([x.amount for x in tx_data["vout"]]) + value_fee = value_in - value_out + + tx = Transaction( + result["txid"], + tx_blockheight, + value_in, + value_out, + value_fee, + ) + + tx.inputs = tx_data["vin"] + tx.outputs = tx_data["vout"] + + return tx + + def _get_raw_tx_out( + self, txid: str, txindex: int, *args, **kwargs + ) -> dict[str, Any]: + result = self.get_raw_transaction(txid, *args, **kwargs) + + for vout in result["vout"]: + if vout["n"] == txindex: + return vout + raise RuntimeError(f"Transaction {txid=} doesn't have {txindex=}") + + @check_stale_sock + def get_tx_amount(self, txid: str, txindex: int, *args, **kwargs) -> int: + result = self.get_raw_transaction(txid, *args, **kwargs) + + for vout in result["vout"]: + if vout["n"] == txindex: + # convert to Decimal again as json doesn't convert 0 value + # that happens in OP_RETRUN + sats = int( + (Decimal(vout["value"]) * BCH_TO_SAT_MULTIPLIER).to_integral_value() + ) + return sats + raise RuntimeError(f"Transaction {txid=} doesn't have {txindex=}") + + @check_stale_sock + def get_unspent(self, address: str, *args, **kwargs) -> list[Unspent]: + result = send_json_rpc_payload( + self.sock, "blockchain.address.listunspent", [address], *args, **kwargs + ) + blockheight = self.get_blockheight() + unspents = [] + for utxo in result: + confirmations = ( + 0 if utxo["height"] == 0 else blockheight - utxo["height"] + 1 + ) + token_data = utxo.get("token_data", {}) + token_category = token_data.get("category", None) + nft = token_data.get("nft", None) + if nft is None: + nft_commitment = None + nft_capability = None + else: + nft_commitment = bytes.fromhex(nft["commitment"]) + nft_capability = nft["capability"] + token_amount = int(token_data.get("amount")) + # add unspent + unspents.append( + Unspent( + int(utxo["value"]), + confirmations, + Address.from_string(address).scriptcode.hex(), + utxo["tx_hash"], + utxo["tx_pos"], + token_category, + nft_capability, + nft_commitment or None, # b"" is None + token_amount or None, # 0 amount is None + ) + ) + return unspents + + @check_stale_sock + def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: + result = send_json_rpc_payload( + self.sock, "blockchain.transaction.get", [txid, True], *args, **kwargs + ) + + return typing.cast(dict[str, Any], result) + + @check_stale_sock + def broadcast_tx(self, tx_hex: str, *args, **kwargs) -> bool: # pragma: no cover + _ = send_json_rpc_payload( + self.sock, "blockchain.transaction.broadcast", [tx_hex] + ) + return True diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 374b8e84..e5285bec 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -1,14 +1,18 @@ import os import requests +import threading +import concurrent.futures # Import supported endpoint APIs from bitcash.network.APIs.BitcoinDotComAPI import BitcoinDotComAPI from bitcash.network.APIs.ChaingraphAPI import ChaingraphAPI +from bitcash.network.APIs.FulcrumProtocolAPI import FulcrumProtocolAPI from bitcash.utils import time_cache # Dictionary of supported endpoint APIs ENDPOINT_ENV_VARIABLES = { "CHAINGRAPH": ChaingraphAPI, + "FULCRUM": FulcrumProtocolAPI, "BITCOINCOM": BitcoinDotComAPI, } @@ -18,6 +22,9 @@ # Default sanitized endpoint, based on blockheigt, cache timeout DEFAULT_SANITIZED_ENDPOINTS_CACHE_TIME = 300 +# Max thread workers to get blockheight +THREADWORKERS = 6 + BCH_TO_SAT_MULTIPLIER = 100000000 NETWORKS = {"mainnet", "testnet", "regtest"} @@ -117,15 +124,30 @@ def get_sanitized_endpoints_for(network="mainnet"): :param network: network in ["mainnet", "testnet", "regtest"]. """ + + class ThreadedGetBlockheight: + def __init__(self, endpoints): + self.endpoints = endpoints + self.endpoints_blockheight = [0 for _ in range(len(endpoints))] + self._lock = threading.Lock() + + def update(self, ind): + try: + blockheight = self.endpoints[ind].get_blockheight( + timeout=DEFAULT_TIMEOUT + ) + except NetworkAPI.IGNORED_ERRORS: # pragma: no cover + return + with self._lock: + self.endpoints_blockheight[ind] = blockheight + endpoints = get_endpoints_for(network) - endpoints_blockheight = [0 for _ in range(len(endpoints))] + threadsafe_blockheight = ThreadedGetBlockheight(endpoints) + with concurrent.futures.ThreadPoolExecutor(max_workers=THREADWORKERS) as executor: + executor.map(threadsafe_blockheight.update, range(len(endpoints))) - for i, endpoint in enumerate(endpoints): - try: - endpoints_blockheight[i] = endpoint.get_blockheight(timeout=DEFAULT_TIMEOUT) - except NetworkAPI.IGNORED_ERRORS: # pragma: no cover - pass + endpoints_blockheight = threadsafe_blockheight.endpoints_blockheight if sum(endpoints_blockheight) == 0: raise ConnectionError("All APIs are unreachable.") # pragma: no cover diff --git a/docs/guide/network.rst b/docs/guide/network.rst index 80a56690..63de4132 100644 --- a/docs/guide/network.rst +++ b/docs/guide/network.rst @@ -115,12 +115,16 @@ Specifically, on `mainnet`, it can access: - ``_ via :class:`~bitcash.network.APIs.ChaingraphAPI.ChaingraphAPI` - ``_ via :class:`~bitcash.network.APIs.ChaingraphAPI.ChaingraphAPI` +- ``_ via :class:`~bitcash.network.APIs.FulcrumProtocolAPI.FulcrumProtocolAPI` +- ``_ via :class:`~bitcash.network.APIs.FulcrumProtocolAPI.FulcrumProtocolAPI` - ``_ via :class:`~bitcash.network.APIs.BitcoinDotComAPI.BitcoinDotComAPI` And on `testnet`, it can access: - ``_ via :class:`~bitcash.network.APIs.ChaingraphAPI.ChaingraphAPI` - ``_ via :class:`~bitcash.network.APIs.ChaingraphAPI.ChaingraphAPI` +- ``_ via :class:`~bitcash.network.APIs.FulcrumProtocolAPI.FulcrumProtocolAPI` +- ``_ via :class:`~bitcash.network.APIs.FulcrumProtocolAPI.FulcrumProtocolAPI` NetworkAPI @@ -131,7 +135,7 @@ it polls a service and if an error occurs it tries another. .. note:: Default chaingraph APIs do not indicate if a transaction broadcast has failed. The NetworkAPI fallbacks to - BitcoinDotComAPI on ``mainnet`` to broadcast a transaction. + FulcrumProtocolAPI on ``mainnet`` to broadcast a transaction. .. _satoshi: https://en.bitcoin.it/wiki/Satoshi_(unit) .. _blockchain: https://en.bitcoin.it/wiki/Block_chain diff --git a/docs/guide/node.rst b/docs/guide/node.rst index dde276bc..413aa944 100644 --- a/docs/guide/node.rst +++ b/docs/guide/node.rst @@ -3,11 +3,14 @@ Customize your node API endpoints ================================= -You can use your own or a compatible node (currently, `bch toolkit`_ and `ChainGraph`_ are supported and works out of the box) by setting the following environment variables:: +You can use your own or a compatible node (currently, `bch toolkit`_, `FulcrumProtocol`_, and `ChainGraph`_ are supported and work out of the box) by setting the following environment variables:: BITCOINCOM_API_MAINNET BITCOINCOM_API_TESTNET BITCOINCOM_API_REGTEST + FULCRUM_API_MAINNET + FULCRUM_API_TESTNET + FULCRUM_API_REGTEST CHAINGRAPH_API CHAINGRAPH_API_MAINNET CHAINGRAPH_API_TESTNET @@ -17,6 +20,12 @@ For example, for BitcoinDotComAPI:: export BITCOINCOM_API_MAINNET=https://rest.bitcoin.com/v2/ +For Fulcrum Protocol API:: + + export FULCRUM_API_MAINNET=electron.jochen-hoenicke.de:51002 + +The port is a necessary component for a Fulcrum Protocol uri. The Fulcrum protocol is connected directly via tcp, hence, avoid "http://" or "https://" prefix. + And for ChainGraph API:: export CHAINGRAPH_API=https://demo.chaingraph.cash/v1/graphql @@ -32,6 +41,11 @@ You can also specify multiple endpoints for redundancy by setting the following BITCOINCOM_API_MAINNET_3 and so on... or + FULCRUM_API_MAINNET_1 + FULCRUM_API_MAINNET_2 + FULCRUM_API_MAINNET_3 + and so on... + or CHAINGRAPH_API_1 CHAINGRAPH_API_2 CHAINGRAPH_API_MAINNET_1 @@ -46,3 +60,4 @@ This works with any supported network (mainnet, testnet and regtest). .. _bch toolkit: https://github.com/actorforth/bch-toolkit .. _ChainGraph: https://chaingraph.cash/ +.. _FulcrumProtocol: https://electrum-cash-protocol.readthedocs.io/en/latest/index.html diff --git a/tests/network/APIs/test_FulcrumProtolAPI.py b/tests/network/APIs/test_FulcrumProtolAPI.py new file mode 100644 index 00000000..64540dc9 --- /dev/null +++ b/tests/network/APIs/test_FulcrumProtolAPI.py @@ -0,0 +1,430 @@ +from _pytest.monkeypatch import MonkeyPatch +from decimal import Decimal + +from bitcash.network.APIs import FulcrumProtocolAPI as _fapi +from bitcash.network.transaction import Transaction, TxPart +from bitcash.network.APIs.FulcrumProtocolAPI import FulcrumProtocolAPI +from bitcash.network.meta import Unspent + + +BITCOIN_CASHADDRESS_CATKN = "bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v" + + +def dummy_handshake(hostname: str, port: int): + return + + +class DummySendPayload: + def __init__(self, return_result): + self.return_result = return_result + + def __call__(self, sock, message, payload): + return self.return_result[message] + + +class TestFulcrumProtolAPI: + def setup_method(self): + self.monkeypatch = MonkeyPatch() + self.monkeypatch.setattr(_fapi, "handshake", dummy_handshake) + self.api = FulcrumProtocolAPI("dummy.com:50002") + + def test_get_blockheight(self): + return_result = { + "height": 800_000, + "hex": "abcdef", + } + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.headers.get_tip": return_result}), + ) + blockheight = self.api.get_blockheight() + assert blockheight == 800_000 + + def test_get_balance(self): + return_result = {"confirmed": 3000, "unconfirmed": 0} + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.address.get_balance": return_result}), + ) + balance = self.api.get_balance(BITCOIN_CASHADDRESS_CATKN) + assert balance == 3000 + + # zero return + return_result = {"confirmed": 0, "unconfirmed": 0} + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.address.get_balance": return_result}), + ) + balance = self.api.get_balance(BITCOIN_CASHADDRESS_CATKN) + assert balance == 0 + + def test_get_transactions(self): + return_result = [ + { + "height": 5, + "tx_hash": "ae3cdb099d52da7dd4b1e16762b5788fd151e69836a45aba53adcecb02fccb4a", + }, + { + "height": 4, + "tx_hash": "d07ee04f7e5792ae848b778c8b802283aff77413865582ebba3f3c7c016e82a9", + }, + { + "height": 3, + "tx_hash": "2cd47128e4af9ab1c3df0bce0305c2bbf5ad7fdecbba3c0b73766294bc88eaf8", + }, + { + "height": 2, + "tx_hash": "616c8f5c64847645a9052d9aec822abf6fb526ec7b01cc918e392cdf24df8f89", + }, + { + "height": 1, + "tx_hash": "2c39cebc1ea104243f87d78a522ec1d499d98ffc2f145c08d474889573300dd7", + }, + ] + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.address.get_history": return_result}), + ) + transactions = self.api.get_transactions(BITCOIN_CASHADDRESS_CATKN) + + assert transactions == [ + "ae3cdb099d52da7dd4b1e16762b5788fd151e69836a45aba53adcecb02fccb4a", + "d07ee04f7e5792ae848b778c8b802283aff77413865582ebba3f3c7c016e82a9", + "2cd47128e4af9ab1c3df0bce0305c2bbf5ad7fdecbba3c0b73766294bc88eaf8", + "616c8f5c64847645a9052d9aec822abf6fb526ec7b01cc918e392cdf24df8f89", + "2c39cebc1ea104243f87d78a522ec1d499d98ffc2f145c08d474889573300dd7", + ] + + # zero return + return_result = [] + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.address.get_history": return_result}), + ) + transactions = self.api.get_transactions(BITCOIN_CASHADDRESS_CATKN) + + assert transactions == [] + + def test_get_transaction(self): + # since the get_raw_tx sends the same tx at each vin, the vin at index 0 matches + # vout at index 0 and same for vin at index 1. + return_result = { + "blockhash": "0000000000000000007c302f8790f32efb996a9d162408ce930e0e70ee3cbe8d", + "blocktime": 1684161846, + "confirmations": 52305, + "hash": "446f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", + "hex": "0200000002cff9a2b2ba6fa0c0bb958ead6178256bd354c3f63030eecd51e34d604fd9738400000000644177033dfa31b3ab4ad8a147d0b7bd10da60e7fe1df51bf1767f5ba7273767d7ffad55feec5c201ea89c6c07a1c8368d8a378aae2f48ddd2076324769b2c23a1ac4121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc600000000cf9a546cb9d6997c68c3c92a2daa658ef8e8778a30e92d7da0b963d0a213ef79010000006441b818b5c19459d64c4f16ac8fbaff844a6c0d05de8cf563173737d56908de56033a1e367f3c7cae8cf3240af06659bcde09d543bc064e208a31d576bbf074bb714121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc60000000003e80300000000000044efcff9a2b2ba6fa0c0bb958ead6178256bd354c3f63030eecd51e34d604fd9738410ff0078a6982000000076a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac0000000000000000706a0442434d52206b2000be5ce5527cd653c49cdba486e2fd0ec4214da2f71d7e56ad027b2139f448676973742e67697468756275736572636f6e74656e742e636f6d2f6d722d7a776574732f38346230303537383038616632306466333932383135666232376434613636312f72617745430000000000001976a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac00000000", + "locktime": 0, + "size": 524, + "time": 1684161846, + "txid": "446f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", + "version": 2, + "vin": [ + { + "scriptSig": { + "asm": "77033dfa31b3ab4ad8a147d0b7bd10da60e7fe1df51bf1767f5ba7273767d7ffad55feec5c201ea89c6c07a1c8368d8a378aae2f48ddd2076324769b2c23a1ac[ALL|FORKID] 031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", + "hex": "4177033dfa31b3ab4ad8a147d0b7bd10da60e7fe1df51bf1767f5ba7273767d7ffad55feec5c201ea89c6c07a1c8368d8a378aae2f48ddd2076324769b2c23a1ac4121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", + }, + "sequence": 0, + "txid": "446f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", + "vout": 0, + }, + { + "scriptSig": { + "asm": "b818b5c19459d64c4f16ac8fbaff844a6c0d05de8cf563173737d56908de56033a1e367f3c7cae8cf3240af06659bcde09d543bc064e208a31d576bbf074bb71[ALL|FORKID] 031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", + "hex": "41b818b5c19459d64c4f16ac8fbaff844a6c0d05de8cf563173737d56908de56033a1e367f3c7cae8cf3240af06659bcde09d543bc064e208a31d576bbf074bb714121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", + }, + "sequence": 0, + "txid": "446f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", + "vout": 1, + }, + ], + "vout": [ + { + "n": 0, + "scriptPubKey": { + "addresses": [ + "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4" + ], + "asm": "OP_DUP OP_HASH160 8ee26d6c9f58369f94864dc3630cdeb17fae2f2d OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "reqSigs": 1, + "type": "pubkeyhash", + }, + "tokenData": { + "amount": "140000000000", + "category": "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", + }, + "value": Decimal("0.00001"), + }, + { + "n": 1, + "scriptPubKey": { + "asm": "OP_RETURN 1380795202 6b2000be5ce5527cd653c49cdba486e2fd0ec4214da2f71d7e56ad027b2139f4 676973742e67697468756275736572636f6e74656e742e636f6d2f6d722d7a776574732f38346230303537383038616632306466333932383135666232376434613636312f726177", + "hex": "6a0442434d52206b2000be5ce5527cd653c49cdba486e2fd0ec4214da2f71d7e56ad027b2139f448676973742e67697468756275736572636f6e74656e742e636f6d2f6d722d7a776574732f38346230303537383038616632306466333932383135666232376434613636312f726177", + "type": "nulldata", + }, + "value": 0, + }, + ], + } + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload( + { + "blockchain.transaction.get": return_result, + "blockchain.headers.get_tip": {"height": 845080}, + } + ), + ) + transaction = self.api.get_transaction(BITCOIN_CASHADDRESS_CATKN) + tx = Transaction( + "446f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", + 792776, + 1000, + 1000, + 0, + ) + tx.inputs = [ + TxPart( + "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + 1000, + category_id="8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", + token_amount=140000000000, + data_hex="76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + ), + TxPart( + None, + 0, + data_hex="6a0442434d52206b2000be5ce5527cd653c49cdba486e2fd0ec4214da2f71d7e56ad027b2139f448676973742e67697468756275736572636f6e74656e742e636f6d2f6d722d7a776574732f38346230303537383038616632306466333932383135666232376434613636312f726177", + ), + ] + tx.outputs = [ + TxPart( + "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + 1000, + category_id="8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", + token_amount=140000000000, + data_hex="76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + ), + TxPart( + None, + 0, + data_hex="6a0442434d52206b2000be5ce5527cd653c49cdba486e2fd0ec4214da2f71d7e56ad027b2139f448676973742e67697468756275736572636f6e74656e742e636f6d2f6d722d7a776574732f38346230303537383038616632306466333932383135666232376434613636312f726177", + ), + ] + + print(transaction.to_dict()) + print(tx.to_dict()) + assert transaction == tx + + # unconfirmed tx + for x in ["blockhash", "blocktime", "confirmations", "time"]: + return_result.pop(x) + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload( + { + "blockchain.transaction.get": return_result, + "blockchain.headers.get_tip": {"height": 845080}, + } + ), + ) + transaction = self.api.get_transaction(BITCOIN_CASHADDRESS_CATKN) + tx.block = None + assert transaction == tx + + def test_get_tx_amount(self): + return_result = { + "blockhash": "00000000000000000098d704446b13bb34ebeeabb687edd4e5f930a5ebfcb8b1", + "blocktime": 1715277577, + "confirmations": 2, + "hash": "faea8b55d0a08422a9a363747b5737f9d18a54dbe70dab4c8e8cd89946c173b4", + "hex": "0100000001cd00487abcfb3a1209d866a2cdb63ef24f3d834e512feaa67ba5ae45155d0766010000006a473044022027d433db2a43816d51707f8126529168788001cfa795fc6484616d60ad2147300220389c8fc40a31133fab1a6757000e32f965d7c5f1ae5c4b2b91e92982a8fe2983412103b632b7037149d41ebaf9ed21321cb5f811f3c61bd6102192e3447cd113040dfaffffffff026e4d23e3000000001976a91411f3e637d0925951f379ee6ef151ccc04873c27488ac6a205300000000001976a9149b0a33f7858e3e3b46298da95e63f2745a66056f88ac00000000", + "locktime": 0, + "size": 225, + "time": 1715277577, + "txid": "faea8b55d0a08422a9a363747b5737f9d18a54dbe70dab4c8e8cd89946c173b4", + "version": 1, + "vin": [ + { + "scriptSig": { + "asm": "3044022027d433db2a43816d51707f8126529168788001cfa795fc6484616d60ad2147300220389c8fc40a31133fab1a6757000e32f965d7c5f1ae5c4b2b91e92982a8fe2983[ALL|FORKID] 03b632b7037149d41ebaf9ed21321cb5f811f3c61bd6102192e3447cd113040dfa", + "hex": "473044022027d433db2a43816d51707f8126529168788001cfa795fc6484616d60ad2147300220389c8fc40a31133fab1a6757000e32f965d7c5f1ae5c4b2b91e92982a8fe2983412103b632b7037149d41ebaf9ed21321cb5f811f3c61bd6102192e3447cd113040dfa", + }, + "sequence": 4294967295, + "txid": "66075d1545aea57ba6ea2f514e833d4ff23eb6cda266d809123afbbc7a4800cd", + "vout": 1, + } + ], + "vout": [ + { + "n": 0, + "scriptPubKey": { + "addresses": [ + "bitcoincash:qqgl8e3h6zf9j50n08hxau23enqysu7zwscgz405rc" + ], + "asm": "OP_DUP OP_HASH160 11f3e637d0925951f379ee6ef151ccc04873c274 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a91411f3e637d0925951f379ee6ef151ccc04873c27488ac", + "reqSigs": 1, + "type": "pubkeyhash", + }, + "value": Decimal("38.10741614"), + }, + { + "n": 1, + "scriptPubKey": { + "addresses": [ + "bitcoincash:qzds5vlhsk8ruw6x9xx6jhnr7f695es9duz4ctlv4a" + ], + "asm": "OP_DUP OP_HASH160 9b0a33f7858e3e3b46298da95e63f2745a66056f OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a9149b0a33f7858e3e3b46298da95e63f2745a66056f88ac", + "reqSigs": 1, + "type": "pubkeyhash", + }, + "value": Decimal("0.05447786"), + }, + ], + } + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.transaction.get": return_result}), + ) + amount = self.api.get_tx_amount( + "faea8b55d0a08422a9a363747b5737f9d18a54dbe70dab4c8e8cd89946c173b4", 0 + ) + assert amount == 3810741614 + + def test_get_unspent(self): + return_result = [ + { + "height": 825636, + "token_data": { + "amount": "10000", + "category": "357dc834af514958b5cb9d5407c26af12e81f442599fbfb99f108563cea126f0", + }, + "tx_hash": "bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + "tx_pos": 0, + "value": 657, + }, + { + "height": 825636, + "token_data": { + "amount": "10000", + "category": "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + "nft": {"capability": "none", "commitment": "62697463617368"}, + }, + "tx_hash": "bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + "tx_pos": 1, + "value": 681, + }, + { + "height": 825636, + "token_data": { + "amount": "0", + "category": "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + "nft": {"capability": "minting", "commitment": ""}, + }, + "tx_hash": "bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + "tx_pos": 2, + "value": 648, + }, + { + "height": 825636, + "token_data": { + "amount": "10000", + "category": "60f451f3cb0ea81fd6c68cf2d42b708bfdf6d74cd08d75c8a7a515ff8adce4ae", + "nft": {"capability": "minting", "commitment": ""}, + }, + "tx_hash": "bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + "tx_pos": 3, + "value": 895078, + }, + ] + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload( + { + "blockchain.address.listunspent": return_result, + "blockchain.headers.get_tip": {"height": 845080}, + } + ), + ) + unspents = self.api.get_unspent(BITCOIN_CASHADDRESS_CATKN) + assert unspents == [ + Unspent( + amount=657, + confirmations=19445, + script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + txindex=0, + category_id="357dc834af514958b5cb9d5407c26af12e81f442599fbfb99f108563cea126f0", + token_amount=10000, + ), + Unspent( + amount=681, + confirmations=19445, + script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + txindex=1, + category_id="afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + nft_capability="none", + nft_commitment=b"bitcash", + token_amount=10000, + ), + Unspent( + amount=648, + confirmations=19445, + script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + txindex=2, + category_id="afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + nft_capability="minting", + ), + Unspent( + amount=895078, + confirmations=19445, + script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", + txindex=3, + category_id="60f451f3cb0ea81fd6c68cf2d42b708bfdf6d74cd08d75c8a7a515ff8adce4ae", + nft_capability="minting", + token_amount=10000, + ), + ] + + # zero return + return_result = [] + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload( + { + "blockchain.address.listunspent": return_result, + "blockchain.headers.get_tip": {"height": 845080}, + } + ), + ) + unspents = self.api.get_unspent(BITCOIN_CASHADDRESS_CATKN) + assert unspents == [] + + def test_get_raw_transaction(self): + return_result = {"dummy": "dummy"} + self.monkeypatch.setattr( + _fapi, + "send_json_rpc_payload", + DummySendPayload({"blockchain.transaction.get": return_result}), + ) + tx = self.api.get_raw_transaction( + "446f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", + ) + assert tx == {"dummy": "dummy"} diff --git a/tests/network/test_services.py b/tests/network/test_services.py index 7e8d4c90..5dfba6b3 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -6,6 +6,7 @@ from _pytest.monkeypatch import MonkeyPatch from bitcash.exceptions import InvalidEndpointURLProvided from bitcash.network import services as _services +from bitcash.network.APIs.FulcrumProtocolAPI import FulcrumProtocolAPI from bitcash.network.meta import Unspent from bitcash.network.services import ( BitcoinDotComAPI, @@ -16,7 +17,11 @@ set_service_timeout, ) from bitcash.network.transaction import Transaction -from tests.samples import VALID_ENDPOINT_URLS, INVALID_ENDPOINT_URLS +from tests.samples import ( + VALID_BITCOINCOM_ENDPOINT_URLS, + INVALID_BITCOINCOM_ENDPOINT_URLS, + VALID_FULCRUM_ENDPOINT_URLS, +) from tests.utils import ( catch_errors_raise_warnings, decorate_methods, @@ -209,47 +214,74 @@ class TestBitcoinDotComAPI: # rate limiting and will return 503 if we query it too quickly. def test_invalid_endpoint_url_mainnet(self): - for url in INVALID_ENDPOINT_URLS: + for url in INVALID_BITCOINCOM_ENDPOINT_URLS: with pytest.raises(InvalidEndpointURLProvided): BitcoinDotComAPI(url) def test_get_single_endpoint_for_env_variable_bitcoincom(self, reset_environ): - os.environ["BITCOINCOM_API_MAINNET"] = VALID_ENDPOINT_URLS[0] + os.environ["BITCOINCOM_API_MAINNET"] = VALID_BITCOINCOM_ENDPOINT_URLS[0] + os.environ["CHAINGRAPH_API_MAINNET"] = "%mainnet" + endpoints = get_endpoints_for("mainnet") + assert len(endpoints) == 5 + assert isinstance(endpoints[0], ChaingraphAPI) # default + assert isinstance(endpoints[1], ChaingraphAPI) # default + assert isinstance(endpoints[2], FulcrumProtocolAPI) # default + assert isinstance(endpoints[3], FulcrumProtocolAPI) # default + assert isinstance(endpoints[4], BitcoinDotComAPI) # env + + def test_get_single_endpoint_for_env_variable_fulcrum(self, reset_environ): + os.environ["FULCRUM_API_MAINNET"] = VALID_FULCRUM_ENDPOINT_URLS[0] os.environ["CHAINGRAPH_API_MAINNET"] = "%mainnet" endpoints = get_endpoints_for("mainnet") - assert len(endpoints) == 3 + assert len(endpoints) == 4 assert isinstance(endpoints[0], ChaingraphAPI) # default assert isinstance(endpoints[1], ChaingraphAPI) # default - assert isinstance(endpoints[2], BitcoinDotComAPI) # env + assert isinstance(endpoints[2], FulcrumProtocolAPI) # env + assert isinstance(endpoints[3], BitcoinDotComAPI) # default def test_get_single_endpoint_for_env_variable_chaingraph(self, reset_environ): - os.environ["CHAINGRAPH_API"] = VALID_ENDPOINT_URLS[0] + os.environ["CHAINGRAPH_API"] = VALID_BITCOINCOM_ENDPOINT_URLS[0] os.environ["CHAINGRAPH_API_MAINNET"] = "%mainnet" endpoints = get_endpoints_for("mainnet") - assert len(endpoints) == 2 + assert len(endpoints) == 4 assert isinstance(endpoints[0], ChaingraphAPI) # env - assert isinstance(endpoints[1], BitcoinDotComAPI) # default + assert isinstance(endpoints[1], FulcrumProtocolAPI) # default + assert isinstance(endpoints[2], FulcrumProtocolAPI) # default + assert isinstance(endpoints[3], BitcoinDotComAPI) # default assert endpoints[0].node_like == "%mainnet" def test_get_multiple_endpoint_for_env_variable_bitcoincom(self, reset_environ): - os.environ["BITCOINCOM_API_MAINNET_1"] = VALID_ENDPOINT_URLS[0] - os.environ["BITCOINCOM_API_MAINNET_2"] = VALID_ENDPOINT_URLS[1] + os.environ["BITCOINCOM_API_MAINNET_1"] = VALID_BITCOINCOM_ENDPOINT_URLS[0] + os.environ["BITCOINCOM_API_MAINNET_2"] = VALID_BITCOINCOM_ENDPOINT_URLS[1] + endpoints = get_endpoints_for("mainnet") + assert len(endpoints) == 6 + assert isinstance(endpoints[0], ChaingraphAPI) # default + assert isinstance(endpoints[1], ChaingraphAPI) # default + assert isinstance(endpoints[2], FulcrumProtocolAPI) # default + assert isinstance(endpoints[3], FulcrumProtocolAPI) # default + assert isinstance(endpoints[4], BitcoinDotComAPI) # env + assert isinstance(endpoints[5], BitcoinDotComAPI) # env + + def test_get_multiple_endpoint_for_env_variable_fulcrum(self, reset_environ): + os.environ["FULCRUM_API_MAINNET_1"] = VALID_FULCRUM_ENDPOINT_URLS[0] endpoints = get_endpoints_for("mainnet") assert len(endpoints) == 4 assert isinstance(endpoints[0], ChaingraphAPI) # default assert isinstance(endpoints[1], ChaingraphAPI) # default - assert isinstance(endpoints[2], BitcoinDotComAPI) # env - assert isinstance(endpoints[3], BitcoinDotComAPI) # env + assert isinstance(endpoints[2], FulcrumProtocolAPI) # env + assert isinstance(endpoints[3], BitcoinDotComAPI) # default def test_get_multiple_endpoint_for_env_variable_chaingraph(self, reset_environ): os.environ["CHAINGRAPH_API_1"] = "https://demo.chaingraph.cash/v1/graphql" os.environ["CHAINGRAPH_API_2"] = "https://demo.chaingraph.cash/v1/graphql" os.environ["CHAINGRAPH_API_MAINNET_2"] = "%mainnet" endpoints = get_endpoints_for("mainnet") - assert len(endpoints) == 3 + assert len(endpoints) == 5 assert isinstance(endpoints[0], ChaingraphAPI) # default assert isinstance(endpoints[1], ChaingraphAPI) # default - assert isinstance(endpoints[2], BitcoinDotComAPI) # env + assert isinstance(endpoints[2], FulcrumProtocolAPI) # default + assert isinstance(endpoints[3], FulcrumProtocolAPI) # default + assert isinstance(endpoints[4], BitcoinDotComAPI) # env assert endpoints[0].node_like == "%" assert endpoints[1].node_like == "%mainnet" diff --git a/tests/samples.py b/tests/samples.py index c00c21eb..42e06a04 100644 --- a/tests/samples.py +++ b/tests/samples.py @@ -37,12 +37,18 @@ "bchreg:pp23x8hm0g8d6nrkesamaqeml3v6daeudvlnvykj0n" ) -VALID_ENDPOINT_URLS = [ +VALID_BITCOINCOM_ENDPOINT_URLS = [ "https://rest.bch.actorforth.org/v2/", "https://rest.bitcoin.com/v2/", ] -INVALID_ENDPOINT_URLS = ["htp://fakesite.com/v2", "https://bitcom.org/", 42] +INVALID_BITCOINCOM_ENDPOINT_URLS = ["htp://fakesite.com/v2", "https://bitcom.org/", 42] + +VALID_FULCRUM_ENDPOINT_URLS = [ + "electron.jochen-hoenicke.de:51002" +] + +INVALID_FULCRUM_ENDPOINT_URLS = ["electron.jochen-hoenicke.de", 42] PRIVATE_KEY_BYTES = b"\xc2\x8a\x9f\x80s\x8fw\rRx\x03\xa5f\xcfo\xc3\xed\xf6\xce\xa5\x86\xc4\xfcJR#\xa5\xady~\x1a\xc3" PRIVATE_KEY_DER = (