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.
- {blockmachine-0.20.0 → blockmachine-0.22.0}/PKG-INFO +1 -1
- {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/PKG-INFO +1 -1
- {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/SOURCES.txt +3 -0
- blockmachine-0.22.0/commands/epoch.py +191 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/miner.py +34 -1
- {blockmachine-0.20.0 → blockmachine-0.22.0}/main.py +2 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/pyproject.toml +1 -1
- blockmachine-0.22.0/tests/test_epoch_payout_cli.py +206 -0
- blockmachine-0.22.0/tests/test_miner_ban_line.py +176 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_eligibility.py +17 -8
- {blockmachine-0.20.0 → blockmachine-0.22.0}/__init__.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/auth_config.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/dependency_links.txt +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/entry_points.txt +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/requires.txt +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/blockmachine.egg-info/top_level.txt +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/client.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/__init__.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/auth.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/leaderboard.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/commands/validator.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/config.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/endpoints.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/settings.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/setup.cfg +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/testing.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_cli_config.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_cli_endpoints_and_testing.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_leaderboard_cli.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_add_class.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_capacity.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_compat_reason.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/tests/test_miner_eth_tier.py +0 -0
- {blockmachine-0.20.0 → blockmachine-0.22.0}/utils.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|