blockmachine 0.21.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.21.0 → blockmachine-0.22.0}/PKG-INFO +1 -1
  2. {blockmachine-0.21.0 → blockmachine-0.22.0}/blockmachine.egg-info/PKG-INFO +1 -1
  3. {blockmachine-0.21.0 → blockmachine-0.22.0}/blockmachine.egg-info/SOURCES.txt +2 -0
  4. blockmachine-0.22.0/commands/epoch.py +191 -0
  5. {blockmachine-0.21.0 → blockmachine-0.22.0}/main.py +2 -0
  6. {blockmachine-0.21.0 → blockmachine-0.22.0}/pyproject.toml +1 -1
  7. blockmachine-0.22.0/tests/test_epoch_payout_cli.py +206 -0
  8. {blockmachine-0.21.0 → blockmachine-0.22.0}/__init__.py +0 -0
  9. {blockmachine-0.21.0 → blockmachine-0.22.0}/auth_config.py +0 -0
  10. {blockmachine-0.21.0 → blockmachine-0.22.0}/blockmachine.egg-info/dependency_links.txt +0 -0
  11. {blockmachine-0.21.0 → blockmachine-0.22.0}/blockmachine.egg-info/entry_points.txt +0 -0
  12. {blockmachine-0.21.0 → blockmachine-0.22.0}/blockmachine.egg-info/requires.txt +0 -0
  13. {blockmachine-0.21.0 → blockmachine-0.22.0}/blockmachine.egg-info/top_level.txt +0 -0
  14. {blockmachine-0.21.0 → blockmachine-0.22.0}/client.py +0 -0
  15. {blockmachine-0.21.0 → blockmachine-0.22.0}/commands/__init__.py +0 -0
  16. {blockmachine-0.21.0 → blockmachine-0.22.0}/commands/auth.py +0 -0
  17. {blockmachine-0.21.0 → blockmachine-0.22.0}/commands/leaderboard.py +0 -0
  18. {blockmachine-0.21.0 → blockmachine-0.22.0}/commands/miner.py +0 -0
  19. {blockmachine-0.21.0 → blockmachine-0.22.0}/commands/validator.py +0 -0
  20. {blockmachine-0.21.0 → blockmachine-0.22.0}/config.py +0 -0
  21. {blockmachine-0.21.0 → blockmachine-0.22.0}/endpoints.py +0 -0
  22. {blockmachine-0.21.0 → blockmachine-0.22.0}/settings.py +0 -0
  23. {blockmachine-0.21.0 → blockmachine-0.22.0}/setup.cfg +0 -0
  24. {blockmachine-0.21.0 → blockmachine-0.22.0}/testing.py +0 -0
  25. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_cli_config.py +0 -0
  26. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_cli_endpoints_and_testing.py +0 -0
  27. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_leaderboard_cli.py +0 -0
  28. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_miner_add_class.py +0 -0
  29. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_miner_ban_line.py +0 -0
  30. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_miner_capacity.py +0 -0
  31. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_miner_compat_reason.py +0 -0
  32. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_miner_eligibility.py +0 -0
  33. {blockmachine-0.21.0 → blockmachine-0.22.0}/tests/test_miner_eth_tier.py +0 -0
  34. {blockmachine-0.21.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.21.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.21.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,11 +25,13 @@ 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
35
37
  tests/test_miner_ban_line.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)))
@@ -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.21.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]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes