diff --git a/bsv/auth/cert_encryption.py b/bsv/auth/cert_encryption.py index 52f9e29..ee5546f 100644 --- a/bsv/auth/cert_encryption.py +++ b/bsv/auth/cert_encryption.py @@ -3,9 +3,9 @@ def get_certificate_encryption_details(field_name: str, serial_number: Optional[str]) -> Tuple[dict, str]: """ - TS/Go準拠の証明書フィールド暗号化メタデータを返す。 + Returns certificate field encryption metadata compatible with TS/Go. - protocol_id: {'protocol': 'certificate field encryption', 'security_level': 1} - - key_id: serial_numberがあれば "{serial_number} {field_name}", なければ field_name + - key_id: If serial_number is present, "{serial_number} {field_name}", otherwise field_name """ protocol_id = { "protocol": "certificate field encryption", diff --git a/bsv/auth/certificate.py b/bsv/auth/certificate.py index 372f3ae..2131095 100644 --- a/bsv/auth/certificate.py +++ b/bsv/auth/certificate.py @@ -3,7 +3,7 @@ from bsv.keys import PublicKey, PrivateKey from bsv.utils import unsigned_to_varint, Reader, Writer, serialize_ecdsa_der, deserialize_ecdsa_der, hash256 -# Outpointの簡易表現 +# Simple representation of Outpoint class Outpoint(NamedTuple): txid: str # 32byte hex string index: int diff --git a/bsv/auth/requested_certificate_set.py b/bsv/auth/requested_certificate_set.py index d2a5b7b..85425ea 100644 --- a/bsv/auth/requested_certificate_set.py +++ b/bsv/auth/requested_certificate_set.py @@ -46,11 +46,11 @@ def items(self): def is_empty(self): return len(self.mapping) == 0 -# --- 補助関数 --- +# --- Helper functions --- def certifier_in_list(certifiers: List[PublicKey], certifier: Optional[PublicKey]) -> bool: """ Checks if the given certifier is in the list of certifiers. - Noneは常にFalse。 + None is always False. """ if certifier is None: return False @@ -59,7 +59,7 @@ def certifier_in_list(certifiers: List[PublicKey], certifier: Optional[PublicKey def is_empty_public_key(key: Optional[PublicKey]) -> bool: """ Checks if a PublicKey is empty/uninitialized. - Noneまたは内部バイト列が全てゼロの場合True。 + Returns True if key is None or its internal byte array is all zeros. """ if key is None: return True @@ -108,7 +108,7 @@ def validate(self): raise ValueError("empty or invalid certificate type specified") if not fields: raise ValueError(f"no fields specified for certificate type: {base64.b64encode(cert_type).decode('ascii')}") - # 追加: certifiersリストに未初期化公開鍵が含まれていないかチェック + # Addition: Check if the certifiers list contains any uninitialized public keys for c in self.certifiers: if is_empty_public_key(c): raise ValueError("certifiers list contains an empty/uninitialized public key") diff --git a/bsv/broadcaster.py b/bsv/broadcaster.py index 852b4c7..0fa1aeb 100644 --- a/bsv/broadcaster.py +++ b/bsv/broadcaster.py @@ -1,47 +1,2 @@ -from abc import ABC, abstractmethod -from typing import Union, Dict, Any, TYPE_CHECKING - - -if TYPE_CHECKING: - from .transaction import Transaction - -class BroadcastResponse: - def __init__(self, status: str, txid: str, message: str): - self.status = status - self.txid = txid - self.message = message - - -class BroadcastFailure: - def __init__( - self, - status: str, - code: str, - description: str, - txid: str = None, - more: Dict[str, Any] = None, - ): - self.status = status - self.code = code - self.txid = txid - self.description = description - self.more = more - - -class Broadcaster(ABC): - def __init__(self): - self.URL = None - - @abstractmethod - async def broadcast( - self, transaction: 'Transaction' - ) -> Union[BroadcastResponse, BroadcastFailure]: - pass - - -def is_broadcast_response(r: Union[BroadcastResponse, BroadcastFailure]) -> bool: - return r.status == "success" - - -def is_broadcast_failure(r: Union[BroadcastResponse, BroadcastFailure]) -> bool: - return r.status == "error" +# DEPRECATED: Use bsv.broadcaster_core instead. +from bsv.broadcaster_core.broadcaster import * diff --git a/bsv/broadcaster_core/__init__.py b/bsv/broadcaster_core/__init__.py new file mode 100644 index 0000000..e0d3183 --- /dev/null +++ b/bsv/broadcaster_core/__init__.py @@ -0,0 +1,2 @@ +# This file makes bsv.broadcaster a package. +from .broadcaster import Broadcaster, BroadcastResponse, BroadcastFailure \ No newline at end of file diff --git a/bsv/broadcaster_core/broadcaster.py b/bsv/broadcaster_core/broadcaster.py new file mode 100644 index 0000000..852b4c7 --- /dev/null +++ b/bsv/broadcaster_core/broadcaster.py @@ -0,0 +1,47 @@ +from abc import ABC, abstractmethod +from typing import Union, Dict, Any, TYPE_CHECKING + + +if TYPE_CHECKING: + from .transaction import Transaction + +class BroadcastResponse: + def __init__(self, status: str, txid: str, message: str): + self.status = status + self.txid = txid + self.message = message + + +class BroadcastFailure: + def __init__( + self, + status: str, + code: str, + description: str, + txid: str = None, + more: Dict[str, Any] = None, + ): + self.status = status + self.code = code + self.txid = txid + self.description = description + self.more = more + + +class Broadcaster(ABC): + def __init__(self): + self.URL = None + + @abstractmethod + async def broadcast( + self, transaction: 'Transaction' + ) -> Union[BroadcastResponse, BroadcastFailure]: + pass + + +def is_broadcast_response(r: Union[BroadcastResponse, BroadcastFailure]) -> bool: + return r.status == "success" + + +def is_broadcast_failure(r: Union[BroadcastResponse, BroadcastFailure]) -> bool: + return r.status == "error" diff --git a/bsv/broadcaster_core/whatsonchain.py b/bsv/broadcaster_core/whatsonchain.py new file mode 100644 index 0000000..69196e1 --- /dev/null +++ b/bsv/broadcaster_core/whatsonchain.py @@ -0,0 +1,28 @@ +import os +import requests + +class WhatsOnChainBroadcaster: + """ + Broadcasts a raw transaction to the Bitcoin SV network via WhatsOnChain API. + Usage: + broadcaster = WhatsOnChainBroadcaster("main") + result = broadcaster.broadcast(tx_hex) + """ + def __init__(self, network="main"): + self.network = network + self.api_key = os.environ.get("WOC_API_KEY", "") + self.url = f"https://api.whatsonchain.com/v1/bsv/{self.network}/tx/raw" + + def broadcast(self, tx_hex: str) -> dict: + headers = {"woc-api-key": self.api_key} if self.api_key else {} + try: + resp = requests.post(self.url, json={"txhex": tx_hex}, headers=headers, timeout=10) + resp.raise_for_status() + try: + data = resp.json() + return {"txid": data.get("txid") or data.get("data")} + except Exception: + # If not JSON, treat as raw txid string + return {"txid": resp.text.strip()} + except Exception as e: + return {"error": str(e), "response": getattr(resp, 'text', None)} diff --git a/bsv/broadcaster_plugins/__init__.py b/bsv/broadcaster_plugins/__init__.py new file mode 100644 index 0000000..419b5ea --- /dev/null +++ b/bsv/broadcaster_plugins/__init__.py @@ -0,0 +1,3 @@ +from .arc import ARC, ARCConfig +from .default import default_broadcaster +from .whatsonchain import WhatsOnChainBroadcaster diff --git a/bsv/broadcaster_plugins/arc.py b/bsv/broadcaster_plugins/arc.py new file mode 100644 index 0000000..c1220b0 --- /dev/null +++ b/bsv/broadcaster_plugins/arc.py @@ -0,0 +1,333 @@ +import json +import random +from typing import Optional, Dict, Union, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from ..transaction import Transaction + +from ..broadcaster import BroadcastResponse, BroadcastFailure, Broadcaster +from ..http_client import HttpClient, default_http_client, SyncHttpClient, default_sync_http_client + +def to_hex(bytes_data): + return "".join(f"{x:02x}" for x in bytes_data) + + +def random_hex(length: int) -> str: + return "".join(f"{random.randint(0, 255):02x}" for _ in range(length)) + + +class ARCConfig: + def __init__( + self, + api_key: Optional[str] = None, + http_client: Optional[HttpClient] = None, + sync_http_client: Optional[SyncHttpClient] = None, + deployment_id: Optional[str] = None, + callback_url: Optional[str] = None, + callback_token: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, + ): + self.api_key = api_key + self.http_client = http_client + self.sync_http_client = sync_http_client + self.deployment_id = deployment_id + self.callback_url = callback_url + self.callback_token = callback_token + self.headers = headers + + +def default_deployment_id() -> str: + return f"py-sdk-{random_hex(16)}" + + +class ARC(Broadcaster): + def __init__(self, url: str, config: Union[str, ARCConfig] = None): + self.URL = url + if isinstance(config, str): + self.api_key = config + self.http_client = default_http_client() + self.sync_http_client = default_sync_http_client() + self.deployment_id = default_deployment_id() + self.callback_url = None + self.callback_token = None + self.headers = None + else: + config = config or ARCConfig() + self.api_key = config.api_key + self.http_client = config.http_client or default_http_client() + self.sync_http_client = config.sync_http_client or default_sync_http_client() + self.deployment_id = config.deployment_id or default_deployment_id() + self.callback_url = config.callback_url + self.callback_token = config.callback_token + self.headers = config.headers + + async def broadcast( + self, tx: 'Transaction' + ) -> Union[BroadcastResponse, BroadcastFailure]: + # Check if all inputs have source_transaction + has_all_source_txs = all(input.source_transaction is not None for input in tx.inputs) + request_options = { + "method": "POST", + "headers": self.request_headers(), + "data": { + "rawTx": + tx.to_ef().hex() if has_all_source_txs else tx.hex() + } + } + try: + response = await self.http_client.fetch( + f"{self.URL}/v1/tx", request_options + ) + + response_json = response.json() + + if response.ok and response.status_code >= 200 and response.status_code <= 299: + data = response_json["data"] + + if data.get("txid"): + return BroadcastResponse( + status="success", + txid=data.get("txid"), + message=f"{data.get('txStatus', '')} {data.get('extraInfo', '')}", + ) + else: + return BroadcastFailure( + status="failure", + code=data.get("status", "ERR_UNKNOWN"), + description=data.get("detail", "Unknown error"), + ) + else: + return BroadcastFailure( + status="failure", + code=str(response.status_code), + description=response_json["data"]["detail"] if "data" in response_json else "Unknown error", + ) + + except Exception as error: + return BroadcastFailure( + status="failure", + code="500", + description=( + str(error) + if isinstance(error, Exception) + else "Internal Server Error" + ), + ) + + def request_headers(self) -> Dict[str, str]: + headers = { + "Content-Type": "application/json", + "XDeployment-ID": self.deployment_id, + } + + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + if self.callback_url: + headers["X-CallbackUrl"] = self.callback_url + + if self.callback_token: + headers["X-CallbackToken"] = self.callback_token + + if self.headers: + headers.update(self.headers) + + return headers + + def sync_broadcast( + self, tx: 'Transaction', timeout: int = 30 + ) -> Union[BroadcastResponse, BroadcastFailure]: + """ + Synchronously broadcast a transaction + + :param tx: Transaction to broadcast + :param timeout: Timeout setting in seconds + :returns: BroadcastResponse or BroadcastFailure + """ + # Check if all inputs have source_transaction + has_all_source_txs = all(input.source_transaction is not None for input in tx.inputs) + + try: + response = self.sync_http_client.post( + f"{self.URL}/v1/tx", + data={"rawTx": tx.to_ef().hex() if has_all_source_txs else tx.hex()}, + headers=self.request_headers(), + timeout=timeout + ) + + response_json = response.json() + data = response_json.get("data", {}) + + if response.ok: + if data.get("txid"): + return BroadcastResponse( + status="success", + txid=data.get("txid"), + message=f"{data.get('txStatus', '')} {data.get('extraInfo', '')}".strip(), + ) + else: + return BroadcastFailure( + status="failure", + code=data.get("status", "ERR_UNKNOWN"), + description=data.get("detail", "Unknown error"), + ) + else: + # Handle special error cases + if response.status_code == 408: + return BroadcastFailure( + status="failure", + code="408", + description=f"Transaction broadcast timed out after {timeout} seconds", + ) + + if response.status_code == 503: + return BroadcastFailure( + status="failure", + code="503", + description="Failed to connect to ARC service", + ) + + return BroadcastFailure( + status="failure", + code=str(response.status_code), + description=data.get("detail", "Unknown error"), + ) + + except Exception as error: + return BroadcastFailure( + status="failure", + code="500", + description=str(error), + ) + + def check_transaction_status(self, txid: str, timeout: int = 5) -> Dict[str, Any]: + """ + Check transaction status synchronously + + :param txid: Transaction ID to check + :param timeout: Timeout setting in seconds + :returns: Dictionary containing transaction status information + """ + + try: + response = self.sync_http_client.get( + f"{self.URL}/v1/tx/{txid}", + headers=self.request_headers(), + timeout=timeout + ) + response_data = response.json() + data = response_data.get("data", {}) + + if response.ok: + return { + "txid": txid, + "txStatus": data.get("txStatus"), + "blockHash": data.get("blockHash"), + "blockHeight": data.get("blockHeight"), + "merklePath": data.get("merklePath"), + "extraInfo": data.get("extraInfo"), + "competingTxs": data.get("competingTxs"), + "timestamp": data.get("timestamp") + } + else: + # Handle special error cases + if response.status_code == 408: + return { + "status": "failure", + "code": 408, + "title": "Request Timeout", + "detail": f"Transaction status check timed out after {timeout} seconds", + "txid": txid, + "extra_info": "Consider retrying or increasing timeout value" + } + + if response.status_code == 503: + return { + "status": "failure", + "code": 503, + "title": "Connection Error", + "detail": "Failed to connect to ARC service", + "txid": txid + } + + # Handle general error cases + return { + "status": "failure", + "code": data.get("status", response.status_code), + "title": data.get("title", "Error"), + "detail": data.get("detail", "Unknown error"), + "txid": data.get("txid", txid), + "extra_info": data.get("extraInfo", "") + } + + except Exception as error: + return { + "status": "failure", + "code": "500", + "title": "Internal Error", + "detail": str(error), + "txid": txid + } + + @staticmethod + def categorize_transaction_status(response: Dict[str, Any]) -> Dict[str, Any]: + """ + Categorize transaction status based on the ARC response + + :param response: The transaction status response dictionary from ARC + :returns: Dictionary containing status category and transaction status + """ + try: + tx_status = response.get("txStatus") + + if tx_status: + # Processing transactions - still being handled by the network + if tx_status in [ + "UNKNOWN", "QUEUED", "RECEIVED", "STORED", + "ANNOUNCED_TO_NETWORK", "REQUESTED_BY_NETWORK", + "SENT_TO_NETWORK", "ACCEPTED_BY_NETWORK" + ]: + status_category = "progressing" + + # Successfully mined transactions + elif tx_status in ["MINED"]: + status_category = "mined" + + # Mined in stale block - needs attention + elif tx_status in ["MINED_IN_STALE_BLOCK"]: + status_category = "0confirmation" + + # Warning status - double spend attempted + elif tx_status in ["DOUBLE_SPEND_ATTEMPTED"]: + status_category = "warning" + + # Seen on network - check for competing transactions + elif tx_status in ["SEEN_ON_NETWORK"]: + # Check if there are competing transactions in mempool + if response.get("competingTxs"): + status_category = "warning" + else: + # Transaction is in mempool without conflicts + status_category = "0confirmation" + + # Rejected transactions - failed to process + elif tx_status in ["ERROR", "REJECTED", "SEEN_IN_ORPHAN_MEMPOOL"]: + status_category = "rejected" + + else: + status_category = f"unknown_txStatus: {tx_status}" + else: + status_category = "error" + tx_status = "No txStatus" + + return { + "status_category": status_category, + "tx_status": tx_status + } + + except Exception as e: + return { + "status_category": "error", + "error": str(e), + "response": response + } \ No newline at end of file diff --git a/bsv/broadcaster_plugins/default.py b/bsv/broadcaster_plugins/default.py new file mode 100644 index 0000000..d75f6ab --- /dev/null +++ b/bsv/broadcaster_plugins/default.py @@ -0,0 +1,47 @@ +from typing import Union + +from .arc import ARC, ARCConfig +from ..broadcaster import Broadcaster +from ..constants import taal_mainnet_apikey, taal_testnet_apikey + + +def default_broadcaster( + is_testnet: bool = False, + config: Union[ARCConfig, dict] = None +) -> Broadcaster: + # Use existing broadcaster functions to get the base broadcaster + if is_testnet: + base_broadcaster = gorillapool_testnet_broadcaster() + else: + base_broadcaster = gorillapool_broadcaster() + + # If no config provided, return the base broadcaster as-is + if config is None: + return base_broadcaster + + # Convert dict config to ARCConfig if needed + if isinstance(config, dict): + config = ARCConfig(**config) + + # Create new ARC instance with the same URL but custom config + return ARC(base_broadcaster.URL, config) + + +def taal_broadcaster() -> Broadcaster: + # taal now requires an API key to broadcast transactions via ARC. If you would like to use it, + # please visit https://taal.com/ to register for one. + arc_config = ARCConfig(api_key=taal_mainnet_apikey) + return ARC('https://arc.taal.com', arc_config) + +def taal_testnet_broadcaster() -> Broadcaster: + # taal now requires an API key to broadcast transactions via ARC. If you would like to use it, + # please visit https://taal.com/ to register for one. + arc_config = ARCConfig(api_key=taal_testnet_apikey) + return ARC('https://arc-test.taal.com/', arc_config) + +def gorillapool_broadcaster() -> Broadcaster: + return ARC('https://arc.gorillapool.io') + +def gorillapool_testnet_broadcaster() -> Broadcaster: + return ARC('https://testnet.arc.gorillapool.io') + diff --git a/bsv/broadcaster_plugins/whatsonchain.py b/bsv/broadcaster_plugins/whatsonchain.py new file mode 100644 index 0000000..c5fe15b --- /dev/null +++ b/bsv/broadcaster_plugins/whatsonchain.py @@ -0,0 +1,59 @@ +from typing import Union, TYPE_CHECKING + +from ..broadcaster import Broadcaster, BroadcastFailure, BroadcastResponse +from ..http_client import HttpClient, default_http_client +from ..constants import Network + +if TYPE_CHECKING: + from ..transaction import Transaction + +class WhatsOnChainBroadcaster(Broadcaster): + def __init__(self, network: Union[Network, str] = Network.MAINNET, http_client: HttpClient = None): + """ + Initialize WhatsOnChainBroadcaster. + + :param network: Network to broadcast to. Can be either Network enum or string ('main'/'test') + :param http_client: Optional HTTP client to use for requests + """ + if isinstance(network, str): + network_str = network.lower() + if network_str in ['main', 'mainnet']: + self.network = 'main' + elif network_str in ['test', 'testnet']: + self.network = 'test' + else: + raise ValueError(f"Invalid network string: {network}. Must be 'main' or 'test'") + else: + self.network = 'main' if network == Network.MAINNET else 'test' + + self.URL = f"https://api.whatsonchain.com/v1/bsv/{self.network}/tx/raw" + self.http_client = http_client if http_client else default_http_client() + + async def broadcast( + self, tx: 'Transaction' + ) -> Union[BroadcastResponse, BroadcastFailure]: + request_options = { + "method": "POST", + "headers": {"Content-Type": "application/json", "Accept": "text/plain"}, + "data": {"txhex": tx.hex()}, + } + + try: + response = await self.http_client.fetch(self.URL, request_options) + if response.ok: + txid = response.json()["data"] + return BroadcastResponse( + status="success", txid=txid, message="broadcast successful" + ) + else: + return BroadcastFailure( + status="error", + code=str(response.status_code), + description=response.json()["data"], + ) + except Exception as error: + return BroadcastFailure( + status="error", + code="500", + description=(str(error) if str(error) else "Internal Server Error"), + )