Skip to content

Commit 4d84ff2

Browse files
authored
Merge pull request #31 from bsv-blockchain/feature/ken/sync_arc
Feature/ken/sync arc
2 parents 5039500 + 0d5ca5b commit 4d84ff2

File tree

6 files changed

+569
-21
lines changed

6 files changed

+569
-21
lines changed

CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## Table of Contents
88

9-
- [Unreleased](#unreleased)
9+
- [Unreleased](#unreleased
10+
- [1.0.6- 2025-06-30](#106---2025-06-30)
1011
- [1.0.5- 2025-05-30](#105---2025-05-30)
1112
- [1.0.4- 2025-04-28](#104---2025-04-28)
1213
- [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).
3940
### Security
4041
- (Notify of any improvements related to security vulnerabilities or potential risks.)
4142

43+
---
44+
45+
## [1.0.6] - 2025-06-30
46+
47+
### Added
48+
- Introduced `SyncHttpClient` for synchronous HTTP operations
49+
- Extended ARC broadcaster with synchronous methods: `sync_broadcast`, `check_transaction_status`, and `categorize_transaction_status`
50+
- Updated ARC configuration to include optional `SyncHttpClient` support
51+
- Added examples, tests, and utilities for synchronous transactions
52+
53+
### Changed
54+
- Updated `SyncHttpClient` to inherit from `HttpClient` for consistency
55+
- Refactored `fetch` into higher-level HTTP methods: `get` and `post`
56+
- Simplified ARC broadcaster by using `get` and `post` methods for sync operations
57+
- Enhanced error handling and response processing in ARC transactions
58+
- Updated tests and examples to align with refactored `SyncHttpClient`
59+
4260
---
4361
## [1.0.5] - 2025-05-30
4462

bsv/broadcasters/arc.py

Lines changed: 216 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import json
22
import random
3-
from typing import Optional, Dict, Union, TYPE_CHECKING
4-
5-
from ..broadcaster import BroadcastResponse, BroadcastFailure, Broadcaster
6-
from ..http_client import HttpClient, default_http_client
7-
3+
from typing import Optional, Dict, Union, Any, TYPE_CHECKING
84

95
if TYPE_CHECKING:
106
from ..transaction import Transaction
117

8+
from ..broadcaster import BroadcastResponse, BroadcastFailure, Broadcaster
9+
from ..http_client import HttpClient, default_http_client, SyncHttpClient, default_sync_http_client
10+
1211
def to_hex(bytes_data):
1312
return "".join(f"{x:02x}" for x in bytes_data)
1413

@@ -19,16 +18,18 @@ def random_hex(length: int) -> str:
1918

2019
class ARCConfig:
2120
def __init__(
22-
self,
23-
api_key: Optional[str] = None,
24-
http_client: Optional[HttpClient] = None,
25-
deployment_id: Optional[str] = None,
26-
callback_url: Optional[str] = None,
27-
callback_token: Optional[str] = None,
28-
headers: Optional[Dict[str, str]] = None,
21+
self,
22+
api_key: Optional[str] = None,
23+
http_client: Optional[HttpClient] = None,
24+
sync_http_client: Optional[SyncHttpClient] = None,
25+
deployment_id: Optional[str] = None,
26+
callback_url: Optional[str] = None,
27+
callback_token: Optional[str] = None,
28+
headers: Optional[Dict[str, str]] = None,
2929
):
3030
self.api_key = api_key
3131
self.http_client = http_client
32+
self.sync_http_client = sync_http_client
3233
self.deployment_id = deployment_id
3334
self.callback_url = callback_url
3435
self.callback_token = callback_token
@@ -45,6 +46,7 @@ def __init__(self, url: str, config: Union[str, ARCConfig] = None):
4546
if isinstance(config, str):
4647
self.api_key = config
4748
self.http_client = default_http_client()
49+
self.sync_http_client = default_sync_http_client()
4850
self.deployment_id = default_deployment_id()
4951
self.callback_url = None
5052
self.callback_token = None
@@ -53,6 +55,7 @@ def __init__(self, url: str, config: Union[str, ARCConfig] = None):
5355
config = config or ARCConfig()
5456
self.api_key = config.api_key
5557
self.http_client = config.http_client or default_http_client()
58+
self.sync_http_client = config.sync_http_client or default_sync_http_client()
5659
self.deployment_id = config.deployment_id or default_deployment_id()
5760
self.callback_url = config.callback_url
5861
self.callback_token = config.callback_token
@@ -75,9 +78,9 @@ async def broadcast(
7578
response = await self.http_client.fetch(
7679
f"{self.URL}/v1/tx", request_options
7780
)
78-
81+
7982
response_json = response.json()
80-
83+
8184
if response.ok and response.status_code >= 200 and response.status_code <= 299:
8285
data = response_json["data"]
8386

@@ -99,7 +102,7 @@ async def broadcast(
99102
code=str(response.status_code),
100103
description=response_json["data"]["detail"] if "data" in response_json else "Unknown error",
101104
)
102-
105+
103106
except Exception as error:
104107
return BroadcastFailure(
105108
status="failure",
@@ -130,3 +133,201 @@ def request_headers(self) -> Dict[str, str]:
130133
headers.update(self.headers)
131134

132135
return headers
136+
137+
def sync_broadcast(
138+
self, tx: 'Transaction', timeout: int = 30
139+
) -> Union[BroadcastResponse, BroadcastFailure]:
140+
"""
141+
Synchronously broadcast a transaction
142+
143+
:param tx: Transaction to broadcast
144+
:param timeout: Timeout setting in seconds
145+
:returns: BroadcastResponse or BroadcastFailure
146+
"""
147+
# Check if all inputs have source_transaction
148+
has_all_source_txs = all(input.source_transaction is not None for input in tx.inputs)
149+
150+
try:
151+
response = self.sync_http_client.post(
152+
f"{self.URL}/v1/tx",
153+
data={"rawTx": tx.to_ef().hex() if has_all_source_txs else tx.hex()},
154+
headers=self.request_headers(),
155+
timeout=timeout
156+
)
157+
158+
response_json = response.json()
159+
data = response_json.get("data", {})
160+
161+
if response.ok:
162+
if data.get("txid"):
163+
return BroadcastResponse(
164+
status="success",
165+
txid=data.get("txid"),
166+
message=f"{data.get('txStatus', '')} {data.get('extraInfo', '')}".strip(),
167+
)
168+
else:
169+
return BroadcastFailure(
170+
status="failure",
171+
code=data.get("status", "ERR_UNKNOWN"),
172+
description=data.get("detail", "Unknown error"),
173+
)
174+
else:
175+
# Handle special error cases
176+
if response.status_code == 408:
177+
return BroadcastFailure(
178+
status="failure",
179+
code="408",
180+
description=f"Transaction broadcast timed out after {timeout} seconds",
181+
)
182+
183+
if response.status_code == 503:
184+
return BroadcastFailure(
185+
status="failure",
186+
code="503",
187+
description="Failed to connect to ARC service",
188+
)
189+
190+
return BroadcastFailure(
191+
status="failure",
192+
code=str(response.status_code),
193+
description=data.get("detail", "Unknown error"),
194+
)
195+
196+
except Exception as error:
197+
return BroadcastFailure(
198+
status="failure",
199+
code="500",
200+
description=str(error),
201+
)
202+
203+
def check_transaction_status(self, txid: str, timeout: int = 5) -> Dict[str, Any]:
204+
"""
205+
Check transaction status synchronously
206+
207+
:param txid: Transaction ID to check
208+
:param timeout: Timeout setting in seconds
209+
:returns: Dictionary containing transaction status information
210+
"""
211+
212+
try:
213+
response = self.sync_http_client.get(
214+
f"{self.URL}/v1/tx/{txid}",
215+
headers=self.request_headers(),
216+
timeout=timeout
217+
)
218+
response_data = response.json()
219+
data = response_data.get("data", {})
220+
221+
if response.ok:
222+
return {
223+
"txid": txid,
224+
"txStatus": data.get("txStatus"),
225+
"blockHash": data.get("blockHash"),
226+
"blockHeight": data.get("blockHeight"),
227+
"merklePath": data.get("merklePath"),
228+
"extraInfo": data.get("extraInfo"),
229+
"competingTxs": data.get("competingTxs"),
230+
"timestamp": data.get("timestamp")
231+
}
232+
else:
233+
# Handle special error cases
234+
if response.status_code == 408:
235+
return {
236+
"status": "failure",
237+
"code": 408,
238+
"title": "Request Timeout",
239+
"detail": f"Transaction status check timed out after {timeout} seconds",
240+
"txid": txid,
241+
"extra_info": "Consider retrying or increasing timeout value"
242+
}
243+
244+
if response.status_code == 503:
245+
return {
246+
"status": "failure",
247+
"code": 503,
248+
"title": "Connection Error",
249+
"detail": "Failed to connect to ARC service",
250+
"txid": txid
251+
}
252+
253+
# Handle general error cases
254+
return {
255+
"status": "failure",
256+
"code": data.get("status", response.status_code),
257+
"title": data.get("title", "Error"),
258+
"detail": data.get("detail", "Unknown error"),
259+
"txid": data.get("txid", txid),
260+
"extra_info": data.get("extraInfo", "")
261+
}
262+
263+
except Exception as error:
264+
return {
265+
"status": "failure",
266+
"code": "500",
267+
"title": "Internal Error",
268+
"detail": str(error),
269+
"txid": txid
270+
}
271+
272+
@staticmethod
273+
def categorize_transaction_status(response: Dict[str, Any]) -> Dict[str, Any]:
274+
"""
275+
Categorize transaction status based on the ARC response
276+
277+
:param response: The transaction status response dictionary from ARC
278+
:returns: Dictionary containing status category and transaction status
279+
"""
280+
try:
281+
tx_status = response.get("txStatus")
282+
283+
if tx_status:
284+
# Processing transactions - still being handled by the network
285+
if tx_status in [
286+
"UNKNOWN", "QUEUED", "RECEIVED", "STORED",
287+
"ANNOUNCED_TO_NETWORK", "REQUESTED_BY_NETWORK",
288+
"SENT_TO_NETWORK", "ACCEPTED_BY_NETWORK"
289+
]:
290+
status_category = "progressing"
291+
292+
# Successfully mined transactions
293+
elif tx_status in ["MINED"]:
294+
status_category = "mined"
295+
296+
# Mined in stale block - needs attention
297+
elif tx_status in ["MINED_IN_STALE_BLOCK"]:
298+
status_category = "0confirmation"
299+
300+
# Warning status - double spend attempted
301+
elif tx_status in ["DOUBLE_SPEND_ATTEMPTED"]:
302+
status_category = "warning"
303+
304+
# Seen on network - check for competing transactions
305+
elif tx_status in ["SEEN_ON_NETWORK"]:
306+
# Check if there are competing transactions in mempool
307+
if response.get("competingTxs"):
308+
status_category = "warning"
309+
else:
310+
# Transaction is in mempool without conflicts
311+
status_category = "0confirmation"
312+
313+
# Rejected transactions - failed to process
314+
elif tx_status in ["ERROR", "REJECTED", "SEEN_IN_ORPHAN_MEMPOOL"]:
315+
status_category = "rejected"
316+
317+
else:
318+
status_category = f"unknown_txStatus: {tx_status}"
319+
else:
320+
status_category = "error"
321+
tx_status = "No txStatus"
322+
323+
return {
324+
"status_category": status_category,
325+
"tx_status": tx_status
326+
}
327+
328+
except Exception as e:
329+
return {
330+
"status_category": "error",
331+
"error": str(e),
332+
"response": response
333+
}

0 commit comments

Comments
 (0)