From 2f4d515b4511ecb8e9a125eda8655cccc7eec2f0 Mon Sep 17 00:00:00 2001 From: kensato Date: Mon, 30 Jun 2025 22:34:11 +0900 Subject: [PATCH 1/5] Add synchronous HTTP client and ARC sync support - Introduced `SyncHttpClient` for synchronous HTTP operations. - Extended ARC broadcaster with synchronous methods: `sync_broadcast` and `check_transaction_status`. - Updated ARC configuration to include optional `SyncHttpClient`. - Added examples, tests, and utilities for synchronous transactions. --- bsv/broadcasters/arc.py | 239 +++++++++++++++++++++++++++++++++++--- bsv/http_client.py | 113 +++++++++++++++++- examples/test_sync_arc.py | 77 ++++++++++++ tests/test_arc.py | 239 +++++++++++++++++++++++++++++++++++++- 4 files changed, 644 insertions(+), 24 deletions(-) create mode 100644 examples/test_sync_arc.py diff --git a/bsv/broadcasters/arc.py b/bsv/broadcasters/arc.py index f2edcd9..520a9f1 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,199 @@ 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) + request_options = { + "method": "POST", + "headers": self.request_headers(), + "data": { + "rawTx": + tx.to_ef().hex() if has_all_source_txs else tx.hex()}, + "timeout": timeout + } + + try: + response = self.sync_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.get("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 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 + """ + request_options = { + "method": "GET", + "headers": self.request_headers(), + "timeout": timeout + } + + try: + response = self.sync_http_client.fetch( + f"{self.URL}/v1/tx/{txid}", request_options + ) + + response_data = response.json() + + if response.ok: + return { + "txid": txid, + "txStatus": response_data.get("txStatus"), + "blockHash": response_data.get("blockHash"), + "blockHeight": response_data.get("blockHeight"), + "merklePath": response_data.get("merklePath"), + "extraInfo": response_data.get("extraInfo"), + "competingTxs": response_data.get("competingTxs"), + "timestamp": response_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": response_data.get("status", response.status_code), + "title": response_data.get("title", "Error"), + "detail": response_data.get("detail", "Unknown error"), + "txid": response_data.get("txid", txid), + "extra_info": response_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..8bdecab 100644 --- a/bsv/http_client.py +++ b/bsv/http_client.py @@ -1,6 +1,8 @@ from abc import ABC, abstractmethod +from typing import Optional, Dict import aiohttp +import requests class HttpClient(ABC): @@ -23,10 +25,10 @@ class DefaultHttpClient(HttpClient): async def fetch(self, url: str, options: dict) -> HttpResponse: async with aiohttp.ClientSession() as session: async with session.request( - method=options["method"], - url=url, - headers=options.get("headers", {}), - json=options.get("data", None), + method=options["method"], + url=url, + headers=options.get("headers", {}), + json=options.get("data", None), ) as response: try: json_data = await response.json() @@ -45,5 +47,108 @@ async def fetch(self, url: str, options: dict) -> HttpResponse: ) +class SyncHttpClient: + """ + Synchronous HTTP client for blocking operations + """ + + def __init__(self, default_timeout: int = 30): + """ + Initialize synchronous HTTP client + + :param default_timeout: Default timeout setting in seconds + """ + self.default_timeout = default_timeout + + def _make_response(self, response: requests.Response) -> HttpResponse: + """ + Convert requests.Response to HttpResponse + + :param response: The requests Response object + :returns: HttpResponse object + """ + try: + json_data = response.json() + except ValueError: + json_data = {} + + return HttpResponse( + ok=response.ok, + status_code=response.status_code, + json_data=json_data + ) + + def _handle_error(self, error: requests.RequestException) -> HttpResponse: + """ + Handle request errors and convert to HttpResponse + + :param error: The requests exception + :returns: HttpResponse object with error information + """ + # Set appropriate status code based on error type + if isinstance(error, requests.Timeout): + status_code = 408 # Request Timeout + elif isinstance(error, requests.ConnectionError): + status_code = 503 # Service Unavailable + else: + status_code = 0 + + return HttpResponse( + ok=False, + status_code=status_code, + json_data={"error": str(error), "error_type": type(error).__name__} + ) + + def fetch(self, url: str, options: dict) -> HttpResponse: + """ + Send HTTP request synchronously + + :param url: The URL to request + :param options: Request options (method, headers, data, timeout) + :returns: HttpResponse object + """ + method = options.get("method", "GET") + headers = options.get("headers", {}) + timeout = options.get("timeout", self.default_timeout) + + try: + if method.upper() == "POST": + data = options.get("data", {}) + response = requests.post(url, headers=headers, json=data, timeout=timeout) + else: + response = requests.request(method, url, headers=headers, timeout=timeout) + + return self._make_response(response) + except requests.RequestException as e: + return self._handle_error(e) + + def get(self, url: str, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None) -> HttpResponse: + """ + Send GET request + + :param url: Request URL + :param headers: HTTP headers + :param timeout: Timeout setting in seconds (None uses default_timeout) + :returns: HttpResponse object + """ + try: + request_timeout = timeout if timeout is not None else self.default_timeout + response = requests.get(url, headers=headers or {}, timeout=request_timeout) + return self._make_response(response) + except requests.RequestException as e: + return self._handle_error(e) + + def default_http_client() -> HttpClient: return DefaultHttpClient() + + +def default_sync_http_client() -> SyncHttpClient: + """ + Create default synchronous HTTP client + + :returns: SyncHttpClient instance + """ + return SyncHttpClient() \ 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..afb4060 --- /dev/null +++ b/examples/test_sync_arc.py @@ -0,0 +1,77 @@ +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. +""" +# ARC_URL='https://api.taal.com/arc' + +async def main(): +# def main(): + + # Setup ARC broadcaster + arc = ARC('https://api.taal.com/arc', "mainnet_2e3a7d0f845a5049b35e9dde98fc4271") + + # Create a simple transaction + private_key = PrivateKey("Kzpr5a6TmrXNw2NxSzt6GUonvcP8ABtfU17bdGEjxCufyxGMo9xV") + public_key = private_key.public_key() + + source_tx = Transaction.from_hex( + "01000000016ccb286539ac3ec33cb2ac0f1be2645a743395b8fe68bebc0b5202c1ce220084000000006b483045022100e5a0b5e592e1a38b0a92071c0da4e4da9658bd6808e0d2edb5282cb562bfe48b022072d492df5b1a903e082113a5b39fcc2504f446ac866ceb93fa62b5f0c60bf377412103e23c79a29b5e5f20127ec2286413510662d0e6befa29d669a623035122753d3affffffff013e000000000000001976a914047f8e69ca8eadec1b327d1b232cdaaffa200d1688ac00000000" + ) + + 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 = await arc.broadcast(tx) + # 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() + asyncio.run(main()) diff --git a/tests/test_arc.py b/tests/test_arc.py index c22e3cb..683916c 100644 --- a/tests/test_arc.py +++ b/tests/test_arc.py @@ -1,9 +1,90 @@ +# import unittest +# from unittest.mock import AsyncMock, MagicMock +# +# from bsv.broadcaster import BroadcastResponse, BroadcastFailure +# from bsv.broadcasters.arc import ARC, ARCConfig +# from bsv.http_client import HttpClient, HttpResponse +# from bsv.transaction import Transaction +# +# +# class TestARCBroadcast(unittest.IsolatedAsyncioTestCase): +# +# def setUp(self): +# self.URL = "https://api.taal.com/arc" +# self.api_key = "apikey_85678993923y454i4jhd803wsd02" +# self.tx = Transaction(tx_data="Hello sCrypt") +# +# # Mocking the Transaction methods +# self.tx.hex = MagicMock(return_value="hexFormat") +# +# async def test_broadcast_success(self): +# mock_response = HttpResponse( +# ok=True, +# status_code=200, +# json_data={ +# "data": { +# "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", +# "txStatus": "success", +# "extraInfo": "extra", +# } +# }, +# ) +# mock_http_client = AsyncMock(HttpClient) +# mock_http_client.fetch = AsyncMock(return_value=mock_response) +# +# arc_config = ARCConfig(api_key=self.api_key, http_client=mock_http_client) +# arc = ARC(self.URL, arc_config) +# result = await arc.broadcast(self.tx) +# +# self.assertIsInstance(result, BroadcastResponse) +# self.assertEqual( +# result.txid, +# "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", +# ) +# self.assertEqual(result.message, "success extra") +# +# async def test_broadcast_failure(self): +# mock_response = HttpResponse( +# ok=False, +# status_code=400, +# json_data={ +# "data": {"status": "ERR_BAD_REQUEST", "detail": "Invalid transaction"} +# }, +# ) +# mock_http_client = AsyncMock(HttpClient) +# mock_http_client.fetch = AsyncMock(return_value=mock_response) +# +# arc_config = ARCConfig(api_key=self.api_key, http_client=mock_http_client) +# arc = ARC(self.URL, arc_config) +# result = await arc.broadcast(self.tx) +# +# self.assertIsInstance(result, BroadcastFailure) +# self.assertEqual(result.code, "400") +# self.assertEqual(result.description, "Invalid transaction") +# +# async def test_broadcast_exception(self): +# mock_http_client = AsyncMock(HttpClient) +# mock_http_client.fetch = AsyncMock(side_effect=Exception("Internal Error")) +# +# arc_config = ARCConfig(api_key=self.api_key, http_client=mock_http_client) +# arc = ARC(self.URL, arc_config) +# result = await arc.broadcast(self.tx) +# +# self.assertIsInstance(result, BroadcastFailure) +# self.assertEqual(result.code, "500") +# self.assertEqual(result.description, "Internal Error") +# +# +# if __name__ == "__main__": +# unittest.main() + + import unittest from unittest.mock import AsyncMock, MagicMock 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 +155,160 @@ 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.fetch = 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, 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.fetch = 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.code, "400") + self.assertEqual(result.description, "Invalid transaction") + + def test_sync_broadcast_timeout_error(self): + mock_response = HttpResponse( + ok=False, + status_code=408, + json_data={"error": "Timeout", "error_type": "Timeout"} + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.fetch = 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.code, "408") + self.assertIn("timeout", result.description.lower()) + self.assertEqual(result.more["timeout_value"], 5) + self.assertTrue(result.more["retry_recommended"]) + + def test_sync_broadcast_connection_error(self): + mock_response = HttpResponse( + ok=False, + status_code=503, + json_data={"error": "Connection failed", "error_type": "ConnectionError"} + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.fetch = 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.code, "503") + self.assertIn("connection", result.description.lower()) + self.assertTrue(result.more["retry_recommended"]) + + def test_sync_broadcast_exception(self): + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.fetch = 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={ + "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", + "txStatus": "MINED", + "blockHash": "000000000000000001234567890abcdef", + "blockHeight": 800000 + }, + ) + mock_sync_http_client = MagicMock(SyncHttpClient) + mock_sync_http_client.fetch = 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.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 From d33353d7640b2dca651e4f2fe39aeb17ea8d81f9 Mon Sep 17 00:00:00 2001 From: kensato Date: Tue, 1 Jul 2025 13:50:52 +0900 Subject: [PATCH 2/5] Refactor HTTP client and ARC module for improved sync operations - 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`. --- bsv/broadcasters/arc.py | 82 +++++++++++----------- bsv/http_client.py | 142 +++++++++++++++++--------------------- examples/test_sync_arc.py | 18 +++-- tests/test_arc.py | 37 +++++----- 4 files changed, 134 insertions(+), 145 deletions(-) diff --git a/bsv/broadcasters/arc.py b/bsv/broadcasters/arc.py index 520a9f1..c1220b0 100644 --- a/bsv/broadcasters/arc.py +++ b/bsv/broadcasters/arc.py @@ -146,30 +146,24 @@ def sync_broadcast( """ # 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()}, - "timeout": timeout - } try: - response = self.sync_http_client.fetch( - f"{self.URL}/v1/tx", request_options + 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 and response.status_code >= 200 and response.status_code <= 299: - 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', '')}", + message=f"{data.get('txStatus', '')} {data.get('extraInfo', '')}".strip(), ) else: return BroadcastFailure( @@ -178,21 +172,32 @@ def sync_broadcast( 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=response_json["data"]["detail"] if "data" in response_json else "Unknown error", + description=data.get("detail", "Unknown error"), ) except Exception as error: return BroadcastFailure( status="failure", code="500", - description=( - str(error) - if isinstance(error, Exception) - else "Internal Server Error" - ), + description=str(error), ) def check_transaction_status(self, txid: str, timeout: int = 5) -> Dict[str, Any]: @@ -203,29 +208,26 @@ def check_transaction_status(self, txid: str, timeout: int = 5) -> Dict[str, Any :param timeout: Timeout setting in seconds :returns: Dictionary containing transaction status information """ - request_options = { - "method": "GET", - "headers": self.request_headers(), - "timeout": timeout - } try: - response = self.sync_http_client.fetch( - f"{self.URL}/v1/tx/{txid}", request_options + 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": response_data.get("txStatus"), - "blockHash": response_data.get("blockHash"), - "blockHeight": response_data.get("blockHeight"), - "merklePath": response_data.get("merklePath"), - "extraInfo": response_data.get("extraInfo"), - "competingTxs": response_data.get("competingTxs"), - "timestamp": response_data.get("timestamp") + "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 @@ -251,11 +253,11 @@ def check_transaction_status(self, txid: str, timeout: int = 5) -> Dict[str, Any # Handle general error cases return { "status": "failure", - "code": response_data.get("status", response.status_code), - "title": response_data.get("title", "Error"), - "detail": response_data.get("detail", "Unknown error"), - "txid": response_data.get("txid", txid), - "extra_info": response_data.get("extraInfo", "") + "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: diff --git a/bsv/http_client.py b/bsv/http_client.py index 8bdecab..523c92f 100644 --- a/bsv/http_client.py +++ b/bsv/http_client.py @@ -1,9 +1,8 @@ -from abc import ABC, abstractmethod -from typing import Optional, Dict - import aiohttp import requests +from abc import ABC, abstractmethod +from typing import Optional, Dict class HttpClient(ABC): @abstractmethod @@ -46,50 +45,61 @@ async def fetch(self, url: str, options: dict) -> HttpResponse: json_data={}, ) - -class SyncHttpClient: - """ - Synchronous HTTP client for blocking operations - """ +class SyncHttpClient(HttpClient): + """Synchronous HTTP client compatible with DefaultHttpClient""" def __init__(self, default_timeout: int = 30): - """ - Initialize synchronous HTTP client - - :param default_timeout: Default timeout setting in seconds - """ self.default_timeout = default_timeout - def _make_response(self, response: requests.Response) -> HttpResponse: - """ - Convert requests.Response to HttpResponse + 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) - :param response: The requests Response object - :returns: HttpResponse object - """ + 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() - except ValueError: - json_data = {} + 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=response.ok, + ok=ok, status_code=response.status_code, - json_data=json_data + json_data=formatted_json ) def _handle_error(self, error: requests.RequestException) -> HttpResponse: - """ - Handle request errors and convert to HttpResponse - - :param error: The requests exception - :returns: HttpResponse object with error information - """ - # Set appropriate status code based on error type if isinstance(error, requests.Timeout): - status_code = 408 # Request Timeout + status_code = 408 elif isinstance(error, requests.ConnectionError): - status_code = 503 # Service Unavailable + status_code = 503 + elif isinstance(error, requests.HTTPError): + status_code = error.response.status_code if error.response else 500 else: status_code = 0 @@ -99,56 +109,32 @@ def _handle_error(self, error: requests.RequestException) -> HttpResponse: json_data={"error": str(error), "error_type": type(error).__name__} ) - def fetch(self, url: str, options: dict) -> HttpResponse: - """ - Send HTTP request synchronously - - :param url: The URL to request - :param options: Request options (method, headers, data, timeout) - :returns: HttpResponse object - """ - method = options.get("method", "GET") - headers = options.get("headers", {}) - timeout = options.get("timeout", self.default_timeout) - - try: - if method.upper() == "POST": - data = options.get("data", {}) - response = requests.post(url, headers=headers, json=data, timeout=timeout) - else: - response = requests.request(method, url, headers=headers, timeout=timeout) - - return self._make_response(response) - except requests.RequestException as e: - return self._handle_error(e) - def get(self, url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[int] = None) -> HttpResponse: - """ - Send GET request - - :param url: Request URL - :param headers: HTTP headers - :param timeout: Timeout setting in seconds (None uses default_timeout) - :returns: HttpResponse object - """ - try: - request_timeout = timeout if timeout is not None else self.default_timeout - response = requests.get(url, headers=headers or {}, timeout=request_timeout) - return self._make_response(response) - except requests.RequestException as e: - return self._handle_error(e) - - -def default_http_client() -> HttpClient: - return DefaultHttpClient() + 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: - """ - Create default synchronous HTTP client + return SyncHttpClient() - :returns: SyncHttpClient instance - """ - return SyncHttpClient() \ No newline at end of file + +def default_http_client() -> HttpClient: + 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 index afb4060..240e89d 100644 --- a/examples/test_sync_arc.py +++ b/examples/test_sync_arc.py @@ -14,20 +14,18 @@ """ Simple example of synchronous ARC broadcasting and status checking. """ -# ARC_URL='https://api.taal.com/arc' -async def main(): -# def main(): +def main(): # Setup ARC broadcaster - arc = ARC('https://api.taal.com/arc', "mainnet_2e3a7d0f845a5049b35e9dde98fc4271") + arc = ARC('https://api.taal.com/arc', "mainnet_2e3a7d0f845a5049b_________98fc4271") # Create a simple transaction - private_key = PrivateKey("Kzpr5a6TmrXNw2NxSzt6GUonvcP8ABtfU17bdGEjxCufyxGMo9xV") + private_key = PrivateKey("Kzpr5a6TmrXNw2NxSzt6GUonvc---------dGEjxCufyxGMo9xV") public_key = private_key.public_key() source_tx = Transaction.from_hex( - "01000000016ccb286539ac3ec33cb2ac0f1be2645a743395b8fe68bebc0b5202c1ce220084000000006b483045022100e5a0b5e592e1a38b0a92071c0da4e4da9658bd6808e0d2edb5282cb562bfe48b022072d492df5b1a903e082113a5b39fcc2504f446ac866ceb93fa62b5f0c60bf377412103e23c79a29b5e5f20127ec2286413510662d0e6befa29d669a623035122753d3affffffff013e000000000000001976a914047f8e69ca8eadec1b327d1b232cdaaffa200d1688ac00000000" + "01000000013462125ff05a9150c25693bbb474a----------ab265e746f523791e01462000000006a4730440220447ac5232e8eb25db0e004bc704a19bc33c9c7ef86070781078bce74e089be44022029195e8cc392bf7c5577dc477a90d157be0356d8fbb52eb66521f4eabe00dcf9412103e23c79a29b5e5f20127ec2286413510662d0e6befa29d669a623035122753d3affffffff013d000000000000001976a914047f8e69ca8eadec1b327d1b232cdaaffa200d1688ac00000000" ) tx = Transaction( @@ -54,8 +52,8 @@ async def main(): print(f"Transaction ID: {txid}") print(f"Transaction hex: {txhex}") # Broadcast transaction - result = await arc.broadcast(tx) - # result = arc.sync_broadcast(tx) + + result = arc.sync_broadcast(tx) if isinstance(result, BroadcastResponse): print(f"Broadcast successful: {result.txid}") @@ -73,5 +71,5 @@ async def main(): if __name__ == "__main__": - # main() - asyncio.run(main()) + main() + diff --git a/tests/test_arc.py b/tests/test_arc.py index 683916c..d65feed 100644 --- a/tests/test_arc.py +++ b/tests/test_arc.py @@ -168,7 +168,7 @@ def test_sync_broadcast_success(self): }, ) mock_sync_http_client = MagicMock(SyncHttpClient) - mock_sync_http_client.fetch = MagicMock(return_value=mock_response) + 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) @@ -190,7 +190,7 @@ def test_sync_broadcast_failure(self): }, ) mock_sync_http_client = MagicMock(SyncHttpClient) - mock_sync_http_client.fetch = MagicMock(return_value=mock_response) + 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) @@ -201,45 +201,46 @@ def test_sync_broadcast_failure(self): 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={"error": "Timeout", "error_type": "Timeout"} + json_data={"data": {"status": "ERR_TIMEOUT", "detail": "Request timed out"}} ) mock_sync_http_client = MagicMock(SyncHttpClient) - mock_sync_http_client.fetch = MagicMock(return_value=mock_response) + 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.assertIn("timeout", result.description.lower()) - self.assertEqual(result.more["timeout_value"], 5) - self.assertTrue(result.more["retry_recommended"]) + 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={"error": "Connection failed", "error_type": "ConnectionError"} + json_data={"data": {"status": "ERR_CONNECTION", "detail": "Service unavailable"}} ) mock_sync_http_client = MagicMock(SyncHttpClient) - mock_sync_http_client.fetch = MagicMock(return_value=mock_response) + 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.assertIn("connection", result.description.lower()) - self.assertTrue(result.more["retry_recommended"]) + 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.fetch = MagicMock(side_effect=Exception("Internal Error")) + 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) @@ -254,14 +255,16 @@ def test_check_transaction_status_success(self): ok=True, status_code=200, json_data={ - "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", - "txStatus": "MINED", - "blockHash": "000000000000000001234567890abcdef", - "blockHeight": 800000 + "data": { # dataキーを追加 + "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", + "txStatus": "MINED", + "blockHash": "000000000000000001234567890abcdef", + "blockHeight": 800000 + } }, ) mock_sync_http_client = MagicMock(SyncHttpClient) - mock_sync_http_client.fetch = MagicMock(return_value=mock_response) + 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) From 33ae2a06d2fbae66c20e903ad179d234244187a8 Mon Sep 17 00:00:00 2001 From: kensato Date: Tue, 1 Jul 2025 13:57:49 +0900 Subject: [PATCH 3/5] Update CHANGELOG.md for version 1.0.6 release notes. --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 From b9a38bf8ef83f6ce226a9db5afd11b9d4022d05f Mon Sep 17 00:00:00 2001 From: kensato Date: Tue, 1 Jul 2025 14:04:01 +0900 Subject: [PATCH 4/5] Add initial dependencies to requirements.txt - Added core libraries: `pycryptodomex`, `coincurve`, `aiohttp`, `requests`, `pytest`, and `setuptools` to support project functionality and testing. --- requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 requirements.txt 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 From 0d5ca5b049277cf0e4a54c2cbdaa74f9716444d6 Mon Sep 17 00:00:00 2001 From: kensato Date: Tue, 1 Jul 2025 14:13:06 +0900 Subject: [PATCH 5/5] Clean up commented test code in `test_arc.py` - Removed unused and outdated commented test cases. - Simplified file content for better readability and maintenance. --- bsv/http_client.py | 8 ++--- tests/test_arc.py | 81 ---------------------------------------------- 2 files changed, 4 insertions(+), 85 deletions(-) diff --git a/bsv/http_client.py b/bsv/http_client.py index 523c92f..e3e05bd 100644 --- a/bsv/http_client.py +++ b/bsv/http_client.py @@ -24,10 +24,10 @@ class DefaultHttpClient(HttpClient): async def fetch(self, url: str, options: dict) -> HttpResponse: async with aiohttp.ClientSession() as session: async with session.request( - method=options["method"], - url=url, - headers=options.get("headers", {}), - json=options.get("data", None), + method=options["method"], + url=url, + headers=options.get("headers", {}), + json=options.get("data", None), ) as response: try: json_data = await response.json() diff --git a/tests/test_arc.py b/tests/test_arc.py index d65feed..4032830 100644 --- a/tests/test_arc.py +++ b/tests/test_arc.py @@ -1,84 +1,3 @@ -# import unittest -# from unittest.mock import AsyncMock, MagicMock -# -# from bsv.broadcaster import BroadcastResponse, BroadcastFailure -# from bsv.broadcasters.arc import ARC, ARCConfig -# from bsv.http_client import HttpClient, HttpResponse -# from bsv.transaction import Transaction -# -# -# class TestARCBroadcast(unittest.IsolatedAsyncioTestCase): -# -# def setUp(self): -# self.URL = "https://api.taal.com/arc" -# self.api_key = "apikey_85678993923y454i4jhd803wsd02" -# self.tx = Transaction(tx_data="Hello sCrypt") -# -# # Mocking the Transaction methods -# self.tx.hex = MagicMock(return_value="hexFormat") -# -# async def test_broadcast_success(self): -# mock_response = HttpResponse( -# ok=True, -# status_code=200, -# json_data={ -# "data": { -# "txid": "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", -# "txStatus": "success", -# "extraInfo": "extra", -# } -# }, -# ) -# mock_http_client = AsyncMock(HttpClient) -# mock_http_client.fetch = AsyncMock(return_value=mock_response) -# -# arc_config = ARCConfig(api_key=self.api_key, http_client=mock_http_client) -# arc = ARC(self.URL, arc_config) -# result = await arc.broadcast(self.tx) -# -# self.assertIsInstance(result, BroadcastResponse) -# self.assertEqual( -# result.txid, -# "8e60c4143879918ed03b8fc67b5ac33b8187daa3b46022ee2a9e1eb67e2e46ec", -# ) -# self.assertEqual(result.message, "success extra") -# -# async def test_broadcast_failure(self): -# mock_response = HttpResponse( -# ok=False, -# status_code=400, -# json_data={ -# "data": {"status": "ERR_BAD_REQUEST", "detail": "Invalid transaction"} -# }, -# ) -# mock_http_client = AsyncMock(HttpClient) -# mock_http_client.fetch = AsyncMock(return_value=mock_response) -# -# arc_config = ARCConfig(api_key=self.api_key, http_client=mock_http_client) -# arc = ARC(self.URL, arc_config) -# result = await arc.broadcast(self.tx) -# -# self.assertIsInstance(result, BroadcastFailure) -# self.assertEqual(result.code, "400") -# self.assertEqual(result.description, "Invalid transaction") -# -# async def test_broadcast_exception(self): -# mock_http_client = AsyncMock(HttpClient) -# mock_http_client.fetch = AsyncMock(side_effect=Exception("Internal Error")) -# -# arc_config = ARCConfig(api_key=self.api_key, http_client=mock_http_client) -# arc = ARC(self.URL, arc_config) -# result = await arc.broadcast(self.tx) -# -# self.assertIsInstance(result, BroadcastFailure) -# self.assertEqual(result.code, "500") -# self.assertEqual(result.description, "Internal Error") -# -# -# if __name__ == "__main__": -# unittest.main() - - import unittest from unittest.mock import AsyncMock, MagicMock