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 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/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/__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/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/__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..fdb4196 --- /dev/null +++ b/bsv/fee_models/live_policy.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import logging +import os +import threading +import time +from dataclasses import dataclass +from typing import Optional, Tuple + +from ..constants import HTTP_REQUEST_TIMEOUT, TRANSACTION_FEE_RATE +from ..http_client import default_http_client +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 = TRANSACTION_FEE_RATE, + 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 = TRANSACTION_FEE_RATE, + 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 + + async def compute_fee(self, tx) -> int: # type: ignore[override] + """Compute a fee for ``tx`` using the latest ARC rate.""" + rate = await self.current_rate_sat_per_kb() + self.value = rate + return super().compute_fee(tx) + + 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 = await 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) + + 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 + + http_client = default_http_client() + response = await http_client.get( + self.arc_policy_url, + headers=headers, + timeout=self.request_timeout, + ) + 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 + + 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/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 3ebf743..771a33f 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 @@ -6,7 +8,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, @@ -169,28 +171,64 @@ 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. - - :param model_or_fee: Fee model or fee amount. Defaults to `SatoshisPerKilobyte` with value 10 if not provided. - :param change_distribution: Method of change distribution ('equal' or 'random'). Defaults to 'equal'. + 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: 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: - model_or_fee = SatoshisPerKilobyte(int(TRANSACTION_FEE_RATE)) + # 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): - fee = model_or_fee - else: - fee = model_or_fee.compute_fee(self) + 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): + # 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 + 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 @@ -215,6 +253,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/docs/fee_models.md b/docs/fee_models.md new file mode 100644 index 0000000..1813832 --- /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() +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()). +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 " +) +tx.fee(policy) +``` + 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 diff --git a/requirements.txt b/requirements.txt index bbe4e4e..83d39c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ coincurve~=20.0.0 aiohttp>=3.12.14 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.24.0 \ No newline at end of file diff --git a/tests/test_live_policy.py b/tests/test_live_policy.py new file mode 100644 index 0000000..4a9aef2 --- /dev/null +++ b/tests/test_live_policy.py @@ -0,0 +1,165 @@ +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 + +@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} + } + } + } + } + + # 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} + } + } + + policy = LivePolicy( + cache_ttl_ms=60000, + fallback_sat_per_kb=1, + arc_policy_url="https://arc.mock/policy" + ) + + # 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()) + + # Verify the results + assert first_rate == 50 + assert second_rate == 50 + mock_http_client.get.assert_called_once() + + +@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_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, + arc_policy_url="https://arc.mock/policy" + ) + + # The first execution succeeds + first_rate = asyncio.run(policy.current_rate_sat_per_kb()) + assert first_rate == 75 + + # Force invalidation of the cache + with policy._cache_lock: + policy._cache.fetched_at_ms -= 10 + + # 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" + mock_http_client.get.assert_called() + + +@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_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 (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 + mock_http_client.get.assert_called() + + +@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_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, + arc_policy_url="https://arc.mock/policy" + ) + + # 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 + mock_http_client.get.assert_called() \ No newline at end of file