diff --git a/HISTORY.rst b/HISTORY.rst index 4b2b786..2d9fdfe 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -29,6 +29,15 @@ Unreleased (see `master `_) Use ``key.subscribe(callback)`` to receive notifications when an address's state changes. +- Add ``NetworkAPI.get_cashtoken_addresses(category_id, ...)`` to fetch + all addresses holding unspent outputs of a given CashToken category, + with optional filters for NFT capability, NFT commitment, and fungible + token presence. Supported by ChaingraphAPI only. + +- All API endpoint classes (``ChaingraphAPI``, ``FulcrumProtocolAPI``, + ``BitcoinDotComAPI``) now accept a ``network`` parameter at construction + and expose it as ``self.network``. + 0.5.2 (2018-05-16) ------------------ diff --git a/README.md b/README.md index 1a151ce..fcfd0b8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ https://explorer.bitcoin.com/bch/tx/6aea7b1c687d976644a430a87e34c93a8a7fd52d77c3 - Compressed public keys by default - Multiple representations of private keys; WIF, PEM, DER, etc. - Standard P2PKH transactions +- CashToken support (fungible tokens and NFTs) +- Real-time address subscriptions via Fulcrum/ElectrumX protocol If you are intrigued, continue reading. If not, continue all the same! diff --git a/bitcash/cashaddress.py b/bitcash/cashaddress.py index 97a9591..4732099 100644 --- a/bitcash/cashaddress.py +++ b/bitcash/cashaddress.py @@ -195,13 +195,20 @@ def scriptcode(self) -> bytes: raise ValueError("Locking script not implemented for this address type") @classmethod - def from_script(cls, scriptcode: bytes) -> Address: + def from_script(cls, scriptcode: bytes, network: Network = Network.main) -> Address: """ Generate Address from a locking script :param scriptcode: The locking script + :param network: Network — :attr:`~bitcash.types.Network.main`, + :attr:`~bitcash.types.Network.test`, or :attr:`~bitcash.types.Network.regtest` :returns: Instance of :class:~bitcash.cashaddress.Address """ + net_suffix = ( + "-TESTNET" + if network == Network.test + else "-REGTEST" if network == Network.regtest else "" + ) # cashtoken suffix catkn = "" if scriptcode.startswith(OpCodes.OP_TOKENPREFIX.binary): @@ -232,19 +239,19 @@ def from_script(cls, scriptcode: bytes) -> Address: ) and scriptcode.endswith( OpCodes.OP_EQUALVERIFY.binary + OpCodes.OP_CHECKSIG.binary ): - return cls("P2PKH" + catkn, list(scriptcode[3:23])) + return cls("P2PKH" + catkn + net_suffix, list(scriptcode[3:23])) # P2SH20 if len(scriptcode) == 23: if scriptcode.startswith( OpCodes.OP_HASH160.binary + OpCodes.OP_DATA_20.binary ) and scriptcode.endswith(OpCodes.OP_EQUAL.binary): - return cls("P2SH20" + catkn, list(scriptcode[2:22])) + return cls("P2SH20" + catkn + net_suffix, list(scriptcode[2:22])) # P2SH32 if len(scriptcode) == 35: if scriptcode.startswith( OpCodes.OP_HASH256.binary + OpCodes.OP_DATA_32.binary ) and scriptcode.endswith(OpCodes.OP_EQUAL.binary): - return cls("P2SH32" + catkn, list(scriptcode[2:34])) + return cls("P2SH32" + catkn + net_suffix, list(scriptcode[2:34])) raise ValueError("Unknown script") @classmethod diff --git a/bitcash/network/APIs/BitcoinDotComAPI.py b/bitcash/network/APIs/BitcoinDotComAPI.py index 0e51b39..3840430 100644 --- a/bitcash/network/APIs/BitcoinDotComAPI.py +++ b/bitcash/network/APIs/BitcoinDotComAPI.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Callable +from typing import Any, Callable, Optional from bitcash.network.http import session from decimal import Decimal from bitcash.exceptions import InvalidEndpointURLProvided @@ -9,7 +9,7 @@ from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction, TxPart from bitcash.format import cashtokenaddress_to_address -from bitcash.types import CashTokens, NFTCapability, NetworkStr +from bitcash.types import CashTokens, NFTCapability, Network, NetworkStr # This class is the interface for Bitcash to interact with # Bitcoin.com based RESTful interfaces. @@ -21,7 +21,7 @@ class BitcoinDotComAPI(BaseAPI): """rest.bitcoin.com API""" - def __init__(self, network_endpoint: str): + def __init__(self, network_endpoint: str, network: Network = Network.main): try: assert isinstance(network_endpoint, str) assert network_endpoint[:4] == "http" @@ -33,6 +33,7 @@ def __init__(self, network_endpoint: str): ) self.network_endpoint = network_endpoint + self.network = network # Default endpoints to use for this interface DEFAULT_ENDPOINTS = { @@ -245,6 +246,19 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: r.raise_for_status() return r.json(parse_float=Decimal) + def get_cashtoken_addresses( + self, + category_id: str, + nft_capability: Optional[NFTCapability] = None, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, + ) -> set[str]: + raise NotImplementedError( + "BitcoinDotComAPI does not support querying addresses by token category" + ) + def broadcast_tx(self, tx_hex: str, *args, **kwargs) -> bool: # pragma: no cover api_url = self.make_endpoint_url("raw-tx") r = session.post(api_url, json={"hexes": [tx_hex]}, *args, **kwargs) diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 9adeadb..4136698 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -11,7 +11,7 @@ from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction, TxPart from bitcash.cashaddress import Address -from bitcash.types import NetworkStr +from bitcash.types import NFTCapability, Network, NetworkStr class ChaingraphAPI(BaseAPI): @@ -21,7 +21,12 @@ class ChaingraphAPI(BaseAPI): :param node_like: node name to match for with "_like" string comparison expression """ - def __init__(self, network_endpoint: str, node_like: Optional[str] = None): + def __init__( + self, + network_endpoint: str, + node_like: Optional[str] = None, + network: Network = Network.main, + ): try: assert isinstance(network_endpoint, str) except AssertionError: @@ -35,6 +40,7 @@ def __init__(self, network_endpoint: str, node_like: Optional[str] = None): self.node_like = "%" else: self.node_like = node_like + self.network = network # Default endpoints to use for this interface DEFAULT_ENDPOINTS = { @@ -242,8 +248,9 @@ def get_transaction(self, txid: str, *args, **kwargs) -> Transaction: txpart = txpart["outpoint"] try: scriptcode = bytes.fromhex(txpart["locking_bytecode"][2:]) - cashaddress = Address.from_script(scriptcode) - cashaddress = cashaddress.cash_address() + cashaddress = Address.from_script( + scriptcode, self.network + ).cash_address() except ValueError: cashaddress = None part = TxPart(cashaddress, sats, data_hex=data_hex) @@ -447,6 +454,83 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: raise DataNotFound(f"Transaction {txid} does not exist") return json["data"]["transaction"][0] + def get_cashtoken_addresses( + self, + category_id: str, + nft_capability: Optional[NFTCapability] = None, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, + ) -> set[str]: + variables: dict[str, Any] = { + "category": f"\\x{category_id}", + "node": self.node_like, + } + + extra_filters = [] + extra_decls = "" + if nft_capability is not None: + extra_filters.append( + "nonfungible_token_capability: { _eq: $nft_capability }" + ) + variables["nft_capability"] = nft_capability.name + extra_decls += ", $nft_capability: enum_nonfungible_token_capability" + if nft_commitment is not None: + extra_filters.append("nonfungible_token_commitment: { _eq: $commitment }") + variables["commitment"] = f"\\x{nft_commitment.hex()}" + extra_decls += ", $commitment: bytea" + if has_token: + extra_filters.append('fungible_token_amount: { _gt: "0" }') + + extra = "\n " + "\n ".join(extra_filters) if extra_filters else "" + + query = ( + "query GetCashtokenAddresses" + "($category: bytea!, $node: String!" + + extra_decls + + """) { + output( + where: { + token_category: { _eq: $category } + _not: { spent_by: {} }""" + + extra + + """ + _or: [ + { + transaction: { + node_validations: { node: { name: { _like: $node } } } + } + } + { + transaction: { + block_inclusions: { + block: { accepted_by: { node: { name: { _like: $node } } } } + } + } + } + ] + } + ) { + locking_bytecode + } +}""" + ) + + json = self.send_request( + {"query": query, "variables": variables}, *args, **kwargs + ) + addresses: set[str] = set() + for output in json["data"]["output"]: + try: + scriptcode = bytes.fromhex(output["locking_bytecode"][2:]) + addresses.add( + Address.from_script(scriptcode, self.network).cash_address() + ) + except ValueError: + pass + return addresses + def broadcast_tx(self, tx_hex: str, *args, **kwargs) -> bool: # pragma: no cover json_request = { "query": """ diff --git a/bitcash/network/APIs/FulcrumProtocolAPI.py b/bitcash/network/APIs/FulcrumProtocolAPI.py index 2338513..12486ac 100644 --- a/bitcash/network/APIs/FulcrumProtocolAPI.py +++ b/bitcash/network/APIs/FulcrumProtocolAPI.py @@ -7,7 +7,7 @@ import threading import typing from requests.exceptions import ConnectTimeout, ContentDecodingError -from typing import Any, Callable, Union +from typing import Any, Callable, Optional, Union from bitcash.exceptions import ( InvalidEndpointURLProvided, @@ -18,7 +18,7 @@ from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction, TxPart from bitcash.cashaddress import Address -from bitcash.types import NetworkStr +from bitcash.types import NFTCapability, Network, NetworkStr context = ssl.create_default_context() FULCRUM_PROTOCOL = "1.5.0" @@ -111,7 +111,12 @@ class FulcrumProtocolAPI(BaseAPI): "regtest": [], } - def __init__(self, network_endpoint: str, timeout: float = DEFAULT_SOCKET_TIMEOUT): + def __init__( + self, + network_endpoint: str, + timeout: float = DEFAULT_SOCKET_TIMEOUT, + network: Network = Network.main, + ): try: assert isinstance(network_endpoint, str) except AssertionError: @@ -130,6 +135,7 @@ def __init__(self, network_endpoint: str, timeout: float = DEFAULT_SOCKET_TIMEOU self.port = int(port) self.timeout = timeout + self.network = network self.sock: Union[None, socket.socket, ssl.SSLSocket] = None self._sock_lock = threading.Lock() @@ -303,6 +309,19 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: ) return typing.cast(dict[str, Any], result) + def get_cashtoken_addresses( + self, + category_id: str, + nft_capability: Optional[NFTCapability] = None, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, + ) -> set[str]: + raise NotImplementedError( + "FulcrumProtocolAPI does not support querying addresses by token category" + ) + def broadcast_tx(self, tx_hex: str, *args, **kwargs) -> bool: # pragma: no cover self._send_rpc("blockchain.transaction.broadcast", [tx_hex], *args, **kwargs) return True diff --git a/bitcash/network/APIs/__init__.py b/bitcash/network/APIs/__init__.py index 8d616dd..ce83921 100644 --- a/bitcash/network/APIs/__init__.py +++ b/bitcash/network/APIs/__init__.py @@ -1,11 +1,11 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any, Callable +from typing import Any, Callable, Optional from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction -from bitcash.types import NetworkStr +from bitcash.types import NFTCapability, Network, NetworkStr class BaseAPI(ABC): @@ -13,10 +13,12 @@ class BaseAPI(ABC): Abstract class for API classes :param network_endpoint: Network endpoint to send requests + :param network: The BCH network this endpoint serves """ - def __init__(self, network_endpoint: str): + def __init__(self, network_endpoint: str, network: Network = Network.main): self.network_endpoint = network_endpoint + self.network = network @classmethod @abstractmethod @@ -119,6 +121,29 @@ def subscribe_address( :return: A SubscriptionHandle object for managing the subscription. """ + @abstractmethod + def get_cashtoken_addresses( + self, + category_id: str, + nft_capability: Optional[NFTCapability] = None, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, + ) -> set[str]: + """Gets all addresses holding unspent outputs of a given cashtoken category. + + :param category_id: The token category ID (hex string). + :param nft_capability: If set, only return addresses holding an NFT with this capability + (one of :attr:`~bitcash.types.NFTCapability.none`, + :attr:`~bitcash.types.NFTCapability.mutable`, + :attr:`~bitcash.types.NFTCapability.minting`). + :param nft_commitment: If set, only return addresses holding an NFT with this commitment. + :param has_token: If True, only return addresses holding fungible tokens of this category. + :returns: A set of addresses holding the cashtoken. + :raises NotImplementedError: If the API does not support this query. + """ + class SubscriptionHandle: """ diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 06c8347..b2d939a 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -5,7 +5,7 @@ import ssl import os import threading -from typing import Any, Callable +from typing import Any, Callable, Optional import requests @@ -18,7 +18,7 @@ from bitcash.network.APIs.FulcrumProtocolAPI import FulcrumProtocolAPI from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction -from bitcash.types import Network, NetworkStr +from bitcash.types import NFTCapability, Network, NetworkStr from bitcash.utils import time_cache # Dictionary of supported endpoint APIs @@ -56,7 +56,7 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: # however many endpoints you'd like. # If neither of these env variables have been set, it returns # the instantiated result of .get_default_endpoints(network) - _ = Network(network) # Validate network input + network_enum = Network(network) # Validate network input endpoints: list[BaseAPI] = [] for endpoint in ENDPOINT_ENV_VARIABLES.keys(): @@ -66,6 +66,7 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: ENDPOINT_ENV_VARIABLES[endpoint]( os.getenv(f"{endpoint}_API".upper()), os.getenv(f"{endpoint}_API_{network}".upper()), + network=network_enum, ) ) elif os.getenv(f"{endpoint}_API_1".upper()): @@ -79,7 +80,7 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: if next_endpoint: endpoints.append( ENDPOINT_ENV_VARIABLES[endpoint]( - next_endpoint, next_pattern + next_endpoint, next_pattern, network=network_enum ) ) counter += 1 @@ -91,14 +92,21 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: ].get_default_endpoints(network) for each in defaults_endpoints: if hasattr(each, "__iter__") and not isinstance(each, str): - endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](*each)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint]( + *each, network=network_enum + ) + ) else: - endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](each)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint](each, network=network_enum) + ) else: if os.getenv(f"{endpoint}_API_{network}".upper()): endpoints.append( ENDPOINT_ENV_VARIABLES[endpoint]( - os.getenv(f"{endpoint}_API_{network}".upper()) + os.getenv(f"{endpoint}_API_{network}".upper()), + network=network_enum, ) ) elif os.getenv(f"{endpoint}_API_{network}_1".upper()): @@ -110,7 +118,9 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: ) if next_endpoint: endpoints.append( - ENDPOINT_ENV_VARIABLES[endpoint](next_endpoint) + ENDPOINT_ENV_VARIABLES[endpoint]( + next_endpoint, network=network_enum + ) ) counter += 1 else: @@ -121,9 +131,15 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: ].get_default_endpoints(network) for each in defaults_endpoints: if hasattr(each, "__iter__") and not isinstance(each, str): - endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](*each)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint]( + *each, network=network_enum + ) + ) else: - endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](each)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint](each, network=network_enum) + ) return tuple(endpoints) @@ -327,6 +343,44 @@ def get_raw_transaction( raise ConnectionError("All APIs are unreachable.") # pragma: no cover + @classmethod + def get_cashtoken_addresses( + cls, + category_id: str, + network: NetworkStr = "mainnet", + nft_capability: Optional[NFTCapability] = None, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + ) -> set[str]: + """Gets all addresses holding unspent outputs of a given cashtoken category. + + :param category_id: The token category ID (hex string). + :param network: The network to query. + :param nft_capability: If set, only return addresses holding an NFT with this capability + (one of :attr:`~bitcash.types.NFTCapability.none`, + :attr:`~bitcash.types.NFTCapability.mutable`, + :attr:`~bitcash.types.NFTCapability.minting`). + :param nft_commitment: If set, only return addresses holding an NFT with this commitment. + :param has_token: If True, only return addresses holding fungible tokens of this category. + :returns: A set of addresses holding the cashtoken. + :raises ConnectionError: If all API services fail. + """ + for endpoint in get_sanitized_endpoints_for(network): + try: + return endpoint.get_cashtoken_addresses( + category_id, + nft_capability, + nft_commitment, + has_token, + timeout=DEFAULT_TIMEOUT, + ) + except NotImplementedError: + continue + except cls.IGNORED_ERRORS: # pragma: no cover + pass + + raise ConnectionError("All APIs are unreachable.") # pragma: no cover + @classmethod def broadcast_tx( cls, tx_hex: str, network: NetworkStr = "mainnet" diff --git a/docs/guide/cashtokens.rst b/docs/guide/cashtokens.rst index d2699e9..e35c018 100644 --- a/docs/guide/cashtokens.rst +++ b/docs/guide/cashtokens.rst @@ -50,7 +50,7 @@ belonging to your address: >>> key = Key(...) >>> key.get_unspents() - [Unspent(amount=900000, confirmations=1, script='76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac', txid='afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802', txindex=0)] + [Unspent(amount=900000, confirmations=1, script='76a91492461bde6283b461ece7ddf4dbf1e0a48bd113d888ac', txid='afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802', txindex=0)] In the above example, the unspent output has an output index 0, which implies it can be a cashtoken genesis unspent. The cashtoken generated with this unspent will have a @@ -63,7 +63,7 @@ nft_capability, nft_commitment, token_amount)`. This can be sent as: >>> key.send([ ... ( - ... "bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v", # destination + ... "bitcoincash:zzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmq37yf2mzf", # destination ... 1000, # amount ... "satoshi", # currency ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", # category @@ -88,7 +88,7 @@ send 6000 fungible tokens of ``category`` ``afe979...`` you can use: >>> key.send([ ... ( - ... "bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v", + ... "bitcoincash:zzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmq37yf2mzf", ... 1000, ... "satoshi", ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", @@ -110,7 +110,7 @@ We can further use the "minting" ``capability`` of NFT to mint a cashtoken of "m >>> key.send( ... [ ... ( - ... "bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v", + ... "bitcoincash:zzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmq37yf2mzf", ... 1000, ... "satoshi", ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", @@ -161,6 +161,62 @@ If the default behaviour is not suitable, then a curated unspent set can be spec which only includes cashtokens which need to be used. +CashToken holders +----------------- + +To find all addresses currently holding unspent outputs of a given cashtoken +``category``, use :func:`~bitcash.network.NetworkAPI.get_cashtoken_addresses`: + +.. code-block:: python + + >>> from bitcash.network import NetworkAPI + >>> NetworkAPI.get_cashtoken_addresses( + ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802" + ... ) + {'bitcoincash:qzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmqk5hhyaa6', + 'bitcoincash:qzvsaasdvw6mt9j2rs3gyps673gj86flev4sthhcc0'} + +The result is a ``set`` of addresses — one entry per unique address, regardless +of how many UTXOs of that category the address holds. Addresses whose locking +scripts cannot be decoded (e.g. ``OP_RETURN`` outputs) are silently excluded. + +Three optional filters narrow the results: + +- ``nft_capability=NFTCapability.`` — only addresses holding an NFT with a specific + capability (``NFTCapability.none``, ``NFTCapability.mutable``, or ``NFTCapability.minting``): + + .. code-block:: python + + >>> from bitcash.types import NFTCapability + >>> NetworkAPI.get_cashtoken_addresses( + ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + ... nft_capability=NFTCapability.minting, + ... ) + +- ``nft_commitment=`` — only addresses holding an NFT with a specific commitment: + + .. code-block:: python + + >>> NetworkAPI.get_cashtoken_addresses( + ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + ... nft_commitment=b"bitcash", + ... ) + +- ``has_token=True`` — only addresses holding fungible tokens of this category: + + .. code-block:: python + + >>> NetworkAPI.get_cashtoken_addresses( + ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + ... has_token=True, + ... ) + +Filters can be combined freely. + +.. note:: + This query is only supported by :class:`~bitcash.network.APIs.ChaingraphAPI.ChaingraphAPI`. + If no Chaingraph endpoint is reachable, a :exc:`ConnectionError` is raised. + CashToken signalling CashAddr ----------------------------- @@ -172,4 +228,4 @@ non-cashtoken-signalling addresses: .. code-block:: python >>> key.cashtoken_address - 'bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v' + 'bitcoincash:zzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmq37yf2mzf' diff --git a/docs/guide/network.rst b/docs/guide/network.rst index 2102fdf..abaa9c3 100644 --- a/docs/guide/network.rst +++ b/docs/guide/network.rst @@ -137,6 +137,11 @@ it polls a service and if an error occurs it tries another. Default chaingraph APIs do not indicate if a transaction broadcast has failed. The NetworkAPI fallbacks to FulcrumProtocolAPI on ``mainnet`` to broadcast a transaction. +.. note:: + :func:`~bitcash.network.NetworkAPI.get_cashtoken_addresses` is only supported by + :class:`~bitcash.network.APIs.ChaingraphAPI.ChaingraphAPI`. See :ref:`cashtokens` + for usage details. + Exceptions ^^^^^^^^^^ diff --git a/tests/network/APIs/test_ChaingraphAPI.py b/tests/network/APIs/test_ChaingraphAPI.py index ca32e50..f262759 100644 --- a/tests/network/APIs/test_ChaingraphAPI.py +++ b/tests/network/APIs/test_ChaingraphAPI.py @@ -5,8 +5,19 @@ from bitcash.network.transaction import Transaction, TxPart from bitcash.network.APIs.ChaingraphAPI import ChaingraphAPI from bitcash.network.meta import Unspent +from tests.samples import ( + BITCOIN_CASHADDRESS, + BITCOIN_CASHADDRESS_COMPRESSED, + BITCOIN_CASHADDRESS_CATKN, + PUBKEY_HASH, + PUBKEY_HASH_COMPRESSED, +) -BITCOIN_CASHADDRESS_CATKN = "bitcoincash:zzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmq37yf2mzf" +# P2PKH locking scripts derived from test key hashes +_LOCKING = "\\x76a914" + PUBKEY_HASH.hex() + "88ac" +_LOCKING_2 = "\\x76a914" + PUBKEY_HASH_COMPRESSED.hex() + "88ac" +_SCRIPT = "76a914" + PUBKEY_HASH.hex() + "88ac" +_SCRIPT_2 = "76a914" + PUBKEY_HASH_COMPRESSED.hex() + "88ac" class DummyRequest: @@ -159,7 +170,7 @@ def test_get_transaction(self, monkeypatch): "value_satoshis": "10000", "unlocking_bytecode": "\\x4177033dfa31b3ab4ad8a147d0b7bd10da60e7fe1df51bf1767f5ba7273767d7ffad55feec5c201ea89c6c07a1c8368d8a378aae2f48ddd2076324769b2c23a1ac4121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", "outpoint": { - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "token_category": None, "nonfungible_token_capability": None, "nonfungible_token_commitment": None, @@ -170,7 +181,7 @@ def test_get_transaction(self, monkeypatch): "value_satoshis": "8746", "unlocking_bytecode": "\\x41b818b5c19459d64c4f16ac8fbaff844a6c0d05de8cf563173737d56908de56033a1e367f3c7cae8cf3240af06659bcde09d543bc064e208a31d576bbf074bb714121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", "outpoint": { - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "token_category": None, "nonfungible_token_capability": None, "nonfungible_token_commitment": None, @@ -181,7 +192,7 @@ def test_get_transaction(self, monkeypatch): "outputs": [ { "value_satoshis": "1000", - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "token_category": "\\x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", "nonfungible_token_capability": None, "nonfungible_token_commitment": None, @@ -197,7 +208,7 @@ def test_get_transaction(self, monkeypatch): }, { "value_satoshis": "17221", - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "token_category": "\\x8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", "nonfungible_token_capability": "minting", "nonfungible_token_commitment": "\\x", @@ -220,23 +231,23 @@ def test_get_transaction(self, monkeypatch): ) tx.inputs = [ TxPart( - "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + BITCOIN_CASHADDRESS, 10000, data_hex="4177033dfa31b3ab4ad8a147d0b7bd10da60e7fe1df51bf1767f5ba7273767d7ffad55feec5c201ea89c6c07a1c8368d8a378aae2f48ddd2076324769b2c23a1ac4121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", ), TxPart( - "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + BITCOIN_CASHADDRESS, 8746, data_hex="41b818b5c19459d64c4f16ac8fbaff844a6c0d05de8cf563173737d56908de56033a1e367f3c7cae8cf3240af06659bcde09d543bc064e208a31d576bbf074bb714121031aa8f87cde6c87de9bf1bdb9e575801a754d2a600be4d1fc89e36eae6db63bc6", ), ] tx.outputs = [ TxPart( - "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + BITCOIN_CASHADDRESS, 1000, category_id="8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", token_amount=140000000000, - data_hex="76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + data_hex=_SCRIPT, ), TxPart( None, @@ -244,11 +255,11 @@ def test_get_transaction(self, monkeypatch): data_hex="6a0442434d52206b2000be5ce5527cd653c49cdba486e2fd0ec4214da2f71d7e56ad027b2139f448676973742e67697468756275736572636f6e74656e742e636f6d2f6d722d7a776574732f38346230303537383038616632306466333932383135666232376434613636312f726177", ), TxPart( - "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + BITCOIN_CASHADDRESS, 17221, category_id="8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf", nft_capability="minting", - data_hex="76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + data_hex=_SCRIPT, ), ] @@ -269,7 +280,7 @@ def test_get_transaction(self, monkeypatch): "value_satoshis": "900000", "unlocking_bytecode": "\\x473044022024d7be8afd3100656889cddc309d2f5fc343a345fe7d7a22191163f463c5aac502200bbcebf4dc1361a2931f585c33a9fdf9816cbc919341b863609551030bcdb97d4141046bad1c4c33157c12dd812e734917f05a65b502658eeb4f164decc087c54f9fca4005df3499ad93f698294ab13259d7da578461930a9cb7312d526ab2d8f82012", "outpoint": { - "locking_bytecode": "\\x76a914f3fe91589a61c3d7ec9eb4f28ee3e36ead0e4ba988ac", + "locking_bytecode": _LOCKING, "token_category": None, "nonfungible_token_capability": None, "nonfungible_token_commitment": None, @@ -280,7 +291,7 @@ def test_get_transaction(self, monkeypatch): "outputs": [ { "value_satoshis": "899745", - "locking_bytecode": "\\x76a914a522e4f6ca57aef5bf893d29029c3e9fc54a67f088ac", + "locking_bytecode": _LOCKING_2, "token_category": None, "nonfungible_token_capability": None, "nonfungible_token_commitment": None, @@ -302,16 +313,16 @@ def test_get_transaction(self, monkeypatch): ) tx.inputs = [ TxPart( - "bitcoincash:qrelay2cnfsu84lvn6609rhrudh26rjt4y6ddw55lf", + BITCOIN_CASHADDRESS, 900000, data_hex="473044022024d7be8afd3100656889cddc309d2f5fc343a345fe7d7a22191163f463c5aac502200bbcebf4dc1361a2931f585c33a9fdf9816cbc919341b863609551030bcdb97d4141046bad1c4c33157c12dd812e734917f05a65b502658eeb4f164decc087c54f9fca4005df3499ad93f698294ab13259d7da578461930a9cb7312d526ab2d8f82012", ) ] tx.outputs = [ TxPart( - "bitcoincash:qzjj9e8keft6aadl3y7jjq5u860u2jn87qxwpv9nzl", + BITCOIN_CASHADDRESS_COMPRESSED, 899745, - data_hex="76a914a522e4f6ca57aef5bf893d29029c3e9fc54a67f088ac", + data_hex=_SCRIPT_2, ) ] @@ -347,7 +358,7 @@ def test_get_unspent(self, monkeypatch): "fungible_token_amount": "0", "nonfungible_token_capability": "none", "nonfungible_token_commitment": "\\x0a", - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "transaction": { "block_inclusions": [{"block": {"height": "792782"}}] }, @@ -360,7 +371,7 @@ def test_get_unspent(self, monkeypatch): "fungible_token_amount": "0", "nonfungible_token_capability": "none", "nonfungible_token_commitment": "\\x", - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "transaction": { "block_inclusions": [{"block": {"height": "792782"}}] }, @@ -373,7 +384,7 @@ def test_get_unspent(self, monkeypatch): "fungible_token_amount": "1000", "nonfungible_token_capability": None, "nonfungible_token_commitment": None, - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "transaction": { "block_inclusions": [{"block": {"height": "792785"}}] }, @@ -386,7 +397,7 @@ def test_get_unspent(self, monkeypatch): "fungible_token_amount": None, "nonfungible_token_capability": None, "nonfungible_token_commitment": None, - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "transaction": { "block_inclusions": [{"block": {"height": "794043"}}] }, @@ -399,7 +410,7 @@ def test_get_unspent(self, monkeypatch): "fungible_token_amount": None, "nonfungible_token_capability": None, "nonfungible_token_commitment": None, - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac", + "locking_bytecode": _LOCKING, "transaction": {"block_inclusions": []}, }, ], @@ -407,7 +418,7 @@ def test_get_unspent(self, monkeypatch): } monkeypatch.setattr(_capi, "session", DummySession(return_json)) unspents = self.api.get_unspent(BITCOIN_CASHADDRESS_CATKN) - script = "76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" + script = _SCRIPT assert unspents == [ Unspent( 1000, @@ -488,3 +499,87 @@ def test_get_raw_transaction(self, monkeypatch): tx = self.api.get_raw_transaction( "546f83e975d2870de740917df1b5221aa4bc52c6e2540188f5897c4ce775b7f4", ) + + def test_get_cashtoken_addresses(self, monkeypatch): + return_json = { + "data": { + "output": [ + {"locking_bytecode": _LOCKING}, + { + # duplicate - should be deduplicated + "locking_bytecode": _LOCKING + }, + {"locking_bytecode": _LOCKING_2}, + { + # OP_RETURN - should be skipped + "locking_bytecode": "\\x6a04deadbeef" + }, + ] + } + } + monkeypatch.setattr(_capi, "session", DummySession(return_json)) + addresses = self.api.get_cashtoken_addresses( + "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + ) + assert addresses == { + BITCOIN_CASHADDRESS, + BITCOIN_CASHADDRESS_COMPRESSED, + } + + # zero return + return_json = {"data": {"output": []}} + monkeypatch.setattr(_capi, "session", DummySession(return_json)) + addresses = self.api.get_cashtoken_addresses( + "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + ) + assert addresses == set() + + def test_get_cashtoken_addresses_filters(self, monkeypatch): + CATEGORY = "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + one_address = {"data": {"output": [{"locking_bytecode": _LOCKING}]}} + + class CapturingSession: + def __init__(self, return_json): + self.return_json = return_json + self.last_request = None + + def post(self, url, json, *args, **kwargs): + self.last_request = json + return DummyRequest(self.return_json) + + session = CapturingSession(one_address) + monkeypatch.setattr(_capi, "session", session) + + # nft_capability → query contains nonfungible_token_capability filter and variable + from bitcash.types import NFTCapability + + self.api.get_cashtoken_addresses(CATEGORY, nft_capability=NFTCapability.minting) + assert session.last_request is not None + assert "nonfungible_token_capability" in session.last_request["query"] + assert session.last_request["variables"]["nft_capability"] == "minting" + assert "commitment" not in session.last_request["variables"] + + # nft_commitment → query contains commitment variable and filter + self.api.get_cashtoken_addresses(CATEGORY, nft_commitment=b"\x0a\x0b") + assert session.last_request is not None + assert "nonfungible_token_commitment" in session.last_request["query"] + assert session.last_request["variables"]["commitment"] == "\\x0a0b" + + # has_token=True → query contains fungible_token_amount filter + self.api.get_cashtoken_addresses(CATEGORY, has_token=True) + assert session.last_request is not None + assert "fungible_token_amount" in session.last_request["query"] + + # all filters combined + self.api.get_cashtoken_addresses( + CATEGORY, + nft_capability=NFTCapability.none, + nft_commitment=b"\xff", + has_token=True, + ) + assert session.last_request is not None + query = session.last_request["query"] + assert "nonfungible_token_capability" in query + assert "nonfungible_token_commitment" in query + assert "fungible_token_amount" in query + assert session.last_request["variables"]["commitment"] == "\\xff" diff --git a/tests/network/APIs/test_FulcrumProtolAPI.py b/tests/network/APIs/test_FulcrumProtolAPI.py index 0bc6a60..ad2e6bf 100644 --- a/tests/network/APIs/test_FulcrumProtolAPI.py +++ b/tests/network/APIs/test_FulcrumProtolAPI.py @@ -9,8 +9,9 @@ from bitcash.network.transaction import Transaction, TxPart from bitcash.network.APIs.FulcrumProtocolAPI import FulcrumProtocolAPI from bitcash.network.meta import Unspent +from tests.samples import BITCOIN_CASHADDRESS_CATKN, PUBKEY_HASH -BITCOIN_CASHADDRESS_CATKN = "bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v" +_SCRIPT = "76a914" + PUBKEY_HASH.hex() + "88ac" def dummy_handshake(hostname: str, port: int, timeout: float): @@ -368,7 +369,7 @@ def test_get_unspent(self): Unspent( amount=657, confirmations=19445, - script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + script=_SCRIPT, txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", txindex=0, category_id="357dc834af514958b5cb9d5407c26af12e81f442599fbfb99f108563cea126f0", @@ -377,7 +378,7 @@ def test_get_unspent(self): Unspent( amount=681, confirmations=19445, - script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + script=_SCRIPT, txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", txindex=1, category_id="afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", @@ -388,7 +389,7 @@ def test_get_unspent(self): Unspent( amount=648, confirmations=19445, - script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + script=_SCRIPT, txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", txindex=2, category_id="afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", @@ -397,7 +398,7 @@ def test_get_unspent(self): Unspent( amount=895078, confirmations=19445, - script="76a914dd9c917762a9f585a40e5c3a54238684d8cc741e88ac", + script=_SCRIPT, txid="bfd2f488f33a77fced7ea4d0bc694ab64fadb0e0f66bf101438b3eb88b2411c3", txindex=3, category_id="60f451f3cb0ea81fd6c68cf2d42b708bfdf6d74cd08d75c8a7a515ff8adce4ae", diff --git a/tests/network/test_services.py b/tests/network/test_services.py index d1d4b2e..9061be7 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -20,7 +20,9 @@ set_service_timeout, ) from bitcash.network.transaction import Transaction +from bitcash.types import NFTCapability, Network from tests.samples import ( + BITCOIN_CASHADDRESS, VALID_BITCOINCOM_ENDPOINT_URLS, INVALID_BITCOINCOM_ENDPOINT_URLS, VALID_FULCRUM_ENDPOINT_URLS, @@ -116,7 +118,7 @@ def mock_get_chaingraph_endpoints_for(network): default_endpoints = ChaingraphAPI.get_default_endpoints(network) endpoints = [] for endpoint in default_endpoints: - endpoints.append(ChaingraphAPI(*endpoint)) + endpoints.append(ChaingraphAPI(*endpoint, network=Network(network))) return endpoints @@ -532,3 +534,68 @@ def test_subscribe_address_raises_connection_error_when_all_fail( callback = MagicMock() with pytest.raises(ConnectionError): NetworkAPI.subscribe_address(MAIN_ADDRESS_USED1, callback) + + +CASHTOKEN_CATEGORY = "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + + +class TestNetworkAPICashtokenAddresses: + @patch("bitcash.network.services.get_sanitized_endpoints_for") + def test_returns_result_from_supporting_endpoint(self, mock_get_endpoints): + expected = {BITCOIN_CASHADDRESS} + mock_endpoint = MagicMock() + mock_endpoint.get_cashtoken_addresses.return_value = expected + mock_get_endpoints.return_value = (mock_endpoint,) + + result = NetworkAPI.get_cashtoken_addresses(CASHTOKEN_CATEGORY) + + assert result == expected + mock_endpoint.get_cashtoken_addresses.assert_called_once() + + @patch("bitcash.network.services.get_sanitized_endpoints_for") + def test_skips_unsupported_endpoints(self, mock_get_endpoints): + expected = {BITCOIN_CASHADDRESS} + mock_endpoint1 = MagicMock() + mock_endpoint1.get_cashtoken_addresses.side_effect = NotImplementedError() + mock_endpoint2 = MagicMock() + mock_endpoint2.get_cashtoken_addresses.return_value = expected + mock_get_endpoints.return_value = (mock_endpoint1, mock_endpoint2) + + result = NetworkAPI.get_cashtoken_addresses(CASHTOKEN_CATEGORY) + + assert result == expected + mock_endpoint1.get_cashtoken_addresses.assert_called_once() + mock_endpoint2.get_cashtoken_addresses.assert_called_once() + + @patch("bitcash.network.services.get_sanitized_endpoints_for") + def test_raises_connection_error_when_all_unsupported(self, mock_get_endpoints): + mock_endpoint1 = MagicMock() + mock_endpoint1.get_cashtoken_addresses.side_effect = NotImplementedError() + mock_endpoint2 = MagicMock() + mock_endpoint2.get_cashtoken_addresses.side_effect = NotImplementedError() + mock_get_endpoints.return_value = (mock_endpoint1, mock_endpoint2) + + with pytest.raises(ConnectionError): + NetworkAPI.get_cashtoken_addresses(CASHTOKEN_CATEGORY) + + @patch("bitcash.network.services.get_sanitized_endpoints_for") + def test_passes_filters_and_network_to_endpoint(self, mock_get_endpoints): + mock_endpoint = MagicMock() + mock_endpoint.get_cashtoken_addresses.return_value = set() + mock_get_endpoints.return_value = (mock_endpoint,) + + NetworkAPI.get_cashtoken_addresses( + CASHTOKEN_CATEGORY, + network="testnet", + nft_capability=NFTCapability.minting, + nft_commitment=b"\xff", + has_token=True, + ) + + mock_endpoint.get_cashtoken_addresses.assert_called_once_with( + CASHTOKEN_CATEGORY, + NFTCapability.minting, + b"\xff", + True, + timeout=mock_endpoint.get_cashtoken_addresses.call_args[1]["timeout"], + ) diff --git a/tests/test_cashaddress.py b/tests/test_cashaddress.py index e2e8391..676f134 100644 --- a/tests/test_cashaddress.py +++ b/tests/test_cashaddress.py @@ -9,6 +9,7 @@ parse_cashaddress, ) from bitcash.exceptions import InvalidAddress +from bitcash.types import Network from .samples import ( BITCOIN_ADDRESS, @@ -257,6 +258,18 @@ def test_to_from_script(self): script + address_catkn.scriptcode ) + def test_from_script_network(self): + scriptcode = Address.from_string(BITCOIN_CASHADDRESS).scriptcode + assert Address.from_script(scriptcode, Network.main) == Address.from_string( + BITCOIN_CASHADDRESS + ) + assert Address.from_script(scriptcode, Network.test) == Address.from_string( + BITCOIN_CASHADDRESS_TEST + ) + assert Address.from_script(scriptcode, Network.regtest) == Address.from_string( + BITCOIN_CASHADDRESS_REGTEST + ) + def test_parse_cashaddress(): # good address