From 6f4df761db67ddb2b4cf79d1e11fcf73861e2042 Mon Sep 17 00:00:00 2001 From: Phil Markarian Date: Thu, 18 Sep 2025 01:13:10 +0900 Subject: [PATCH 1/9] Live Policy implementation based off the Typescript library: - Added Live Policy implmentation - Added mock tests to test the Live Policy implementation - Updated transaction.py to use Live Policy instead of SatoshisPerKilobyte - Made Live Policy the default model in __init__.py in fee_models - Added documentation reference to README.md and wrote documentation for live_policy --- README.md | 2 + bsv/fee_models/__init__.py | 3 +- bsv/fee_models/live_policy.py | 195 ++++++++++++++++++++++++++++++++++ bsv/transaction.py | 9 +- docs/fee_models.md | 77 ++++++++++++++ 5 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 bsv/fee_models/live_policy.py create mode 100644 docs/fee_models.md diff --git a/README.md b/README.md index 13892ee..4899770 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ For a more detailed tutorial and advanced examples, check our [Documentation](#d Detailed documentation of the SDK with code examples can be found at [BSV Skills Center](https://docs.bsvblockchain.org/guides/sdks/py). +- [Dynamic fee models](./docs/fee_models.md) + You can also refer to the [User Test Report](./docs/Py-SDK%20User%20Test%20Report.pdf) for insights and feedback provided by [Yenpoint](https://yenpoint.jp/). diff --git a/bsv/fee_models/__init__.py b/bsv/fee_models/__init__.py index 7ab6cfd..cb66ee2 100644 --- a/bsv/fee_models/__init__.py +++ b/bsv/fee_models/__init__.py @@ -1,4 +1,5 @@ from .satoshis_per_kilobyte import SatoshisPerKilobyte +from .live_policy import LivePolicy # Alias for the default fee model -DefaultFeeModel = SatoshisPerKilobyte +DefaultFeeModel = LivePolicy diff --git a/bsv/fee_models/live_policy.py b/bsv/fee_models/live_policy.py new file mode 100644 index 0000000..47958a6 --- /dev/null +++ b/bsv/fee_models/live_policy.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import logging +import os +import threading +import time +from dataclasses import dataclass +from typing import Optional, Tuple + +import requests + +from ..constants import HTTP_REQUEST_TIMEOUT +from .satoshis_per_kilobyte import SatoshisPerKilobyte + + +logger = logging.getLogger(__name__) + + +_DEFAULT_ARC_POLICY_URL = os.getenv( + "BSV_PY_SDK_ARC_POLICY_URL", "https://arc.gorillapool.io/v1/policy" +) +_DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000 + + +@dataclass +class _CachedRate: + value: int + fetched_at_ms: float + + +class LivePolicy(SatoshisPerKilobyte): + """Dynamic fee model that fetches the live ARC policy endpoint. + + The first successful response is cached for ``cache_ttl_ms`` milliseconds so repeated + calls to :meth:`compute_fee` do not repeatedly query the remote API. If a fetch fails, + the model falls back to ``fallback_sat_per_kb`` and caches that value for the TTL so + offline environments still return consistent fees. + """ + + _instance: Optional["LivePolicy"] = None + _instance_lock = threading.Lock() + + def __init__( + self, + cache_ttl_ms: int = _DEFAULT_CACHE_TTL_MS, + arc_policy_url: Optional[str] = None, + fallback_sat_per_kb: int = 1, + request_timeout: Optional[int] = None, + api_key: Optional[str] = None, + ) -> None: + """Create a policy that fetches rates from ARC. + + Args: + cache_ttl_ms: Duration to keep a fetched rate before refreshing. + arc_policy_url: Override for the ARC policy endpoint. + fallback_sat_per_kb: Fee to use when live retrieval fails. + request_timeout: Timeout passed to ``requests.get``. + api_key: Optional token included as an ``Authorization`` header. + """ + super().__init__(fallback_sat_per_kb) + self.cache_ttl_ms = cache_ttl_ms + self.arc_policy_url = (arc_policy_url or _DEFAULT_ARC_POLICY_URL).rstrip("/") + self.fallback_sat_per_kb = max(1, int(fallback_sat_per_kb)) + self.request_timeout = request_timeout or HTTP_REQUEST_TIMEOUT + self.api_key = api_key or os.getenv("BSV_PY_SDK_ARC_POLICY_API_KEY") + self._cache: Optional[_CachedRate] = None + self._cache_lock = threading.Lock() + + @classmethod + def get_instance( + cls, + cache_ttl_ms: int = _DEFAULT_CACHE_TTL_MS, + arc_policy_url: Optional[str] = None, + fallback_sat_per_kb: int = 1, + request_timeout: Optional[int] = None, + api_key: Optional[str] = None, + ) -> "LivePolicy": + """Return a singleton instance so callers share the cached rate.""" + + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls( + cache_ttl_ms=cache_ttl_ms, + arc_policy_url=arc_policy_url, + fallback_sat_per_kb=fallback_sat_per_kb, + request_timeout=request_timeout, + api_key=api_key, + ) + return cls._instance + + def compute_fee(self, tx) -> int: # type: ignore[override] + """Compute a fee for ``tx`` using the latest ARC rate.""" + rate = self.current_rate_sat_per_kb() + self.value = rate + return super().compute_fee(tx) + + def current_rate_sat_per_kb(self) -> int: + """Return the cached sat/kB rate or fetch a new value from ARC.""" + cache = self._get_cache(allow_stale=True) + if cache and self._cache_valid(cache): + return cache.value + + rate, error = self._fetch_sat_per_kb() + if rate is not None: + self._set_cache(rate) + return rate + + if cache is not None: + message = error if error is not None else "unknown error" + logger.warning( + "Failed to fetch live fee rate, using cached value: %s", + message, + ) + return cache.value + + message = error if error is not None else "unknown error" + logger.warning( + "Failed to fetch live fee rate, using fallback %d sat/kB: %s", + self.fallback_sat_per_kb, + message, + ) + return self.fallback_sat_per_kb + + def _cache_valid(self, cache: _CachedRate) -> bool: + """Return True if ``cache`` is still within the TTL window.""" + current_ms = time.time() * 1000 + return (current_ms - cache.fetched_at_ms) < self.cache_ttl_ms + + def _get_cache(self, allow_stale: bool = False) -> Optional[_CachedRate]: + """Read the cached value optionally even when the TTL has expired.""" + with self._cache_lock: + if self._cache is None: + return None + if allow_stale: + return self._cache + if self._cache_valid(self._cache): + return self._cache + return None + + def _set_cache(self, value: int) -> None: + """Persist ``value`` as the most recent fetched sat/kB rate.""" + with self._cache_lock: + self._cache = _CachedRate(value=value, fetched_at_ms=time.time() * 1000) + + def _fetch_sat_per_kb(self) -> Tuple[Optional[int], Optional[Exception]]: + """Fetch the latest fee policy from ARC and coerce it to sat/kB.""" + try: + headers = {"Accept": "application/json"} + if self.api_key: + headers["Authorization"] = self.api_key + + response = requests.get( + self.arc_policy_url, + headers=headers, + timeout=self.request_timeout, + ) + response.raise_for_status() + payload = response.json() + except Exception as exc: + return None, exc + + rate = self._extract_rate(payload) + if rate is None: + return None, ValueError("Invalid policy response format") + return rate, None + + @staticmethod + def _extract_rate(payload: dict) -> Optional[int]: + """Extract a sat/kB rate from the ARC policy payload.""" + policy = payload.get("policy") if isinstance(payload, dict) else None + if not isinstance(policy, dict): + return None + + # Primary structure: policy.fees.miningFee {'satoshis': x, 'bytes': y} + mining_fee = None + fees_section = policy.get("fees") + if isinstance(fees_section, dict): + mining_fee = fees_section.get("miningFee") + if mining_fee is None: + mining_fee = policy.get("miningFee") + + if isinstance(mining_fee, dict): + satoshis = mining_fee.get("satoshis") + bytes_ = mining_fee.get("bytes") + if isinstance(satoshis, (int, float)) and isinstance(bytes_, (int, float)) and bytes_ > 0: + sat_per_byte = float(satoshis) / float(bytes_) + return max(1, int(round(sat_per_byte * 1000))) + + for key in ("satPerKb", "sat_per_kb", "satoshisPerKb"): + value = policy.get(key) + if isinstance(value, (int, float)) and value > 0: + return max(1, int(round(value))) + + return None diff --git a/bsv/transaction.py b/bsv/transaction.py index 3ebf743..8a65a6c 100644 --- a/bsv/transaction.py +++ b/bsv/transaction.py @@ -6,7 +6,7 @@ from .broadcasters import default_broadcaster from .chaintracker import ChainTracker from .chaintrackers import default_chain_tracker -from .fee_models import SatoshisPerKilobyte +from .fee_models import LivePolicy from .constants import ( TRANSACTION_VERSION, TRANSACTION_LOCKTIME, @@ -173,12 +173,15 @@ def fee(self, model_or_fee=None, change_distribution='equal'): """ Computes the fee for the transaction and adjusts the change outputs accordingly. - :param model_or_fee: Fee model or fee amount. Defaults to `SatoshisPerKilobyte` with value 10 if not provided. + :param model_or_fee: Fee model or fee amount. Defaults to a `LivePolicy` instance + that retrieves the latest mining fees from ARC if not provided. :param change_distribution: Method of change distribution ('equal' or 'random'). Defaults to 'equal'. """ if model_or_fee is None: - model_or_fee = SatoshisPerKilobyte(int(TRANSACTION_FEE_RATE)) + model_or_fee = LivePolicy.get_instance( + fallback_sat_per_kb=int(TRANSACTION_FEE_RATE) + ) if isinstance(model_or_fee, int): fee = model_or_fee diff --git a/docs/fee_models.md b/docs/fee_models.md new file mode 100644 index 0000000..e6c698e --- /dev/null +++ b/docs/fee_models.md @@ -0,0 +1,77 @@ +# Fee Models + +The SDK exposes two fee calculation helpers: the simple +`SatoshisPerKilobyte` model and the new `LivePolicy` model that mirrors the +behaviour of the TypeScript SDK. This document focuses on `LivePolicy`, which +retrieves fee rates from the ARC policy API before computing the transaction +fee. + +## LivePolicy + +`LivePolicy` subclasses `SatoshisPerKilobyte` so it reuses the same byte-size +estimation logic while sourcing the sat/kB rate dynamically. A singleton helper +is provided so consumers can share the cached rate across transactions. + +```python +from bsv.fee_models.live_policy import LivePolicy + +policy = LivePolicy.get_instance() +await tx.fee(policy) +``` + +### Configuration + +```python +LivePolicy( + cache_ttl_ms: int = 5 * 60 * 1000, + arc_policy_url: Optional[str] = "https://arc.gorillapool.io/v1/policy", + fallback_sat_per_kb: int = 1, + request_timeout: Optional[int] = 30, + api_key: Optional[str] = None, +) +``` + +- `cache_ttl_ms`: Milliseconds a fetched rate remains valid. Subsequent calls + within the TTL reuse the cached value instead of re-querying ARC. +- `arc_policy_url`: Override the ARC policy endpoint. Defaults to GorillaPool's + public service but honours the `BSV_PY_SDK_ARC_POLICY_URL` environment + variable when set. +- `fallback_sat_per_kb`: Fee to use when the API response cannot be parsed or + the network request fails. The default respects the + `TRANSACTION_FEE_RATE` constant via the `Transaction.fee()` helper. +- `request_timeout`: Timeout passed to `requests.get`. Defaults to + `HTTP_REQUEST_TIMEOUT` from `bsv.constants` (30 seconds by default). +- `api_key`: Optional token added as the `Authorization` header. You can also + supply it through the `BSV_PY_SDK_ARC_POLICY_API_KEY` environment variable. + +### Behaviour + +* On a successful fetch, `LivePolicy` caches the sat/kB rate for the configured + TTL. +* If ARC returns an error or an unexpected payload, the model logs a warning, + falls back to the most recent cached value when available, otherwise uses the + configured fallback rate. +* The singleton returned by `LivePolicy.get_instance()` stores cache data in a + process-wide shared instance, making it suitable for repeated + `Transaction.fee()` calls. + +### Example Usage + +```python +from bsv.transaction import Transaction +from bsv.fee_models.live_policy import LivePolicy + +tx = Transaction(...) + +# Use the shared singleton (default behaviour of Transaction.fee()). +await tx.fee(LivePolicy.get_instance()) + +# Or create a custom policy with a shorter cache TTL and private endpoint. +policy = LivePolicy( + cache_ttl_ms=60_000, + arc_policy_url="https://arc.example.com/v1/policy", + api_key="Bearer " +) +await tx.fee(policy) +``` + From a8c9067d509569f8bf27caca22d21f0925e525ac Mon Sep 17 00:00:00 2001 From: Phil Markarian Date: Wed, 24 Sep 2025 01:29:04 +0900 Subject: [PATCH 2/9] Updated requirements.txt --- requirements.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bbe4e4e..5466b24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ +bsv-sdk~=1.0.8 pycryptodomex~=3.21.0 coincurve~=20.0.0 aiohttp>=3.12.14 +nest_asyncio~=1.6.0 requests~=2.32.3 pytest~=8.3.4 -setuptools>=78.1.1 \ No newline at end of file +setuptools>=78.1.1 +pytest-asyncio~=0.23.7 +yenpoint_1satordinals~=0.1.1 \ No newline at end of file From 88af10ce28609698276f1be90d2f7d58814f1f66 Mon Sep 17 00:00:00 2001 From: Phil Markarian Date: Wed, 24 Sep 2025 01:29:19 +0900 Subject: [PATCH 3/9] Added file needed to run pytest in the home folder of py-sdk. --- pytest.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests From 2a70179d6e6a71bd21d29d909ee0832ab727eefa Mon Sep 17 00:00:00 2001 From: Phil Markarian Date: Wed, 24 Sep 2025 01:32:19 +0900 Subject: [PATCH 4/9] Update to live policy test. --- tests/test_live_policy.py | 138 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/test_live_policy.py diff --git a/tests/test_live_policy.py b/tests/test_live_policy.py new file mode 100644 index 0000000..66c2952 --- /dev/null +++ b/tests/test_live_policy.py @@ -0,0 +1,138 @@ +from unittest.mock import MagicMock, patch + +import requests + +from bsv.fee_models.live_policy import LivePolicy + + +def setup_function(_): + LivePolicy._instance = None + + +def teardown_function(_): + LivePolicy._instance = None + + +def _mock_response(payload): + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = payload + return response + + +@patch("bsv.fee_models.live_policy.requests.get") +def test_parses_mining_fee(mock_get): + payload = { + "policy": { + "fees": { + "miningFee": {"satoshis": 5, "bytes": 250} + } + } + } + mock_get.return_value = _mock_response(payload) + + policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=1) + + assert policy.current_rate_sat_per_kb() == 20 + + +@patch("bsv.fee_models.live_policy.requests.get") +def test_cache_reused_when_valid(mock_get): + payload = {"policy": {"satPerKb": 50}} + mock_get.return_value = _mock_response(payload) + + policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=1) + + first = policy.current_rate_sat_per_kb() + second = policy.current_rate_sat_per_kb() + + assert first == 50 + assert second == 50 + mock_get.assert_called_once() + + +@patch("bsv.fee_models.live_policy.requests.get") +@patch("bsv.fee_models.live_policy.logger.warning") +def test_uses_cached_value_when_fetch_fails(mock_log, mock_get): + payload = {"policy": {"satPerKb": 75}} + mock_get.side_effect = [ + _mock_response(payload), + requests.RequestException("Network down"), + ] + + policy = LivePolicy(cache_ttl_ms=1, fallback_sat_per_kb=5) + + first = policy.current_rate_sat_per_kb() + assert first == 75 + + # Expire cache manually + with policy._cache_lock: + policy._cache.fetched_at_ms -= 10 + + second = policy.current_rate_sat_per_kb() + assert second == 75 + + assert mock_log.call_count == 1 + args, _ = mock_log.call_args + assert args[0] == "Failed to fetch live fee rate, using cached value: %s" + assert isinstance(args[1], requests.RequestException) + assert str(args[1]) == "Network down" + + +@patch("bsv.fee_models.live_policy.requests.get", side_effect=requests.RequestException("boom")) +@patch("bsv.fee_models.live_policy.logger.warning") +def test_falls_back_to_default_when_no_cache(mock_log, _mock_get): + policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=9) + + assert policy.current_rate_sat_per_kb() == 9 + + assert mock_log.call_count == 1 + args, _ = mock_log.call_args + assert args[0] == "Failed to fetch live fee rate, using fallback %d sat/kB: %s" + assert args[1] == 9 + assert isinstance(args[2], requests.RequestException) + assert str(args[2]) == "boom" + + +@patch("bsv.fee_models.live_policy.requests.get") +@patch("bsv.fee_models.live_policy.logger.warning") +def test_invalid_response_triggers_fallback(mock_log, mock_get): + mock_get.return_value = _mock_response({"policy": {"invalid": True}}) + + policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=3) + + assert policy.current_rate_sat_per_kb() == 3 + + assert mock_log.call_count == 1 + args, _ = mock_log.call_args + assert args[0] == "Failed to fetch live fee rate, using fallback %d sat/kB: %s" + assert args[1] == 3 + assert isinstance(args[2], ValueError) + assert str(args[2]) == "Invalid policy response format" + + +def test_singleton_returns_same_instance(): + first = LivePolicy.get_instance(cache_ttl_ms=10000) + second = LivePolicy.get_instance(cache_ttl_ms=20000) + + assert first is second + assert first.cache_ttl_ms == 10000 + + +def test_custom_instance_uses_provided_ttl(): + policy = LivePolicy(cache_ttl_ms=30000) + assert policy.cache_ttl_ms == 30000 + + +@patch("bsv.fee_models.live_policy.requests.get") +def test_singleton_cache_shared(mock_get): + payload = {"policy": {"satPerKb": 25}} + mock_get.return_value = _mock_response(payload) + + policy1 = LivePolicy.get_instance() + policy2 = LivePolicy.get_instance() + + assert policy1 is policy2 + assert policy1.current_rate_sat_per_kb() == 25 + assert policy2.current_rate_sat_per_kb() == 25 + mock_get.assert_called_once() From 326464a0b1270ddaa9eb13716e90c0795a12e432 Mon Sep 17 00:00:00 2001 From: Phil Markarian Date: Wed, 24 Sep 2025 01:32:49 +0900 Subject: [PATCH 5/9] Update .gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4f1939a..55d132b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ htmlcov/ .coverage build/ +.venv/ \ No newline at end of file From 1bd7df7ff4bfcf9016aa74f29353735659b98e86 Mon Sep 17 00:00:00 2001 From: Phil Markarian Date: Wed, 24 Sep 2025 02:41:36 +0900 Subject: [PATCH 6/9] Test related files (may delete some after PR review). --- bsv/fee_models/live_policy.py | 23 +++++---- bsv/http_client.py | 42 +++++++++++++++- bsv/transaction.py | 26 ++++++++-- create_wallet.py | 36 +++++++++++++ inspect_live_policy.py | 30 +++++++++++ live_test.py | 95 +++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 create_wallet.py create mode 100644 inspect_live_policy.py create mode 100644 live_test.py diff --git a/bsv/fee_models/live_policy.py b/bsv/fee_models/live_policy.py index 47958a6..930ec8d 100644 --- a/bsv/fee_models/live_policy.py +++ b/bsv/fee_models/live_policy.py @@ -7,9 +7,8 @@ from dataclasses import dataclass from typing import Optional, Tuple -import requests - from ..constants import HTTP_REQUEST_TIMEOUT +from ..http_client import default_http_client from .satoshis_per_kilobyte import SatoshisPerKilobyte @@ -89,19 +88,19 @@ def get_instance( ) return cls._instance - def compute_fee(self, tx) -> int: # type: ignore[override] + async def compute_fee(self, tx) -> int: # type: ignore[override] """Compute a fee for ``tx`` using the latest ARC rate.""" - rate = self.current_rate_sat_per_kb() + rate = await self.current_rate_sat_per_kb() self.value = rate return super().compute_fee(tx) - def current_rate_sat_per_kb(self) -> int: + async def current_rate_sat_per_kb(self) -> int: """Return the cached sat/kB rate or fetch a new value from ARC.""" cache = self._get_cache(allow_stale=True) if cache and self._cache_valid(cache): return cache.value - rate, error = self._fetch_sat_per_kb() + rate, error = await self._fetch_sat_per_kb() if rate is not None: self._set_cache(rate) return rate @@ -143,20 +142,24 @@ def _set_cache(self, value: int) -> None: with self._cache_lock: self._cache = _CachedRate(value=value, fetched_at_ms=time.time() * 1000) - def _fetch_sat_per_kb(self) -> Tuple[Optional[int], Optional[Exception]]: + async def _fetch_sat_per_kb(self) -> Tuple[Optional[int], Optional[Exception]]: """Fetch the latest fee policy from ARC and coerce it to sat/kB.""" try: headers = {"Accept": "application/json"} if self.api_key: headers["Authorization"] = self.api_key - response = requests.get( + http_client = default_http_client() + response = await http_client.get( self.arc_policy_url, headers=headers, timeout=self.request_timeout, ) - response.raise_for_status() - payload = response.json() + payload = response.json_data + if isinstance(payload, dict) and "data" in payload: + data_section = payload.get("data") + if isinstance(data_section, dict): + payload = data_section except Exception as exc: return None, exc diff --git a/bsv/http_client.py b/bsv/http_client.py index 6c0e9b3..63d7010 100644 --- a/bsv/http_client.py +++ b/bsv/http_client.py @@ -19,15 +19,27 @@ def __init__(self, ok: bool, status_code: int, json_data: dict): def json(self): return self._json_data + @property + def json_data(self): + return self._json_data + class DefaultHttpClient(HttpClient): async def fetch(self, url: str, options: dict) -> HttpResponse: + timeout_value = options.get("timeout") + aiohttp_timeout = ( + aiohttp.ClientTimeout(total=timeout_value) + if timeout_value is not None + else None + ) + async with aiohttp.ClientSession() as session: async with session.request( method=options["method"], url=url, headers=options.get("headers", {}), json=options.get("data", None), + timeout=aiohttp_timeout, ) as response: try: json_data = await response.json() @@ -45,6 +57,34 @@ async def fetch(self, url: str, options: dict) -> HttpResponse: json_data={}, ) + async def get( + self, + url: str, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None, + ) -> HttpResponse: + options = { + "method": "GET", + "headers": headers or {}, + "timeout": timeout, + } + return await self.fetch(url, options) + + async 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, + } + return await self.fetch(url, options) + class SyncHttpClient(HttpClient): """Synchronous HTTP client compatible with DefaultHttpClient""" @@ -137,4 +177,4 @@ def default_sync_http_client() -> SyncHttpClient: def default_http_client() -> HttpClient: - return DefaultHttpClient() \ No newline at end of file + return DefaultHttpClient() diff --git a/bsv/transaction.py b/bsv/transaction.py index 8a65a6c..b580e93 100644 --- a/bsv/transaction.py +++ b/bsv/transaction.py @@ -1,3 +1,5 @@ +import asyncio +import inspect import math from contextlib import suppress from typing import List, Optional, Union, Dict, Any @@ -184,16 +186,31 @@ def fee(self, model_or_fee=None, change_distribution='equal'): ) if isinstance(model_or_fee, int): - fee = model_or_fee - else: - fee = model_or_fee.compute_fee(self) + return self._apply_fee_amount(model_or_fee, change_distribution) + + fee_estimate = model_or_fee.compute_fee(self) + + if inspect.isawaitable(fee_estimate): + async def _resolve_and_apply(): + resolved_fee = await fee_estimate + return self._apply_fee_amount(resolved_fee, change_distribution) + + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(_resolve_and_apply()) + else: + return _resolve_and_apply() + return self._apply_fee_amount(fee_estimate, change_distribution) + + def _apply_fee_amount(self, fee: int, change_distribution: str): change = 0 for tx_in in self.inputs: if not tx_in.source_transaction: raise ValueError('Source transactions are required for all inputs during fee computation') change += tx_in.source_transaction.outputs[tx_in.source_output_index].satoshis - + change -= fee change_count = 0 @@ -218,6 +235,7 @@ def fee(self, model_or_fee=None, change_distribution='equal'): for out in self.outputs: if out.change: out.satoshis = per_output + return None async def broadcast( self, diff --git a/create_wallet.py b/create_wallet.py new file mode 100644 index 0000000..1087ec8 --- /dev/null +++ b/create_wallet.py @@ -0,0 +1,36 @@ +from bsv import PrivateKey + + +def main(): + """ + Generates a new BSV sender and receiver wallet and saves the info to wallet_info.txt. + """ + # Generate sender address (Address A) + priv_key_a = PrivateKey() + wif_a = priv_key_a.wif() # Wallet Import Format + address_a = priv_key_a.address() + + # Generate receiver address (Address B) + priv_key_b = PrivateKey() + wif_b = priv_key_b.wif() + address_b = priv_key_b.address() + + # Print out the keys and addresses + print("\n===== SENDER INFORMATION =====") + print(f"Private Key: {wif_a}") + print(f"Address: {address_a}") + + print("\n===== RECEIVER INFORMATION =====") + print(f"Private Key: {wif_b}") + print(f"Address: {address_b}") + + # Save data to file for easy reference + with open("wallet_info.txt", "w") as f: + f.write(f"Sender Private Key: {wif_a}\n") + f.write(f"Sender Address: {address_a}\n\n") + f.write(f"Receiver Private Key: {wif_b}\n") + f.write(f"Receiver Address: {address_b}\n") + print("\nThis information has been saved to wallet_info.txt") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/inspect_live_policy.py b/inspect_live_policy.py new file mode 100644 index 0000000..0674be1 --- /dev/null +++ b/inspect_live_policy.py @@ -0,0 +1,30 @@ +import asyncio +import json +import logging + +from bsv.fee_models.live_policy import LivePolicy +from bsv.http_client import default_http_client + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + logging.getLogger("bsv.fee_models.live_policy").setLevel(logging.DEBUG) + + policy = LivePolicy(cache_ttl_ms=0) + live_rate = await policy.current_rate_sat_per_kb() + print(f"Live fee rate: {live_rate} sat/kB") + + http_client = default_http_client() + response = await http_client.get( + policy.arc_policy_url, + headers={"Accept": "application/json"}, + timeout=policy.request_timeout, + ) + print(f"HTTP status: {response.status_code}") + payload = response.json_data + print("Policy payload:") + print(json.dumps(payload, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/live_test.py b/live_test.py new file mode 100644 index 0000000..51c665e --- /dev/null +++ b/live_test.py @@ -0,0 +1,95 @@ +import asyncio +import logging +from bsv import ( + PrivateKey, P2PKH, Transaction, TransactionInput, TransactionOutput +) +from bsv.fee_models.live_policy import LivePolicy +from bsv.keys import PublicKey + +logging.basicConfig(level=logging.INFO) +logging.getLogger("bsv.fee_models.live_policy").setLevel(logging.DEBUG) + +async def main(): + """ + A live test script to send BSV. + + Instructions: + 1. Fund the SENDER_WIF with some BSV. You can get a WIF from a new wallet or use one you have. + 2. Go to a block explorer like https://whatsonchain.com and find a transaction + where you received funds to the sender's address. + 3. Copy the 'raw transaction hex' of that transaction and paste it into SOURCE_TX_HEX. + 4. Update SOURCE_OUTPUT_INDEX to the correct index (usually 0 or 1) that corresponds + to the UTXO you want to spend. + 5. Run the script: `python live_test.py` + """ + + # --- CONFIGURATION - PLEASE EDIT THESE VALUES --- + + # 1. The private key of the wallet that has funds. + # You can generate one using create_wallet.py, or use an existing one. + SENDER_WIF = "Kwr1hjXs7E9uCKknaKLXDHoKMLZ37EbnNU7b4bHx6qLh2tPiwkNf" + + # 2. The address you want to send BSV to. + RECIPIENT_ADDRESS = "1CaS8TVYPWdGhHukE3Q1nxqN1NMPQYUUnJ" # The address from your wallet_info.txt + + # 3. The raw hex of a transaction where you received BSV to the SENDER_WIF address. + SOURCE_TX_HEX = '010000000309c18e11424ab71674d4bc9e390cc928ed27c001316ea607e6abd8e5fd996849010000006a473044022061b06684612b3d72e824430d93ccf09b04cd6872f5f116ea3214565938ecb0d802203178f8ecca4146852adee9ff5d8078aeb4fb9412c24cfe2694bcd9e3edd18de6412102a773b3a312dc7e0488d978b4fb2089ef466780cbdb639c49af97ffe06fca671cffffffff3bc3ce7935248145779ebdff453a22f9b39819155ba93bde35aba7944a8a64d4030000006a473044022033b1a478bd834349abb768e788dbbebd44f71bbe3bc618f689cd9e7c2defb35f022032582013de69e4fb62ad90b2a0299f21e76956f01032399ef3bc1445cf15331e41210387c8dc9c38330bec6118692b864da8f2a18e6cc732ba106c0d803dfbc74932ccffffffff3bc3ce7935248145779ebdff453a22f9b39819155ba93bde35aba7944a8a64d4040000006a473044022061d20e11129c9c4beb5eeee26652de855614011e05b2aa28b5f2b00a571c4fe902205c6795d9461a1d409c54b1586237d7f9f8ca5b847dbb6200ebb7e7a5dc16d9d141210387c8dc9c38330bec6118692b864da8f2a18e6cc732ba106c0d803dfbc74932ccffffffff02f59e0000000000001976a9146d2d67bed1863b2e39794df441532b5ed02f136588ac5b240000000000001976a914e2d61c65f720d6f8020b5211c3945f65ad7da3f988ac00000000' # From https://whatsonchain.com/tx/831e5b10660ff612ec3a0f0ae15cc74573366c7423ee7efbe94a457b30a7f323 + + # 4. The output index from the source transaction that you want to spend. + SOURCE_OUTPUT_INDEX = 0 # This is the output that sent funds to 1AxH3ishqURaeNUvuQoqNQXELdDyBti52v + + # 5. Amount to send in satoshis (1 BSV = 100,000,000 satoshis) + SATOSHIS_TO_SEND = 500 # A small amount for a test + + # --- END OF CONFIGURATION --- + + if "L1xx" in SENDER_WIF or "0100000001xx" in SOURCE_TX_HEX: + print("ERROR: Please update the SENDER_WIF and SOURCE_TX_HEX variables in the script.") + return + + sender_priv_key = PrivateKey(SENDER_WIF) + sender_address = sender_priv_key.address() + print(f"\nSender Address: {sender_address}") + + # Create a transaction object from the source hex + source_tx = Transaction.from_hex(SOURCE_TX_HEX) + + # Create the transaction input from the UTXO we want to spend + tx_input = TransactionInput( + source_transaction=source_tx, + source_txid=source_tx.txid(), + source_output_index=SOURCE_OUTPUT_INDEX, + unlocking_script_template=P2PKH().unlock(sender_priv_key), + ) + + # Create the output to the recipient + tx_output_recipient = TransactionOutput( + locking_script=P2PKH().lock(RECIPIENT_ADDRESS), + satoshis=SATOSHIS_TO_SEND + ) + + # Create the change output back to the sender + tx_output_change = TransactionOutput( + locking_script=P2PKH().lock(sender_address), + change=True + ) + + # Build, sign, and broadcast the transaction + print("\nFetching live fee policy...") + live_policy = LivePolicy.get_instance() # Use a safer fallback rate + fee_rate = await live_policy.current_rate_sat_per_kb() + print(f"Using fee rate: {fee_rate} sat/kB") + + tx = Transaction([tx_input], [tx_output_recipient, tx_output_change]) + await tx.fee(live_policy) # Automatically calculate fee and adjust change + + tx.sign() + + print(f"\nBroadcasting transaction... Raw Hex: {tx.hex()}") + response = await tx.broadcast() + print(f"Broadcast Response: {response}") + print(f"Transaction ID: {tx.txid()}") + print(f"\nCheck on WhatsOnChain: https://whatsonchain.com/tx/{tx.txid()}") + +if __name__ == "__main__": + asyncio.run(main()) From 8513eee8659368030e85581b3e46e93ac98aa05a Mon Sep 17 00:00:00 2001 From: kensato Date: Fri, 3 Oct 2025 16:47:56 +0900 Subject: [PATCH 7/9] Update: Transaction Fee Handling and Dependencies Enhanced the Transaction.fee() method to support synchronous usage while internally maintaining asynchronous logic. This ensures backward compatibility is not broken. Increased the default TRANSACTION_FEE_RATE to 10 satoshis per kilobyte for more realistic fee calculations. Updated the pytest-asyncio dependency to version 0.24.0. Modified the LivePolicy fallback fee logic to utilize the default TRANSACTION_FEE_RATE constant, ensuring consistency across fee calculations. --- bsv/constants.py | 2 +- bsv/fee_models/live_policy.py | 6 +++--- bsv/transaction.py | 35 +++++++++++++++++++++-------------- docs/fee_models.md | 6 +++--- live_test.py | 9 +++++---- requirements.txt | 5 +---- 6 files changed, 34 insertions(+), 29 deletions(-) diff --git a/bsv/constants.py b/bsv/constants.py index 3a6ba47..8cfa0eb 100644 --- a/bsv/constants.py +++ b/bsv/constants.py @@ -7,7 +7,7 @@ TRANSACTION_SEQUENCE: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_SEQUENCE') or 0xffffffff) TRANSACTION_VERSION: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_VERSION') or 1) TRANSACTION_LOCKTIME: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_LOCKTIME') or 0) -TRANSACTION_FEE_RATE: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_FEE_RATE') or 5) # satoshi per kilobyte +TRANSACTION_FEE_RATE: int = int(os.getenv('BSV_PY_SDK_TRANSACTION_FEE_RATE') or 10) # satoshi per kilobyte BIP32_DERIVATION_PATH = os.getenv('BSV_PY_SDK_BIP32_DERIVATION_PATH') or "m/" BIP39_ENTROPY_BIT_LENGTH: int = int(os.getenv('BSV_PY_SDK_BIP39_ENTROPY_BIT_LENGTH') or 128) BIP44_DERIVATION_PATH = os.getenv('BSV_PY_SDK_BIP44_DERIVATION_PATH') or "m/44'/236'/0'" diff --git a/bsv/fee_models/live_policy.py b/bsv/fee_models/live_policy.py index 930ec8d..fdb4196 100644 --- a/bsv/fee_models/live_policy.py +++ b/bsv/fee_models/live_policy.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from typing import Optional, Tuple -from ..constants import HTTP_REQUEST_TIMEOUT +from ..constants import HTTP_REQUEST_TIMEOUT, TRANSACTION_FEE_RATE from ..http_client import default_http_client from .satoshis_per_kilobyte import SatoshisPerKilobyte @@ -43,7 +43,7 @@ def __init__( self, cache_ttl_ms: int = _DEFAULT_CACHE_TTL_MS, arc_policy_url: Optional[str] = None, - fallback_sat_per_kb: int = 1, + fallback_sat_per_kb: int = TRANSACTION_FEE_RATE, request_timeout: Optional[int] = None, api_key: Optional[str] = None, ) -> None: @@ -70,7 +70,7 @@ def get_instance( cls, cache_ttl_ms: int = _DEFAULT_CACHE_TTL_MS, arc_policy_url: Optional[str] = None, - fallback_sat_per_kb: int = 1, + fallback_sat_per_kb: int = TRANSACTION_FEE_RATE, request_timeout: Optional[int] = None, api_key: Optional[str] = None, ) -> "LivePolicy": diff --git a/bsv/transaction.py b/bsv/transaction.py index b580e93..e0b7eb3 100644 --- a/bsv/transaction.py +++ b/bsv/transaction.py @@ -174,35 +174,42 @@ def estimated_byte_length(self) -> int: def fee(self, model_or_fee=None, change_distribution='equal'): """ Computes the fee for the transaction and adjusts the change outputs accordingly. - + This method can be called synchronously, even if it internally uses async operations. + :param model_or_fee: Fee model or fee amount. Defaults to a `LivePolicy` instance that retrieves the latest mining fees from ARC if not provided. :param change_distribution: Method of change distribution ('equal' or 'random'). Defaults to 'equal'. """ - if model_or_fee is None: model_or_fee = LivePolicy.get_instance( fallback_sat_per_kb=int(TRANSACTION_FEE_RATE) ) + # モデルが同期型の処理を返す場合 if isinstance(model_or_fee, int): - return self._apply_fee_amount(model_or_fee, change_distribution) + self._apply_fee_amount(model_or_fee, change_distribution) + return model_or_fee + # 非同期型の処理を返す場合 fee_estimate = model_or_fee.compute_fee(self) if inspect.isawaitable(fee_estimate): - async def _resolve_and_apply(): - resolved_fee = await fee_estimate - return self._apply_fee_amount(resolved_fee, change_distribution) - try: - asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(_resolve_and_apply()) - else: - return _resolve_and_apply() - - return self._apply_fee_amount(fee_estimate, change_distribution) + async def _resolve_and_apply(): + try: + resolved_fee = await fee_estimate + self._apply_fee_amount(resolved_fee, change_distribution) + return resolved_fee + except Exception as e: + return None + + # `async` を内部で実行して結果を取得 + resolved_fee = asyncio.run(_resolve_and_apply()) + return resolved_fee + + # 同期的な計算の結果を返す + self._apply_fee_amount(fee_estimate, change_distribution) + return fee_estimate def _apply_fee_amount(self, fee: int, change_distribution: str): change = 0 diff --git a/docs/fee_models.md b/docs/fee_models.md index e6c698e..1813832 100644 --- a/docs/fee_models.md +++ b/docs/fee_models.md @@ -16,7 +16,7 @@ is provided so consumers can share the cached rate across transactions. from bsv.fee_models.live_policy import LivePolicy policy = LivePolicy.get_instance() -await tx.fee(policy) +tx.fee(policy) ``` ### Configuration @@ -64,7 +64,7 @@ from bsv.fee_models.live_policy import LivePolicy tx = Transaction(...) # Use the shared singleton (default behaviour of Transaction.fee()). -await tx.fee(LivePolicy.get_instance()) +tx.fee(LivePolicy.get_instance()) # Or create a custom policy with a shorter cache TTL and private endpoint. policy = LivePolicy( @@ -72,6 +72,6 @@ policy = LivePolicy( arc_policy_url="https://arc.example.com/v1/policy", api_key="Bearer " ) -await tx.fee(policy) +tx.fee(policy) ``` diff --git a/live_test.py b/live_test.py index 51c665e..c22e9aa 100644 --- a/live_test.py +++ b/live_test.py @@ -4,6 +4,7 @@ PrivateKey, P2PKH, Transaction, TransactionInput, TransactionOutput ) from bsv.fee_models.live_policy import LivePolicy +from bsv.constants import HTTP_REQUEST_TIMEOUT, TRANSACTION_FEE_RATE from bsv.keys import PublicKey logging.basicConfig(level=logging.INFO) @@ -81,13 +82,13 @@ async def main(): print(f"Using fee rate: {fee_rate} sat/kB") tx = Transaction([tx_input], [tx_output_recipient, tx_output_change]) - await tx.fee(live_policy) # Automatically calculate fee and adjust change - + fee = tx.fee(live_policy) # Automatically calculate fee and adjust change + print(f"Transaction fee: {fee} satoshis") tx.sign() print(f"\nBroadcasting transaction... Raw Hex: {tx.hex()}") - response = await tx.broadcast() - print(f"Broadcast Response: {response}") + # response = await tx.broadcast() + # print(f"Broadcast Response: {response}") print(f"Transaction ID: {tx.txid()}") print(f"\nCheck on WhatsOnChain: https://whatsonchain.com/tx/{tx.txid()}") diff --git a/requirements.txt b/requirements.txt index 5466b24..83d39c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,7 @@ -bsv-sdk~=1.0.8 pycryptodomex~=3.21.0 coincurve~=20.0.0 aiohttp>=3.12.14 -nest_asyncio~=1.6.0 requests~=2.32.3 pytest~=8.3.4 setuptools>=78.1.1 -pytest-asyncio~=0.23.7 -yenpoint_1satordinals~=0.1.1 \ No newline at end of file +pytest-asyncio~=0.24.0 \ No newline at end of file From 86cd318ba32dac78366d8eda14815b42d056a391 Mon Sep 17 00:00:00 2001 From: kensato Date: Fri, 3 Oct 2025 17:44:17 +0900 Subject: [PATCH 8/9] Refactor: Transition to synchronous live policy fee computation - Replaced `async` logic in `live_test.py` with synchronous calls for better compatibility. - Refactored `Transaction.fee()` to include a private helper for asynchronous fee handling. - Updated `LivePolicy` tests to utilize `default_http_client` with mock responses. - Improved test coverage for cache utilization and fallback scenarios. - Removed outdated and unused code in `LivePolicy` test suite. --- bsv/transaction.py | 49 +++++---- live_test.py | 19 +++- tests/test_live_policy.py | 207 +++++++++++++++++++++----------------- 3 files changed, 161 insertions(+), 114 deletions(-) diff --git a/bsv/transaction.py b/bsv/transaction.py index e0b7eb3..771a33f 100644 --- a/bsv/transaction.py +++ b/bsv/transaction.py @@ -171,43 +171,54 @@ def estimated_byte_length(self) -> int: estimated_size = estimated_byte_length + # Private helper method for handling asynchronous fee resolution and application + async def _resolve_and_apply_fee(self, fee_estimate, change_distribution): + """ + A helper method to resolve and apply the transaction fee asynchronously. + + :param fee_estimate: An awaitable object that resolves to the estimated fee + :param change_distribution: The method of distributing change ('equal' or 'random') + :return: The resolved fee value (int) or None in case of an error + """ + try: + # Resolve the fee asynchronously + resolved_fee = await fee_estimate + # Apply the resolved fee to the transaction + self._apply_fee_amount(resolved_fee, change_distribution) + return resolved_fee + except Exception as e: + # Handle any errors and return None on failure + return None + def fee(self, model_or_fee=None, change_distribution='equal'): """ - Computes the fee for the transaction and adjusts the change outputs accordingly. - This method can be called synchronously, even if it internally uses async operations. + Computes the transaction fee and adjusts the change outputs accordingly. + This method can be called synchronously, even if it internally uses asynchronous operations. - :param model_or_fee: Fee model or fee amount. Defaults to a `LivePolicy` instance - that retrieves the latest mining fees from ARC if not provided. - :param change_distribution: Method of change distribution ('equal' or 'random'). Defaults to 'equal'. + :param model_or_fee: A fee model or a fee amount. If not provided, it defaults to an instance + of `LivePolicy` that fetches the latest mining fees. + :param change_distribution: Method of distributing change ('equal' or 'random'). Defaults to 'equal'. """ if model_or_fee is None: + # Retrieve the default fee model model_or_fee = LivePolicy.get_instance( fallback_sat_per_kb=int(TRANSACTION_FEE_RATE) ) - # モデルが同期型の処理を返す場合 + # If the fee is provided as a fixed value (synchronous) if isinstance(model_or_fee, int): self._apply_fee_amount(model_or_fee, change_distribution) return model_or_fee - # 非同期型の処理を返す場合 + # If the fee estimation requires asynchronous computation fee_estimate = model_or_fee.compute_fee(self) if inspect.isawaitable(fee_estimate): - - async def _resolve_and_apply(): - try: - resolved_fee = await fee_estimate - self._apply_fee_amount(resolved_fee, change_distribution) - return resolved_fee - except Exception as e: - return None - - # `async` を内部で実行して結果を取得 - resolved_fee = asyncio.run(_resolve_and_apply()) + # Execute the asynchronous task synchronously and get the result + resolved_fee = asyncio.run(self._resolve_and_apply_fee(fee_estimate, change_distribution)) return resolved_fee - # 同期的な計算の結果を返す + # Apply the fee directly if it is computed synchronously self._apply_fee_amount(fee_estimate, change_distribution) return fee_estimate diff --git a/live_test.py b/live_test.py index c22e9aa..53bde19 100644 --- a/live_test.py +++ b/live_test.py @@ -10,7 +10,9 @@ logging.basicConfig(level=logging.INFO) logging.getLogger("bsv.fee_models.live_policy").setLevel(logging.DEBUG) -async def main(): +# async def main(): + +def main(): """ A live test script to send BSV. @@ -77,9 +79,15 @@ async def main(): # Build, sign, and broadcast the transaction print("\nFetching live fee policy...") - live_policy = LivePolicy.get_instance() # Use a safer fallback rate - fee_rate = await live_policy.current_rate_sat_per_kb() - print(f"Using fee rate: {fee_rate} sat/kB") + # live_policy = LivePolicy.get_instance() # Use a safer fallback rate + live_policy = LivePolicy( + cache_ttl_ms=60_000, + arc_policy_url="https://arc.taal.com/v1/policy", + api_key="Bearer " + ) + + # fee_rate = await live_policy.current_rate_sat_per_kb() + # print(f"Using fee rate: {fee_rate} sat/kB") tx = Transaction([tx_input], [tx_output_recipient, tx_output_change]) fee = tx.fee(live_policy) # Automatically calculate fee and adjust change @@ -93,4 +101,5 @@ async def main(): print(f"\nCheck on WhatsOnChain: https://whatsonchain.com/tx/{tx.txid()}") if __name__ == "__main__": - asyncio.run(main()) + # asyncio.run(main()) + main() diff --git a/tests/test_live_policy.py b/tests/test_live_policy.py index 66c2952..4a9aef2 100644 --- a/tests/test_live_policy.py +++ b/tests/test_live_policy.py @@ -1,138 +1,165 @@ -from unittest.mock import MagicMock, patch - -import requests - +import asyncio +from unittest.mock import AsyncMock, patch, MagicMock from bsv.fee_models.live_policy import LivePolicy - +# Reset the singleton instance before each test def setup_function(_): LivePolicy._instance = None - +# Reset the singleton instance after each test def teardown_function(_): LivePolicy._instance = None - -def _mock_response(payload): - response = MagicMock() - response.raise_for_status.return_value = None - response.json.return_value = payload - return response - - -@patch("bsv.fee_models.live_policy.requests.get") -def test_parses_mining_fee(mock_get): - payload = { - "policy": { - "fees": { - "miningFee": {"satoshis": 5, "bytes": 250} +@patch("bsv.fee_models.live_policy.default_http_client", autospec=True) +def test_parses_mining_fee(mock_http_client_factory): + # Prepare the mocked DefaultHttpClient instance + mock_http_client = AsyncMock() + mock_http_client_factory.return_value = mock_http_client + + # Set up a mock response + mock_http_client.get.return_value.json_data = { + "data": { + "policy": { + "fees": { + "miningFee": {"satoshis": 5, "bytes": 250} + } } } } - mock_get.return_value = _mock_response(payload) - - policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=1) - - assert policy.current_rate_sat_per_kb() == 20 + # Create the test instance + policy = LivePolicy( + cache_ttl_ms=60000, + fallback_sat_per_kb=1, + arc_policy_url="https://arc.mock/policy" + ) + + # Execute and verify the result + rate = asyncio.run(policy.current_rate_sat_per_kb()) + assert rate == 20 + mock_http_client.get.assert_called_once() + + +@patch("bsv.fee_models.live_policy.default_http_client", autospec=True) +def test_cache_reused_when_valid(mock_http_client_factory): + # Prepare the mocked DefaultHttpClient instance + mock_http_client = AsyncMock() + mock_http_client_factory.return_value = mock_http_client + + # Set up a mock response + mock_http_client.get.return_value.json_data = { + "data": { + "policy": {"satPerKb": 50} + } + } -@patch("bsv.fee_models.live_policy.requests.get") -def test_cache_reused_when_valid(mock_get): - payload = {"policy": {"satPerKb": 50}} - mock_get.return_value = _mock_response(payload) - - policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=1) + policy = LivePolicy( + cache_ttl_ms=60000, + fallback_sat_per_kb=1, + arc_policy_url="https://arc.mock/policy" + ) - first = policy.current_rate_sat_per_kb() - second = policy.current_rate_sat_per_kb() + # Call multiple times within the cache validity period + first_rate = asyncio.run(policy.current_rate_sat_per_kb()) + second_rate = asyncio.run(policy.current_rate_sat_per_kb()) - assert first == 50 - assert second == 50 - mock_get.assert_called_once() + # Verify the results + assert first_rate == 50 + assert second_rate == 50 + mock_http_client.get.assert_called_once() -@patch("bsv.fee_models.live_policy.requests.get") +@patch("bsv.fee_models.live_policy.default_http_client", autospec=True) @patch("bsv.fee_models.live_policy.logger.warning") -def test_uses_cached_value_when_fetch_fails(mock_log, mock_get): - payload = {"policy": {"satPerKb": 75}} - mock_get.side_effect = [ - _mock_response(payload), - requests.RequestException("Network down"), +def test_uses_cached_value_when_fetch_fails(mock_log, mock_http_client_factory): + # Prepare the mocked DefaultHttpClient instance + mock_http_client = AsyncMock() + mock_http_client_factory.return_value = mock_http_client + + # Set up mock responses (success first, then failure) + mock_http_client.get.side_effect = [ + AsyncMock(json_data={"data": {"policy": {"satPerKb": 75}}}), + Exception("Network down") ] - policy = LivePolicy(cache_ttl_ms=1, fallback_sat_per_kb=5) + policy = LivePolicy( + cache_ttl_ms=1, + fallback_sat_per_kb=5, + arc_policy_url="https://arc.mock/policy" + ) - first = policy.current_rate_sat_per_kb() - assert first == 75 + # The first execution succeeds + first_rate = asyncio.run(policy.current_rate_sat_per_kb()) + assert first_rate == 75 - # Expire cache manually + # Force invalidation of the cache with policy._cache_lock: policy._cache.fetched_at_ms -= 10 - second = policy.current_rate_sat_per_kb() - assert second == 75 + # The second execution uses the cache + second_rate = asyncio.run(policy.current_rate_sat_per_kb()) + assert second_rate == 75 + # Verify that a log is recorded for cache usage assert mock_log.call_count == 1 args, _ = mock_log.call_args assert args[0] == "Failed to fetch live fee rate, using cached value: %s" - assert isinstance(args[1], requests.RequestException) - assert str(args[1]) == "Network down" + mock_http_client.get.assert_called() -@patch("bsv.fee_models.live_policy.requests.get", side_effect=requests.RequestException("boom")) +@patch("bsv.fee_models.live_policy.default_http_client", autospec=True) @patch("bsv.fee_models.live_policy.logger.warning") -def test_falls_back_to_default_when_no_cache(mock_log, _mock_get): - policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=9) +def test_falls_back_to_default_when_no_cache(mock_log, mock_http_client_factory): + # Prepare the mocked DefaultHttpClient instance + mock_http_client = AsyncMock() + mock_http_client_factory.return_value = mock_http_client - assert policy.current_rate_sat_per_kb() == 9 + # Set up a mock response (always failing) + mock_http_client.get.side_effect = Exception("Network failure") + policy = LivePolicy( + cache_ttl_ms=60000, + fallback_sat_per_kb=9, + arc_policy_url="https://arc.mock/policy" + ) + + # Fallback value is returned during execution + rate = asyncio.run(policy.current_rate_sat_per_kb()) + assert rate == 9 + + # Verify that a log is recorded assert mock_log.call_count == 1 args, _ = mock_log.call_args assert args[0] == "Failed to fetch live fee rate, using fallback %d sat/kB: %s" assert args[1] == 9 - assert isinstance(args[2], requests.RequestException) - assert str(args[2]) == "boom" + mock_http_client.get.assert_called() -@patch("bsv.fee_models.live_policy.requests.get") +@patch("bsv.fee_models.live_policy.default_http_client", autospec=True) @patch("bsv.fee_models.live_policy.logger.warning") -def test_invalid_response_triggers_fallback(mock_log, mock_get): - mock_get.return_value = _mock_response({"policy": {"invalid": True}}) +def test_invalid_response_triggers_fallback(mock_log, mock_http_client_factory): + # Prepare the mocked DefaultHttpClient instance + mock_http_client = AsyncMock() + mock_http_client_factory.return_value = mock_http_client + + # Set up an invalid response + mock_http_client.get.return_value.json_data = { + "data": {"policy": {"invalid": True}} + } - policy = LivePolicy(cache_ttl_ms=60000, fallback_sat_per_kb=3) + policy = LivePolicy( + cache_ttl_ms=60000, + fallback_sat_per_kb=3, + arc_policy_url="https://arc.mock/policy" + ) - assert policy.current_rate_sat_per_kb() == 3 + # Fallback value is returned due to the invalid response + rate = asyncio.run(policy.current_rate_sat_per_kb()) + assert rate == 3 + # Verify that a log is recorded assert mock_log.call_count == 1 args, _ = mock_log.call_args assert args[0] == "Failed to fetch live fee rate, using fallback %d sat/kB: %s" assert args[1] == 3 - assert isinstance(args[2], ValueError) - assert str(args[2]) == "Invalid policy response format" - - -def test_singleton_returns_same_instance(): - first = LivePolicy.get_instance(cache_ttl_ms=10000) - second = LivePolicy.get_instance(cache_ttl_ms=20000) - - assert first is second - assert first.cache_ttl_ms == 10000 - - -def test_custom_instance_uses_provided_ttl(): - policy = LivePolicy(cache_ttl_ms=30000) - assert policy.cache_ttl_ms == 30000 - - -@patch("bsv.fee_models.live_policy.requests.get") -def test_singleton_cache_shared(mock_get): - payload = {"policy": {"satPerKb": 25}} - mock_get.return_value = _mock_response(payload) - - policy1 = LivePolicy.get_instance() - policy2 = LivePolicy.get_instance() - - assert policy1 is policy2 - assert policy1.current_rate_sat_per_kb() == 25 - assert policy2.current_rate_sat_per_kb() == 25 - mock_get.assert_called_once() + mock_http_client.get.assert_called() \ No newline at end of file From a4db7485c768b140209264f2eb03b9c0a74bf6a8 Mon Sep 17 00:00:00 2001 From: kensato Date: Fri, 3 Oct 2025 17:52:39 +0900 Subject: [PATCH 9/9] Remove unused scripts and increment version to 1.0.9 - Deleted outdated scripts: `create_wallet.py`, `inspect_live_policy.py`, and `live_test.py`. - Updated `__init__.py` to reflect version 1.0.9. - Added release notes for version 1.0.9 in `CHANGELOG.md`. --- CHANGELOG.md | 9 ++++ bsv/__init__.py | 2 +- create_wallet.py | 36 -------------- inspect_live_policy.py | 30 ------------ live_test.py | 105 ----------------------------------------- 5 files changed, 10 insertions(+), 172 deletions(-) delete mode 100644 create_wallet.py delete mode 100644 inspect_live_policy.py delete mode 100644 live_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d6304..ba4e3a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Table of Contents - [Unreleased](#unreleased) +- [1.0.9 - 2025-09-30](#109---2025-09-30) - [1.0.8 - 2025-08-13](#108---2025-08-13) - [1.0.7.1- 2025-07-28](#1071---2025-07-28) - [1.0.7- 2025-07-28](#107---2025-07-28) @@ -44,6 +45,14 @@ 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.9] - 2025-09-30 + +### Added +- Integrated `LivePolicy` for dynamic fee computations with caching and fallback mechanisms. + [ts-sdk#343](https://github.com/bsv-blockchain/ts-sdk/pull/343). + --- ## [1.0.8] - 2025-08-13 diff --git a/bsv/__init__.py b/bsv/__init__.py index 7123012..c59c58f 100644 --- a/bsv/__init__.py +++ b/bsv/__init__.py @@ -20,4 +20,4 @@ from .signed_message import * -__version__ = '1.0.8' \ No newline at end of file +__version__ = '1.0.9' \ No newline at end of file diff --git a/create_wallet.py b/create_wallet.py deleted file mode 100644 index 1087ec8..0000000 --- a/create_wallet.py +++ /dev/null @@ -1,36 +0,0 @@ -from bsv import PrivateKey - - -def main(): - """ - Generates a new BSV sender and receiver wallet and saves the info to wallet_info.txt. - """ - # Generate sender address (Address A) - priv_key_a = PrivateKey() - wif_a = priv_key_a.wif() # Wallet Import Format - address_a = priv_key_a.address() - - # Generate receiver address (Address B) - priv_key_b = PrivateKey() - wif_b = priv_key_b.wif() - address_b = priv_key_b.address() - - # Print out the keys and addresses - print("\n===== SENDER INFORMATION =====") - print(f"Private Key: {wif_a}") - print(f"Address: {address_a}") - - print("\n===== RECEIVER INFORMATION =====") - print(f"Private Key: {wif_b}") - print(f"Address: {address_b}") - - # Save data to file for easy reference - with open("wallet_info.txt", "w") as f: - f.write(f"Sender Private Key: {wif_a}\n") - f.write(f"Sender Address: {address_a}\n\n") - f.write(f"Receiver Private Key: {wif_b}\n") - f.write(f"Receiver Address: {address_b}\n") - print("\nThis information has been saved to wallet_info.txt") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/inspect_live_policy.py b/inspect_live_policy.py deleted file mode 100644 index 0674be1..0000000 --- a/inspect_live_policy.py +++ /dev/null @@ -1,30 +0,0 @@ -import asyncio -import json -import logging - -from bsv.fee_models.live_policy import LivePolicy -from bsv.http_client import default_http_client - - -async def main() -> None: - logging.basicConfig(level=logging.INFO) - logging.getLogger("bsv.fee_models.live_policy").setLevel(logging.DEBUG) - - policy = LivePolicy(cache_ttl_ms=0) - live_rate = await policy.current_rate_sat_per_kb() - print(f"Live fee rate: {live_rate} sat/kB") - - http_client = default_http_client() - response = await http_client.get( - policy.arc_policy_url, - headers={"Accept": "application/json"}, - timeout=policy.request_timeout, - ) - print(f"HTTP status: {response.status_code}") - payload = response.json_data - print("Policy payload:") - print(json.dumps(payload, indent=2, sort_keys=True)) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/live_test.py b/live_test.py deleted file mode 100644 index 53bde19..0000000 --- a/live_test.py +++ /dev/null @@ -1,105 +0,0 @@ -import asyncio -import logging -from bsv import ( - PrivateKey, P2PKH, Transaction, TransactionInput, TransactionOutput -) -from bsv.fee_models.live_policy import LivePolicy -from bsv.constants import HTTP_REQUEST_TIMEOUT, TRANSACTION_FEE_RATE -from bsv.keys import PublicKey - -logging.basicConfig(level=logging.INFO) -logging.getLogger("bsv.fee_models.live_policy").setLevel(logging.DEBUG) - -# async def main(): - -def main(): - """ - A live test script to send BSV. - - Instructions: - 1. Fund the SENDER_WIF with some BSV. You can get a WIF from a new wallet or use one you have. - 2. Go to a block explorer like https://whatsonchain.com and find a transaction - where you received funds to the sender's address. - 3. Copy the 'raw transaction hex' of that transaction and paste it into SOURCE_TX_HEX. - 4. Update SOURCE_OUTPUT_INDEX to the correct index (usually 0 or 1) that corresponds - to the UTXO you want to spend. - 5. Run the script: `python live_test.py` - """ - - # --- CONFIGURATION - PLEASE EDIT THESE VALUES --- - - # 1. The private key of the wallet that has funds. - # You can generate one using create_wallet.py, or use an existing one. - SENDER_WIF = "Kwr1hjXs7E9uCKknaKLXDHoKMLZ37EbnNU7b4bHx6qLh2tPiwkNf" - - # 2. The address you want to send BSV to. - RECIPIENT_ADDRESS = "1CaS8TVYPWdGhHukE3Q1nxqN1NMPQYUUnJ" # The address from your wallet_info.txt - - # 3. The raw hex of a transaction where you received BSV to the SENDER_WIF address. - SOURCE_TX_HEX = '010000000309c18e11424ab71674d4bc9e390cc928ed27c001316ea607e6abd8e5fd996849010000006a473044022061b06684612b3d72e824430d93ccf09b04cd6872f5f116ea3214565938ecb0d802203178f8ecca4146852adee9ff5d8078aeb4fb9412c24cfe2694bcd9e3edd18de6412102a773b3a312dc7e0488d978b4fb2089ef466780cbdb639c49af97ffe06fca671cffffffff3bc3ce7935248145779ebdff453a22f9b39819155ba93bde35aba7944a8a64d4030000006a473044022033b1a478bd834349abb768e788dbbebd44f71bbe3bc618f689cd9e7c2defb35f022032582013de69e4fb62ad90b2a0299f21e76956f01032399ef3bc1445cf15331e41210387c8dc9c38330bec6118692b864da8f2a18e6cc732ba106c0d803dfbc74932ccffffffff3bc3ce7935248145779ebdff453a22f9b39819155ba93bde35aba7944a8a64d4040000006a473044022061d20e11129c9c4beb5eeee26652de855614011e05b2aa28b5f2b00a571c4fe902205c6795d9461a1d409c54b1586237d7f9f8ca5b847dbb6200ebb7e7a5dc16d9d141210387c8dc9c38330bec6118692b864da8f2a18e6cc732ba106c0d803dfbc74932ccffffffff02f59e0000000000001976a9146d2d67bed1863b2e39794df441532b5ed02f136588ac5b240000000000001976a914e2d61c65f720d6f8020b5211c3945f65ad7da3f988ac00000000' # From https://whatsonchain.com/tx/831e5b10660ff612ec3a0f0ae15cc74573366c7423ee7efbe94a457b30a7f323 - - # 4. The output index from the source transaction that you want to spend. - SOURCE_OUTPUT_INDEX = 0 # This is the output that sent funds to 1AxH3ishqURaeNUvuQoqNQXELdDyBti52v - - # 5. Amount to send in satoshis (1 BSV = 100,000,000 satoshis) - SATOSHIS_TO_SEND = 500 # A small amount for a test - - # --- END OF CONFIGURATION --- - - if "L1xx" in SENDER_WIF or "0100000001xx" in SOURCE_TX_HEX: - print("ERROR: Please update the SENDER_WIF and SOURCE_TX_HEX variables in the script.") - return - - sender_priv_key = PrivateKey(SENDER_WIF) - sender_address = sender_priv_key.address() - print(f"\nSender Address: {sender_address}") - - # Create a transaction object from the source hex - source_tx = Transaction.from_hex(SOURCE_TX_HEX) - - # Create the transaction input from the UTXO we want to spend - tx_input = TransactionInput( - source_transaction=source_tx, - source_txid=source_tx.txid(), - source_output_index=SOURCE_OUTPUT_INDEX, - unlocking_script_template=P2PKH().unlock(sender_priv_key), - ) - - # Create the output to the recipient - tx_output_recipient = TransactionOutput( - locking_script=P2PKH().lock(RECIPIENT_ADDRESS), - satoshis=SATOSHIS_TO_SEND - ) - - # Create the change output back to the sender - tx_output_change = TransactionOutput( - locking_script=P2PKH().lock(sender_address), - change=True - ) - - # Build, sign, and broadcast the transaction - print("\nFetching live fee policy...") - # live_policy = LivePolicy.get_instance() # Use a safer fallback rate - live_policy = LivePolicy( - cache_ttl_ms=60_000, - arc_policy_url="https://arc.taal.com/v1/policy", - api_key="Bearer " - ) - - # fee_rate = await live_policy.current_rate_sat_per_kb() - # print(f"Using fee rate: {fee_rate} sat/kB") - - tx = Transaction([tx_input], [tx_output_recipient, tx_output_change]) - fee = tx.fee(live_policy) # Automatically calculate fee and adjust change - print(f"Transaction fee: {fee} satoshis") - tx.sign() - - print(f"\nBroadcasting transaction... Raw Hex: {tx.hex()}") - # response = await tx.broadcast() - # print(f"Broadcast Response: {response}") - print(f"Transaction ID: {tx.txid()}") - print(f"\nCheck on WhatsOnChain: https://whatsonchain.com/tx/{tx.txid()}") - -if __name__ == "__main__": - # asyncio.run(main()) - main()