From 7885dc4c1f6428b6208e069f840d6f724ee28c9f Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Wed, 29 Apr 2026 22:54:28 +0300 Subject: [PATCH 1/2] Paginate get_cashtoken_addresses to handle large token holders Unbounded queries against the Chaingraph endpoint fail with a DB error for tokens with many holders (e.g. 4710 UTXOs). Add limit/offset pagination with CASHTOKEN_ADDRESSES_PAGE_SIZE=1000 and use LONG_DEFAULT_TIMEOUT=60 per page in NetworkAPI. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/ChaingraphAPI.py | 37 ++++++++++++++++++--------- bitcash/network/services.py | 7 ++++- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 4136698..07b74ed 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -13,6 +13,8 @@ from bitcash.cashaddress import Address from bitcash.types import NFTCapability, Network, NetworkStr +CASHTOKEN_ADDRESSES_PAGE_SIZE = 1000 + class ChaingraphAPI(BaseAPI): """ChaingraphAPI API, chaingraph.cash @@ -487,7 +489,7 @@ def get_cashtoken_addresses( query = ( "query GetCashtokenAddresses" - "($category: bytea!, $node: String!" + "($category: bytea!, $node: String!, $limit: Int!, $offset: Int!" + extra_decls + """) { output( @@ -511,24 +513,35 @@ def get_cashtoken_addresses( } ] } + limit: $limit + offset: $offset ) { locking_bytecode } }""" ) - json = self.send_request( - {"query": query, "variables": variables}, *args, **kwargs - ) + _PAGE_SIZE = CASHTOKEN_ADDRESSES_PAGE_SIZE 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 + offset = 0 + while True: + variables["limit"] = _PAGE_SIZE + variables["offset"] = offset + json = self.send_request( + {"query": query, "variables": variables}, *args, **kwargs + ) + rows = json["data"]["output"] + for output in rows: + try: + scriptcode = bytes.fromhex(output["locking_bytecode"][2:]) + addresses.add( + Address.from_script(scriptcode, self.network).cash_address() + ) + except ValueError: + pass + if len(rows) < _PAGE_SIZE: + break + offset += _PAGE_SIZE return addresses def broadcast_tx(self, tx_hex: str, *args, **kwargs) -> bool: # pragma: no cover diff --git a/bitcash/network/services.py b/bitcash/network/services.py index b2d939a..5481a60 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -31,6 +31,9 @@ # Default API call total time timeout DEFAULT_TIMEOUT = 5 +# Timeout for queries that may fetch large result sets +LONG_DEFAULT_TIMEOUT = 60 + # Default sanitized endpoint, based on blockheigt, cache timeout DEFAULT_SANITIZED_ENDPOINTS_CACHE_TIME = 300 @@ -364,6 +367,8 @@ def get_cashtoken_addresses( :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. + .. note:: This call may take over a minute for tokens with thousands of holders, + as results are fetched in pages. """ for endpoint in get_sanitized_endpoints_for(network): try: @@ -372,7 +377,7 @@ def get_cashtoken_addresses( nft_capability, nft_commitment, has_token, - timeout=DEFAULT_TIMEOUT, + timeout=LONG_DEFAULT_TIMEOUT, ) except NotImplementedError: continue From 031c596b24ec4545118915df2a669d72d2b57889 Mon Sep 17 00:00:00 2001 From: "Yashasvi S. Ranawat" Date: Sat, 2 May 2026 14:28:43 +0300 Subject: [PATCH 2/2] Paginate get_unspent and get_transactions for large addresses Unbounded search_output queries fail or time out for addresses with many UTXOs or transactions. Add limit/offset pagination (PAGE_SIZE=1000) and use LONG_DEFAULT_TIMEOUT=60 per page, matching the pattern introduced for get_cashtoken_addresses. Co-Authored-By: Claude Sonnet 4.6 --- bitcash/network/APIs/ChaingraphAPI.py | 173 +++++++++++++++----------- bitcash/network/services.py | 4 +- 2 files changed, 103 insertions(+), 74 deletions(-) diff --git a/bitcash/network/APIs/ChaingraphAPI.py b/bitcash/network/APIs/ChaingraphAPI.py index 07b74ed..2858360 100644 --- a/bitcash/network/APIs/ChaingraphAPI.py +++ b/bitcash/network/APIs/ChaingraphAPI.py @@ -14,6 +14,8 @@ from bitcash.types import NFTCapability, Network, NetworkStr CASHTOKEN_ADDRESSES_PAGE_SIZE = 1000 +UNSPENT_PAGE_SIZE = 1000 +TRANSACTIONS_PAGE_SIZE = 1000 class ChaingraphAPI(BaseAPI): @@ -135,9 +137,8 @@ def get_balance(self, address: str, *args, **kwargs) -> int: return sum([int(_["value_satoshis"]) for _ in data]) def get_transactions(self, address: str, *args, **kwargs) -> list[str]: - json_request = { - "query": """ -query GetOutputs($lb: _text!, $node: String!) { + query = """ +query GetOutputs($lb: _text!, $node: String!, $limit: Int!, $offset: Int!) { block( limit: 1 order_by: { height: desc } @@ -163,6 +164,8 @@ def get_transactions(self, address: str, *args, **kwargs) -> list[str]: } ] } + limit: $limit + offset: $offset ) { transaction_hash transaction { @@ -184,36 +187,49 @@ def get_transactions(self, address: str, *args, **kwargs) -> list[str]: } } } -""", - "variables": { - "lb": f"{{{Address.from_string(address).scriptcode.hex()}}}", - "node": self.node_like, - }, +""" + variables: dict[str, Any] = { + "lb": f"{{{Address.from_string(address).scriptcode.hex()}}}", + "node": self.node_like, } - json = self.send_request(json_request, *args, **kwargs) - blockheight = int(json["data"]["block"][0]["height"]) + _PAGE_SIZE = TRANSACTIONS_PAGE_SIZE + blockheight = None transactions = [] - for output in json["data"]["search_output"]: - # outputs - block_inclusions = output["transaction"]["block_inclusions"] - if len(block_inclusions) == 0: - # assume next block confirmation, - # only needed to sort transactions - height = blockheight + 1 - else: - height = int(block_inclusions[0]["block"]["height"]) - transactions.append((output["transaction_hash"][2:], height)) - # inputs - if len(output["spent_by"]) == 0: - # unspent - continue - input_ = output["spent_by"][0]["transaction"] - block_inclusions = input_["block_inclusions"] - if len(block_inclusions) == 0: - height = blockheight + 1 - else: - height = int(block_inclusions[0]["block"]["height"]) - transactions.append((input_["hash"][2:], height)) + offset = 0 + while True: + variables["limit"] = _PAGE_SIZE + variables["offset"] = offset + json = self.send_request( + {"query": query, "variables": variables}, *args, **kwargs + ) + data = json["data"] + if blockheight is None: + blockheight = int(data["block"][0]["height"]) + rows = data["search_output"] + for output in rows: + # outputs + block_inclusions = output["transaction"]["block_inclusions"] + if len(block_inclusions) == 0: + # assume next block confirmation, + # only needed to sort transactions + height = blockheight + 1 + else: + height = int(block_inclusions[0]["block"]["height"]) + transactions.append((output["transaction_hash"][2:], height)) + # inputs + if len(output["spent_by"]) == 0: + # unspent + continue + input_ = output["spent_by"][0]["transaction"] + block_inclusions = input_["block_inclusions"] + if len(block_inclusions) == 0: + height = blockheight + 1 + else: + height = int(block_inclusions[0]["block"]["height"]) + transactions.append((input_["hash"][2:], height)) + if len(rows) < _PAGE_SIZE: + break + offset += _PAGE_SIZE # sort by block height transactions.sort(key=lambda x: x[1]) transactions = [_[0] for _ in transactions][::-1] @@ -311,9 +327,8 @@ def get_tx_amount(self, txid: str, txindex: int, *args, **kwargs) -> int: return int(json["data"]["output"][0]["value_satoshis"]) def get_unspent(self, address: str, *args, **kwargs) -> list[Unspent]: - json_request = { - "query": """ -query GetUTXO($lb: _text!, $node: String!) { + query = """ +query GetUTXO($lb: _text!, $node: String!, $limit: Int!, $offset: Int!) { block( limit: 1 order_by: { height: desc } @@ -340,6 +355,8 @@ def get_unspent(self, address: str, *args, **kwargs) -> list[Unspent]: } ] } + limit: $limit + offset: $offset ) { transaction_hash output_index @@ -358,47 +375,59 @@ def get_unspent(self, address: str, *args, **kwargs) -> list[Unspent]: } } } -""", - "variables": { - "lb": f"{{{Address.from_string(address).scriptcode.hex()}}}", - "node": self.node_like, - }, +""" + variables: dict[str, Any] = { + "lb": f"{{{Address.from_string(address).scriptcode.hex()}}}", + "node": self.node_like, } - data = self.send_request(json_request, *args, **kwargs)["data"] - blockheight = int(data["block"][0]["height"]) + _PAGE_SIZE = UNSPENT_PAGE_SIZE + blockheight = None unspents = [] - for utxo in data["search_output"]: - block_inclusions = utxo["transaction"]["block_inclusions"] - if len(block_inclusions) == 0: - # unconfirmed - confirmations = 0 - else: - confirmations = ( - -int(block_inclusions[0]["block"]["height"]) + blockheight + 1 - ) - token_category = utxo["token_category"] - if token_category: - token_category = token_category[2:] - nft_commitment = utxo["nonfungible_token_commitment"] - if nft_commitment: - nft_commitment = bytes.fromhex(nft_commitment[2:]) or None - token_amount = utxo["fungible_token_amount"] - if token_amount: - token_amount = int(token_amount) - # add unspent - unspents.append( - Unspent( - int(utxo["value_satoshis"]), - confirmations, - utxo["locking_bytecode"][2:], - utxo["transaction_hash"][2:], - int(utxo["output_index"]), - token_category, - utxo["nonfungible_token_capability"], - nft_commitment or None, # b"" is None - token_amount or None, # 0 amount is None + offset = 0 + while True: + variables["limit"] = _PAGE_SIZE + variables["offset"] = offset + data = self.send_request( + {"query": query, "variables": variables}, *args, **kwargs + )["data"] + if blockheight is None: + blockheight = int(data["block"][0]["height"]) + rows = data["search_output"] + for utxo in rows: + block_inclusions = utxo["transaction"]["block_inclusions"] + if len(block_inclusions) == 0: + # unconfirmed + confirmations = 0 + else: + confirmations = ( + -int(block_inclusions[0]["block"]["height"]) + blockheight + 1 + ) + token_category = utxo["token_category"] + if token_category: + token_category = token_category[2:] + nft_commitment = utxo["nonfungible_token_commitment"] + if nft_commitment: + nft_commitment = bytes.fromhex(nft_commitment[2:]) or None + token_amount = utxo["fungible_token_amount"] + if token_amount: + token_amount = int(token_amount) + # add unspent + unspents.append( + Unspent( + int(utxo["value_satoshis"]), + confirmations, + utxo["locking_bytecode"][2:], + utxo["transaction_hash"][2:], + int(utxo["output_index"]), + token_category, + utxo["nonfungible_token_capability"], + nft_commitment or None, # b"" is None + token_amount or None, # 0 amount is None + ) ) - ) + if len(rows) < _PAGE_SIZE: + break + offset += _PAGE_SIZE return unspents def get_raw_transaction(self, txid: str, *args, **kwargs) -> dict[str, Any]: diff --git a/bitcash/network/services.py b/bitcash/network/services.py index 5481a60..9c1b1a7 100644 --- a/bitcash/network/services.py +++ b/bitcash/network/services.py @@ -260,7 +260,7 @@ def get_transactions( """ for endpoint in get_sanitized_endpoints_for(network): try: - return endpoint.get_transactions(address, timeout=DEFAULT_TIMEOUT) + return endpoint.get_transactions(address, timeout=LONG_DEFAULT_TIMEOUT) except cls.IGNORED_ERRORS: # pragma: no cover pass @@ -319,7 +319,7 @@ def get_unspent( for endpoint in get_sanitized_endpoints_for(network): try: - return endpoint.get_unspent(address, timeout=DEFAULT_TIMEOUT) + return endpoint.get_unspent(address, timeout=LONG_DEFAULT_TIMEOUT) except cls.IGNORED_ERRORS: # pragma: no cover pass