Skip to content
Open
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
121 changes: 121 additions & 0 deletions allways/cli/swap_commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
import os
import sys
import tempfile
import time
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Tuple

import bittensor as bt
import click
import requests
from rich.console import Console
from rich.text import Text

from allways.chain_providers.base import ProviderUnreachableError
from allways.classes import MinerPair, Swap, SwapStatus
Expand All @@ -32,6 +36,123 @@

SECONDS_PER_BLOCK = 12

# --- Miner reliability (swap success rate) -------------------------------
# The CLI reads miner state from chain/contract, but per-miner success rate is
# not on-chain. `view rates` and `swap now` fetch resolved-swap history from
# this API and aggregate it. Override with ALLWAYS_API_URL for testnet or a
# self-hosted indexer.
DEFAULT_API_URL = 'https://api.all-ways.io'
RELIABILITY_CACHE_TTL = 600 # seconds — stats move slowly; avoid re-paging every call
RELIABILITY_WINDOW_DAYS = 30 # matches the subnet's 30-day credibility window


def _api_url() -> str:
return os.environ.get('ALLWAYS_API_URL', DEFAULT_API_URL).rstrip('/')


def _parse_iso(ts: Optional[str]) -> Optional[datetime]:
"""Parse an ISO-8601 timestamp (tolerating a trailing 'Z'); None on failure."""
if not ts:
return None
try:
return datetime.fromisoformat(ts.replace('Z', '+00:00'))
except (ValueError, AttributeError):
return None


def fetch_miner_reliability(window_days: int = RELIABILITY_WINDOW_DAYS, use_cache: bool = True) -> Optional[dict]:
"""Per-miner, per-direction swap success counts from the allways API.

Returns ``{hotkey: {'btc->tao': (completed, total), ...}}`` over the last
``window_days`` — counting only resolved swaps (COMPLETED + TIMED_OUT), the
same basis the subnet's success_rate uses. Returns ``None`` if the API is
unreachable: callers must degrade gracefully, since `view rates` and
`swap now` have to work whether or not the indexer is up.
"""
cache_file = ALLWAYS_DIR / 'miner_reliability_cache.json'
api_url = _api_url()
if use_cache and cache_file.exists():
try:
cached = json.loads(cache_file.read_text())
fresh = time.time() - cached.get('fetched_at', 0) < RELIABILITY_CACHE_TTL
# A cache from a different API host or window must not be reused.
same = cached.get('api_url') == api_url and cached.get('window_days') == window_days
if fresh and same:
return {hk: {d: tuple(v) for d, v in dirs.items()} for hk, dirs in cached['stats'].items()}
except (json.JSONDecodeError, KeyError, OSError):
pass # stale/corrupt cache — fall through and refetch

cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
# The API rejects unknown user agents; identify ourselves explicitly.
headers = {'User-Agent': f'allways-cli/{__import__("allways").__version__}'}
swaps: list = []
try:
for offset in range(0, 100_000, 50):
resp = requests.get(
f'{api_url}/swaps',
params={'limit': 50, 'offset': offset},
headers=headers,
timeout=10,
)
resp.raise_for_status()
page = resp.json()
# A JSON error object (dict) instead of a list must not be iterated.
if not isinstance(page, list) or not page:
break
swaps.extend(page)
# Swaps are newest-first; once the oldest on a page predates the
# window, every later page does too — stop paging.
oldest = _parse_iso(page[-1].get('createdAt'))
if oldest is not None and oldest < cutoff:
break
except (requests.RequestException, ValueError):
return None

stats: dict = {}
for s in swaps:
hk = s.get('minerHotkey')
status = s.get('status')
if not hk or status not in ('COMPLETED', 'TIMED_OUT'):
continue
resolved = _parse_iso(s.get('resolvedAt')) or _parse_iso(s.get('updatedAt'))
if resolved is None or resolved < cutoff:
continue
direction = f'{s.get("sourceChain")}->{s.get("destChain")}'
comp, tot = stats.setdefault(hk, {}).get(direction, (0, 0))
stats[hk][direction] = (comp + (status == 'COMPLETED'), tot + 1)

try:
ALLWAYS_DIR.mkdir(parents=True, exist_ok=True)
cache_file.write_text(
json.dumps(
{
'fetched_at': int(time.time()),
'api_url': api_url,
'window_days': window_days,
'stats': {hk: {d: list(v) for d, v in dirs.items()} for hk, dirs in stats.items()},
}
)
)
except OSError:
pass # cache write is best-effort
return stats


def reliability_text(hotkey: str, src: str, dst: str, reliability: Optional[dict]) -> Text:
"""Colored ``completed/total`` for one swap direction.

Green ≥90%, yellow ≥50%, red below; dim ``—`` when reliability is
unavailable or the miner has no resolved swap in that direction.
"""
if reliability is None:
return Text('—', style='dim')
comp, tot = reliability.get(hotkey, {}).get(f'{src}->{dst}', (0, 0))
if tot == 0:
return Text('—', style='dim')
pct = comp / tot
style = 'green' if pct >= 0.9 else 'yellow' if pct >= 0.5 else 'red'
return Text(f'{comp}/{tot}', style=style)


def blocks_to_minutes_str(blocks: int) -> str:
"""Render a block count as an approximate minutes string like '~5 min'."""
Expand Down
22 changes: 21 additions & 1 deletion allways/cli/swap_commands/swap.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
blocks_to_minutes_str,
clear_pending_swap,
console,
fetch_miner_reliability,
find_matching_miners,
from_rao,
get_cli_context,
Expand All @@ -28,6 +29,7 @@
loading,
mark_pending_swap_tx_sent,
probe_pending_reservation,
reliability_text,
resolve_source_tx_block,
save_pending_swap,
sign_or_prompt_external,
Expand Down Expand Up @@ -773,17 +775,35 @@ def swap_now_command(
canon_is_reverse = from_chain != canon_from
available_miners.sort(key=lambda x: x[0].rate, reverse=not canon_is_reverse)

# Per-miner success rate for this direction (None if the API is down).
with loading('Checking miner reliability...'):
reliability = fetch_miner_reliability()

# Show miners table
table = Table(title='Available Miners', show_header=True)
table.add_column('#', style='dim')
table.add_column('UID', style='cyan')
table.add_column('Rate (TAO)', style='green')
table.add_column('Reliability', no_wrap=True)
table.add_column('Collateral (TAO)', style='yellow')

for idx, (pair, collateral) in enumerate(available_miners, 1):
table.add_row(str(idx), str(pair.uid), f'{pair.rate:g}', f'{from_rao(collateral):.4f}')
table.add_row(
str(idx),
str(pair.uid),
f'{pair.rate:g}',
reliability_text(pair.hotkey, from_chain, to_chain, reliability),
f'{from_rao(collateral):.4f}',
)

console.print(table)
if reliability is None:
console.print('[dim]Reliability unavailable — swap-history API unreachable.[/dim]')
else:
console.print(
f'[dim]Reliability = {from_chain.upper()}→{to_chain.upper()} swaps completed/resolved '
'over the last 30 days; green ≥90%, yellow ≥50%, red <50%.[/dim]'
)

# Step 3: Select miner (default to best rate)
best_pair = available_miners[0][0]
Expand Down
39 changes: 38 additions & 1 deletion allways/cli/swap_commands/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
blocks_to_minutes_str,
clear_pending_swap,
console,
fetch_miner_reliability,
from_rao,
get_cli_context,
hydrate_pending_swap,
load_pending_swap,
loading,
probe_pending_reservation,
read_miner_commitments,
reliability_text,
)
from allways.constants import (
CHALLENGE_WINDOW_BLOCKS,
Expand All @@ -43,6 +45,23 @@ def _dashboard_url() -> str:
return os.environ.get('ALLWAYS_DASHBOARD_URL', DEFAULT_DASHBOARD_URL).rstrip('/')


def _reliability_cell(hotkey: str, src: str, dst: str, reliability: dict | None) -> Text:
"""Combined two-direction success cell for one `view rates` row.

``S→D c/t · D→S c/t`` — each side colored by `reliability_text`; the
whole cell is a dim ``—`` when reliability is unavailable.
"""
if reliability is None:
return Text('—', style='dim')
cell = Text()
cell.append(f'{src.upper()[0]}→{dst.upper()[0]} ', style='dim')
cell.append_text(reliability_text(hotkey, src, dst, reliability))
cell.append(' · ', style='dim')
cell.append(f'{dst.upper()[0]}→{src.upper()[0]} ', style='dim')
cell.append_text(reliability_text(hotkey, dst, src, reliability))
return cell


@click.group('view', cls=StyledGroup)
def view_group():
"""View swaps, miners, and rates."""
Expand Down Expand Up @@ -351,7 +370,11 @@ def view_rates(
TAO→BTC N reads: N TAO → 1 BTC

Capacity (TAO) is the miner's posted collateral — the hard cap on the
TAO leg of any single swap.[/dim]
TAO leg of any single swap.

Reliability shows completed/resolved swaps per direction over the last
30 days (green ≥90%, yellow ≥50%, red below), from the swap-history
API — '—' if that API is unreachable.[/dim]

[dim]Examples:
$ alw view rates
Expand Down Expand Up @@ -388,6 +411,10 @@ def view_rates(
min_swap_rao = 0
max_swap_rao = 0

# Per-miner success rate — fetched from the swap-history API and
# aggregated. None if the API is unreachable; the table still renders.
reliability = fetch_miner_reliability()

if pair:
parts = pair.lower().split('-')
if len(parts) != 2:
Expand Down Expand Up @@ -452,6 +479,7 @@ def _trunc(s: str) -> str:
table.add_column('UID', style='cyan')
table.add_column(f'{src_up}→{dst_up}', style='green')
table.add_column(f'{dst_up}→{src_up}', style='green')
table.add_column('Reliability', no_wrap=True)
table.add_column('Capacity (TAO)', style='yellow')
table.add_column(f'{src_up} Addr', style='dim')
table.add_column(f'{dst_up} Addr', style='dim')
Expand Down Expand Up @@ -480,6 +508,7 @@ def _trunc(s: str) -> str:
str(p.uid),
fwd,
rev,
_reliability_cell(p.hotkey, src, dst, reliability),
f'{from_rao(collateral):.4f}',
_trunc(p.from_address),
_trunc(p.to_address),
Expand Down Expand Up @@ -532,6 +561,14 @@ def _trunc(s: str) -> str:
shown = len(pairs_with_collateral)
if shown != total_before_filter:
console.print(f'[dim]Showing {shown} of {total_before_filter} miners after filters.[/dim]')
if reliability is None:
console.print('[yellow]Reliability unavailable — swap-history API unreachable.[/yellow]')
else:
console.print(
'[dim]Reliability = completed/resolved swaps per direction; '
'green ≥90%, yellow ≥50%, red <50%. A small sample (e.g. 1/1) is '
'noisy — prefer miners with a track record.[/dim]'
)
console.print(f'[dim]Sorted by: {sort_by}[/dim]')
if not full:
console.print('[dim]Use --full to show untruncated addresses.[/dim]')
Expand Down
Loading