- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
627 lines
24 KiB
Python
627 lines
24 KiB
Python
"""
|
|
Consensus RPC Client - Multi-Provider Voting with Health Tracking.
|
|
|
|
Queries 5+ Solana RPC endpoints in parallel, compares results via
|
|
N-of-M consensus voting, and returns a ConsensusResult with confidence
|
|
score, agreed/disagreed/failed sources.
|
|
|
|
EVM support via chain_id mapping for cross-chain queries.
|
|
|
|
Pattern: Match queries from N providers → vote on result → return
|
|
consensus with confidence %. 5 providers, majority=3 for high confidence.
|
|
|
|
Depends on: httpx (async HTTP), HELIUS_API_KEY env var.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Chain → RPC URL Mapping ────────────────────────────────────────────────
|
|
|
|
# Solana RPC endpoints (free + keyed, no cost for public tiers)
|
|
SOLANA_RPC_ENDPOINTS = {
|
|
"helius": "https://mainnet.helius-rpc.com/?api-key={key}",
|
|
"quicknode": "https://quiknode.pro/{key}/solana-mainnet/",
|
|
"alchemy": "https://solana-mainnet.g.alchemy.com/v2/{key}",
|
|
"drpc": "https://solana.drpc.org",
|
|
"publicnode": "https://solana-rpc.publicnode.com",
|
|
"anvil": "https://api.mainnet-beta.solana.com",
|
|
}
|
|
|
|
# EVM chain_id → default PublicNode RPC
|
|
CHAIN_ID_TO_DEFAULT_RPC = {
|
|
1: "https://ethereum-rpc.publicnode.com",
|
|
56: "https://bsc-rpc.publicnode.com",
|
|
137: "https://polygon-rpc.publicnode.com",
|
|
8453: "https://base-rpc.publicnode.com",
|
|
42161: "https://arbitrum-rpc.publicnode.com",
|
|
10: "https://optimism-rpc.publicnode.com",
|
|
43114: "https://avalanche-c-chain-rpc.publicnode.com",
|
|
250: "https://fantom-rpc.publicnode.com",
|
|
}
|
|
|
|
# EVM multi-provider map: chain_id → [(name, url_template, requires_key)]
|
|
# url_template uses {key} if API-keyed
|
|
EVM_RPC_PROVIDERS: dict[int, list[tuple[str, str, bool]]] = {
|
|
1: [
|
|
("ethereum_publicnode", "https://ethereum-rpc.publicnode.com", False),
|
|
("llama_rpc", "https://eth.llamarpc.com", False),
|
|
("1rpc", "https://1rpc.io/eth", False),
|
|
("blastapi", "https://eth-mainnet.public.blastapi.io", False),
|
|
],
|
|
56: [
|
|
("bsc_publicnode", "https://bsc-rpc.publicnode.com", False),
|
|
("1rpc", "https://1rpc.io/bnb", False),
|
|
("blastapi", "https://bsc-mainnet.public.blastapi.io", False),
|
|
],
|
|
137: [
|
|
("polygon_publicnode", "https://polygon-rpc.publicnode.com", False),
|
|
("1rpc", "https://1rpc.io/matic", False),
|
|
],
|
|
8453: [
|
|
("base_publicnode", "https://base-rpc.publicnode.com", False),
|
|
("1rpc", "https://1rpc.io/base", False),
|
|
],
|
|
42161: [
|
|
("arbitrum_publicnode", "https://arbitrum-rpc.publicnode.com", False),
|
|
("1rpc", "https://1rpc.io/arb", False),
|
|
],
|
|
}
|
|
|
|
|
|
# ── Data Classes ───────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class RpcEndpoint:
|
|
"""A single RPC provider with health tracking."""
|
|
|
|
name: str
|
|
url: str
|
|
weight: float = 1.0 # Initial weight; decays on failure
|
|
requires_key: bool = False
|
|
# Health tracking
|
|
total_calls: int = 0
|
|
success_calls: int = 0
|
|
failure_calls: int = 0
|
|
last_latency: float = 0.0
|
|
last_error: str = ""
|
|
is_disabled: bool = False
|
|
|
|
@property
|
|
def success_rate(self) -> float:
|
|
if self.total_calls == 0:
|
|
return 1.0
|
|
return self.success_calls / self.total_calls
|
|
|
|
@property
|
|
def effective_weight(self) -> float:
|
|
"""Weight adjusted by health. Disabled endpoints get weight 0."""
|
|
if self.is_disabled:
|
|
return 0.0
|
|
# Blend base weight with recent success rate (70/30)
|
|
return self.weight * (0.7 + 0.3 * self.success_rate)
|
|
|
|
|
|
@dataclass
|
|
class ConsensusResult:
|
|
"""Result of a multi-provider consensus query."""
|
|
|
|
value: Any = None
|
|
confidence: float = 0.0 # 0-100%
|
|
agreed_sources: list[str] = field(default_factory=list)
|
|
disagreed_sources: list[str] = field(default_factory=list)
|
|
failed_sources: list[str] = field(default_factory=list)
|
|
total_sources: int = 0
|
|
response_count: int = 0
|
|
details: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@property
|
|
def is_reliable(self) -> bool:
|
|
return self.confidence >= 60.0
|
|
|
|
|
|
# ── Consensus RPC Client ───────────────────────────────────────────────────
|
|
|
|
|
|
class ConsensusRpcClient:
|
|
"""Multi-provider RPC client with N-of-M consensus voting.
|
|
|
|
Queries 5+ Solana RPC endpoints in parallel. Compares responses
|
|
(JSON-string equality for complex types, direct equality for scalars).
|
|
Returns the majority-agreed value with confidence score.
|
|
|
|
For EVM chains, uses the chain_id → provider mapping with
|
|
eth_call / eth_getBalance / etc.
|
|
"""
|
|
|
|
# Individual timeout per endpoint query
|
|
PER_ENDPOINT_TIMEOUT = 8.0
|
|
|
|
# Minimum number of responders before we compute consensus
|
|
MIN_RESPONDERS = 2
|
|
|
|
# If an endpoint fails 3 times in a row, disable it for 60s
|
|
MAX_CONSECUTIVE_FAILURES = 3
|
|
DISABLE_DURATION = 60.0
|
|
|
|
def __init__(self):
|
|
self._endpoints: dict[str, RpcEndpoint] = {}
|
|
self._lock = asyncio.Lock()
|
|
self._setup_solana_endpoints()
|
|
self._disabled_until: dict[str, float] = {}
|
|
|
|
def _helius_keys(self) -> list[str]:
|
|
"""Return all configured Helius API keys (up to 3)."""
|
|
keys = []
|
|
for suffix in ("", "_2", "_3"):
|
|
k = os.getenv(f"HELIUS_API_KEY{suffix}", "")
|
|
if k and k != "your_helius_key_here" and len(k) > 10:
|
|
keys.append(k)
|
|
return keys
|
|
|
|
def _setup_solana_endpoints(self):
|
|
"""Build Solana RPC endpoint roster with ALL keys where available.
|
|
|
|
Each Helius key becomes its own endpoint (helius, helius_2, helius_3)
|
|
so consensus voting can use them as independent providers with their
|
|
own rate limits.
|
|
"""
|
|
helius_keys = self._helius_keys()
|
|
quicknode_key = os.getenv("QUICKNODE_KEY", "")
|
|
alchemy_key = os.getenv("ALCHEMY_SOLANA_KEY", "")
|
|
|
|
endpoints = []
|
|
|
|
# Helius: one endpoint per key (each has its own 25 RPS free tier)
|
|
for i, key in enumerate(helius_keys):
|
|
suffix = f"_{i + 1}" if i > 0 else ""
|
|
endpoints.append(
|
|
(
|
|
f"helius{suffix}",
|
|
SOLANA_RPC_ENDPOINTS["helius"].format(key=key),
|
|
1.2,
|
|
True,
|
|
)
|
|
)
|
|
|
|
# QuickNode
|
|
if quicknode_key and quicknode_key != "your_quicknode_key_here":
|
|
endpoints.append(
|
|
(
|
|
"quicknode",
|
|
SOLANA_RPC_ENDPOINTS["quicknode"].format(key=quicknode_key),
|
|
1.1,
|
|
True,
|
|
)
|
|
)
|
|
|
|
# Alchemy
|
|
if alchemy_key and len(alchemy_key) > 5:
|
|
endpoints.append(
|
|
(
|
|
"alchemy",
|
|
SOLANA_RPC_ENDPOINTS["alchemy"].format(key=alchemy_key),
|
|
1.0,
|
|
True,
|
|
)
|
|
)
|
|
|
|
# Public/free endpoints - always available
|
|
endpoints += [
|
|
("drpc", SOLANA_RPC_ENDPOINTS["drpc"], 0.9, False),
|
|
("publicnode", SOLANA_RPC_ENDPOINTS["publicnode"], 0.8, False),
|
|
("anvil", SOLANA_RPC_ENDPOINTS["anvil"], 0.7, False),
|
|
]
|
|
|
|
for name, url, weight, requires_key in endpoints:
|
|
# Skip keyed providers if we don't have a valid key
|
|
if requires_key and ("missing" in url or not url.split("/")[-1].strip()):
|
|
logger.debug(f"Skipping {name}: no API key configured")
|
|
continue
|
|
self._endpoints[name] = RpcEndpoint(
|
|
name=name,
|
|
url=url,
|
|
weight=weight,
|
|
requires_key=requires_key,
|
|
)
|
|
|
|
active = [n for n, e in self._endpoints.items() if not e.is_disabled]
|
|
logger.info(f"ConsensusRPC: {len(active)} Solana endpoints active: {active}")
|
|
|
|
# ── Health Management ────────────────────────────────────────────────
|
|
|
|
async def _record_success(self, name: str, latency: float):
|
|
async with self._lock:
|
|
ep = self._endpoints.get(name)
|
|
if ep:
|
|
ep.total_calls += 1
|
|
ep.success_calls += 1
|
|
ep.last_latency = latency
|
|
ep.last_error = ""
|
|
|
|
async def _record_failure(self, name: str, error: str):
|
|
async with self._lock:
|
|
ep = self._endpoints.get(name)
|
|
if ep:
|
|
ep.total_calls += 1
|
|
ep.failure_calls += 1
|
|
ep.last_error = error
|
|
# Check for consecutive failures to disable
|
|
if ep.failure_calls >= self.MAX_CONSECUTIVE_FAILURES:
|
|
# Only disable if success rate recently is 0
|
|
if ep.success_calls == 0 or (ep.total_calls >= 5 and ep.success_rate < 0.2):
|
|
ep.is_disabled = True
|
|
self._disabled_until[name] = time.monotonic() + self.DISABLE_DURATION
|
|
logger.warning(
|
|
f"RPC {name} disabled for {self.DISABLE_DURATION}s "
|
|
f"(failures={ep.failure_calls}, rate={ep.success_rate:.0%})"
|
|
)
|
|
|
|
async def _maybe_reenable(self, name: str):
|
|
"""Re-enable an endpoint after its disable duration expires."""
|
|
until = self._disabled_until.get(name, 0)
|
|
if until and time.monotonic() > until:
|
|
async with self._lock:
|
|
ep = self._endpoints.get(name)
|
|
if ep and ep.is_disabled:
|
|
ep.is_disabled = False
|
|
ep.failure_calls = max(0, ep.failure_calls - 2) # soft reset
|
|
del self._disabled_until[name]
|
|
logger.info(f"RPC {name} re-enabled after cooldown")
|
|
|
|
# ── Core Query Logic ──────────────────────────────────────────────────
|
|
|
|
async def _query_one(
|
|
self,
|
|
endpoint: RpcEndpoint,
|
|
method: str,
|
|
params: list[Any],
|
|
) -> tuple[str, dict | None, float, str | None]:
|
|
"""Query a single endpoint. Returns (name, result_dict, latency, error_string)."""
|
|
await self._maybe_reenable(endpoint.name)
|
|
if endpoint.is_disabled:
|
|
return (endpoint.name, None, 0.0, "endpoint_disabled")
|
|
|
|
start = time.monotonic()
|
|
try:
|
|
async with httpx.AsyncClient(timeout=self.PER_ENDPOINT_TIMEOUT) as client:
|
|
r = await client.post(
|
|
endpoint.url,
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": method,
|
|
"params": params,
|
|
},
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
latency = time.monotonic() - start
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
await self._record_success(endpoint.name, latency)
|
|
return (endpoint.name, data, latency, None)
|
|
elif r.status_code == 429:
|
|
await self._record_failure(endpoint.name, "rate_limited")
|
|
return (endpoint.name, None, latency, "rate_limited")
|
|
else:
|
|
await self._record_failure(endpoint.name, f"HTTP_{r.status_code}")
|
|
return (endpoint.name, None, latency, f"HTTP_{r.status_code}")
|
|
except httpx.TimeoutException:
|
|
latency = time.monotonic() - start
|
|
await self._record_failure(endpoint.name, "timeout")
|
|
return (endpoint.name, None, latency, "timeout")
|
|
except Exception as e:
|
|
latency = time.monotonic() - start
|
|
await self._record_failure(endpoint.name, str(e)[:200])
|
|
return (endpoint.name, None, latency, str(e)[:200])
|
|
|
|
async def query_with_consensus(
|
|
self,
|
|
method: str,
|
|
params: list[Any],
|
|
min_agreement: int = 2,
|
|
) -> ConsensusResult:
|
|
"""Query all endpoints in parallel and return consensus result.
|
|
|
|
Args:
|
|
method: JSON-RPC method name (e.g., 'getAccountInfo', 'getBalance')
|
|
params: JSON-RPC params list
|
|
min_agreement: Minimum number of agreeing sources for consensus
|
|
|
|
Returns:
|
|
ConsensusResult with voted value, confidence %, and source breakdown.
|
|
"""
|
|
active = [ep for ep in self._endpoints.values() if not ep.is_disabled]
|
|
if not active:
|
|
logger.error("No active RPC endpoints available")
|
|
return ConsensusResult(
|
|
value=None,
|
|
confidence=0.0,
|
|
failed_sources=[ep.name for ep in self._endpoints.values()],
|
|
total_sources=len(self._endpoints),
|
|
)
|
|
|
|
# Fire all queries in parallel with individual timeouts
|
|
tasks = [self._query_one(ep, method, params) for ep in active]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
# Process results
|
|
responses: dict[str, str] = {} # name → normalized_value
|
|
failures: dict[str, str] = {} # name → error
|
|
raw_results: dict[str, Any] = {} # name → raw_data (for details)
|
|
|
|
for r in results:
|
|
if isinstance(r, Exception):
|
|
logger.warning(f"Consensus query exception: {r}")
|
|
continue
|
|
name, data, _latency, error = r
|
|
if error:
|
|
failures[name] = error
|
|
elif data is not None:
|
|
# Normalize: extract 'result' field, use JSON repr for voting
|
|
result_value = data.get("result")
|
|
normalized = self._normalize(result_value)
|
|
responses[name] = normalized
|
|
raw_results[name] = data
|
|
|
|
# Count votes per normalized value
|
|
vote_counts: dict[str, list[str]] = {}
|
|
for name, norm in responses.items():
|
|
vote_counts.setdefault(norm, []).append(name)
|
|
|
|
# Find the winning value (most votes)
|
|
best_sources: list[str] = []
|
|
for norm, sources in vote_counts.items(): # noqa: B007
|
|
if len(sources) > len(best_sources):
|
|
best_sources = sources
|
|
|
|
# Determine agreed / disagreed / failed
|
|
agreed = best_sources
|
|
disagreed = [n for n in responses if n not in best_sources]
|
|
failed = list(failures.keys())
|
|
|
|
# Also count disabled as failed
|
|
failed += [
|
|
ep.name
|
|
for ep in self._endpoints.values()
|
|
if ep.is_disabled and ep.name not in agreed and ep.name not in disagreed
|
|
]
|
|
|
|
total = len(self._endpoints)
|
|
responded = len(responses)
|
|
|
|
# Calculate confidence
|
|
if responded < self.MIN_RESPONDERS:
|
|
confidence = 0.0
|
|
elif responded == 1:
|
|
confidence = 30.0 # Single source is weak
|
|
elif len(agreed) >= min_agreement:
|
|
# Confidence = (agreed / responded) * 100, with bonus for supermajority
|
|
agreement_ratio = len(agreed) / responded
|
|
supermajority_bonus = min(0.2, (len(agreed) - min_agreement) / max(total, 1) * 0.2)
|
|
confidence = min(100.0, (agreement_ratio * 80.0) + (supermajority_bonus * 100.0))
|
|
else:
|
|
# Weak consensus - disagreement
|
|
confidence = max(10.0, (len(agreed) / responded) * 50.0)
|
|
|
|
# Recover the actual value from the first agreeing source
|
|
value = None
|
|
if agreed:
|
|
first_agreeing = agreed[0]
|
|
if first_agreeing in raw_results:
|
|
value = raw_results[first_agreeing].get("result")
|
|
|
|
return ConsensusResult(
|
|
value=value,
|
|
confidence=round(confidence, 1),
|
|
agreed_sources=agreed,
|
|
disagreed_sources=disagreed,
|
|
failed_sources=failed,
|
|
total_sources=total,
|
|
response_count=responded,
|
|
details={
|
|
"method": method,
|
|
"params": params,
|
|
"raw_results": {k: v.get("result") for k, v in raw_results.items()},
|
|
"vote_counts": dict(vote_counts.items()),
|
|
},
|
|
)
|
|
|
|
def _normalize(self, value: Any) -> str:
|
|
"""Normalize a value for comparison in voting."""
|
|
if value is None:
|
|
return "__NONE__"
|
|
if isinstance(value, (bool, int, float, str)):
|
|
return str(value)
|
|
# For lists and dicts, use JSON representation
|
|
import json
|
|
|
|
try:
|
|
return json.dumps(value, sort_keys=True, default=str)
|
|
except (TypeError, ValueError):
|
|
return str(value)
|
|
|
|
# ── Convenience Methods ───────────────────────────────────────────────
|
|
|
|
async def get_account_info(self, address: str) -> ConsensusResult:
|
|
"""Get Solana account info with consensus across providers."""
|
|
return await self.query_with_consensus(
|
|
"getAccountInfo",
|
|
[address, {"encoding": "jsonParsed"}],
|
|
)
|
|
|
|
async def get_balance(self, address: str) -> ConsensusResult:
|
|
"""Get SOL balance with consensus."""
|
|
return await self.query_with_consensus("getBalance", [address])
|
|
|
|
async def get_token_supply(self, mint: str) -> ConsensusResult:
|
|
"""Get SPL token supply with consensus."""
|
|
return await self.query_with_consensus("getTokenSupply", [mint])
|
|
|
|
async def get_signatures_for_address(
|
|
self,
|
|
address: str,
|
|
limit: int = 20,
|
|
) -> ConsensusResult:
|
|
"""Get transaction signatures with consensus."""
|
|
return await self.query_with_consensus(
|
|
"getSignaturesForAddress",
|
|
[address, {"limit": limit}],
|
|
)
|
|
|
|
async def get_token_account_balance(self, token_account: str) -> ConsensusResult:
|
|
"""Get SPL token account balance with consensus."""
|
|
return await self.query_with_consensus(
|
|
"getTokenAccountBalance",
|
|
[token_account],
|
|
)
|
|
|
|
# ── EVM Chain Support ─────────────────────────────────────────────────
|
|
|
|
async def evm_query_with_consensus(
|
|
self,
|
|
chain_id: int,
|
|
method: str,
|
|
params: list[Any],
|
|
) -> ConsensusResult:
|
|
"""Query EVM chain RPC endpoints with consensus.
|
|
|
|
For EVM, we use PublicNode + other free providers (1rpc, llama, blastapi).
|
|
Map chain_id to provider list; fall back to PublicNode for unknown chains.
|
|
|
|
Args:
|
|
chain_id: Ethereum chain ID (1, 56, 8453, etc.)
|
|
method: eth_* RPC method
|
|
params: JSON-RPC params
|
|
"""
|
|
providers = EVM_RPC_PROVIDERS.get(chain_id, [])
|
|
if not providers:
|
|
# Fallback: use the default PublicNode URL for this chain
|
|
default_url = CHAIN_ID_TO_DEFAULT_RPC.get(chain_id)
|
|
if default_url:
|
|
providers = [(f"chain_{chain_id}_publicnode", default_url, False)]
|
|
else:
|
|
return ConsensusResult(
|
|
value=None,
|
|
confidence=0.0,
|
|
failed_sources=[f"unknown_chain_{chain_id}"],
|
|
total_sources=0,
|
|
)
|
|
|
|
# Build temporary endpoints for this query
|
|
evm_endpoints = []
|
|
for name, url, requires_key in providers:
|
|
evm_endpoints.append(
|
|
RpcEndpoint(
|
|
name=f"evm_{name}",
|
|
url=url,
|
|
weight=1.0,
|
|
requires_key=requires_key,
|
|
)
|
|
)
|
|
|
|
# Run queries in parallel
|
|
tasks = [self._query_one(ep, method, params) for ep in evm_endpoints]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
responses: dict[str, str] = {}
|
|
failures: dict[str, str] = {}
|
|
raw_results: dict[str, Any] = {}
|
|
|
|
for r in results:
|
|
if isinstance(r, Exception):
|
|
continue
|
|
name, data, _latency, error = r
|
|
if error:
|
|
failures[name] = error
|
|
elif data is not None:
|
|
result_value = data.get("result")
|
|
responses[name] = self._normalize(result_value)
|
|
raw_results[name] = data
|
|
|
|
# Voting
|
|
vote_counts: dict[str, list[str]] = {}
|
|
for name, norm in responses.items():
|
|
vote_counts.setdefault(norm, []).append(name)
|
|
|
|
best_sources: list[str] = []
|
|
for norm, sources in vote_counts.items(): # noqa: B007
|
|
if len(sources) > len(best_sources):
|
|
best_sources = sources[:]
|
|
|
|
agreed = best_sources
|
|
disagreed = [n for n in responses if n not in best_sources]
|
|
total = len(evm_endpoints)
|
|
responded = len(responses)
|
|
|
|
if responded >= 2 and len(agreed) >= 2:
|
|
confidence = min(100.0, (len(agreed) / responded) * 100.0)
|
|
elif responded == 1:
|
|
confidence = 40.0
|
|
else:
|
|
confidence = 0.0
|
|
|
|
value = None
|
|
if agreed and agreed[0] in raw_results:
|
|
value = raw_results[agreed[0]].get("result")
|
|
|
|
return ConsensusResult(
|
|
value=value,
|
|
confidence=round(confidence, 1),
|
|
agreed_sources=agreed,
|
|
disagreed_sources=disagreed,
|
|
failed_sources=list(failures.keys()),
|
|
total_sources=total,
|
|
response_count=responded,
|
|
)
|
|
|
|
# ── Health & Stats ────────────────────────────────────────────────────
|
|
|
|
async def health_summary(self) -> dict[str, Any]:
|
|
"""Return health report for all configured endpoints."""
|
|
summary = {}
|
|
async with self._lock:
|
|
for name, ep in self._endpoints.items():
|
|
summary[name] = {
|
|
"url": ep.url[:80],
|
|
"weight": ep.effective_weight,
|
|
"success_rate": round(ep.success_rate * 100, 1),
|
|
"total_calls": ep.total_calls,
|
|
"success_calls": ep.success_calls,
|
|
"failure_calls": ep.failure_calls,
|
|
"last_latency": round(ep.last_latency, 3),
|
|
"last_error": ep.last_error[:100],
|
|
"disabled": ep.is_disabled,
|
|
}
|
|
return summary
|
|
|
|
async def stats(self) -> dict[str, Any]:
|
|
"""Aggregate statistics."""
|
|
return {
|
|
"total_endpoints": len(self._endpoints),
|
|
"active": sum(1 for ep in self._endpoints.values() if not ep.is_disabled),
|
|
"disabled": sum(1 for ep in self._endpoints.values() if ep.is_disabled),
|
|
"health": await self.health_summary(),
|
|
}
|
|
|
|
|
|
# ── Singleton ──────────────────────────────────────────────────────────────
|
|
|
|
_consensus_client: ConsensusRpcClient | None = None
|
|
|
|
|
|
def get_consensus_rpc() -> ConsensusRpcClient:
|
|
"""Get the global ConsensusRpcClient singleton."""
|
|
global _consensus_client
|
|
if _consensus_client is None:
|
|
_consensus_client = ConsensusRpcClient()
|
|
return _consensus_client
|