diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f4af8..cd2717f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Table of Contents -- [Unreleased](#unreleased) +- [Unreleased](#unreleased +- [1.0.6- 2025-06-30](#106---2025-06-30) - [1.0.5- 2025-05-30](#105---2025-05-30) - [1.0.4- 2025-04-28](#104---2025-04-28) - [1.0.3 - 2025-03-26](#103---2025-03-26) @@ -39,6 +40,23 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Security - (Notify of any improvements related to security vulnerabilities or potential risks.) +--- + +## [1.0.6] - 2025-06-30 + +### Added +- Introduced `SyncHttpClient` for synchronous HTTP operations +- Extended ARC broadcaster with synchronous methods: `sync_broadcast`, `check_transaction_status`, and `categorize_transaction_status` +- Updated ARC configuration to include optional `SyncHttpClient` support +- Added examples, tests, and utilities for synchronous transactions + +### Changed +- Updated `SyncHttpClient` to inherit from `HttpClient` for consistency +- Refactored `fetch` into higher-level HTTP methods: `get` and `post` +- Simplified ARC broadcaster by using `get` and `post` methods for sync operations +- Enhanced error handling and response processing in ARC transactions +- Updated tests and examples to align with refactored `SyncHttpClient` + --- ## [1.0.5] - 2025-05-30 diff --git a/bsv/broadcasters/arc.py b/bsv/broadcasters/arc.py index f2edcd9..c1220b0 100644 --- a/bsv/broadcasters/arc.py +++ b/bsv/broadcasters/arc.py @@ -1,14 +1,13 @@ import json import random -from typing import Optional, Dict, Union, TYPE_CHECKING - -from ..broadcaster import BroadcastResponse, BroadcastFailure, Broadcaster -from ..http_client import HttpClient, default_http_client - +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) @@ -19,16 +18,18 @@ def random_hex(length: int) -> str: class ARCConfig: def __init__( - self, - api_key: Optional[str] = None, - http_client: Optional[HttpClient] = 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: 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 @@ -45,6 +46,7 @@ def __init__(self, url: str, config: Union[str, ARCConfig] = None): 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 @@ -53,27 +55,32 @@ def __init__(self, url: str, config: Union[str, ARCConfig] = None): 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' + 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()}, + "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"] @@ -95,7 +102,7 @@ async def broadcast( 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", @@ -126,3 +133,201 @@ def request_headers(self) -> Dict[str, str]: 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/http_client.py b/bsv/http_client.py index e7253e3..e3e05bd 100644 --- a/bsv/http_client.py +++ b/bsv/http_client.py @@ -1,7 +1,8 @@ -from abc import ABC, abstractmethod - import aiohttp +import requests +from abc import ABC, abstractmethod +from typing import Optional, Dict class HttpClient(ABC): @abstractmethod @@ -44,6 +45,96 @@ async def fetch(self, url: str, options: dict) -> HttpResponse: json_data={}, ) +class SyncHttpClient(HttpClient): + """Synchronous HTTP client compatible with DefaultHttpClient""" + + def __init__(self, default_timeout: int = 30): + self.default_timeout = default_timeout + + def fetch(self, url: str, options: dict) -> HttpResponse: + method = options.get("method", "GET") + headers = options.get("headers", {}) + timeout = options.get("timeout", self.default_timeout) + data = options.get("data", None) + + try: + if method.upper() in ["POST", "PUT", "PATCH"] and data is not None: + response = requests.request( + method=method, + url=url, + headers=headers, + json=data, + timeout=timeout + ) + else: + response = requests.request( + method=method, + url=url, + headers=headers, + timeout=timeout + ) + + return self._make_response(response) + except requests.RequestException as e: + return self._handle_error(e) + + def _make_response(self, response: requests.Response) -> HttpResponse: + try: + json_data = response.json() + formatted_json = {'data': json_data} + except (ValueError, requests.exceptions.JSONDecodeError): + formatted_json = {} + + ok = response.status_code >= 200 and response.status_code <= 299 + + return HttpResponse( + ok=ok, + status_code=response.status_code, + json_data=formatted_json + ) + + def _handle_error(self, error: requests.RequestException) -> HttpResponse: + if isinstance(error, requests.Timeout): + status_code = 408 + elif isinstance(error, requests.ConnectionError): + status_code = 503 + elif isinstance(error, requests.HTTPError): + status_code = error.response.status_code if error.response else 500 + else: + status_code = 0 + + return HttpResponse( + ok=False, + status_code=status_code, + json_data={"error": str(error), "error_type": type(error).__name__} + ) + + def get(self, url: str, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None) -> HttpResponse: + options = { + "method": "GET", + "headers": headers or {}, + "timeout": timeout if timeout is not None else self.default_timeout + } + return self.fetch(url, options) + + def post(self, url: str, + data: Optional[dict] = None, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None) -> HttpResponse: + options = { + "method": "POST", + "headers": headers or {}, + "data": data, + "timeout": timeout if timeout is not None else self.default_timeout + } + return self.fetch(url, options) + + +def default_sync_http_client() -> SyncHttpClient: + return SyncHttpClient() + def default_http_client() -> HttpClient: - return DefaultHttpClient() + return default_sync_http_client() \ No newline at end of file diff --git a/examples/test_sync_arc.py b/examples/test_sync_arc.py new file mode 100644 index 0000000..240e89d --- /dev/null +++ b/examples/test_sync_arc.py @@ -0,0 +1,75 @@ +from idlelib.configdialog import changes +import asyncio + +from bsv import ( + Transaction, + TransactionInput, + TransactionOutput, + PrivateKey, + P2PKH, + BroadcastResponse, + ARC +) + +""" +Simple example of synchronous ARC broadcasting and status checking. +""" + +def main(): + + # Setup ARC broadcaster + arc = ARC('https://api.taal.com/arc', "mainnet_2e3a7d0f845a5049b_________98fc4271") + + # Create a simple transaction + private_key = PrivateKey("Kzpr5a6TmrXNw2NxSzt6GUonvc---------dGEjxCufyxGMo9xV") + public_key = private_key.public_key() + + source_tx = Transaction.from_hex( + "01000000013462125ff05a9150c25693bbb474a----------ab265e746f523791e01462000000006a4730440220447ac5232e8eb25db0e004bc704a19bc33c9c7ef86070781078bce74e089be44022029195e8cc392bf7c5577dc477a90d157be0356d8fbb52eb66521f4eabe00dcf9412103e23c79a29b5e5f20127ec2286413510662d0e6befa29d669a623035122753d3affffffff013d000000000000001976a914047f8e69ca8eadec1b327d1b232cdaaffa200d1688ac00000000" + ) + + tx = Transaction( + [ + TransactionInput( + source_transaction=source_tx, + source_txid=source_tx.txid(), + source_output_index=0, + unlocking_script_template=P2PKH().unlock(private_key), + ) + ], + [ + TransactionOutput( + locking_script=P2PKH().lock("1QnWY1CWbWGeqobBBoxdZZ3DDeWUC2VLn"), + change=True + ) + ], + ) + + tx.fee() + tx.sign() + txid = tx.txid() + txhex = tx.hex() + print(f"Transaction ID: {txid}") + print(f"Transaction hex: {txhex}") + # Broadcast transaction + + result = arc.sync_broadcast(tx) + + if isinstance(result, BroadcastResponse): + print(f"Broadcast successful: {result.txid}") + + # Check status + status = arc.check_transaction_status(result.txid) + print(f"Status: {status.get('txStatus', 'Unknown')}") + + # Categorize status + category = arc.categorize_transaction_status(status) + print(f"Category: {category.get('status_category')}") + + else: + print(f"Broadcast failed: {result.description}") + + +if __name__ == "__main__": + main() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32dd738 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pycryptodomex~=3.21.0 +coincurve~=20.0.0 +aiohttp~=3.11.11 +requests~=2.32.3 +pytest~=8.3.4 +setuptools~=75.6.0 \ No newline at end of file diff --git a/tests/test_arc.py b/tests/test_arc.py index c22e3cb..4032830 100644 --- a/tests/test_arc.py +++ b/tests/test_arc.py @@ -3,7 +3,7 @@ from bsv.broadcaster import BroadcastResponse, BroadcastFailure from bsv.broadcasters.arc import ARC, ARCConfig -from bsv.http_client import HttpClient, HttpResponse +from bsv.http_client import HttpClient, HttpResponse, SyncHttpClient from bsv.transaction import Transaction @@ -74,6 +74,163 @@ async def test_broadcast_exception(self): self.assertEqual(result.code, "500") self.assertEqual(result.description, "Internal Error") + def test_sync_broadcast_success(self): + mock_response = HttpResponse( + ok=True, + status_code=200, + json_data={ + "data": { + "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", + "txStatus": "success", + "extraInfo": "extra", + } + }, + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.post = MagicMock(return_value=mock_response) # fetch → post + + arc_config = ARCConfig(api_key=self.api_key, sync_http_client=mock_sync_http_client) + arc = ARC(self.URL, arc_config) + result = arc.sync_broadcast(self.tx) + + self.assertIsInstance(result, BroadcastResponse) + self.assertEqual( + result.txid, + "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", + ) + self.assertEqual(result.message, "success extra") + + def test_sync_broadcast_failure(self): + mock_response = HttpResponse( + ok=False, + status_code=400, + json_data={ + "data": {"status": "ERR_BAD_REQUEST", "detail": "Invalid transaction"} + }, + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.post = MagicMock(return_value=mock_response) # fetch → post + + arc_config = ARCConfig(api_key=self.api_key, sync_http_client=mock_sync_http_client) + arc = ARC(self.URL, arc_config) + result = arc.sync_broadcast(self.tx) + + self.assertIsInstance(result, BroadcastFailure) + self.assertEqual(result.code, "400") + self.assertEqual(result.description, "Invalid transaction") + + def test_sync_broadcast_timeout_error(self): + """408 time out error test""" + mock_response = HttpResponse( + ok=False, + status_code=408, + json_data={"data": {"status": "ERR_TIMEOUT", "detail": "Request timed out"}} + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.post = MagicMock(return_value=mock_response) + + arc_config = ARCConfig(api_key=self.api_key, sync_http_client=mock_sync_http_client) + arc = ARC(self.URL, arc_config) + result = arc.sync_broadcast(self.tx, timeout=5) + + self.assertIsInstance(result, BroadcastFailure) + self.assertEqual(result.status, "failure") + self.assertEqual(result.code, "408") + self.assertEqual(result.description, "Transaction broadcast timed out after 5 seconds") + + def test_sync_broadcast_connection_error(self): + """503 error test""" + mock_response = HttpResponse( + ok=False, + status_code=503, + json_data={"data": {"status": "ERR_CONNECTION", "detail": "Service unavailable"}} + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.post = MagicMock(return_value=mock_response) + + arc_config = ARCConfig(api_key=self.api_key, sync_http_client=mock_sync_http_client) + arc = ARC(self.URL, arc_config) + result = arc.sync_broadcast(self.tx) + + self.assertIsInstance(result, BroadcastFailure) + self.assertEqual(result.status, "failure") + self.assertEqual(result.code, "503") + self.assertEqual(result.description, "Failed to connect to ARC service") + + def test_sync_broadcast_exception(self): + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.post = MagicMock(side_effect=Exception("Internal Error")) + + arc_config = ARCConfig(api_key=self.api_key, sync_http_client=mock_sync_http_client) + arc = ARC(self.URL, arc_config) + result = arc.sync_broadcast(self.tx) + + self.assertIsInstance(result, BroadcastFailure) + self.assertEqual(result.code, "500") + self.assertEqual(result.description, "Internal Error") + + def test_check_transaction_status_success(self): + mock_response = HttpResponse( + ok=True, + status_code=200, + json_data={ + "data": { # dataキーを追加 + "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", + "txStatus": "MINED", + "blockHash": "000000000000000001234567890abcdef", + "blockHeight": 800000 + } + }, + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.get = MagicMock(return_value=mock_response) # fetch → get + + arc_config = ARCConfig(api_key=self.api_key, sync_http_client=mock_sync_http_client) + arc = ARC(self.URL, arc_config) + result = arc.check_transaction_status("8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec") + + self.assertEqual(result["txid"], "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec") + self.assertEqual(result["txStatus"], "MINED") + self.assertEqual(result["blockHeight"], 800000) + + def test_categorize_transaction_status_mined(self): + response = { + "txStatus": "MINED", + "blockHeight": 800000 + } + result = ARC.categorize_transaction_status(response) + + self.assertEqual(result["status_category"], "mined") + self.assertEqual(result["tx_status"], "MINED") + + def test_categorize_transaction_status_progressing(self): + response = { + "txStatus": "QUEUED" + } + result = ARC.categorize_transaction_status(response) + + self.assertEqual(result["status_category"], "progressing") + self.assertEqual(result["tx_status"], "QUEUED") + + def test_categorize_transaction_status_warning(self): + response = { + "txStatus": "SEEN_ON_NETWORK", + "competingTxs": ["some_competing_tx"] + } + result = ARC.categorize_transaction_status(response) + + self.assertEqual(result["status_category"], "warning") + self.assertEqual(result["tx_status"], "SEEN_ON_NETWORK") + + def test_categorize_transaction_status_0confirmation(self): + response = { + "txStatus": "SEEN_ON_NETWORK" + } + result = ARC.categorize_transaction_status(response) + + self.assertEqual(result["status_category"], "0confirmation") + self.assertEqual(result["tx_status"], "SEEN_ON_NETWORK") + if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file