Skip to content

Commit 35668c4

Browse files
author
defiant1708
committed
Refactor bsv: ensure unique context/basket and local output usage
1 parent 3c380db commit 35668c4

File tree

11 files changed

+1709
-463
lines changed

11 files changed

+1709
-463
lines changed

bsv/auth/clients/auth_fetch.py

Lines changed: 191 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import threading
2-
from typing import Any, Callable, Dict, Optional, List
2+
from typing import Any, Callable, Dict, Optional, List, Tuple
33
import logging
44
import base64
55
import os
66
import time
77
import urllib.parse
8+
import json
9+
import struct
810
import requests
911
from requests.exceptions import RetryError, HTTPError
1012

@@ -13,6 +15,7 @@
1315
from bsv.auth.requested_certificate_set import RequestedCertificateSet
1416
from bsv.auth.verifiable_certificate import VerifiableCertificate
1517
from bsv.auth.transports.simplified_http_transport import SimplifiedHTTPTransport
18+
from bsv.auth.peer import PeerOptions
1619

1720
class SimplifiedFetchRequestOptions:
1821
def __init__(self, method: str = "GET", headers: Optional[Dict[str, str]] = None, body: Optional[bytes] = None, retry_counter: Optional[int] = None):
@@ -54,17 +57,29 @@ def fetch(self, ctx: Any, url_str: str, config: Optional[SimplifiedFetchRequestO
5457
# Create peer if needed
5558
if base_url not in self.peers:
5659
transport = SimplifiedHTTPTransport(base_url)
57-
peer = Peer(
60+
peer = Peer(PeerOptions(
5861
wallet=self.wallet,
5962
transport=transport,
6063
certificates_to_request=self.requested_certificates,
6164
session_manager=self.session_manager
62-
)
65+
))
6366
auth_peer = AuthPeer()
6467
auth_peer.peer = peer
6568
self.peers[base_url] = auth_peer
66-
# Set up certificate received/requested listeners(省略: 必要に応じて追加)
69+
# Set up certificate listeners similar to TS/Go implementations
70+
def _on_certs_received(sender_public_key, certs):
71+
try:
72+
self.certificates_received.extend(certs or [])
73+
except Exception:
74+
pass
75+
self.peers[base_url].peer.listen_for_certificates_received(_on_certs_received)
6776
peer_to_use = self.peers[base_url]
77+
# If mutual auth explicitly unsupported for this base URL, fall back to normal HTTP
78+
if peer_to_use.supports_mutual_auth is not None and peer_to_use.supports_mutual_auth is False:
79+
resp = self.handle_fetch_and_validate(url_str, config, peer_to_use)
80+
if getattr(resp, 'status_code', None) == 402:
81+
return self.handle_payment_and_retry(ctx, url_str, config, resp)
82+
return resp
6883
# Generate request nonce
6984
request_nonce = os.urandom(32)
7085
request_nonce_b64 = base64.b64encode(request_nonce).decode()
@@ -86,32 +101,132 @@ def fetch(self, ctx: Any, url_str: str, config: Optional[SimplifiedFetchRequestO
86101
}
87102
# Peerのgeneral messageリスナー登録
88103
def on_general_message(sender_public_key, payload):
89-
# 先頭32バイトがresponse_nonce
90-
if not payload or len(payload) < 32:
104+
try:
105+
resp_obj = self._parse_general_response(sender_public_key, payload, request_nonce_b64, url_str, config)
106+
except Exception:
91107
return
92-
response_nonce = payload[:32]
93-
response_nonce_b64 = base64.b64encode(response_nonce).decode()
94-
if response_nonce_b64 != request_nonce_b64:
95-
return # 自分のリクエストでなければ無視
96-
# 以降はHTTPレスポンスのデシリアライズ等(省略: 必要に応じて実装)
97-
self.callbacks[request_nonce_b64]['resolve'](payload)
108+
if resp_obj is None:
109+
return
110+
self.callbacks[request_nonce_b64]['resolve'](resp_obj)
98111
listener_id = peer_to_use.peer.listen_for_general_messages(on_general_message)
99112
try:
100113
# Peer経由で送信(ToPeer相当)
101114
err = peer_to_use.peer.to_peer(ctx, request_data, None, 30000)
102115
if err:
103-
self.callbacks[request_nonce_b64]['reject'](err)
116+
# Fallback handling similar to TS/Go
117+
err_str = str(err)
118+
if 'Session not found for nonce' in err_str:
119+
try:
120+
del self.peers[base_url]
121+
except Exception:
122+
pass
123+
if config.retry_counter is None:
124+
config.retry_counter = 3
125+
# Retry request afresh
126+
self.callbacks[request_nonce_b64]['resolve'](self.fetch(ctx, url_str, config))
127+
elif 'HTTP server failed to authenticate' in err_str:
128+
try:
129+
resp = self.handle_fetch_and_validate(url_str, config, peer_to_use)
130+
self.callbacks[request_nonce_b64]['resolve'](resp)
131+
except Exception as e:
132+
self.callbacks[request_nonce_b64]['reject'](e)
133+
else:
134+
self.callbacks[request_nonce_b64]['reject'](err)
104135
except Exception as e:
105136
self.callbacks[request_nonce_b64]['reject'](e)
106137
# レスポンス待機(またはタイムアウト)
107138
response_event.wait(timeout=30) # 30秒タイムアウト
108139
# コールバック解除
109140
peer_to_use.peer.stop_listening_for_general_messages(listener_id)
110141
self.callbacks.pop(request_nonce_b64, None)
111-
# 結果返却
142+
# 結果返却
112143
if response_holder['err']:
113144
raise RuntimeError(response_holder['err'])
114-
return response_holder['resp']
145+
resp_obj = response_holder['resp']
146+
try:
147+
if getattr(resp_obj, 'status_code', None) == 402:
148+
return self.handle_payment_and_retry(ctx, url_str, config, resp_obj)
149+
except Exception:
150+
pass
151+
return resp_obj
152+
153+
# --- Helpers to parse the general response payload and build a Response-like object ---
154+
def _parse_general_response(self, sender_public_key: Optional[Any], payload: bytes, request_nonce_b64: str, url_str: str, config: SimplifiedFetchRequestOptions):
155+
if not payload:
156+
return None
157+
# Try binary format first (Go/TS protocol)
158+
resp = self._try_parse_binary_general(sender_public_key, payload, request_nonce_b64, url_str, config)
159+
if resp is not None:
160+
return resp
161+
# Fallback to JSON structure used by the simplified Python transport
162+
try:
163+
txt = payload.decode('utf-8', errors='strict')
164+
obj = json.loads(txt)
165+
status = int(obj.get('status_code', 0))
166+
headers = obj.get('headers', {}) or {}
167+
body_str = obj.get('body', '')
168+
body_bytes = body_str.encode('utf-8')
169+
return self._build_response(url_str, config.method or 'GET', status, headers, body_bytes)
170+
except Exception:
171+
return None
172+
173+
def _try_parse_binary_general(self, sender_public_key: Optional[Any], payload: bytes, request_nonce_b64: str, url_str: str, config: SimplifiedFetchRequestOptions):
174+
try:
175+
if len(payload) < 33: # require nonce + at least one byte for status code varint
176+
return None
177+
reader = _BinaryReader(payload)
178+
response_nonce = reader.read_bytes(32)
179+
response_nonce_b64 = base64.b64encode(response_nonce).decode()
180+
if response_nonce_b64 != request_nonce_b64:
181+
return None
182+
# Save identity key and mutual auth support flag
183+
if sender_public_key is not None:
184+
try:
185+
self.peers[urllib.parse.urlparse(url_str).scheme + '://' + urllib.parse.urlparse(url_str).netloc].identity_key = getattr(sender_public_key, 'to_der_hex', lambda: str(sender_public_key))()
186+
self.peers[urllib.parse.urlparse(url_str).scheme + '://' + urllib.parse.urlparse(url_str).netloc].supports_mutual_auth = True
187+
except Exception:
188+
try:
189+
self.peers[urllib.parse.urlparse(url_str).scheme + '://' + urllib.parse.urlparse(url_str).netloc].supports_mutual_auth = True
190+
except Exception:
191+
pass
192+
status_code = reader.read_varint32()
193+
n_headers = reader.read_varint32()
194+
headers: Dict[str, str] = {}
195+
for _ in range(n_headers):
196+
key = reader.read_string()
197+
val = reader.read_string()
198+
headers[key] = val
199+
# Add back server identity key if available
200+
if sender_public_key is not None:
201+
try:
202+
headers['x-bsv-auth-identity-key'] = getattr(sender_public_key, 'to_der_hex', lambda: str(sender_public_key))()
203+
except Exception:
204+
headers['x-bsv-auth-identity-key'] = str(sender_public_key)
205+
body_len = reader.read_varint32()
206+
body_bytes = b''
207+
if body_len > 0:
208+
body_bytes = reader.read_bytes(body_len)
209+
return self._build_response(url_str, config.method or 'GET', int(status_code), headers, body_bytes)
210+
except Exception:
211+
return None
212+
213+
def _build_response(self, url_str: str, method: str, status: int, headers: Dict[str, str], body: bytes):
214+
resp_obj = requests.Response()
215+
resp_obj.status_code = int(status)
216+
try:
217+
from requests.structures import CaseInsensitiveDict
218+
resp_obj.headers = CaseInsensitiveDict(headers or {})
219+
except Exception:
220+
resp_obj.headers = headers or {}
221+
resp_obj._content = body or b''
222+
resp_obj.url = url_str
223+
try:
224+
req = requests.Request(method=method or 'GET', url=url_str)
225+
resp_obj.request = req.prepare()
226+
except Exception:
227+
pass
228+
resp_obj.reason = str(status)
229+
return resp_obj
115230

116231
def send_certificate_request(self, ctx: Any, base_url: str, certificates_to_request):
117232
"""
@@ -121,12 +236,12 @@ def send_certificate_request(self, ctx: Any, base_url: str, certificates_to_requ
121236
base_url_str = f"{parsed_url.scheme}://{parsed_url.netloc}"
122237
if base_url_str not in self.peers:
123238
transport = SimplifiedHTTPTransport(base_url_str)
124-
peer = Peer(
239+
peer = Peer(PeerOptions(
125240
wallet=self.wallet,
126241
transport=transport,
127242
certificates_to_request=self.requested_certificates,
128243
session_manager=self.session_manager
129-
)
244+
))
130245
auth_peer = AuthPeer()
131246
auth_peer.peer = peer
132247
self.peers[base_url_str] = auth_peer
@@ -223,7 +338,6 @@ def _write_body(self, buf, body):
223338
self._write_varint(buf, 0xFFFFFFFFFFFFFFFF) # -1
224339

225340
def _write_varint(self, writer: bytearray, value: int):
226-
import struct
227341
writer.extend(struct.pack('<Q', value))
228342

229343
def _write_bytes(self, writer: bytearray, b: bytes):
@@ -354,6 +468,64 @@ def _set_payment_header(self, config, payment_info, derivation_suffix, tx_b64):
354468
config.headers = {}
355469
config.headers["x-bsv-payment"] = payment_info_json
356470

471+
472+
class _BinaryReader:
473+
"""
474+
Minimal binary reader compatible with the Go util.Reader used by AuthHTTP.
475+
Supports:
476+
- read_bytes(n)
477+
- read_varint() / read_varint32()
478+
- read_string() where string is prefixed with varint length and -1 encoded as 0xFFFFFFFFFFFFFFFF
479+
"""
480+
481+
def __init__(self, data: bytes):
482+
self._data = data
483+
self._pos = 0
484+
485+
def _require(self, n: int):
486+
if self._pos + n > len(self._data) or n < 0:
487+
raise ValueError("read past end of data")
488+
489+
def read_bytes(self, n: int) -> bytes:
490+
self._require(n)
491+
b = self._data[self._pos:self._pos + n]
492+
self._pos += n
493+
return b
494+
495+
def read_varint(self) -> int:
496+
self._require(1)
497+
first = self._data[self._pos]
498+
self._pos += 1
499+
if first < 0xFD:
500+
return first
501+
if first == 0xFD:
502+
self._require(2)
503+
val = struct.unpack_from('<H', self._data, self._pos)[0]
504+
self._pos += 2
505+
return val
506+
if first == 0xFE:
507+
self._require(4)
508+
val = struct.unpack_from('<I', self._data, self._pos)[0]
509+
self._pos += 4
510+
return val
511+
# 0xFF
512+
self._require(8)
513+
val = struct.unpack_from('<Q', self._data, self._pos)[0]
514+
self._pos += 8
515+
return val
516+
517+
def read_varint32(self) -> int:
518+
return int(self.read_varint() & 0xFFFFFFFF)
519+
520+
def read_string(self) -> str:
521+
length = self.read_varint()
522+
NEG_ONE = 0xFFFFFFFFFFFFFFFF
523+
if length == 0 or length == NEG_ONE:
524+
return ""
525+
b = self.read_bytes(int(length))
526+
return b.decode('utf-8', errors='strict')
527+
528+
357529
# --- P2PKH lockingScript生成関数 ---
358530
def p2pkh_locking_script_from_pubkey(pubkey_hex: str) -> str:
359531
"""
@@ -375,4 +547,4 @@ def p2pkh_locking_script_from_pubkey(pubkey_hex: str) -> str:
375547
+ b'88' # OP_EQUALVERIFY
376548
+ b'ac' # OP_CHECKSIG
377549
)
378-
return binascii.hexlify(script).decode()
550+
return binascii.hexlify(script).decode()

bsv/beef/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# General-purpose BEEF utilities package
2+
3+
from .builder import build_beef_v2_from_raw_hexes # re-export for convenience
4+
5+
__all__ = [
6+
"build_beef_v2_from_raw_hexes",
7+
]
8+
9+

bsv/beef/builder.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
from typing import List
4+
5+
from bsv.transaction.beef import BEEF_V2
6+
from bsv.utils import Writer
7+
8+
9+
def build_beef_v2_from_raw_hexes(tx_hex_list: List[str]) -> bytes:
10+
"""Build a minimal BEEF v2 bundle from a list of raw transaction hex strings.
11+
12+
- No bumps are included (bump_cnt = 0)
13+
- Each transaction is encoded as data_format = 0 (RawTx)
14+
This is sufficient for consumers that need to extract locking scripts for
15+
outputs by vout index, or to rehydrate Transaction objects for simple flows.
16+
"""
17+
if not tx_hex_list:
18+
return b""
19+
w = Writer()
20+
w.write_uint32_le(int(BEEF_V2))
21+
w.write_var_int_num(0) # bump count
22+
w.write_var_int_num(len(tx_hex_list)) # transaction count
23+
for h in tx_hex_list:
24+
if not isinstance(h, str):
25+
continue
26+
if len(h) % 2 != 0:
27+
continue
28+
try:
29+
w.write_uint8(0) # data_format = 0 (RawTx)
30+
w.write(bytes.fromhex(h))
31+
except Exception:
32+
continue
33+
return w.to_bytes()
34+
35+

bsv/keystore/interfaces.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ class KVStoreConfig:
100100
context: str # Developer-supplied logical namespace (basket)
101101
originator: str = "" # Name/id of the app using the store (optional)
102102
encrypt: bool = False # Whether to encrypt values before storage
103+
# Optional TS/GO-style defaults for call arguments
104+
fee_rate: int | None = None
105+
default_ca: dict | None = None
103106

104107

105108
@dataclass

0 commit comments

Comments
 (0)