From dbda6b48a5e7ef0c3b75f5ab1523256526ec794d Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Fri, 20 Mar 2026 08:17:41 +0200 Subject: [PATCH 01/14] Add get_cashtoken_addresses to NetworkAPI Adds a new method to fetch all addresses currently holding unspent outputs of a given cashtoken category. Implemented via ChaingraphAPI GraphQL query on the output table filtered by token_category. FulcrumProtocolAPI and BitcoinDotComAPI raise NotImplementedError, which NetworkAPI skips transparently. Returns a set[str] since address order is not meaningful. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/BitcoinDotComAPI.py | 7 ++++ bitcash/network/APIs/ChaingraphAPI.py | 45 ++++++++++++++++++++++ bitcash/network/APIs/FulcrumProtocolAPI.py | 7 ++++ bitcash/network/APIs/__init__.py | 11 ++++++ bitcash/network/services.py | 23 +++++++++++ tests/network/APIs/test_ChaingraphAPI.py | 38 ++++++++++++++++++ 6 files changed, 131 insertions(+) diff --git a/bitcash/network/APIs/BitcoinDotComAPI.py b/bitcash/network/APIs/BitcoinDotComAPI.py index 0e51b39..22c7fec 100644 --- a/bitcash/network/APIs/BitcoinDotComAPI.py +++ b/bitcash/network/APIs/BitcoinDotComAPI.py @@ -245,6 +245,13 @@ 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, *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 d0ac726..463f334 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -439,6 +439,51 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: raise RuntimeError(f"Transaction {txid} does not exist") return json["data"]["transaction"][0] + def get_cashtoken_addresses( + self, category_id: str, *args, **kwargs + ) -> set[str]: + json_request = { + "query": """ +query GetCashtokenAddresses($category: bytea!, $node: String!) { + output( + where: { + token_category: { _eq: $category } + _not: { spent_by: {} } + _or: [ + { + transaction: { + node_validations: { node: { name: { _like: $node } } } + } + } + { + transaction: { + block_inclusions: { + block: { accepted_by: { node: { name: { _like: $node } } } } + } + } + } + ] + } + ) { + locking_bytecode + } +} +""", + "variables": { + "category": f"\\x{category_id}", + "node": self.node_like, + }, + } + json = self.send_request(json_request, *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).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 f078f4c..b965a1b 100644 --- a/bitcash/network/APIs/FulcrumProtocolAPI.py +++ b/bitcash/network/APIs/FulcrumProtocolAPI.py @@ -300,6 +300,13 @@ 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, *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 e8b80e8..98f2eef 100644 --- a/bitcash/network/APIs/__init__.py +++ b/bitcash/network/APIs/__init__.py @@ -110,6 +110,17 @@ def subscribe_address( :return: A SubscriptionHandle object for managing the subscription. """ + @abstractmethod + def get_cashtoken_addresses( + self, category_id: str, *args, **kwargs + ) -> set[str]: + """Gets all addresses holding unspent outputs of a given cashtoken category. + + :param category_id: The token category ID (hex string). + :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 cbc3e7b..4fed79c 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -321,6 +321,29 @@ 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" + ) -> 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. + :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, 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/tests/network/APIs/test_ChaingraphAPI.py b/tests/network/APIs/test_ChaingraphAPI.py index 450efb6..3922f0d 100644 --- a/tests/network/APIs/test_ChaingraphAPI.py +++ b/tests/network/APIs/test_ChaingraphAPI.py @@ -488,3 +488,41 @@ 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": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" + }, + { + # duplicate - should be deduplicated + "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" + }, + { + "locking_bytecode": "\\x76a914a522e4f6ca57aef5bf893d29029c3e9fc54a67f088ac" + }, + { + # OP_RETURN - should be skipped + "locking_bytecode": "\\x6a04deadbeef" + }, + ] + } + } + monkeypatch.setattr(_capi, "session", DummySession(return_json)) + addresses = self.api.get_cashtoken_addresses( + "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + ) + assert addresses == { + "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", + "bitcoincash:qzjj9e8keft6aadl3y7jjq5u860u2jn87qxwpv9nzl", + } + + # zero return + return_json = {"data": {"output": []}} + monkeypatch.setattr(_capi, "session", DummySession(return_json)) + addresses = self.api.get_cashtoken_addresses( + "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + ) + assert addresses == set() From fb253fb6e3b7d6966d91aabce9a9369bcf7a8c28 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Fri, 20 Mar 2026 20:31:17 +0200 Subject: [PATCH 02/14] Add has_nft, nft_commitment, has_token filters to get_cashtoken_addresses Allows callers to narrow results to addresses holding NFTs, a specific NFT commitment, or fungible tokens of a given cashtoken category. Filters are composable and translate to additional Chaingraph GraphQL where conditions; nft_commitment is passed as a typed bytea variable. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/BitcoinDotComAPI.py | 10 +++- bitcash/network/APIs/ChaingraphAPI.py | 58 +++++++++++++++++----- bitcash/network/APIs/FulcrumProtocolAPI.py | 10 +++- bitcash/network/APIs/__init__.py | 13 ++++- bitcash/network/services.py | 15 ++++-- docs/guide/cashtokens.rst | 54 ++++++++++++++++++++ docs/guide/network.rst | 7 ++- tests/network/APIs/test_ChaingraphAPI.py | 52 +++++++++++++++++++ 8 files changed, 196 insertions(+), 23 deletions(-) diff --git a/bitcash/network/APIs/BitcoinDotComAPI.py b/bitcash/network/APIs/BitcoinDotComAPI.py index 22c7fec..a0dd280 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 @@ -246,7 +246,13 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: return r.json(parse_float=Decimal) def get_cashtoken_addresses( - self, category_id: str, *args, **kwargs + self, + category_id: str, + has_nft: bool = False, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, ) -> set[str]: raise NotImplementedError( "BitcoinDotComAPI does not support querying addresses by token category" diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 463f334..2063f21 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -440,15 +440,49 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: return json["data"]["transaction"][0] def get_cashtoken_addresses( - self, category_id: str, *args, **kwargs + self, + category_id: str, + has_nft: bool = False, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, ) -> set[str]: - json_request = { - "query": """ -query GetCashtokenAddresses($category: bytea!, $node: String!) { + variables: dict[str, Any] = { + "category": f"\\x{category_id}", + "node": self.node_like, + } + + extra_filters = [] + commitment_decl = "" + if has_nft: + extra_filters.append( + "nonfungible_token_capability: { _is_null: false }" + ) + if nft_commitment is not None: + extra_filters.append( + "nonfungible_token_commitment: { _eq: $commitment }" + ) + variables["commitment"] = f"\\x{nft_commitment.hex()}" + commitment_decl = ", $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!" + + commitment_decl + + """) { output( where: { token_category: { _eq: $category } - _not: { spent_by: {} } + _not: { spent_by: {} }""" + + extra + + """ _or: [ { transaction: { @@ -467,14 +501,12 @@ def get_cashtoken_addresses( ) { locking_bytecode } -} -""", - "variables": { - "category": f"\\x{category_id}", - "node": self.node_like, - }, - } - json = self.send_request(json_request, *args, **kwargs) +}""" + ) + + json = self.send_request( + {"query": query, "variables": variables}, *args, **kwargs + ) addresses: set[str] = set() for output in json["data"]["output"]: try: diff --git a/bitcash/network/APIs/FulcrumProtocolAPI.py b/bitcash/network/APIs/FulcrumProtocolAPI.py index b965a1b..c6cb21c 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 from bitcash.network.APIs import BaseAPI, SubscriptionHandle @@ -301,7 +301,13 @@ 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, *args, **kwargs + self, + category_id: str, + has_nft: bool = False, + nft_commitment: Optional[bytes] = None, + has_token: bool = False, + *args, + **kwargs, ) -> set[str]: raise NotImplementedError( "FulcrumProtocolAPI does not support querying addresses by token category" diff --git a/bitcash/network/APIs/__init__.py b/bitcash/network/APIs/__init__.py index 98f2eef..2a55d3f 100644 --- a/bitcash/network/APIs/__init__.py +++ b/bitcash/network/APIs/__init__.py @@ -1,7 +1,7 @@ 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 @@ -112,11 +112,20 @@ def subscribe_address( @abstractmethod def get_cashtoken_addresses( - self, category_id: str, *args, **kwargs + self, + category_id: str, + has_nft: bool = False, + 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 has_nft: If True, only return addresses holding an NFT of this category. + :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. """ diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 4fed79c..4d9294b 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 @@ -323,19 +323,28 @@ def get_raw_transaction( @classmethod def get_cashtoken_addresses( - cls, category_id: str, network: NetworkStr = "mainnet" + cls, + category_id: str, + network: NetworkStr = "mainnet", + has_nft: bool = False, + 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 has_nft: If True, only return addresses holding an NFT of this category. + :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, timeout=DEFAULT_TIMEOUT + category_id, has_nft, nft_commitment, has_token, + timeout=DEFAULT_TIMEOUT, ) except NotImplementedError: continue diff --git a/docs/guide/cashtokens.rst b/docs/guide/cashtokens.rst index d2699e9..0489040 100644 --- a/docs/guide/cashtokens.rst +++ b/docs/guide/cashtokens.rst @@ -161,6 +161,60 @@ 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:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4', + 'bitcoincash:qzjj9e8keft6aadl3y7jjq5u860u2jn87qxwpv9nzl'} + +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: + +- ``has_nft=True`` — only addresses holding an NFT of this category: + + .. code-block:: python + + >>> NetworkAPI.get_cashtoken_addresses( + ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802", + ... has_nft=True, + ... ) + +- ``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 ----------------------------- diff --git a/docs/guide/network.rst b/docs/guide/network.rst index 63de413..51b146e 100644 --- a/docs/guide/network.rst +++ b/docs/guide/network.rst @@ -134,9 +134,14 @@ Private key network operations use :class:`~bitcash.network.NetworkAPI`. For eac 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 + 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. + .. _satoshi: https://en.bitcoin.it/wiki/Satoshi_(unit) .. _blockchain: https://en.bitcoin.it/wiki/Block_chain .. _unspent transaction outputs: https://en.bitcoin.it/wiki/Transaction#Input diff --git a/tests/network/APIs/test_ChaingraphAPI.py b/tests/network/APIs/test_ChaingraphAPI.py index 3922f0d..ef5b436 100644 --- a/tests/network/APIs/test_ChaingraphAPI.py +++ b/tests/network/APIs/test_ChaingraphAPI.py @@ -526,3 +526,55 @@ def test_get_cashtoken_addresses(self, monkeypatch): "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" ) assert addresses == set() + + def test_get_cashtoken_addresses_filters(self, monkeypatch): + CATEGORY = "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" + one_address = { + "data": { + "output": [ + { + "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" + } + ] + } + } + + 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) + + # has_nft=True → query contains nonfungible_token_capability filter + self.api.get_cashtoken_addresses(CATEGORY, has_nft=True) + assert session.last_request is not None + assert "nonfungible_token_capability" in session.last_request["query"] + 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, has_nft=True, 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" From 344ebf2331ff182b59c4d2cd2c9a50f8f18cdd0e Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Fri, 20 Mar 2026 20:47:22 +0200 Subject: [PATCH 03/14] Replace has_nft bool with nft_capability: Optional[NFTCapability] Allows filtering addresses by a specific NFT capability (none, mutable, minting) rather than a plain boolean. The capability enum name is passed as a typed enum_nonfungible_token_capability variable in the Chaingraph GraphQL query. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/BitcoinDotComAPI.py | 2 +- bitcash/network/APIs/ChaingraphAPI.py | 16 +++++++++------- bitcash/network/APIs/FulcrumProtocolAPI.py | 4 ++-- bitcash/network/APIs/__init__.py | 7 ++++--- bitcash/network/services.py | 8 ++++---- docs/guide/cashtokens.rst | 6 ++++-- tests/network/APIs/test_ChaingraphAPI.py | 8 +++++--- 7 files changed, 29 insertions(+), 22 deletions(-) diff --git a/bitcash/network/APIs/BitcoinDotComAPI.py b/bitcash/network/APIs/BitcoinDotComAPI.py index a0dd280..c039fd2 100644 --- a/bitcash/network/APIs/BitcoinDotComAPI.py +++ b/bitcash/network/APIs/BitcoinDotComAPI.py @@ -248,7 +248,7 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: def get_cashtoken_addresses( self, category_id: str, - has_nft: bool = False, + nft_capability: Optional[NFTCapability] = None, nft_commitment: Optional[bytes] = None, has_token: bool = False, *args, diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 2063f21..6d7a760 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -7,7 +7,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, NetworkStr class ChaingraphAPI(BaseAPI): @@ -442,7 +442,7 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: def get_cashtoken_addresses( self, category_id: str, - has_nft: bool = False, + nft_capability: Optional[NFTCapability] = None, nft_commitment: Optional[bytes] = None, has_token: bool = False, *args, @@ -454,17 +454,19 @@ def get_cashtoken_addresses( } extra_filters = [] - commitment_decl = "" - if has_nft: + extra_decls = "" + if nft_capability is not None: extra_filters.append( - "nonfungible_token_capability: { _is_null: false }" + "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()}" - commitment_decl = ", $commitment: bytea" + extra_decls += ", $commitment: bytea" if has_token: extra_filters.append('fungible_token_amount: { _gt: "0" }') @@ -475,7 +477,7 @@ def get_cashtoken_addresses( query = ( "query GetCashtokenAddresses" "($category: bytea!, $node: String!" - + commitment_decl + + extra_decls + """) { output( where: { diff --git a/bitcash/network/APIs/FulcrumProtocolAPI.py b/bitcash/network/APIs/FulcrumProtocolAPI.py index c6cb21c..7f2111e 100644 --- a/bitcash/network/APIs/FulcrumProtocolAPI.py +++ b/bitcash/network/APIs/FulcrumProtocolAPI.py @@ -14,7 +14,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, NetworkStr context = ssl.create_default_context() @@ -303,7 +303,7 @@ def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: def get_cashtoken_addresses( self, category_id: str, - has_nft: bool = False, + nft_capability: Optional[NFTCapability] = None, nft_commitment: Optional[bytes] = None, has_token: bool = False, *args, diff --git a/bitcash/network/APIs/__init__.py b/bitcash/network/APIs/__init__.py index 2a55d3f..8f8e47c 100644 --- a/bitcash/network/APIs/__init__.py +++ b/bitcash/network/APIs/__init__.py @@ -5,7 +5,7 @@ from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction -from bitcash.types import NetworkStr +from bitcash.types import NFTCapability, NetworkStr class BaseAPI(ABC): @@ -114,7 +114,7 @@ def subscribe_address( def get_cashtoken_addresses( self, category_id: str, - has_nft: bool = False, + nft_capability: Optional[NFTCapability] = None, nft_commitment: Optional[bytes] = None, has_token: bool = False, *args, @@ -123,7 +123,8 @@ def get_cashtoken_addresses( """Gets all addresses holding unspent outputs of a given cashtoken category. :param category_id: The token category ID (hex string). - :param has_nft: If True, only return addresses holding an NFT of this category. + :param nft_capability: If set, only return addresses holding an NFT with this capability + (one of ``"none"``, ``"mutable"``, ``"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. diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 4d9294b..2ec68db 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -17,7 +17,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 @@ -326,7 +326,7 @@ def get_cashtoken_addresses( cls, category_id: str, network: NetworkStr = "mainnet", - has_nft: bool = False, + nft_capability: Optional[NFTCapability] = None, nft_commitment: Optional[bytes] = None, has_token: bool = False, ) -> set[str]: @@ -334,7 +334,7 @@ def get_cashtoken_addresses( :param category_id: The token category ID (hex string). :param network: The network to query. - :param has_nft: If True, only return addresses holding an NFT of this category. + :param nft_capability: If set, only return addresses holding an NFT with this capability. :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. @@ -343,7 +343,7 @@ def get_cashtoken_addresses( for endpoint in get_sanitized_endpoints_for(network): try: return endpoint.get_cashtoken_addresses( - category_id, has_nft, nft_commitment, has_token, + category_id, nft_capability, nft_commitment, has_token, timeout=DEFAULT_TIMEOUT, ) except NotImplementedError: diff --git a/docs/guide/cashtokens.rst b/docs/guide/cashtokens.rst index 0489040..7eb5c18 100644 --- a/docs/guide/cashtokens.rst +++ b/docs/guide/cashtokens.rst @@ -182,13 +182,15 @@ scripts cannot be decoded (e.g. ``OP_RETURN`` outputs) are silently excluded. Three optional filters narrow the results: -- ``has_nft=True`` — only addresses holding an NFT of this category: +- ``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", - ... has_nft=True, + ... nft_capability=NFTCapability.minting, ... ) - ``nft_commitment=`` — only addresses holding an NFT with a specific commitment: diff --git a/tests/network/APIs/test_ChaingraphAPI.py b/tests/network/APIs/test_ChaingraphAPI.py index ef5b436..f71e157 100644 --- a/tests/network/APIs/test_ChaingraphAPI.py +++ b/tests/network/APIs/test_ChaingraphAPI.py @@ -551,10 +551,12 @@ def post(self, url, json, *args, **kwargs): session = CapturingSession(one_address) monkeypatch.setattr(_capi, "session", session) - # has_nft=True → query contains nonfungible_token_capability filter - self.api.get_cashtoken_addresses(CATEGORY, has_nft=True) + # 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 @@ -570,7 +572,7 @@ def post(self, url, json, *args, **kwargs): # all filters combined self.api.get_cashtoken_addresses( - CATEGORY, has_nft=True, nft_commitment=b"\xff", has_token=True + CATEGORY, nft_capability=NFTCapability.none, nft_commitment=b"\xff", has_token=True ) assert session.last_request is not None query = session.last_request["query"] From 167d268ca2c8c237d8a8c4b9b5123544a13c8e8f Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Fri, 20 Mar 2026 20:49:33 +0200 Subject: [PATCH 04/14] Fix nft_capability docstrings to reference NFTCapability enum Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/__init__.py | 4 +++- bitcash/network/services.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bitcash/network/APIs/__init__.py b/bitcash/network/APIs/__init__.py index 8f8e47c..ba72704 100644 --- a/bitcash/network/APIs/__init__.py +++ b/bitcash/network/APIs/__init__.py @@ -124,7 +124,9 @@ def get_cashtoken_addresses( :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 ``"none"``, ``"mutable"``, ``"minting"``). + (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. diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 2ec68db..47e7485 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -334,7 +334,10 @@ def get_cashtoken_addresses( :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. + :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. From 6233f33182fef6dbfda481dcdb1e90c312f4a4b5 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Fri, 20 Mar 2026 20:50:42 +0200 Subject: [PATCH 05/14] Update README to mention CashToken and address subscription support Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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! From 236e0c1abaea270313918c8032eacaca1b40ab3d Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Fri, 20 Mar 2026 21:03:58 +0200 Subject: [PATCH 06/14] Fix black formatting Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/ChaingraphAPI.py | 8 ++------ bitcash/network/services.py | 5 ++++- tests/network/APIs/test_ChaingraphAPI.py | 6 +++++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 6d7a760..8590808 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -462,17 +462,13 @@ def get_cashtoken_addresses( 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 }" - ) + 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 "" - ) + extra = "\n " + "\n ".join(extra_filters) if extra_filters else "" query = ( "query GetCashtokenAddresses" diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 47e7485..ed0ef62 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -346,7 +346,10 @@ def get_cashtoken_addresses( for endpoint in get_sanitized_endpoints_for(network): try: return endpoint.get_cashtoken_addresses( - category_id, nft_capability, nft_commitment, has_token, + category_id, + nft_capability, + nft_commitment, + has_token, timeout=DEFAULT_TIMEOUT, ) except NotImplementedError: diff --git a/tests/network/APIs/test_ChaingraphAPI.py b/tests/network/APIs/test_ChaingraphAPI.py index f71e157..2882df0 100644 --- a/tests/network/APIs/test_ChaingraphAPI.py +++ b/tests/network/APIs/test_ChaingraphAPI.py @@ -553,6 +553,7 @@ def post(self, url, json, *args, **kwargs): # 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"] @@ -572,7 +573,10 @@ def post(self, url, json, *args, **kwargs): # all filters combined self.api.get_cashtoken_addresses( - CATEGORY, nft_capability=NFTCapability.none, nft_commitment=b"\xff", has_token=True + CATEGORY, + nft_capability=NFTCapability.none, + nft_commitment=b"\xff", + has_token=True, ) assert session.last_request is not None query = session.last_request["query"] From bc41ad167ae61c390ea4253ac8ba490eb5f23934 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Sat, 18 Apr 2026 12:06:34 +0300 Subject: [PATCH 07/14] Run black formatting Co-Authored-By: Claude Sonnet 4.6 --- docs/guide/cashtokens.rst | 14 ++-- tests/network/APIs/test_ChaingraphAPI.py | 79 ++++++++++----------- tests/network/APIs/test_FulcrumProtolAPI.py | 11 +-- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/docs/guide/cashtokens.rst b/docs/guide/cashtokens.rst index 7eb5c18..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", @@ -173,8 +173,8 @@ To find all addresses currently holding unspent outputs of a given cashtoken >>> NetworkAPI.get_cashtoken_addresses( ... "afe979e6b52e37d29f6c4d7edd922bddb91b5e4d55ebfa8cd59a0f90bc03b802" ... ) - {'bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4', - 'bitcoincash:qzjj9e8keft6aadl3y7jjq5u860u2jn87qxwpv9nzl'} + {'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 @@ -228,4 +228,4 @@ non-cashtoken-signalling addresses: .. code-block:: python >>> key.cashtoken_address - 'bitcoincash:zrweeythv25ltpdypewr54prs6zd3nr5rcjhrnhy2v' + 'bitcoincash:zzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmq37yf2mzf' diff --git a/tests/network/APIs/test_ChaingraphAPI.py b/tests/network/APIs/test_ChaingraphAPI.py index a88e419..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, @@ -493,16 +504,12 @@ def test_get_cashtoken_addresses(self, monkeypatch): return_json = { "data": { "output": [ - { - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" - }, + {"locking_bytecode": _LOCKING}, { # duplicate - should be deduplicated - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" - }, - { - "locking_bytecode": "\\x76a914a522e4f6ca57aef5bf893d29029c3e9fc54a67f088ac" + "locking_bytecode": _LOCKING }, + {"locking_bytecode": _LOCKING_2}, { # OP_RETURN - should be skipped "locking_bytecode": "\\x6a04deadbeef" @@ -515,8 +522,8 @@ def test_get_cashtoken_addresses(self, monkeypatch): "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" ) assert addresses == { - "bitcoincash:qz8wymtvnavrd8u5sexuxccvm6chlt3095hczr7px4", - "bitcoincash:qzjj9e8keft6aadl3y7jjq5u860u2jn87qxwpv9nzl", + BITCOIN_CASHADDRESS, + BITCOIN_CASHADDRESS_COMPRESSED, } # zero return @@ -529,15 +536,7 @@ def test_get_cashtoken_addresses(self, monkeypatch): def test_get_cashtoken_addresses_filters(self, monkeypatch): CATEGORY = "8473d94f604de351cdee3030f6c354d36b257861ad8e95bbc0a06fbab2a2f9cf" - one_address = { - "data": { - "output": [ - { - "locking_bytecode": "\\x76a9148ee26d6c9f58369f94864dc3630cdeb17fae2f2d88ac" - } - ] - } - } + one_address = {"data": {"output": [{"locking_bytecode": _LOCKING}]}} class CapturingSession: def __init__(self, return_json): 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", From 565a2e1a0d6fa87c97f5689000d99ac2c27b4403 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:10:22 +0300 Subject: [PATCH 08/14] Fix network prefix in get_cashtoken_addresses Address.from_script now accepts a Network enum parameter (defaults to Network.main) so testnet/regtest addresses are encoded with the correct prefix and checksum. NetworkAPI.get_cashtoken_addresses passes the network through to the endpoint. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cashaddress.py | 15 +++++++++++---- bitcash/network/APIs/ChaingraphAPI.py | 15 +++++++++++++-- bitcash/network/services.py | 1 + 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/bitcash/cashaddress.py b/bitcash/cashaddress.py index 97a9591..8c603c7 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/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 18d8db6..956cf13 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 NFTCapability, NetworkStr +from bitcash.types import NFTCapability, Network, NetworkStr class ChaingraphAPI(BaseAPI): @@ -510,6 +510,15 @@ def get_cashtoken_addresses( }""" ) + network_str: Optional[str] = kwargs.get("network") + if network_str is None: + node_like = self.node_like.lower() + if "regtest" in node_like: + network_str = "regtest" + elif "testnet" in node_like or "chipnet" in node_like: + network_str = "testnet" + network_enum = Network(network_str) if network_str else Network.main + json = self.send_request( {"query": query, "variables": variables}, *args, **kwargs ) @@ -517,7 +526,9 @@ def get_cashtoken_addresses( for output in json["data"]["output"]: try: scriptcode = bytes.fromhex(output["locking_bytecode"][2:]) - addresses.add(Address.from_script(scriptcode).cash_address()) + addresses.add( + Address.from_script(scriptcode, network_enum).cash_address() + ) except ValueError: pass return addresses diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 163c06f..dd8f0dc 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -357,6 +357,7 @@ def get_cashtoken_addresses( nft_commitment, has_token, timeout=DEFAULT_TIMEOUT, + network=network, ) except NotImplementedError: continue From 80f6b455b0854569b908d2ff3d998dd1213fd8fc Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:11:19 +0300 Subject: [PATCH 09/14] Add NetworkAPI-level tests for get_cashtoken_addresses Co-Authored-By: Claude Sonnet 4.6 --- tests/network/test_services.py | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/network/test_services.py b/tests/network/test_services.py index d1d4b2e..a4ba7c8 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -20,6 +20,7 @@ set_service_timeout, ) from bitcash.network.transaction import Transaction +from bitcash.types import NFTCapability from tests.samples import ( VALID_BITCOINCOM_ENDPOINT_URLS, INVALID_BITCOINCOM_ENDPOINT_URLS, @@ -532,3 +533,69 @@ 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 = {"bitcoincash:qzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmqk5hhyaa6"} + 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 = {"bitcoincash:qzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmqk5hhyaa6"} + 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"], + network="testnet", + ) From ed836ec4c77776dc9b62a4a7837e0e5dc8c40b6b Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:19:22 +0300 Subject: [PATCH 10/14] Store network on endpoint instances; fix address prefix in ChaingraphAPI Add network: Network param to BaseAPI.__init__ and all concrete endpoint constructors. get_endpoints_for passes the correct Network enum at construction time, so ChaingraphAPI can use self.network in get_transaction and get_cashtoken_addresses without relying on kwargs. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/BitcoinDotComAPI.py | 5 +++-- bitcash/network/APIs/ChaingraphAPI.py | 17 ++++------------- bitcash/network/APIs/FulcrumProtocolAPI.py | 5 +++-- bitcash/network/APIs/__init__.py | 6 ++++-- bitcash/network/services.py | 19 ++++++++++--------- tests/network/test_services.py | 1 - 6 files changed, 24 insertions(+), 29 deletions(-) diff --git a/bitcash/network/APIs/BitcoinDotComAPI.py b/bitcash/network/APIs/BitcoinDotComAPI.py index c039fd2..3840430 100644 --- a/bitcash/network/APIs/BitcoinDotComAPI.py +++ b/bitcash/network/APIs/BitcoinDotComAPI.py @@ -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 = { diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 956cf13..9f2f5f2 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -21,7 +21,7 @@ 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 +35,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 +243,7 @@ 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) @@ -510,15 +510,6 @@ def get_cashtoken_addresses( }""" ) - network_str: Optional[str] = kwargs.get("network") - if network_str is None: - node_like = self.node_like.lower() - if "regtest" in node_like: - network_str = "regtest" - elif "testnet" in node_like or "chipnet" in node_like: - network_str = "testnet" - network_enum = Network(network_str) if network_str else Network.main - json = self.send_request( {"query": query, "variables": variables}, *args, **kwargs ) @@ -527,7 +518,7 @@ def get_cashtoken_addresses( try: scriptcode = bytes.fromhex(output["locking_bytecode"][2:]) addresses.add( - Address.from_script(scriptcode, network_enum).cash_address() + Address.from_script(scriptcode, self.network).cash_address() ) except ValueError: pass diff --git a/bitcash/network/APIs/FulcrumProtocolAPI.py b/bitcash/network/APIs/FulcrumProtocolAPI.py index 7bb6bc9..46fef83 100644 --- a/bitcash/network/APIs/FulcrumProtocolAPI.py +++ b/bitcash/network/APIs/FulcrumProtocolAPI.py @@ -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 NFTCapability, NetworkStr +from bitcash.types import NFTCapability, Network, NetworkStr context = ssl.create_default_context() FULCRUM_PROTOCOL = "1.5.0" @@ -111,7 +111,7 @@ 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 +130,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() diff --git a/bitcash/network/APIs/__init__.py b/bitcash/network/APIs/__init__.py index a139c80..ce83921 100644 --- a/bitcash/network/APIs/__init__.py +++ b/bitcash/network/APIs/__init__.py @@ -5,7 +5,7 @@ from bitcash.network.meta import Unspent from bitcash.network.transaction import Transaction -from bitcash.types import NFTCapability, 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 diff --git a/bitcash/network/services.py b/bitcash/network/services.py index dd8f0dc..ad83cbd 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -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,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)) 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 +112,7 @@ 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 +123,9 @@ 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) @@ -357,7 +359,6 @@ def get_cashtoken_addresses( nft_commitment, has_token, timeout=DEFAULT_TIMEOUT, - network=network, ) except NotImplementedError: continue diff --git a/tests/network/test_services.py b/tests/network/test_services.py index a4ba7c8..e138d4f 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -597,5 +597,4 @@ def test_passes_filters_and_network_to_endpoint(self, mock_get_endpoints): b"\xff", True, timeout=mock_endpoint.get_cashtoken_addresses.call_args[1]["timeout"], - network="testnet", ) From adfcfb8cb5651bc0e30f97468ec97b41f719632a Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:21:08 +0300 Subject: [PATCH 11/14] Update HISTORY.rst Co-Authored-By: Claude Sonnet 4.6 --- HISTORY.rst | 9 +++++++++ 1 file changed, 9 insertions(+) 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) ------------------ From 506a7007d17822c8ab351a37c08aa964688179cb Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:25:33 +0300 Subject: [PATCH 12/14] Use sample fixtures in tests; add from_script network test Co-Authored-By: Claude Sonnet 4.6 --- tests/network/test_services.py | 5 +++-- tests/test_cashaddress.py | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/network/test_services.py b/tests/network/test_services.py index e138d4f..898008a 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -22,6 +22,7 @@ from bitcash.network.transaction import Transaction from bitcash.types import NFTCapability from tests.samples import ( + BITCOIN_CASHADDRESS, VALID_BITCOINCOM_ENDPOINT_URLS, INVALID_BITCOINCOM_ENDPOINT_URLS, VALID_FULCRUM_ENDPOINT_URLS, @@ -541,7 +542,7 @@ def test_subscribe_address_raises_connection_error_when_all_fail( class TestNetworkAPICashtokenAddresses: @patch("bitcash.network.services.get_sanitized_endpoints_for") def test_returns_result_from_supporting_endpoint(self, mock_get_endpoints): - expected = {"bitcoincash:qzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmqk5hhyaa6"} + expected = {BITCOIN_CASHADDRESS} mock_endpoint = MagicMock() mock_endpoint.get_cashtoken_addresses.return_value = expected mock_get_endpoints.return_value = (mock_endpoint,) @@ -553,7 +554,7 @@ def test_returns_result_from_supporting_endpoint(self, mock_get_endpoints): @patch("bitcash.network.services.get_sanitized_endpoints_for") def test_skips_unsupported_endpoints(self, mock_get_endpoints): - expected = {"bitcoincash:qzfyvx77v2pmgc0vulwlfkl3uzjgh5gnmqk5hhyaa6"} + expected = {BITCOIN_CASHADDRESS} mock_endpoint1 = MagicMock() mock_endpoint1.get_cashtoken_addresses.side_effect = NotImplementedError() mock_endpoint2 = MagicMock() 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 From 87780866789d236b3534b46504dc78b32a58c3a8 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:32:40 +0300 Subject: [PATCH 13/14] Run black formatting Co-Authored-By: Claude Sonnet 4.6 --- bitcash/cashaddress.py | 6 +++--- bitcash/network/APIs/ChaingraphAPI.py | 11 ++++++++-- bitcash/network/APIs/FulcrumProtocolAPI.py | 7 ++++++- bitcash/network/services.py | 24 +++++++++++++++++----- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/bitcash/cashaddress.py b/bitcash/cashaddress.py index 8c603c7..4732099 100644 --- a/bitcash/cashaddress.py +++ b/bitcash/cashaddress.py @@ -205,9 +205,9 @@ def from_script(cls, scriptcode: bytes, network: Network = Network.main) -> Addr :returns: Instance of :class:~bitcash.cashaddress.Address """ net_suffix = ( - "-TESTNET" if network == Network.test else - "-REGTEST" if network == Network.regtest else - "" + "-TESTNET" + if network == Network.test + else "-REGTEST" if network == Network.regtest else "" ) # cashtoken suffix catkn = "" diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 9f2f5f2..4136698 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -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, network: Network = Network.main): + def __init__( + self, + network_endpoint: str, + node_like: Optional[str] = None, + network: Network = Network.main, + ): try: assert isinstance(network_endpoint, str) except AssertionError: @@ -243,7 +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, self.network).cash_address() + cashaddress = Address.from_script( + scriptcode, self.network + ).cash_address() except ValueError: cashaddress = None part = TxPart(cashaddress, sats, data_hex=data_hex) diff --git a/bitcash/network/APIs/FulcrumProtocolAPI.py b/bitcash/network/APIs/FulcrumProtocolAPI.py index 46fef83..12486ac 100644 --- a/bitcash/network/APIs/FulcrumProtocolAPI.py +++ b/bitcash/network/APIs/FulcrumProtocolAPI.py @@ -111,7 +111,12 @@ class FulcrumProtocolAPI(BaseAPI): "regtest": [], } - def __init__(self, network_endpoint: str, timeout: float = DEFAULT_SOCKET_TIMEOUT, network: Network = Network.main): + def __init__( + self, + network_endpoint: str, + timeout: float = DEFAULT_SOCKET_TIMEOUT, + network: Network = Network.main, + ): try: assert isinstance(network_endpoint, str) except AssertionError: diff --git a/bitcash/network/services.py b/bitcash/network/services.py index ad83cbd..b2d939a 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -92,9 +92,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, network=network_enum)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint]( + *each, network=network_enum + ) + ) else: - endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](each, network=network_enum)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint](each, network=network_enum) + ) else: if os.getenv(f"{endpoint}_API_{network}".upper()): endpoints.append( @@ -112,7 +118,9 @@ def get_endpoints_for(network: str) -> tuple[BaseAPI, ...]: ) if next_endpoint: endpoints.append( - ENDPOINT_ENV_VARIABLES[endpoint](next_endpoint, network=network_enum) + ENDPOINT_ENV_VARIABLES[endpoint]( + next_endpoint, network=network_enum + ) ) counter += 1 else: @@ -123,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, network=network_enum)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint]( + *each, network=network_enum + ) + ) else: - endpoints.append(ENDPOINT_ENV_VARIABLES[endpoint](each, network=network_enum)) + endpoints.append( + ENDPOINT_ENV_VARIABLES[endpoint](each, network=network_enum) + ) return tuple(endpoints) From 9422130b6f49ef07eab26fbd0f4115cc9c0a3780 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Tue, 28 Apr 2026 22:34:57 +0300 Subject: [PATCH 14/14] Fix pyright error in mock_get_chaingraph_endpoints_for Co-Authored-By: Claude Sonnet 4.6 --- tests/network/test_services.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/network/test_services.py b/tests/network/test_services.py index 898008a..9061be7 100644 --- a/tests/network/test_services.py +++ b/tests/network/test_services.py @@ -20,7 +20,7 @@ set_service_timeout, ) from bitcash.network.transaction import Transaction -from bitcash.types import NFTCapability +from bitcash.types import NFTCapability, Network from tests.samples import ( BITCOIN_CASHADDRESS, VALID_BITCOINCOM_ENDPOINT_URLS, @@ -118,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