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
5 changes: 5 additions & 0 deletions allways/chain_providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ def create_chain_providers(check: bool = False, require_send: bool = True, **kwa
raise RuntimeError(f'{cls.__name__} failed startup check: {e}') from e
bt.logging.warning(f'{cls.__name__} not available: {e}')

if check and providers:
bt.logging.info('Chain providers ready:')
for chain_id, provider in providers.items():
bt.logging.info(f' {chain_id} → {provider.describe()}')

return providers
4 changes: 4 additions & 0 deletions allways/chain_providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ class ChainProvider(ABC):
@abstractmethod
def get_chain(self) -> ChainDefinition: ...

def describe(self) -> str:
"""One-line summary of the backend/API this provider talks to, for startup logs."""
return self.get_chain().name

@abstractmethod
def check_connection(self, **kwargs) -> None:
"""Verify the chain provider can reach its backend (RPC node, subtensor, etc).
Expand Down
50 changes: 34 additions & 16 deletions allways/chain_providers/bitcoin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import time
from typing import Any, Optional, Tuple
from urllib.parse import urlparse

import base58
import bech32
Expand All @@ -17,6 +18,9 @@
ADDR_TYPE_P2WPKH = 'p2wpkh'
ADDR_TYPE_P2TR = 'p2tr'

LOG_RPC = '[BTC-RPC]'
LOG_ESPLORA = '[Esplora]'


def detect_address_type(address: str) -> str:
"""Detect Bitcoin address type from its prefix."""
Expand Down Expand Up @@ -89,6 +93,13 @@ def parse_esplora_urls(raw: str, auth_header: str = 'Authorization') -> list[tup
return bases


def esplora_tag(base: str) -> str:
"""Short, log-friendly host label for an Esplora endpoint (e.g. 'blockstream', 'gomaestro-api')."""
host = (urlparse(base).netloc or base).split(':')[0].removeprefix('www.')
parts = host.split('.')
return parts[-2] if len(parts) >= 2 else host


class BitcoinProvider(ChainProvider):
"""Bitcoin chain provider. Supports two modes:

Expand Down Expand Up @@ -149,6 +160,12 @@ def _send_error(self, msg: str) -> None:
def get_chain(self) -> ChainDefinition:
return CHAIN_BTC

def describe(self) -> str:
hosts = ', '.join(urlparse(base).netloc or base for base, _ in self.btc_api_bases())
if self.mode == 'lightweight':
return f'Esplora API ({self.network}): {hosts}'
return f'Core RPC {self.rpc_url} (primary) + Esplora fallback: {hosts}'

def check_connection(self, require_send: bool = True) -> None:
if self.mode == 'lightweight':
if require_send and not os.environ.get('BTC_PRIVATE_KEY'):
Expand All @@ -161,15 +178,15 @@ def check_connection(self, require_send: bool = True) -> None:
resp = self.btc_api_get('/blocks/tip/height', timeout=10)
resp.raise_for_status()
tip = int(resp.text.strip())
bt.logging.success(f'BTC lightweight mode: network={self.network}, Esplora tip={tip}')
bt.logging.success(f'{LOG_ESPLORA} connected: network={self.network}, tip={tip}')
except Exception as e:
raise ConnectionError(f'Cannot reach Esplora API: {e}') from e
return

result = self.rpc_call('getblockchaininfo', [])
if result is None:
raise ConnectionError(f'Cannot reach Bitcoin RPC at {self.rpc_url}')
bt.logging.success(f'BTC RPC connected: chain={result.get("chain")}, blocks={result.get("blocks")}')
bt.logging.success(f'{LOG_RPC} connected: chain={result.get("chain")}, blocks={result.get("blocks")}')

def rpc_call(self, method: str, params: Optional[list] = None) -> Optional[dict]:
"""Generic JSON-RPC helper for BTC Core."""
Expand All @@ -187,11 +204,11 @@ def rpc_call(self, method: str, params: Optional[list] = None) -> Optional[dict]
response.raise_for_status()
result = response.json()
if result.get('error'):
bt.logging.error(f'BTC RPC error ({method}): {result["error"]}')
bt.logging.error(f'{LOG_RPC} error ({method}): {result["error"]}')
return None
return result.get('result')
except Exception as e:
bt.logging.error(f'BTC RPC call failed ({method}): {e}')
bt.logging.error(f'{LOG_RPC} call failed ({method}): {e}')
return None

def fetch_matching_tx(
Expand All @@ -213,9 +230,9 @@ def fetch_matching_tx(

result = self.rpc_verify_transaction(tx_hash, expected_recipient, expected_amount)
if result is not None:
bt.logging.debug(f'BTC verify: served by local RPC (tx {tx_hash[:16]}...)')
bt.logging.debug(f'{LOG_RPC} served tx {tx_hash[:16]}...')
return result
bt.logging.debug(f'BTC verify: local RPC had no match, falling back to Esplora (tx {tx_hash[:16]}...)')
bt.logging.debug(f'{LOG_RPC} no match for tx {tx_hash[:16]}..., falling back to Esplora')
return self.api_verify_transaction(tx_hash, expected_recipient, expected_amount)

def rpc_verify_transaction(
Expand Down Expand Up @@ -257,7 +274,7 @@ def rpc_verify_transaction(
)

bt.logging.warning(
f'BTC RPC: tx {tx_hash[:16]}... has no vout paying {expected_recipient} >= {expected_amount} sat'
f'{LOG_RPC} tx {tx_hash[:16]}... has no vout paying {expected_recipient} >= {expected_amount} sat'
)
return None

Expand Down Expand Up @@ -303,7 +320,7 @@ def api_verify_transaction(
try:
resp = self.btc_api_get(f'/tx/{tx_hash}', timeout=15)
if resp.status_code == 404:
bt.logging.debug(f'BTC Esplora: tx {tx_hash[:16]}... not found (404)')
bt.logging.debug(f'{LOG_ESPLORA} tx {tx_hash[:16]}... not found (404)')
return None
resp.raise_for_status()
data = resp.json()
Expand All @@ -329,7 +346,7 @@ def api_verify_transaction(
if status_resp.ok and status_resp.json().get('in_best_chain') is False:
return None # block was reorged out
except Exception as e:
bt.logging.debug(f'canonical-chain check skipped for {tx_hash}: {e}')
bt.logging.debug(f'{LOG_ESPLORA} canonical-chain check skipped for {tx_hash}: {e}')

for vout in data.get('vout', []):
addr = vout.get('scriptpubkey_address', '')
Expand All @@ -351,7 +368,7 @@ def api_verify_transaction(
)

bt.logging.warning(
f'BTC Esplora: tx {tx_hash[:16]}... has no vout paying {expected_recipient} >= {expected_amount} sat'
f'{LOG_ESPLORA} tx {tx_hash[:16]}... has no vout paying {expected_recipient} >= {expected_amount} sat'
)
return None
except (requests.ConnectionError, requests.Timeout) as e:
Expand Down Expand Up @@ -429,25 +446,26 @@ def btc_api_request(self, method: str, path: str, timeout: int, **kwargs) -> req
last_err: Optional[Exception] = None
for i, (base, headers) in enumerate(bases):
pos = f'[{i + 1}/{len(bases)}]'
nxt = bases[i + 1][0] if i + 1 < len(bases) else None
tail = f'falling back to next provider: {nxt}' if nxt else 'no providers left, giving up'
tag = esplora_tag(base)
nxt = esplora_tag(bases[i + 1][0]) if i + 1 < len(bases) else None
tail = f'falling back to: {nxt}' if nxt else 'no providers left, giving up'
try:
resp = self.http.request(method, f'{base}{path}', timeout=timeout, headers=headers, **kwargs)
except Exception as e:
last_err = e
bt.logging.warning(f'Esplora {pos} {base}{path} → request error: {e}; {tail}')
bt.logging.warning(f'Esplora {pos} {tag}{path} → request error: {e}; {tail}')
continue

reason = self.failover_reason(resp)
if reason:
last_err = requests.HTTPError(f'{base}{path}: {resp.status_code}', response=resp)
bt.logging.warning(f'Esplora {pos} {base}{path} → {reason}; {tail}')
bt.logging.warning(f'Esplora {pos} {tag}{path} → {reason}; {tail}')
continue

if resp.status_code >= 400 and resp.status_code != 404:
bt.logging.warning(f'Esplora {pos} {base}{path} → HTTP {resp.status_code}: {resp.text[:200].strip()}')
bt.logging.warning(f'Esplora {pos} {tag}{path} → HTTP {resp.status_code}: {resp.text[:200].strip()}')
elif i > 0:
bt.logging.info(f'Esplora {pos} {base}{path} → {resp.status_code} (served after {i} fallback(s))')
bt.logging.info(f'Esplora {pos} {tag}{path} → {resp.status_code} (served after {i} fallback(s))')
return resp
raise last_err or RuntimeError('all BTC APIs failed')

Expand Down
11 changes: 8 additions & 3 deletions allways/chain_providers/subtensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from allways.chain_providers.base import ChainProvider, ProviderUnreachableError, TransactionInfo
from allways.chains import CHAIN_TAO, ChainDefinition

LOG_SUB = '[Subtensor]'


class SubtensorProvider(ChainProvider):
"""TAO chain provider using bt.Subtensor and substrate-interface.
Expand All @@ -30,10 +32,13 @@ def __init__(self, subtensor: bt.Subtensor, wallet: Optional['bt.Wallet'] = None
def get_chain(self) -> ChainDefinition:
return CHAIN_TAO

def describe(self) -> str:
return f'Subtensor {self.subtensor.chain_endpoint}'

def check_connection(self, **kwargs) -> None:
try:
block = self.subtensor.get_current_block()
bt.logging.success(f'Subtensor connected: block={block}')
bt.logging.success(f'{LOG_SUB} connected: block={block}')
except Exception as e:
raise ConnectionError(f'Cannot reach Subtensor: {e}') from e

Expand Down Expand Up @@ -222,10 +227,10 @@ def fetch_matching_tx(

if tx_hash_seen:
bt.logging.warning(
f'TAO scan: tx {tx_hash[:16]}... found but no transfer pays {expected_recipient} >= {expected_amount} rao'
f'{LOG_SUB} scan: tx {tx_hash[:16]}... found but no transfer pays {expected_recipient} >= {expected_amount} rao'
)
else:
bt.logging.debug(f'TAO scan: tx {tx_hash[:16]}... not found in scan window')
bt.logging.debug(f'{LOG_SUB} scan: tx {tx_hash[:16]}... not found in scan window')
return None
except ProviderUnreachableError:
raise
Expand Down
Loading