From 2b20aa908af2942d36ceca6a84931e6c96dd0543 Mon Sep 17 00:00:00 2001 From: anderdc Date: Fri, 22 May 2026 11:21:01 -0500 Subject: [PATCH 1/2] feat: per-miner success rate in view rates and swap now Users pick a miner by posted rate alone, so a miner can quote the best rate and still fail most of its swaps. Add a Reliability column (completed vs resolved swaps, per direction) to the 'alw view rates' shopping table and the 'alw swap now' miner picker. Stats aggregate resolved swaps from the public swap-history API over a 30-day window (matching the subnet credibility window), cached 10 min and keyed by API host. The column degrades to a dim dash when the API is unreachable, so both commands still work offline of the indexer. --- allways/cli/swap_commands/helpers.py | 121 ++++++++++++++++++++++++++ allways/cli/swap_commands/swap.py | 22 ++++- allways/cli/swap_commands/view.py | 39 ++++++++- tests/test_miner_reliability.py | 124 +++++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 tests/test_miner_reliability.py diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index 77168f8e..5d95894a 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -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 @@ -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'.""" diff --git a/allways/cli/swap_commands/swap.py b/allways/cli/swap_commands/swap.py index 131f571e..35754f15 100644 --- a/allways/cli/swap_commands/swap.py +++ b/allways/cli/swap_commands/swap.py @@ -20,6 +20,7 @@ blocks_to_minutes_str, clear_pending_swap, console, + fetch_miner_reliability, find_matching_miners, from_rao, get_cli_context, @@ -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, @@ -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] diff --git a/allways/cli/swap_commands/view.py b/allways/cli/swap_commands/view.py index a2088052..c0091c45 100644 --- a/allways/cli/swap_commands/view.py +++ b/allways/cli/swap_commands/view.py @@ -19,6 +19,7 @@ blocks_to_minutes_str, clear_pending_swap, console, + fetch_miner_reliability, from_rao, get_cli_context, hydrate_pending_swap, @@ -26,6 +27,7 @@ loading, probe_pending_reservation, read_miner_commitments, + reliability_text, ) from allways.constants import ( CHALLENGE_WINDOW_BLOCKS, @@ -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.""" @@ -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 @@ -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: @@ -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') @@ -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), @@ -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]') diff --git a/tests/test_miner_reliability.py b/tests/test_miner_reliability.py new file mode 100644 index 00000000..df2a2dc1 --- /dev/null +++ b/tests/test_miner_reliability.py @@ -0,0 +1,124 @@ +"""fetch_miner_reliability / reliability_text: per-miner swap success aggregation. + +Pins the aggregation behind the Reliability column in `alw view rates` and the +`swap now` miner picker: per-direction completed/total over a 30-day window, +unresolved statuses excluded, a graceful None when the API is down, and the +page-shape guard against a JSON error object served instead of a list. +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import requests + +from allways.cli.swap_commands import helpers +from allways.cli.swap_commands.helpers import fetch_miner_reliability, reliability_text + + +def _iso(dt: datetime) -> str: + return dt.strftime('%Y-%m-%dT%H:%M:%S.000Z') + + +def _swap(miner: str, src: str, dst: str, status: str, age_days: float = 1) -> dict: + ts = _iso(datetime.now(timezone.utc) - timedelta(days=age_days)) + return { + 'minerHotkey': miner, + 'sourceChain': src, + 'destChain': dst, + 'status': status, + 'createdAt': ts, + 'resolvedAt': ts, + } + + +def _mock_get(pages: list): + """Fake requests.get that serves `pages` by offset, then empty pages.""" + + def _get(url, params=None, headers=None, timeout=None): + idx = (params or {}).get('offset', 0) // 50 + resp = MagicMock() + resp.json.return_value = pages[idx] if idx < len(pages) else [] + resp.raise_for_status.return_value = None + return resp + + return _get + + +def test_aggregates_per_miner_per_direction(tmp_path, monkeypatch): + monkeypatch.setattr(helpers, 'ALLWAYS_DIR', tmp_path) + page = [ + _swap('5A', 'tao', 'btc', 'COMPLETED'), + _swap('5A', 'tao', 'btc', 'COMPLETED'), + _swap('5A', 'tao', 'btc', 'TIMED_OUT'), + _swap('5A', 'btc', 'tao', 'COMPLETED'), + _swap('5B', 'tao', 'btc', 'TIMED_OUT'), + ] + with patch.object(helpers.requests, 'get', _mock_get([page])): + stats = fetch_miner_reliability(use_cache=False) + assert stats['5A']['tao->btc'] == (2, 3) + assert stats['5A']['btc->tao'] == (1, 1) + assert stats['5B']['tao->btc'] == (0, 1) + + +def test_excludes_swaps_outside_window(tmp_path, monkeypatch): + monkeypatch.setattr(helpers, 'ALLWAYS_DIR', tmp_path) + page = [ + _swap('5A', 'tao', 'btc', 'COMPLETED', age_days=2), + _swap('5A', 'tao', 'btc', 'TIMED_OUT', age_days=40), # older than the 30d window + ] + with patch.object(helpers.requests, 'get', _mock_get([page])): + stats = fetch_miner_reliability(window_days=30, use_cache=False) + assert stats['5A']['tao->btc'] == (1, 1) # the 40-day-old swap is dropped + + +def test_ignores_unresolved_statuses(tmp_path, monkeypatch): + monkeypatch.setattr(helpers, 'ALLWAYS_DIR', tmp_path) + page = [ + _swap('5A', 'tao', 'btc', 'COMPLETED'), + _swap('5A', 'tao', 'btc', 'ACTIVE'), + _swap('5A', 'tao', 'btc', 'REFUNDED'), + ] + with patch.object(helpers.requests, 'get', _mock_get([page])): + stats = fetch_miner_reliability(use_cache=False) + assert stats['5A']['tao->btc'] == (1, 1) # only COMPLETED/TIMED_OUT count + + +def test_returns_none_when_api_unreachable(tmp_path, monkeypatch): + monkeypatch.setattr(helpers, 'ALLWAYS_DIR', tmp_path) + + def _boom(*args, **kwargs): + raise requests.ConnectionError('indexer down') + + with patch.object(helpers.requests, 'get', _boom): + assert fetch_miner_reliability(use_cache=False) is None + + +def test_json_error_object_does_not_crash(tmp_path, monkeypatch): + """A dict error body instead of a list must be treated as no data, not iterated.""" + monkeypatch.setattr(helpers, 'ALLWAYS_DIR', tmp_path) + + def _get(url, params=None, headers=None, timeout=None): + resp = MagicMock() + resp.json.return_value = {'error': 'Not Found', 'statusCode': 404} + resp.raise_for_status.return_value = None + return resp + + with patch.object(helpers.requests, 'get', _get): + assert fetch_miner_reliability(use_cache=False) == {} + + +def test_reliability_text_colors_and_formats(): + rel = {'5A': {'tao->btc': (9, 10), 'btc->tao': (1, 4)}} + + high = reliability_text('5A', 'tao', 'btc', rel) + assert high.plain == '9/10' + assert high.style == 'green' + + low = reliability_text('5A', 'btc', 'tao', rel) + assert low.plain == '1/4' + assert low.style == 'red' + + # no resolved swap for this miner/direction → dim dash + assert reliability_text('5Z', 'tao', 'btc', rel).plain == '—' + # API unavailable → dim dash + assert reliability_text('5A', 'tao', 'btc', None).plain == '—' From effbe6e08cedbab8a8b8552499b00e79d0a9b477 Mon Sep 17 00:00:00 2001 From: anderdc <61125407+anderdc@users.noreply.github.com> Date: Fri, 22 May 2026 16:21:59 +0000 Subject: [PATCH 2/2] style: auto-fix pre-commit hooks --- allways/cli/swap_commands/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/allways/cli/swap_commands/helpers.py b/allways/cli/swap_commands/helpers.py index 5d95894a..4c7ca079 100644 --- a/allways/cli/swap_commands/helpers.py +++ b/allways/cli/swap_commands/helpers.py @@ -117,7 +117,7 @@ def fetch_miner_reliability(window_days: int = RELIABILITY_WINDOW_DAYS, use_cach 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')}" + 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)