Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ Unreleased (see `master <https://github.com/ofek/bitcash>`_)
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)
------------------

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down
15 changes: 11 additions & 4 deletions bitcash/cashaddress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions bitcash/network/APIs/BitcoinDotComAPI.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
92 changes: 88 additions & 4 deletions bitcash/network/APIs/ChaingraphAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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": """
Expand Down
25 changes: 22 additions & 3 deletions bitcash/network/APIs/FulcrumProtocolAPI.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions bitcash/network/APIs/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
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):
"""
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
Expand Down Expand Up @@ -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:
"""
Expand Down
Loading