blockmachine 0.20.0__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {blockmachine-0.20.0 → blockmachine-0.22.0}/PKG-INFO +1 -1
  2. {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/PKG-INFO +1 -1
  3. {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/SOURCES.txt +3 -0
  4. blockmachine-0.22.0/commands/epoch.py +191 -0
  5. {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/miner.py +34 -1
  6. {blockmachine-0.20.0 → blockmachine-0.22.0}/main.py +2 -0
  7. {blockmachine-0.20.0 → blockmachine-0.22.0}/pyproject.toml +1 -1
  8. blockmachine-0.22.0/tests/test_epoch_payout_cli.py +206 -0
  9. blockmachine-0.22.0/tests/test_miner_ban_line.py +176 -0
  10. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_eligibility.py +17 -8
  11. {blockmachine-0.20.0 → blockmachine-0.22.0}/__init__.py +0 -0
  12. {blockmachine-0.20.0 → blockmachine-0.22.0}/auth_config.py +0 -0
  13. {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/dependency_links.txt +0 -0
  14. {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/entry_points.txt +0 -0
  15. {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/requires.txt +0 -0
  16. {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/top_level.txt +0 -0
  17. {blockmachine-0.20.0 → blockmachine-0.22.0}/client.py +0 -0
  18. {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/__init__.py +0 -0
  19. {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/auth.py +0 -0
  20. {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/leaderboard.py +0 -0
  21. {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/validator.py +0 -0
  22. {blockmachine-0.20.0 → blockmachine-0.22.0}/config.py +0 -0
  23. {blockmachine-0.20.0 → blockmachine-0.22.0}/endpoints.py +0 -0
  24. {blockmachine-0.20.0 → blockmachine-0.22.0}/settings.py +0 -0
  25. {blockmachine-0.20.0 → blockmachine-0.22.0}/setup.cfg +0 -0
  26. {blockmachine-0.20.0 → blockmachine-0.22.0}/testing.py +0 -0
  27. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_cli_config.py +0 -0
  28. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_cli_endpoints_and_testing.py +0 -0
  29. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_leaderboard_cli.py +0 -0
  30. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_add_class.py +0 -0
  31. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_capacity.py +0 -0
  32. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_compat_reason.py +0 -0
  33. {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_eth_tier.py +0 -0
  34. {blockmachine-0.20.0 → blockmachine-0.22.0}/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blockmachine
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: Blockmachine CLI - Miner and validator operator interface
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/taostat/blockmachine
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blockmachine
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: Blockmachine CLI - Miner and validator operator interface
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/taostat/blockmachine
@@ -25,13 +25,16 @@ blockmachine.egg-info/requires.txt
25
25
  blockmachine.egg-info/top_level.txt
26
26
  commands/__init__.py
27
27
  commands/auth.py
28
+ commands/epoch.py
28
29
  commands/leaderboard.py
29
30
  commands/miner.py
30
31
  commands/validator.py
31
32
  tests/test_cli_config.py
32
33
  tests/test_cli_endpoints_and_testing.py
34
+ tests/test_epoch_payout_cli.py
33
35
  tests/test_leaderboard_cli.py
34
36
  tests/test_miner_add_class.py
37
+ tests/test_miner_ban_line.py
35
38
  tests/test_miner_capacity.py
36
39
  tests/test_miner_compat_reason.py
37
40
  tests/test_miner_eligibility.py
@@ -0,0 +1,191 @@
1
+ """Epoch commands: what every miner will be paid for a finalized epoch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from cli.client import RegistryClient
13
+ from cli.config import load_config
14
+
15
+ app = typer.Typer(help="Epoch payouts")
16
+
17
+ console = Console()
18
+
19
+ # Rows on the summary table before the rest are folded into one line, and
20
+ # breakdown entries shown per row before "+N more".
21
+ TOP_MINERS = 12
22
+ TOP_BREAKDOWN = 6
23
+
24
+
25
+ def _fmt_time(iso: Optional[str]) -> str:
26
+ """``2026-09-16T20:21:59+00:00`` -> ``16 Sep 20:22 UTC``."""
27
+ if not iso:
28
+ return "?"
29
+ try:
30
+ dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
31
+ except ValueError:
32
+ return iso
33
+ # Round to the minute rather than truncate: 20:21:59 reads as 20:22.
34
+ dt = dt.astimezone(timezone.utc) + timedelta(seconds=30)
35
+ return f"{dt.day} {dt.strftime('%b %H:%M')} UTC"
36
+
37
+
38
+ def _fmt_usd(value: Optional[float]) -> str:
39
+ if value is None:
40
+ return "-"
41
+ return f"{value:,.0f}"
42
+
43
+
44
+ def _breakdown_summary(entries: list[dict], limit: int = TOP_BREAKDOWN) -> str:
45
+ shown = [
46
+ f"{e.get('chain')}/{e.get('node_class')} {float(e.get('alpha') or 0):.1f}"
47
+ for e in entries[:limit]
48
+ ]
49
+ more = len(entries) - limit
50
+ if more > 0:
51
+ shown.append(f"+{more} more")
52
+ return ", ".join(shown)
53
+
54
+
55
+ def render_payout(body: dict, top: int = TOP_MINERS) -> list[str]:
56
+ """The summary table as lines of plain text."""
57
+ # The registry omits zero-alpha miners; a zero row is nothing to show.
58
+ miners = [m for m in body.get("miners") or [] if float(m.get("alpha") or 0) > 0]
59
+ pool_alpha = body.get("pool_alpha")
60
+ price = body.get("alpha_price_usd")
61
+ lines = [
62
+ f"Epoch {body.get('epoch_number')} — finalized "
63
+ f"{_fmt_time(body.get('finalized_at'))}, paid out by the boundary "
64
+ f"~{_fmt_time(body.get('pays_at_estimate'))}",
65
+ ]
66
+ pool = (
67
+ f"miner pool {pool_alpha:,.0f} α (${_fmt_usd(body.get('pool_usd'))})"
68
+ if pool_alpha is not None
69
+ else "miner pool ?"
70
+ )
71
+ price_txt = f"α price ${price:.2f}" if price is not None else "α price ?"
72
+ lines.append(f"{price_txt} {pool} {len(miners)} miners paid")
73
+ if body.get("caveat"):
74
+ lines.append(f"Note: {body['caveat']}.")
75
+ lines.append("")
76
+
77
+ if not miners:
78
+ burn = body.get("burn_pct")
79
+ if burn is not None and burn >= 1.0:
80
+ lines.append("No miners paid this epoch: the whole pool burns.")
81
+ else:
82
+ lines.append("No miners paid this epoch.")
83
+ return lines
84
+
85
+ lines.append(
86
+ f" {'UID':<4} {'alpha':>9} {'USD':>9} {'share':>5} "
87
+ "breakdown (chain/class → α)"
88
+ )
89
+ for m in miners[:top]:
90
+ lines.append(
91
+ f" {m.get('uid', '?'):<4} {float(m.get('alpha') or 0):>9.1f} "
92
+ f"{_fmt_usd(m.get('usd')):>9} {float(m.get('share') or 0) * 100:>4.1f}% "
93
+ f"{_breakdown_summary(m.get('breakdown') or [])}"
94
+ )
95
+ rest = miners[top:]
96
+ if rest:
97
+ rest_alpha = sum(float(m.get("alpha") or 0) for m in rest)
98
+ lines.append(f" … {len(rest)} more miners, {rest_alpha:.1f} α between them")
99
+ lines.append("")
100
+ lines.append(
101
+ "USD is an estimate at the α price the finalizer recorded; the chain pays in α."
102
+ )
103
+ return lines
104
+
105
+
106
+ def render_miner(body: dict, miner: dict) -> list[str]:
107
+ """One miner with every (chain, class) line."""
108
+ header = render_payout(body)
109
+ lines = header[: header.index("")]
110
+ lines.append("")
111
+ lines.append(
112
+ f"UID {miner.get('uid')} {miner.get('hotkey')}\n"
113
+ f" {float(miner.get('alpha') or 0):.1f} α "
114
+ f"(${_fmt_usd(miner.get('usd'))}, "
115
+ f"{float(miner.get('share') or 0) * 100:.1f}% of the pool, "
116
+ f"{int(miner.get('cu_total') or 0):,} CU)"
117
+ )
118
+ lines.append("")
119
+ lines.append(f" {'chain/class':<18} {'CU':>15} {'alpha':>9} {'USD':>9}")
120
+ for e in miner.get("breakdown") or []:
121
+ node = f"{e.get('chain')}/{e.get('node_class')}"
122
+ lines.append(
123
+ f" {node:<18} {int(e.get('cu') or 0):>15,} "
124
+ f"{float(e.get('alpha') or 0):>9.1f} {_fmt_usd(e.get('usd')):>9}"
125
+ )
126
+ lines.append("")
127
+ lines.append(
128
+ "USD is an estimate at the α price the finalizer recorded; the chain pays in α."
129
+ )
130
+ return lines
131
+
132
+
133
+ @app.command("payout")
134
+ def payout(
135
+ epoch: Optional[int] = typer.Option(
136
+ None, "--epoch", "-e", help="Epoch number (default: the latest finalized)"
137
+ ),
138
+ uid: Optional[int] = typer.Option(
139
+ None, "--uid", "-u", help="Show one miner with its full breakdown"
140
+ ),
141
+ as_json: bool = typer.Option(False, "--json", help="Print the raw response"),
142
+ ) -> None:
143
+ """Show what every miner will be paid for a finalized epoch.
144
+
145
+ Per UID: alpha, USD (an estimate at the price recorded when the epoch
146
+ was finalized), share of the miner pool, and the alpha split by chain
147
+ and node class. Available the moment an epoch finalizes; the chain pays
148
+ it out over the following epoch.
149
+ """
150
+ path = "/epochs/latest/payout" if epoch is None else f"/epochs/{epoch}/payout"
151
+ with RegistryClient(load_config()) as client:
152
+ response = client.get(path)
153
+
154
+ if response.status_code == 404:
155
+ which = f"Epoch {epoch}" if epoch is not None else "No epoch"
156
+ console.print(f"[yellow]{which} is not finalized yet, or is unknown.[/yellow]")
157
+ raise typer.Exit(1)
158
+ if response.status_code == 503:
159
+ console.print(
160
+ "[yellow]Epoch payouts are not available on this registry.[/yellow]"
161
+ )
162
+ raise typer.Exit(1)
163
+ if response.status_code != 200:
164
+ detail = ""
165
+ try:
166
+ detail = str(response.json().get("detail", ""))
167
+ except Exception:
168
+ detail = response.text
169
+ console.print(f"[red]Error:[/red] {response.status_code} {detail}")
170
+ raise typer.Exit(1)
171
+
172
+ body = response.json()
173
+
174
+ if uid is not None:
175
+ matches = [m for m in body.get("miners") or [] if m.get("uid") == uid]
176
+ if not matches:
177
+ console.print(
178
+ f"[yellow]UID {uid} is not paid for epoch "
179
+ f"{body.get('epoch_number')}.[/yellow]"
180
+ )
181
+ raise typer.Exit(1)
182
+ if as_json:
183
+ typer.echo(json.dumps(matches[0], indent=2))
184
+ return
185
+ typer.echo("\n".join(render_miner(body, matches[0])))
186
+ return
187
+
188
+ if as_json:
189
+ typer.echo(json.dumps(body, indent=2))
190
+ return
191
+ typer.echo("\n".join(render_payout(body)))
@@ -482,6 +482,36 @@ def use(
482
482
  console.print(f"Active miner node: [bold]{alias}[/bold]")
483
483
 
484
484
 
485
+ def _print_ban(payload: dict) -> None:
486
+ """One line at the top of ``ls`` / ``show`` when the caller is banned.
487
+
488
+ THE DELIBERATE EXCEPTION to the no-"banned"-language rule (#1175): that
489
+ rule guards the ELIGIBILITY column, which is informational, from reading
490
+ as enforcement. A ban IS enforcement — every node under the coldkey is
491
+ out of routing and payment — and until this line the miner was told
492
+ nothing and learned it from their traffic vanishing. Ben ruled
493
+ 2026-09-12: say so, with the reason.
494
+
495
+ Rendered through ``Text`` (no Rich markup parsing): the reason is prose
496
+ the registry wrote for the miner and is printed as written.
497
+ """
498
+ ban = payload.get("ban")
499
+ if not ban:
500
+ return
501
+ reason = str(ban.get("public_reason") or "").strip()
502
+ if not reason.endswith((".", "!", "?")):
503
+ reason += "."
504
+ console.print(
505
+ Text(f"BANNED since {ban.get('since')}: ", style="bold red"),
506
+ Text(f"{reason} ", style="red"),
507
+ Text(
508
+ "All nodes under this coldkey are excluded from routing and payment.",
509
+ style="red",
510
+ ),
511
+ sep="",
512
+ )
513
+
514
+
485
515
  @app.command("ls")
486
516
  def ls(
487
517
  chain: Optional[str] = typer.Option(
@@ -504,7 +534,9 @@ def ls(
504
534
  console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
505
535
  raise typer.Exit(1)
506
536
 
507
- nodes = response.json().get("nodes", [])
537
+ payload = response.json()
538
+ _print_ban(payload)
539
+ nodes = payload.get("nodes", [])
508
540
  if chain is not None:
509
541
  nodes = [n for n in nodes if n.get("chain") == chain]
510
542
  if not nodes:
@@ -1105,6 +1137,7 @@ def show(
1105
1137
  raise typer.Exit(1)
1106
1138
 
1107
1139
  node = response.json()
1140
+ _print_ban(node)
1108
1141
  console.print(f"[bold]ID:[/bold] {node['id']}")
1109
1142
  console.print(f"[bold]Alias:[/bold] {node.get('alias') or '-'}")
1110
1143
  console.print(f"[bold]Chain:[/bold] {node['chain']}")
@@ -7,6 +7,7 @@ import typer
7
7
  from rich.console import Console
8
8
 
9
9
  from cli import settings
10
+ from cli.commands.epoch import app as epoch_app
10
11
  from cli.commands.leaderboard import app as leaderboard_app
11
12
  from cli.commands.miner import app as miner_app
12
13
  from cli.commands.validator import app as validator_app
@@ -31,6 +32,7 @@ def _global_options(
31
32
 
32
33
  app.add_typer(miner_app, name="miner", help="Miner node management")
33
34
  app.add_typer(leaderboard_app, name="leaderboard", help="Miner leaderboard")
35
+ app.add_typer(epoch_app, name="epoch", help="Epoch payouts")
34
36
  app.add_typer(validator_app, name="validator", help="Validator authentication")
35
37
 
36
38
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blockmachine"
7
- version = "0.20.0"
7
+ version = "0.22.0"
8
8
  description = "Blockmachine CLI - Miner and validator operator interface"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -0,0 +1,206 @@
1
+ """``bm epoch payout``: the table every miner reads to see what they will be
2
+ paid for the epoch that just finalized.
3
+
4
+ Rendering only — the numbers are the registry's. What is pinned: the
5
+ layout Ben signed off (UID, alpha, USD, share, breakdown with "+N more"),
6
+ zero rows never appear, the fold line for the long tail, ``--uid`` shows
7
+ every chain/class line, ``--json`` is the body untouched, and each failure
8
+ status gets a sentence rather than a traceback.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ from typer.testing import CliRunner
16
+
17
+ from cli.commands import epoch as epoch_mod
18
+ from cli.main import app
19
+
20
+ runner = CliRunner()
21
+
22
+ HK = "5F" + "a" * 46
23
+
24
+
25
+ def _miner(uid, alpha, share, breakdown, usd=None, hotkey=HK, cu=1000):
26
+ return {
27
+ "uid": uid,
28
+ "hotkey": hotkey,
29
+ "alpha": alpha,
30
+ "usd": alpha * 2.15 if usd is None else usd,
31
+ "share": share,
32
+ "cu_total": cu,
33
+ "breakdown": [
34
+ {"chain": c, "node_class": k, "cu": 10, "alpha": a, "usd": a * 2.15}
35
+ for c, k, a in breakdown
36
+ ],
37
+ }
38
+
39
+
40
+ BODY = {
41
+ "epoch_number": 9075361,
42
+ "finalized_at": "2026-09-16T20:21:59.117368+00:00",
43
+ "pays_at_estimate": "2026-09-17T20:21:59.117368+00:00",
44
+ "alpha_price_usd": 2.15009611114,
45
+ "pool_alpha": 2952.0,
46
+ "pool_usd": 6347.08,
47
+ "burn_pct": 0.0,
48
+ "miner_count": 3,
49
+ "miners": [
50
+ _miner(
51
+ 129,
52
+ 776.3,
53
+ 0.263,
54
+ [
55
+ ("tao", "arch", 197.3),
56
+ ("polygon", "arch", 165.7),
57
+ ("base", "arch", 146.0),
58
+ ("arbitrum", "arch", 95.6),
59
+ ("eth", "arch", 43.1),
60
+ ("robinhood", "arch", 34.8),
61
+ ("scroll", "arch", 20.0),
62
+ ("mantle", "full", 1.0),
63
+ ],
64
+ ),
65
+ _miner(178, 708.3, 0.240, [("polygon", "arch", 700.0), ("tao", "full", 8.3)]),
66
+ _miner(237, 327.0, 0.111, [("bsc", "arch", 327.0)]),
67
+ ],
68
+ }
69
+
70
+
71
+ class _Resp:
72
+ def __init__(self, payload, status_code=200, text=""):
73
+ self._payload = payload
74
+ self.status_code = status_code
75
+ self.text = text
76
+
77
+ def json(self):
78
+ return self._payload
79
+
80
+
81
+ class _Client:
82
+ calls: list[str] = []
83
+
84
+ def __init__(self, *args, **kwargs):
85
+ pass
86
+
87
+ def __enter__(self):
88
+ return self
89
+
90
+ def __exit__(self, *args):
91
+ return None
92
+
93
+ def get(self, path, params=None):
94
+ _Client.calls.append(path)
95
+ return _Client.response
96
+
97
+
98
+ def _run(monkeypatch, response, *args):
99
+ _Client.response = response
100
+ _Client.calls = []
101
+ monkeypatch.setattr(epoch_mod, "RegistryClient", _Client)
102
+ monkeypatch.setattr(epoch_mod, "load_config", lambda: None)
103
+ return runner.invoke(app, ["epoch", "payout", *args])
104
+
105
+
106
+ def test_table_matches_the_signed_off_layout(monkeypatch):
107
+ result = _run(monkeypatch, _Resp(BODY))
108
+ assert result.exit_code == 0, result.output
109
+ assert _Client.calls == ["/epochs/latest/payout"]
110
+ out = result.output
111
+ assert out.startswith(
112
+ "Epoch 9075361 — finalized 16 Sep 20:22 UTC, paid out by the boundary "
113
+ "~17 Sep 20:22 UTC\n"
114
+ )
115
+ assert "α price $2.15 miner pool 2,952 α ($6,347) 3 miners paid" in out
116
+ assert " UID alpha USD share breakdown (chain/class → α)" in out
117
+ assert (
118
+ " 129 776.3 1,669 26.3% tao/arch 197.3, polygon/arch 165.7, "
119
+ "base/arch 146.0, arbitrum/arch 95.6, eth/arch 43.1, robinhood/arch 34.8, "
120
+ "+2 more"
121
+ ) in out
122
+ assert (
123
+ " 178 708.3 1,523 24.0% polygon/arch 700.0, tao/full 8.3\n" in out
124
+ )
125
+ assert "more miners" not in out # three rows fit; nothing folded
126
+ assert "USD is an estimate" in out
127
+
128
+
129
+ def test_long_tail_is_folded_into_one_line():
130
+ miners = [_miner(i, 100.0 - i, 0.01, [("tao", "arch", 1.0)]) for i in range(15)]
131
+ lines = epoch_mod.render_payout({**BODY, "miners": miners})
132
+ assert sum(1 for line in lines if line.startswith(" 1")) >= 1
133
+ fold = [line for line in lines if "more miners" in line]
134
+ assert fold == [" … 3 more miners, 261.0 α between them"]
135
+ assert sum(1 for line in lines if line.startswith(" ") and "%" in line) == 12
136
+
137
+
138
+ def test_epoch_option_targets_that_epoch(monkeypatch):
139
+ result = _run(monkeypatch, _Resp(BODY), "--epoch", "9068161")
140
+ assert result.exit_code == 0, result.output
141
+ assert _Client.calls == ["/epochs/9068161/payout"]
142
+
143
+
144
+ def test_uid_shows_every_breakdown_line(monkeypatch):
145
+ result = _run(monkeypatch, _Resp(BODY), "--uid", "129")
146
+ assert result.exit_code == 0, result.output
147
+ out = result.output
148
+ assert f"UID 129 {HK}" in out
149
+ assert "776.3 α ($1,669, 26.3% of the pool, 1,000 CU)" in out
150
+ for chain in ("tao", "polygon", "base", "arbitrum", "eth", "robinhood", "scroll"):
151
+ assert f"{chain}/arch" in out
152
+ assert "mantle/full" in out
153
+ assert "+" not in out.split("chain/class")[1] # nothing elided
154
+
155
+
156
+ def test_uid_not_paid_is_a_sentence(monkeypatch):
157
+ result = _run(monkeypatch, _Resp(BODY), "--uid", "5")
158
+ assert result.exit_code == 1
159
+ assert "UID 5 is not paid for epoch 9075361" in result.output
160
+
161
+
162
+ def test_json_dumps_the_body_untouched(monkeypatch):
163
+ result = _run(monkeypatch, _Resp(BODY), "--json")
164
+ assert result.exit_code == 0, result.output
165
+ assert json.loads(result.output) == BODY
166
+
167
+ result = _run(monkeypatch, _Resp(BODY), "--json", "--uid", "237")
168
+ assert json.loads(result.output) == BODY["miners"][2]
169
+
170
+
171
+ def test_burn_epoch_says_so(monkeypatch):
172
+ body = {**BODY, "miners": [], "miner_count": 0, "burn_pct": 1.0}
173
+ result = _run(monkeypatch, _Resp(body))
174
+ assert result.exit_code == 0, result.output
175
+ assert "0 miners paid" in result.output
176
+ assert "the whole pool burns" in result.output
177
+
178
+
179
+ def test_failure_statuses_get_a_sentence(monkeypatch):
180
+ result = _run(monkeypatch, _Resp({"detail": "not found"}, 404), "--epoch", "1")
181
+ assert result.exit_code == 1
182
+ assert "Epoch 1 is not finalized yet, or is unknown." in result.output
183
+
184
+ result = _run(monkeypatch, _Resp({"detail": "x"}, 503))
185
+ assert result.exit_code == 1
186
+ assert "not available on this registry" in result.output
187
+
188
+ result = _run(monkeypatch, _Resp({"detail": "epoch 5 has no price snapshot"}, 422))
189
+ assert result.exit_code == 1
190
+ assert "422 epoch 5 has no price snapshot" in result.output
191
+
192
+
193
+ def test_zero_alpha_rows_never_render():
194
+ body = {**BODY, "miners": [*BODY["miners"], _miner(1, 0.0, 0.0, [])]}
195
+ lines = epoch_mod.render_payout(body)
196
+ assert not any(line.startswith(" 1 ") for line in lines)
197
+ assert "3 miners paid" in lines[1]
198
+
199
+
200
+ def test_caveat_is_printed_when_the_registry_sends_one():
201
+ body = {**BODY, "caveat": "computed late"}
202
+ lines = epoch_mod.render_payout(body)
203
+ assert "Note: computed late." in lines
204
+ one = epoch_mod.render_miner(body, BODY["miners"][0])
205
+ assert "Note: computed late." in one
206
+ assert "miners paid" not in epoch_mod.render_miner(BODY, BODY["miners"][0])[-1]
@@ -0,0 +1,176 @@
1
+ """The ban line on ``bm miner ls`` / ``bm miner show`` (#1175).
2
+
3
+ A ban removes every node under the coldkey from routing and payment while
4
+ the nodes stay ``active``; until this line the CLI showed a banned miner
5
+ nothing. Ben ruled 2026-09-12: one line at the top, with the public reason.
6
+ This is the deliberate exception to the eligibility column's
7
+ no-"banned"-language rule, which still holds for that column
8
+ (``test_miner_eligibility.py``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from types import SimpleNamespace
14
+
15
+ import pytest
16
+ from rich.console import Console
17
+
18
+ from cli.commands import miner as miner_mod
19
+
20
+ NODE_ID = "11111111-2222-3333-4444-555555555555"
21
+ PUBLIC_REASON = "50 registered nodes were served from one machine."
22
+ BAN = {"since": "2026-08-16", "public_reason": PUBLIC_REASON}
23
+ EXPECTED_LINE = (
24
+ f"BANNED since 2026-08-16: {PUBLIC_REASON} "
25
+ "All nodes under this coldkey are excluded from routing and payment."
26
+ )
27
+
28
+
29
+ class _Resp:
30
+ def __init__(self, payload: dict, status_code: int = 200) -> None:
31
+ self._payload = payload
32
+ self.status_code = status_code
33
+ self.text = ""
34
+
35
+ def json(self) -> dict:
36
+ return self._payload
37
+
38
+
39
+ class _FakeClient:
40
+ """Routes the GETs ``ls``/``show`` make. ``ban`` rides on the list
41
+ payload for ``ls`` and on each node for ``show``, as the registry sends."""
42
+
43
+ def __init__(self, nodes: list[dict], ban: dict | None) -> None:
44
+ self._nodes = nodes
45
+ self._ban = ban
46
+
47
+ def __enter__(self) -> "_FakeClient":
48
+ return self
49
+
50
+ def __exit__(self, *args) -> None:
51
+ return None
52
+
53
+ def get(self, path: str, params=None) -> _Resp:
54
+ if path == "/nodes":
55
+ return _Resp({"nodes": self._nodes, "ban": self._ban})
56
+ if path.endswith("/metrics"):
57
+ return _Resp({"available": False})
58
+ node_id = path.rsplit("/", 1)[-1]
59
+ for node in self._nodes:
60
+ if node["id"] == node_id:
61
+ return _Resp({**node, "ban": self._ban})
62
+ return _Resp({}, status_code=404)
63
+
64
+
65
+ def _node(**kw) -> dict:
66
+ node = {
67
+ "id": NODE_ID,
68
+ "alias": "node-a",
69
+ "chain": "eth",
70
+ "endpoint": "wss://a.example.com/ws",
71
+ "capabilities": {"is_archive": True, "serves_proofs": False},
72
+ "status": "active",
73
+ "last_seen_at": "2026-08-23T15:00:00+00:00",
74
+ "created_at": "2026-08-01T00:00:00+00:00",
75
+ }
76
+ node.update(kw)
77
+ return node
78
+
79
+
80
+ def _run(monkeypatch, command: str, ban: dict | None, nodes=None) -> str:
81
+ nodes = nodes if nodes is not None else [_node()]
82
+ render_console = Console(record=True, width=300)
83
+ monkeypatch.setattr(miner_mod, "console", render_console)
84
+ monkeypatch.setattr(
85
+ miner_mod, "load_config", lambda: SimpleNamespace(active_miner_node=None)
86
+ )
87
+ monkeypatch.setattr(
88
+ miner_mod, "RegistryClient", lambda config: _FakeClient(nodes, ban)
89
+ )
90
+ if command == "ls":
91
+ miner_mod.ls(chain=None)
92
+ else:
93
+ miner_mod.show(alias=nodes[0]["alias"])
94
+ return render_console.export_text()
95
+
96
+
97
+ @pytest.mark.parametrize("command", ["show", "ls"])
98
+ def test_a_banned_miner_sees_the_line_first(monkeypatch, command):
99
+ out = _run(monkeypatch, command, BAN)
100
+ lines = [line for line in out.splitlines() if line.strip()]
101
+ assert lines[0] == EXPECTED_LINE
102
+ assert out.count("BANNED") == 1
103
+
104
+
105
+ def test_the_line_comes_before_an_empty_node_list(monkeypatch):
106
+ """A banned miner with no nodes left still sees why."""
107
+ out = _run(monkeypatch, "ls", BAN, nodes=[])
108
+ lines = [line for line in out.splitlines() if line.strip()]
109
+ assert lines[0] == EXPECTED_LINE
110
+ assert "No nodes registered" in out
111
+
112
+
113
+ @pytest.mark.parametrize("command", ["show", "ls"])
114
+ def test_an_unbanned_miner_sees_no_line(monkeypatch, command):
115
+ out = _run(monkeypatch, command, None)
116
+ assert "BANNED" not in out
117
+ assert "excluded from routing" not in out
118
+
119
+
120
+ @pytest.mark.parametrize("command", ["show", "ls"])
121
+ def test_an_older_registry_without_the_field_prints_nothing(monkeypatch, command):
122
+ """A registry that predates the field sends no ``ban`` key at all."""
123
+ monkeypatch.setattr(
124
+ _FakeClient, "get", lambda self, path, params=None: _OldRegistry.get(self, path)
125
+ )
126
+ out = _run(monkeypatch, command, None)
127
+ assert "BANNED" not in out
128
+
129
+
130
+ class _OldRegistry:
131
+ @staticmethod
132
+ def get(client: _FakeClient, path: str) -> _Resp:
133
+ if path == "/nodes":
134
+ return _Resp({"nodes": client._nodes})
135
+ if path.endswith("/metrics"):
136
+ return _Resp({"available": False})
137
+ return _Resp(client._nodes[0])
138
+
139
+
140
+ def test_the_reason_is_printed_as_written_not_as_markup(monkeypatch):
141
+ """The sentence is the registry's prose; brackets in it must not be
142
+ eaten by Rich markup, and one full stop ends it, never two."""
143
+ reason = 'Answered every request with a fabricated result ("0x0") [sic].'
144
+ out = _run(monkeypatch, "show", {"since": "2026-08-14", "public_reason": reason})
145
+ assert (
146
+ "BANNED since 2026-08-14: Answered every request with a fabricated result "
147
+ '("0x0") [sic]. All nodes under this coldkey' in out
148
+ )
149
+ assert ".." not in out
150
+
151
+
152
+ def test_ls_and_show_are_still_registered_commands():
153
+ """The helper sits between two commands; a decorator that slides onto it
154
+ unregisters ``ls`` (found by review — the function-level tests above
155
+ cannot see it)."""
156
+ registered = {
157
+ c.name or c.callback.__name__: c.callback
158
+ for c in miner_mod.app.registered_commands
159
+ }
160
+ assert registered["ls"] is miner_mod.ls
161
+ assert registered["show"] is miner_mod.show
162
+ assert miner_mod._print_ban not in registered.values()
163
+
164
+
165
+ def test_prose_that_already_ends_in_a_terminal_mark_is_left_alone(monkeypatch):
166
+ for reason, expect in (
167
+ ("Requests were fabricated!", "Requests were fabricated! All nodes"),
168
+ ("Were requests fabricated?", "Were requests fabricated? All nodes"),
169
+ ("Requests vanished...", "Requests vanished... All nodes"),
170
+ ("Requests vanished", "Requests vanished. All nodes"),
171
+ ):
172
+ out = _run(
173
+ monkeypatch, "show", {"since": "2026-08-14", "public_reason": reason}
174
+ )
175
+ assert expect in out, out
176
+ assert "!." not in out and "?." not in out and "..." + "." not in out
@@ -233,15 +233,24 @@ class TestDetailView:
233
233
 
234
234
  def test_no_banned_or_blocked_language_anywhere(self, monkeypatch):
235
235
  """Shadow mode: eligibility is informational. The CLI must not tell a
236
- miner their node is banned, blocked, or excluded from anything."""
236
+ miner their node is banned, blocked, or excluded from anything.
237
+
238
+ The one exception is a REAL ban (#1175, Ben's ruling): a banned
239
+ caller's response carries a ``ban`` object and the CLI says so on its
240
+ own line — tested in ``test_miner_ban_line.py``. Nothing here is
241
+ banned, so the whole of ``show`` and ``ls`` must still be clean."""
237
242
  for state in ("eligible", "ineligible", "pending_first_test", "unaudited"):
238
- out = _run(monkeypatch, [_node(eligibility=_elig(state=state))], "show")
239
- lowered = out.lower()
240
- # Not "block": the auditor's own verbatim detail legitimately
241
- # says "state at block #123 is pruned".
242
- for word in ("banned", "blocked", "excluded", "disqualif", "suspend"):
243
- assert word not in lowered, f"{word!r} in {state} output"
244
- assert "informational" in lowered
243
+ for command in ("show", "ls"):
244
+ out = _run(
245
+ monkeypatch, [_node(eligibility=_elig(state=state))], command
246
+ )
247
+ lowered = out.lower()
248
+ # Not "block": the auditor's own verbatim detail legitimately
249
+ # says "state at block #123 is pruned".
250
+ for word in ("banned", "blocked", "excluded", "disqualif", "suspend"):
251
+ assert word not in lowered, f"{word!r} in {state} {command} output"
252
+ if command == "show":
253
+ assert "informational" in lowered
245
254
 
246
255
  def test_an_unknown_state_is_shown_verbatim(self, monkeypatch):
247
256
  """A newer registry may add a state. Show what it said rather than
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes