Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ dist/
htmlcov/
.coverage
build/
.venv/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down
2 changes: 1 addition & 1 deletion bsv/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
from .signed_message import *


__version__ = '1.0.8'
__version__ = '1.0.9'
2 changes: 1 addition & 1 deletion bsv/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
Expand Down
3 changes: 2 additions & 1 deletion bsv/fee_models/__init__.py
Original file line number Diff line number Diff line change
@@ -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
198 changes: 198 additions & 0 deletions bsv/fee_models/live_policy.py
Original file line number Diff line number Diff line change
@@ -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
42 changes: 41 additions & 1 deletion bsv/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"""

Expand Down Expand Up @@ -137,4 +177,4 @@ def default_sync_http_client() -> SyncHttpClient:


def default_http_client() -> HttpClient:
return DefaultHttpClient()
return DefaultHttpClient()
Loading