Skip to content

Commit aac5fff

Browse files
authored
Merge pull request #94 from voyager1708/feature/auth/certificates-port
Feature/auth/certificates port
2 parents 7cf5728 + 086aee8 commit aac5fff

File tree

10 files changed

+359
-67
lines changed

10 files changed

+359
-67
lines changed

bsv/broadcaster.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,47 @@
1-
# DEPRECATED: Use bsv.broadcaster_core instead.
2-
from bsv.broadcaster_core.broadcaster import *
1+
from abc import ABC, abstractmethod
2+
from typing import Union, Dict, Any, TYPE_CHECKING
3+
4+
5+
if TYPE_CHECKING:
6+
from .transaction import Transaction
7+
8+
class BroadcastResponse:
9+
def __init__(self, status: str, txid: str, message: str):
10+
self.status = status
11+
self.txid = txid
12+
self.message = message
13+
14+
15+
class BroadcastFailure:
16+
def __init__(
17+
self,
18+
status: str,
19+
code: str,
20+
description: str,
21+
txid: str = None,
22+
more: Dict[str, Any] = None,
23+
):
24+
self.status = status
25+
self.code = code
26+
self.txid = txid
27+
self.description = description
28+
self.more = more
29+
30+
31+
class Broadcaster(ABC):
32+
def __init__(self):
33+
self.URL = None
34+
35+
@abstractmethod
36+
async def broadcast(
37+
self, transaction: 'Transaction'
38+
) -> Union[BroadcastResponse, BroadcastFailure]:
39+
pass
40+
41+
42+
def is_broadcast_response(r: Union[BroadcastResponse, BroadcastFailure]) -> bool:
43+
return r.status == "success"
44+
45+
46+
def is_broadcast_failure(r: Union[BroadcastResponse, BroadcastFailure]) -> bool:
47+
return r.status == "error"

bsv/broadcasters/broadcaster.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Union, Dict, Any, TYPE_CHECKING
3+
from typing import Optional
4+
from ..http_client import HttpClient, default_http_client
5+
from ..constants import Network
6+
from .whatsonchain import WhatsOnChainBroadcaster
7+
8+
if TYPE_CHECKING:
9+
from ..transaction import Transaction
10+
11+
12+
class BroadcastResponse:
13+
def __init__(self, status: str, txid: str, message: str):
14+
self.status = status
15+
self.txid = txid
16+
self.message = message
17+
18+
19+
class BroadcastFailure:
20+
def __init__(
21+
self,
22+
status: str,
23+
code: str,
24+
description: str,
25+
txid: str = None,
26+
more: Dict[str, Any] = None,
27+
):
28+
self.status = status
29+
self.code = code
30+
self.txid = txid
31+
self.description = description
32+
self.more = more
33+
34+
35+
class Broadcaster(ABC):
36+
def __init__(self):
37+
self.URL = None
38+
39+
@abstractmethod
40+
async def broadcast(
41+
self, transaction: 'Transaction'
42+
) -> Union[BroadcastResponse, BroadcastFailure]:
43+
pass
44+
45+
46+
def is_broadcast_response(r: Union[BroadcastResponse, BroadcastFailure]) -> bool:
47+
return r.status == "success"
48+
49+
50+
def is_broadcast_failure(r: Union[BroadcastResponse, BroadcastFailure]) -> bool:
51+
return r.status == "error"
52+
53+
54+
class BroadcasterInterface:
55+
"""Abstract broadcaster interface.
56+
57+
Implementations should return a dict with either:
58+
{"accepted": True, "txid": "..."}
59+
or {"accepted": False, "code": "network|client", "error": "..."}
60+
"""
61+
62+
def broadcast(self, tx_hex: str, *, api_key: Optional[str] = None, timeout: int = 10) -> Dict[str, Any]: # noqa: D401
63+
raise NotImplementedError
64+
65+
66+
class MAPIClientBroadcaster(BroadcasterInterface):
67+
"""mAPI (Merchant API) broadcaster for BSV miners."""
68+
def __init__(self, *, api_url: str, api_key: Optional[str] = None, network: str = "main"):
69+
self.api_url = api_url
70+
self.api_key = api_key or ""
71+
self.network = network
72+
73+
def broadcast(self, tx_hex: str, *, api_key: Optional[str] = None, timeout: int = 10) -> Dict[str, Any]:
74+
url = self.api_url
75+
key = api_key or self.api_key
76+
headers = {"Content-Type": "application/json"}
77+
if key:
78+
headers["Authorization"] = key
79+
return self._post_with_retries(url, headers, tx_hex, timeout)
80+
81+
def _post_with_retries(self, url, headers, tx_hex, timeout):
82+
import requests
83+
last_err: Optional[Exception] = None
84+
for attempt in range(3):
85+
try:
86+
resp = requests.post(url, json={"rawtx": tx_hex}, headers=headers, timeout=timeout)
87+
if resp.status_code >= 500:
88+
raise RuntimeError(f"mAPI server error {resp.status_code}")
89+
resp.raise_for_status()
90+
data = resp.json() or {}
91+
txid = data.get("txid") or data.get("payload", {}).get("txid") or ""
92+
if data.get("returnResult") == "success" or data.get("payload", {}).get("returnResult") == "success":
93+
return {"accepted": True, "txid": txid}
94+
return {"accepted": False, "error": data.get("resultDescription", "broadcast failed"), "txid": txid}
95+
except Exception as e:
96+
last_err = e
97+
try:
98+
time.sleep(0.25 * (2 ** attempt))
99+
except Exception:
100+
pass
101+
msg = str(last_err or "broadcast failed")
102+
code = "network" if "server error" in msg or "timeout" in msg.lower() else "client"
103+
return {"accepted": False, "code": code, "error": f"mAPI broadcast failed: {msg}"}
104+
105+
class CustomNodeBroadcaster(BroadcasterInterface):
106+
"""Custom node broadcaster (e.g., direct to bitcoind REST)."""
107+
def __init__(self, *, api_url: str, api_key: Optional[str] = None):
108+
self.api_url = api_url
109+
self.api_key = api_key or ""
110+
111+
def broadcast(self, tx_hex: str, *, api_key: Optional[str] = None, timeout: int = 10) -> Dict[str, Any]:
112+
import requests
113+
key = api_key or self.api_key
114+
headers = {"Content-Type": "application/json"}
115+
if key:
116+
headers["Authorization"] = key
117+
url = self.api_url
118+
last_err: Optional[Exception] = None
119+
for attempt in range(3):
120+
try:
121+
resp = requests.post(url, json={"hex": tx_hex}, headers=headers, timeout=timeout)
122+
if resp.status_code >= 500:
123+
raise RuntimeError(f"custom node server error {resp.status_code}")
124+
resp.raise_for_status()
125+
data = resp.json() or {}
126+
txid = data.get("txid") or data.get("result") or ""
127+
if txid:
128+
return {"accepted": True, "txid": txid}
129+
return {"accepted": False, "error": data.get("error", "broadcast failed"), "txid": txid}
130+
except Exception as e:
131+
last_err = e
132+
try:
133+
time.sleep(0.25 * (2 ** attempt))
134+
except Exception:
135+
pass
136+
msg = str(last_err or "broadcast failed")
137+
code = "network" if "server error" in msg or "timeout" in msg.lower() else "client"
138+
return {"accepted": False, "code": code, "error": f"Custom node broadcast failed: {msg}"}
139+
140+
141+
def default_broadcaster(network: Union[Network, str] = Network.MAINNET, http_client: HttpClient = None) -> Broadcaster:
142+
return WhatsOnChainBroadcaster(network=network, http_client=http_client)
143+
144+
__all__ = [
145+
"BroadcastResponse",
146+
"BroadcastFailure",
147+
"Broadcaster",
148+
"is_broadcast_response",
149+
"is_broadcast_failure",
150+
]

bsv/broadcasters/default.py

Lines changed: 0 additions & 47 deletions
This file was deleted.

bsv/broadcasters/whatsonchain.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
from typing import Union, TYPE_CHECKING
2-
3-
from ..broadcaster import Broadcaster, BroadcastFailure, BroadcastResponse
4-
from ..http_client import HttpClient, default_http_client
5-
from ..constants import Network
1+
from typing import Union, TYPE_CHECKING, Optional, Dict, Any
2+
import time
3+
from .broadcaster import Broadcaster, BroadcastFailure, BroadcastResponse
4+
from .default import Network
5+
from .default import HttpClient, default_http_client
66

77
if TYPE_CHECKING:
88
from ..transaction import Transaction
99

1010
class WhatsOnChainBroadcaster(Broadcaster):
11+
"""
12+
Asynchronous WhatsOnChain broadcaster using HttpClient.
13+
"""
1114
def __init__(self, network: Union[Network, str] = Network.MAINNET, http_client: HttpClient = None):
12-
"""
13-
Initialize WhatsOnChainBroadcaster.
14-
15-
:param network: Network to broadcast to. Can be either Network enum or string ('main'/'test')
16-
:param http_client: Optional HTTP client to use for requests
17-
"""
1815
if isinstance(network, str):
1916
network_str = network.lower()
2017
if network_str in ['main', 'mainnet']:
@@ -25,7 +22,6 @@ def __init__(self, network: Union[Network, str] = Network.MAINNET, http_client:
2522
raise ValueError(f"Invalid network string: {network}. Must be 'main' or 'test'")
2623
else:
2724
self.network = 'main' if network == Network.MAINNET else 'test'
28-
2925
self.URL = f"https://api.whatsonchain.com/v1/bsv/{self.network}/tx/raw"
3026
self.http_client = http_client if http_client else default_http_client()
3127

@@ -37,7 +33,6 @@ async def broadcast(
3733
"headers": {"Content-Type": "application/json", "Accept": "text/plain"},
3834
"data": {"txhex": tx.hex()},
3935
}
40-
4136
try:
4237
response = await self.http_client.fetch(self.URL, request_options)
4338
if response.ok:
@@ -57,3 +52,44 @@ async def broadcast(
5752
code="500",
5853
description=(str(error) if str(error) else "Internal Server Error"),
5954
)
55+
56+
class WhatsOnChainBroadcasterSync:
57+
"""
58+
Synchronous WhatsOnChain broadcaster using requests, with retry/backoff and error classification.
59+
"""
60+
def __init__(self, *, api_key: Optional[str] = None, network: str = "main"):
61+
self.api_key = api_key or ""
62+
self.network = network
63+
64+
def broadcast(self, tx_hex: str, *, api_key: Optional[str] = None, timeout: int = 10) -> Dict[str, Any]:
65+
import requests
66+
key = api_key or self.api_key
67+
headers = {}
68+
if key:
69+
headers["Authorization"] = key
70+
headers["woc-api-key"] = key
71+
url = f"https://api.whatsonchain.com/v1/bsv/{self.network}/tx/raw"
72+
last_err: Optional[Exception] = None
73+
for attempt in range(3):
74+
try:
75+
resp = requests.post(url, json={"txhex": tx_hex}, headers=headers, timeout=timeout)
76+
if resp.status_code >= 500:
77+
raise RuntimeError(f"woc server error {resp.status_code}")
78+
resp.raise_for_status()
79+
data = resp.json() or {}
80+
txid = data.get("txid") or data.get("data") or ""
81+
return {"accepted": True, "txid": txid}
82+
except Exception as e: # noqa: PERF203
83+
last_err = e
84+
try:
85+
time.sleep(0.25 * (2 ** attempt))
86+
except Exception:
87+
pass
88+
msg = str(last_err or "broadcast failed")
89+
code = "network" if "server error" in msg or "timeout" in msg.lower() else "client"
90+
return {"accepted": False, "code": code, "error": f"WOC broadcast failed: {msg}"}
91+
92+
__all__ = [
93+
"WhatsOnChainBroadcaster",
94+
"WhatsOnChainBroadcasterSync",
95+
]

bsv/chaintrackers/whatsonchain.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,25 @@ def get_headers(self) -> Dict[str, str]:
3939
if self.api_key:
4040
headers["Authorization"] = self.api_key
4141
return headers
42+
43+
def query_tx(self, txid: str, *, api_key: Optional[str] = None, network: str = "main", timeout: int = 10) -> Dict[str, Any]:
44+
import requests
45+
key = api_key or self.api_key
46+
net = network or self.network
47+
url = f"https://api.whatsonchain.com/v1/bsv/{net}/tx/{txid}/info"
48+
headers = {}
49+
if key:
50+
headers["Authorization"] = key
51+
headers["woc-api-key"] = key
52+
try:
53+
resp = requests.get(url, headers=headers, timeout=timeout)
54+
if resp.status_code == 404:
55+
return {"known": False}
56+
resp.raise_for_status()
57+
data = resp.json() or {}
58+
conf = data.get("confirmations")
59+
return {"known": True, "confirmations": conf or 0}
60+
except Exception as e: # noqa: PERF203
61+
return {"known": False, "error": str(e)}
62+
63+

0 commit comments

Comments
 (0)