blockmachine 0.22.0__tar.gz → 0.22.1__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.22.0 → blockmachine-0.22.1}/PKG-INFO +1 -1
- {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/PKG-INFO +1 -1
- {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/SOURCES.txt +2 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/epoch.py +48 -4
- {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/leaderboard.py +1 -1
- {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/miner.py +158 -44
- {blockmachine-0.22.0 → blockmachine-0.22.1}/pyproject.toml +1 -1
- {blockmachine-0.22.0 → blockmachine-0.22.1}/settings.py +1 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_epoch_payout_cli.py +96 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_add_class.py +0 -1
- blockmachine-0.22.1/tests/test_miner_capacity_helper.py +243 -0
- blockmachine-0.22.1/tests/test_miner_capacity_pack.py +117 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_eligibility.py +24 -0
- blockmachine-0.22.1/tests/test_miner_eth_tier.py +61 -0
- blockmachine-0.22.0/tests/test_miner_eth_tier.py +0 -195
- {blockmachine-0.22.0 → blockmachine-0.22.1}/__init__.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/auth_config.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/dependency_links.txt +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/entry_points.txt +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/requires.txt +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/top_level.txt +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/client.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/__init__.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/auth.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/validator.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/config.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/endpoints.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/main.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/setup.cfg +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/testing.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_cli_config.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_cli_endpoints_and_testing.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_leaderboard_cli.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_ban_line.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_capacity.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_compat_reason.py +0 -0
- {blockmachine-0.22.0 → blockmachine-0.22.1}/utils.py +0 -0
|
@@ -36,6 +36,8 @@ tests/test_leaderboard_cli.py
|
|
|
36
36
|
tests/test_miner_add_class.py
|
|
37
37
|
tests/test_miner_ban_line.py
|
|
38
38
|
tests/test_miner_capacity.py
|
|
39
|
+
tests/test_miner_capacity_helper.py
|
|
40
|
+
tests/test_miner_capacity_pack.py
|
|
39
41
|
tests/test_miner_compat_reason.py
|
|
40
42
|
tests/test_miner_eligibility.py
|
|
41
43
|
tests/test_miner_eth_tier.py
|
|
@@ -103,27 +103,67 @@ def render_payout(body: dict, top: int = TOP_MINERS) -> list[str]:
|
|
|
103
103
|
return lines
|
|
104
104
|
|
|
105
105
|
|
|
106
|
+
def _has_cu_split(miner: dict) -> bool:
|
|
107
|
+
"""Does this body separate served CU from the base-incentive subsidy?
|
|
108
|
+
|
|
109
|
+
Registries built before taostat/blockmachine_playground#1529 send one
|
|
110
|
+
unqualified ``cu`` and nothing else. Printing zero served CU against
|
|
111
|
+
those would be a new lie, so the old single column is kept for them.
|
|
112
|
+
"""
|
|
113
|
+
return "cu_served" in miner and "cu_base_incentive" in miner
|
|
114
|
+
|
|
115
|
+
|
|
106
116
|
def render_miner(body: dict, miner: dict) -> list[str]:
|
|
107
117
|
"""One miner with every (chain, class) line."""
|
|
108
118
|
header = render_payout(body)
|
|
109
119
|
lines = header[: header.index("")]
|
|
120
|
+
split = _has_cu_split(miner)
|
|
121
|
+
cu_total = int(miner.get("cu_total") or 0)
|
|
110
122
|
lines.append("")
|
|
111
123
|
lines.append(
|
|
112
124
|
f"UID {miner.get('uid')} {miner.get('hotkey')}\n"
|
|
113
125
|
f" {float(miner.get('alpha') or 0):.1f} α "
|
|
114
126
|
f"(${_fmt_usd(miner.get('usd'))}, "
|
|
115
|
-
f"{float(miner.get('share') or 0) * 100:.1f}% of the pool
|
|
116
|
-
f"{
|
|
127
|
+
f"{float(miner.get('share') or 0) * 100:.1f}% of the pool"
|
|
128
|
+
+ (")" if split else f", {cu_total:,} CU)")
|
|
117
129
|
)
|
|
130
|
+
if split:
|
|
131
|
+
lines.append(
|
|
132
|
+
f" {cu_total:,} CU: {int(miner.get('cu_served') or 0):,} served "
|
|
133
|
+
f"+ {int(miner.get('cu_base_incentive') or 0):,} base incentive"
|
|
134
|
+
)
|
|
118
135
|
lines.append("")
|
|
119
|
-
|
|
136
|
+
if split:
|
|
137
|
+
lines.append(
|
|
138
|
+
f" {'chain/class':<18} {'served CU':>15} "
|
|
139
|
+
f"{'base incentive CU':>19} {'alpha':>9} {'USD':>9}"
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
lines.append(f" {'chain/class':<18} {'CU':>15} {'alpha':>9} {'USD':>9}")
|
|
120
143
|
for e in miner.get("breakdown") or []:
|
|
121
144
|
node = f"{e.get('chain')}/{e.get('node_class')}"
|
|
145
|
+
cu_col = (
|
|
146
|
+
f"{int(e.get('cu_served') or 0):>15,} "
|
|
147
|
+
f"{int(e.get('cu_base_incentive') or 0):>19,}"
|
|
148
|
+
if split
|
|
149
|
+
else f"{int(e.get('cu') or 0):>15,}"
|
|
150
|
+
)
|
|
122
151
|
lines.append(
|
|
123
|
-
f" {node:<18} {
|
|
152
|
+
f" {node:<18} {cu_col} "
|
|
124
153
|
f"{float(e.get('alpha') or 0):>9.1f} {_fmt_usd(e.get('usd')):>9}"
|
|
125
154
|
)
|
|
126
155
|
lines.append("")
|
|
156
|
+
if split:
|
|
157
|
+
lines.append(
|
|
158
|
+
"Served CU is the customer traffic this miner answered. Base incentive "
|
|
159
|
+
"CU is the"
|
|
160
|
+
)
|
|
161
|
+
lines.append(
|
|
162
|
+
"subnet's own top-up, injected as CU — nobody sent those requests. "
|
|
163
|
+
"Both count"
|
|
164
|
+
)
|
|
165
|
+
lines.append("toward the payout, and together they are the CU total above.")
|
|
166
|
+
lines.append("")
|
|
127
167
|
lines.append(
|
|
128
168
|
"USD is an estimate at the α price the finalizer recorded; the chain pays in α."
|
|
129
169
|
)
|
|
@@ -146,6 +186,10 @@ def payout(
|
|
|
146
186
|
was finalized), share of the miner pool, and the alpha split by chain
|
|
147
187
|
and node class. Available the moment an epoch finalizes; the chain pays
|
|
148
188
|
it out over the following epoch.
|
|
189
|
+
|
|
190
|
+
With --uid, CU is shown as two columns: the customer traffic the miner
|
|
191
|
+
answered, and the base-incentive subsidy injected as synthetic CU. Both
|
|
192
|
+
count toward the payout; only the first is traffic.
|
|
149
193
|
"""
|
|
150
194
|
path = "/epochs/latest/payout" if epoch is None else f"/epochs/{epoch}/payout"
|
|
151
195
|
with RegistryClient(load_config()) as client:
|
|
@@ -560,7 +560,7 @@ def show(
|
|
|
560
560
|
None,
|
|
561
561
|
"--chain",
|
|
562
562
|
"-c",
|
|
563
|
-
help="Filter to a single chain (e.g. eth, tao, bsc, base, polygon, optimism, arbitrum, avalanche, scroll, mantle, robinhood, tron). Default: all chains.",
|
|
563
|
+
help="Filter to a single chain (e.g. eth, tao, bsc, base, polygon, optimism, arbitrum, avalanche, arc, scroll, mantle, robinhood, tron). Default: all chains.",
|
|
564
564
|
),
|
|
565
565
|
verbose: bool = typer.Option(
|
|
566
566
|
False,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""Miner commands: node registration, secrets, and pricing"""
|
|
2
2
|
|
|
3
3
|
import getpass
|
|
4
|
+
import hashlib
|
|
4
5
|
import io
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
7
10
|
import zipfile
|
|
8
11
|
from typing import Callable, Optional
|
|
9
12
|
from urllib.parse import urlparse
|
|
@@ -254,7 +257,7 @@ def add(
|
|
|
254
257
|
if needs_pricing and not price:
|
|
255
258
|
price = typer.prompt("Price (USD/1M CU)")
|
|
256
259
|
|
|
257
|
-
|
|
260
|
+
_create_node(
|
|
258
261
|
client,
|
|
259
262
|
endpoint,
|
|
260
263
|
alias,
|
|
@@ -263,7 +266,6 @@ def add(
|
|
|
263
266
|
cert_fingerprint=fp,
|
|
264
267
|
node_class=node_class,
|
|
265
268
|
)
|
|
266
|
-
_maybe_print_eth_software_notice(client, chain, node_id)
|
|
267
269
|
|
|
268
270
|
if needs_pricing and price:
|
|
269
271
|
_create_default_pricing_rule(client, chain, price)
|
|
@@ -281,44 +283,6 @@ def _default_alias(endpoint: str, chain: str = DEFAULT_CHAIN) -> str:
|
|
|
281
283
|
return f"{chain}-{host.replace('.', '-')}"
|
|
282
284
|
|
|
283
285
|
|
|
284
|
-
def _maybe_print_eth_software_notice(
|
|
285
|
-
client: RegistryClient, chain: str, node_id: str
|
|
286
|
-
) -> None:
|
|
287
|
-
"""Print a network-policy notice when the operator registers an Erigon
|
|
288
|
-
ETH backend.
|
|
289
|
-
|
|
290
|
-
The gateway's network rule routes Erigon to `eth_getProof` only —
|
|
291
|
-
history methods always land on Reth. Surface that explicitly at
|
|
292
|
-
registration so the operator knows what to expect; no prompt, no
|
|
293
|
-
flag, no veto.
|
|
294
|
-
|
|
295
|
-
The follow-up GET is best-effort. A transient error here (timeout,
|
|
296
|
-
connection reset, malformed body) must NOT fail the surrounding
|
|
297
|
-
``bm miner add`` flow — the node is already registered at this
|
|
298
|
-
point, the notice is purely informational, and a re-run would
|
|
299
|
-
surface a confusing "alias already exists" 409 instead of the
|
|
300
|
-
success the operator just got.
|
|
301
|
-
"""
|
|
302
|
-
if chain != "eth":
|
|
303
|
-
return
|
|
304
|
-
try:
|
|
305
|
-
resp = client.get(f"/nodes/{node_id}")
|
|
306
|
-
if resp.status_code != 200:
|
|
307
|
-
return
|
|
308
|
-
sw = (resp.json().get("software_name") or "").lower()
|
|
309
|
-
except Exception:
|
|
310
|
-
# Best-effort: any failure (transport, decode, non-string
|
|
311
|
-
# software_name) is silently swallowed. Registration already
|
|
312
|
-
# succeeded; the operator sees normal "Node registered" output.
|
|
313
|
-
return
|
|
314
|
-
if "erigon" in sw:
|
|
315
|
-
console.print(
|
|
316
|
-
"[yellow]Network policy notice:[/yellow] Erigon backends "
|
|
317
|
-
"currently serve only eth_getProof. Other ETH RPC traffic "
|
|
318
|
-
"will be routed to Reth backends in the network."
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
|
|
322
286
|
def _eth_tier(node: Optional[dict]) -> str:
|
|
323
287
|
"""Derive an EVM display tier for one node row from ``GET /nodes``.
|
|
324
288
|
|
|
@@ -637,6 +601,22 @@ def _as_dict_list(value: object) -> list[dict]:
|
|
|
637
601
|
return [item for item in value if isinstance(item, dict)]
|
|
638
602
|
|
|
639
603
|
|
|
604
|
+
def _held_rounds(elig: dict) -> int:
|
|
605
|
+
"""How many consecutive batteries could not conclude (0 = the state is
|
|
606
|
+
fresh). Defensive: an older registry omits the key."""
|
|
607
|
+
value = elig.get("could_not_test_streak")
|
|
608
|
+
return value if isinstance(value, int) and value > 0 else 0
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _unmeasured_checks(elig: dict) -> str:
|
|
612
|
+
"""The checks the last battery marked could-not-test, comma-joined."""
|
|
613
|
+
return ", ".join(
|
|
614
|
+
str(entry.get("check") or "-")
|
|
615
|
+
for entry in _as_dict_list(elig.get("last_battery"))
|
|
616
|
+
if entry.get("status") == "could_not_test"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
640
620
|
def _eligibility_display(node: dict) -> tuple[str, str, str]:
|
|
641
621
|
"""(short label, long label, colour) for a node's eligibility state."""
|
|
642
622
|
elig = _eligibility(node)
|
|
@@ -707,6 +687,21 @@ def _print_eligibility(node: dict) -> None:
|
|
|
707
687
|
table.add_row("Since", format_timestamp(elig.get("since")))
|
|
708
688
|
table.add_row("Last Tested", format_timestamp(elig.get("last_tested_at")))
|
|
709
689
|
table.add_row("Next Re-test", format_timestamp(elig.get("next_retest_at")))
|
|
690
|
+
held = _held_rounds(elig)
|
|
691
|
+
if held:
|
|
692
|
+
# The state shown is the last verdict that concluded, not this
|
|
693
|
+
# battery's: say so, and name what could not be measured, so an
|
|
694
|
+
# operator does not read a held state as a fresh judgement.
|
|
695
|
+
table.add_row(
|
|
696
|
+
"Held",
|
|
697
|
+
Text(
|
|
698
|
+
f"{held} battery round(s) since "
|
|
699
|
+
f"{format_timestamp(elig.get('could_not_test_since'))} could "
|
|
700
|
+
f"not reach a verdict; could not measure: "
|
|
701
|
+
f"{_unmeasured_checks(elig) or '-'}",
|
|
702
|
+
style="dim",
|
|
703
|
+
),
|
|
704
|
+
)
|
|
710
705
|
console.print(table)
|
|
711
706
|
|
|
712
707
|
if elig is None:
|
|
@@ -1951,11 +1946,101 @@ def price_simulate(
|
|
|
1951
1946
|
)
|
|
1952
1947
|
|
|
1953
1948
|
|
|
1949
|
+
@app.command("capacity-helper")
|
|
1950
|
+
def capacity_helper(
|
|
1951
|
+
out: str = typer.Option(
|
|
1952
|
+
"capacity-helper",
|
|
1953
|
+
"--out",
|
|
1954
|
+
"-o",
|
|
1955
|
+
help="Where to write it (default ./capacity-helper)",
|
|
1956
|
+
),
|
|
1957
|
+
arch: str = typer.Option(
|
|
1958
|
+
None,
|
|
1959
|
+
"--arch",
|
|
1960
|
+
help="Architecture, as `uname -m` reports it (default: this machine's)",
|
|
1961
|
+
),
|
|
1962
|
+
) -> None:
|
|
1963
|
+
"""Download the capacity helper, to run next to an archive node.
|
|
1964
|
+
|
|
1965
|
+
OPTIONAL. Without it your node is capacity-tested exactly as it is today.
|
|
1966
|
+
With it, our questions are put to your node from inside your own server and
|
|
1967
|
+
only a short summary of each answer crosses the internet, so the time a large
|
|
1968
|
+
answer takes to reach us is not counted against your node.
|
|
1969
|
+
|
|
1970
|
+
Run it on the same machine as your node, and route /capacity-test on the
|
|
1971
|
+
endpoint you registered to it. See the miner README.
|
|
1972
|
+
"""
|
|
1973
|
+
wanted = arch or platform.machine()
|
|
1974
|
+
config = load_config()
|
|
1975
|
+
with RegistryClient(config) as client:
|
|
1976
|
+
response = client.get("/quality/capacity-helper", params={"arch": wanted})
|
|
1977
|
+
if response.status_code == 404:
|
|
1978
|
+
# ITS WORDS, NOT OURS. Only the registry knows which architectures
|
|
1979
|
+
# this build can serve, and being told "not for your machine" is a
|
|
1980
|
+
# different thing from being told the download is broken.
|
|
1981
|
+
console.print(f"[yellow]No capacity helper for {wanted}.[/yellow]")
|
|
1982
|
+
console.print(_detail(response, "That architecture is not served."))
|
|
1983
|
+
raise typer.Exit(1)
|
|
1984
|
+
if response.status_code != 200:
|
|
1985
|
+
console.print(f"[red]Could not fetch it: {_detail(response)}[/red]")
|
|
1986
|
+
raise typer.Exit(1)
|
|
1987
|
+
blob = response.content
|
|
1988
|
+
|
|
1989
|
+
# WHAT ARRIVED, CHECKED, BEFORE IT REACHES THE DISK. An empty or truncated
|
|
1990
|
+
# file written and made executable is a download that looks like it worked,
|
|
1991
|
+
# and the operator finds out when a test does not improve.
|
|
1992
|
+
if len(blob) < 1_000_000:
|
|
1993
|
+
console.print(
|
|
1994
|
+
f"[red]That download is only {len(blob)} bytes, which is too small to "
|
|
1995
|
+
"be the helper. Please report it rather than running it.[/red]"
|
|
1996
|
+
)
|
|
1997
|
+
raise typer.Exit(1)
|
|
1998
|
+
|
|
1999
|
+
# THE DIGEST, CHECKED RATHER THAN PRINTED. A size check only catches an empty
|
|
2000
|
+
# file; this catches a download that stopped early or was mangled in transit
|
|
2001
|
+
# (bm-pm, review of #1703). Absent means an older registry, which is not a
|
|
2002
|
+
# reason to refuse a binary that is otherwise the right size — so it is said
|
|
2003
|
+
# out loud instead.
|
|
2004
|
+
got = hashlib.sha256(blob).hexdigest()
|
|
2005
|
+
expected = response.headers.get("X-Capacity-Helper-Sha256")
|
|
2006
|
+
if expected and got != expected:
|
|
2007
|
+
console.print(
|
|
2008
|
+
"[red]That download does not match the checksum it was served with, so "
|
|
2009
|
+
"it arrived damaged or altered. Nothing has been written.[/red]"
|
|
2010
|
+
)
|
|
2011
|
+
console.print(f" served: {expected}\n got: {got}")
|
|
2012
|
+
raise typer.Exit(1)
|
|
2013
|
+
|
|
2014
|
+
with open(out, "wb") as fh:
|
|
2015
|
+
fh.write(blob)
|
|
2016
|
+
os.chmod(out, 0o755)
|
|
2017
|
+
|
|
2018
|
+
served = response.headers.get("X-Capacity-Helper-Arch", wanted)
|
|
2019
|
+
console.print(f"[green]Wrote {out}[/green] — {len(blob)} bytes, {served}.")
|
|
2020
|
+
if expected:
|
|
2021
|
+
console.print(f" sha256 {got} (matches what was served)")
|
|
2022
|
+
else:
|
|
2023
|
+
console.print(
|
|
2024
|
+
" [yellow]no checksum was served with it, so it could not be "
|
|
2025
|
+
"verified beyond its size[/yellow]"
|
|
2026
|
+
)
|
|
2027
|
+
console.print(
|
|
2028
|
+
"\n Run it beside your node, with the secret you registered:\n"
|
|
2029
|
+
f" [cyan]CAPACITY_HELPER_SECRET='<your node secret>' ./{out} \\\n"
|
|
2030
|
+
" --node ws://127.0.0.1:8546 --family evm --listen 127.0.0.1:9955[/cyan]\n"
|
|
2031
|
+
"\n On a tao node use --node ws://127.0.0.1:9944 --family substrate.\n"
|
|
2032
|
+
"\n Then route /capacity-test on your registered endpoint to it, and check it:\n"
|
|
2033
|
+
f" [cyan]./{out} check --endpoint wss://<your endpoint> --secret '<your node secret>'[/cyan]"
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
|
|
1954
2037
|
@app.command("capacity-pack")
|
|
1955
2038
|
def capacity_pack(
|
|
1956
2039
|
chain: str = typer.Option(..., "--chain", "-c", help="Which chain's pack"),
|
|
1957
2040
|
node_class: str = typer.Option(
|
|
1958
|
-
"archive",
|
|
2041
|
+
"archive",
|
|
2042
|
+
"--class",
|
|
2043
|
+
help="archive or full (default archive; only archive nodes have a pack)",
|
|
1959
2044
|
),
|
|
1960
2045
|
out: str = typer.Option(
|
|
1961
2046
|
None,
|
|
@@ -1981,12 +2066,29 @@ def capacity_pack(
|
|
|
1981
2066
|
|
|
1982
2067
|
Your number will not match ours exactly. We measure a node through its
|
|
1983
2068
|
registration with it taken out of customer traffic; you measure yours
|
|
1984
|
-
directly, while it serves.
|
|
2069
|
+
directly, while it serves. Both warm every connection with unscored
|
|
2070
|
+
head reads first, time each workload from its first question sent to
|
|
2071
|
+
its last correct answer received, and take the round trip to the node
|
|
2072
|
+
off that once, so distance from us is not what is scored.
|
|
1985
2073
|
"""
|
|
1986
2074
|
chain = _validate_chain(chain) or chain
|
|
1987
2075
|
if node_class not in ("archive", "full"):
|
|
1988
2076
|
console.print(f"[red]--class must be archive or full, not {node_class}[/red]")
|
|
1989
2077
|
raise typer.Exit(1)
|
|
2078
|
+
if node_class == "full":
|
|
2079
|
+
# SAID HERE, WITHOUT ASKING. There is no full-class pack and there
|
|
2080
|
+
# never will be: a full node is asked about the newest blocks, so the
|
|
2081
|
+
# tester keeps no question set for that class at all. Sending the
|
|
2082
|
+
# request and printing "one appears after the next run" told a miner
|
|
2083
|
+
# to wait for a file no run can produce (Ben, 21 Sep).
|
|
2084
|
+
console.print(
|
|
2085
|
+
"[yellow]There is no self-test pack for full nodes.[/yellow] A full "
|
|
2086
|
+
"node's capacity questions are the newest blocks on the chain, "
|
|
2087
|
+
"drawn fresh at the moment it's tested, so there is no fixed set "
|
|
2088
|
+
"to hand out. Packs exist for archive nodes only:\n\n"
|
|
2089
|
+
f" [cyan]bm miner capacity-pack --chain {chain}[/cyan]"
|
|
2090
|
+
)
|
|
2091
|
+
raise typer.Exit(1)
|
|
1990
2092
|
out = out or f"capacity-test-{chain}.zip"
|
|
1991
2093
|
|
|
1992
2094
|
config = load_config()
|
|
@@ -1996,9 +2098,21 @@ def capacity_pack(
|
|
|
1996
2098
|
params={"chain": chain, "is_archive": node_class == "archive"},
|
|
1997
2099
|
)
|
|
1998
2100
|
if response.status_code == 404:
|
|
2101
|
+
# THE REGISTRY'S OWN WORDS, not ours. There are two reasons for
|
|
2102
|
+
# this 404 — nothing stored for the chain yet, or everything
|
|
2103
|
+
# stored is in a question-set format the shipped test binary
|
|
2104
|
+
# cannot read — and only the registry knows which. Printing our
|
|
2105
|
+
# own "nothing stored yet" over the second one tells the operator
|
|
2106
|
+
# to wait for a run that has already happened.
|
|
1999
2107
|
console.print(
|
|
2000
|
-
f"[yellow]No capacity
|
|
2001
|
-
|
|
2108
|
+
f"[yellow]No capacity pack for {chain} ({node_class}).[/yellow]"
|
|
2109
|
+
)
|
|
2110
|
+
console.print(
|
|
2111
|
+
_detail(
|
|
2112
|
+
response,
|
|
2113
|
+
"Nothing is stored for that chain yet. One appears after "
|
|
2114
|
+
"the next run of that chain.",
|
|
2115
|
+
)
|
|
2002
2116
|
)
|
|
2003
2117
|
raise typer.Exit(1)
|
|
2004
2118
|
if response.status_code != 200:
|
|
@@ -153,6 +153,102 @@ def test_uid_shows_every_breakdown_line(monkeypatch):
|
|
|
153
153
|
assert "+" not in out.split("chain/class")[1] # nothing elided
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
# One real row from taostat/blockmachine_playground#1529: uid 178's avalanche
|
|
157
|
+
# archive node on epoch 9111361 was credited 2,964,586,197 CU, of which 321,030
|
|
158
|
+
# was traffic anyone asked for. The rest is the base-incentive subsidy.
|
|
159
|
+
SERVED, SUBSIDY = 321_030, 2_964_265_167
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _split_miner(uid, alpha, share, rows, hotkey=HK):
|
|
163
|
+
"""A miner as a registry that separates served CU from the subsidy
|
|
164
|
+
reports it. ``rows`` is (chain, class, alpha, served cu, subsidy cu)."""
|
|
165
|
+
breakdown = [
|
|
166
|
+
{
|
|
167
|
+
"chain": c,
|
|
168
|
+
"node_class": k,
|
|
169
|
+
"cu": served + subsidy,
|
|
170
|
+
"cu_served": served,
|
|
171
|
+
"cu_base_incentive": subsidy,
|
|
172
|
+
"alpha": a,
|
|
173
|
+
"usd": a * 2.15,
|
|
174
|
+
}
|
|
175
|
+
for c, k, a, served, subsidy in rows
|
|
176
|
+
]
|
|
177
|
+
return {
|
|
178
|
+
"uid": uid,
|
|
179
|
+
"hotkey": hotkey,
|
|
180
|
+
"alpha": alpha,
|
|
181
|
+
"usd": alpha * 2.15,
|
|
182
|
+
"share": share,
|
|
183
|
+
"cu_total": sum(b["cu"] for b in breakdown),
|
|
184
|
+
"cu_served": sum(b["cu_served"] for b in breakdown),
|
|
185
|
+
"cu_base_incentive": sum(b["cu_base_incentive"] for b in breakdown),
|
|
186
|
+
"breakdown": breakdown,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
SPLIT_MINER = _split_miner(
|
|
191
|
+
178,
|
|
192
|
+
708.3,
|
|
193
|
+
0.240,
|
|
194
|
+
[
|
|
195
|
+
("avalanche", "arch", 700.0, SERVED, SUBSIDY),
|
|
196
|
+
("tao", "full", 8.3, 1_000_000, 0),
|
|
197
|
+
],
|
|
198
|
+
)
|
|
199
|
+
SPLIT_BODY = {**BODY, "miners": [SPLIT_MINER], "miner_count": 1}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_uid_separates_served_cu_from_the_base_incentive_subsidy(monkeypatch):
|
|
203
|
+
"""The defect this fixes: one unqualified CU figure reads as traffic
|
|
204
|
+
when almost all of it is the subsidy we injected."""
|
|
205
|
+
result = _run(monkeypatch, _Resp(SPLIT_BODY), "--uid", "178")
|
|
206
|
+
assert result.exit_code == 0, result.output
|
|
207
|
+
out = result.output
|
|
208
|
+
# The total says which half is which rather than printing one number.
|
|
209
|
+
assert "708.3 α ($1,523, 24.0% of the pool)" in out
|
|
210
|
+
assert " 2,965,586,197 CU: 1,321,030 served + 2,964,265,167 base incentive" in out
|
|
211
|
+
assert "2,965,586,197 CU)" not in out # the old unqualified total is gone
|
|
212
|
+
# Two columns per chain/class row, and they sum to what `cu` was.
|
|
213
|
+
assert (
|
|
214
|
+
" chain/class served CU base incentive CU alpha USD"
|
|
215
|
+
in out
|
|
216
|
+
)
|
|
217
|
+
assert (
|
|
218
|
+
" avalanche/arch 321,030 2,964,265,167 700.0 1,505"
|
|
219
|
+
in out
|
|
220
|
+
)
|
|
221
|
+
assert (
|
|
222
|
+
" tao/full 1,000,000 0 8.3 18"
|
|
223
|
+
in out
|
|
224
|
+
)
|
|
225
|
+
assert "Served CU is the customer traffic this miner answered." in out
|
|
226
|
+
assert "nobody sent those requests" in out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_uid_json_carries_both_halves_on_every_row(monkeypatch):
|
|
230
|
+
"""The production check on taostat/blockmachine_playground#1529 reads these
|
|
231
|
+
fields: served + base incentive == cu_total, on the miner and every row."""
|
|
232
|
+
result = _run(monkeypatch, _Resp(SPLIT_BODY), "--json", "--uid", "178")
|
|
233
|
+
assert result.exit_code == 0, result.output
|
|
234
|
+
m = json.loads(result.output)
|
|
235
|
+
assert m["cu_served"] + m["cu_base_incentive"] == m["cu_total"]
|
|
236
|
+
for row in m["breakdown"]:
|
|
237
|
+
assert row["cu_served"] + row["cu_base_incentive"] == row["cu"]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_a_registry_without_the_split_still_gets_the_old_single_column(monkeypatch):
|
|
241
|
+
"""Newer CLI against an older registry: the fields are absent, and
|
|
242
|
+
printing zero served CU there would be a fresh lie. Old layout instead."""
|
|
243
|
+
result = _run(monkeypatch, _Resp(BODY), "--uid", "178")
|
|
244
|
+
assert result.exit_code == 0, result.output
|
|
245
|
+
out = result.output
|
|
246
|
+
assert "708.3 α ($1,523, 24.0% of the pool, 1,000 CU)" in out
|
|
247
|
+
assert " chain/class CU alpha USD" in out
|
|
248
|
+
assert "served CU" not in out
|
|
249
|
+
assert "base incentive" not in out
|
|
250
|
+
|
|
251
|
+
|
|
156
252
|
def test_uid_not_paid_is_a_sentence(monkeypatch):
|
|
157
253
|
result = _run(monkeypatch, _Resp(BODY), "--uid", "5")
|
|
158
254
|
assert result.exit_code == 1
|
|
@@ -132,7 +132,6 @@ class TestThePromptKeepsThePrintedCommandWorking:
|
|
|
132
132
|
patch(
|
|
133
133
|
"cli.commands.miner._needs_chain_default_pricing", return_value=False
|
|
134
134
|
),
|
|
135
|
-
patch("cli.commands.miner._maybe_print_eth_software_notice"),
|
|
136
135
|
patch("cli.commands.miner._create_node", return_value="node-1") as create,
|
|
137
136
|
patch("cli.commands.miner.typer.prompt", return_value="archive") as prompt,
|
|
138
137
|
):
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""What ``bm miner capacity-helper`` does with each answer it can get.
|
|
2
|
+
|
|
3
|
+
The capacity helper is OPTIONAL: a node without one is tested exactly as it was
|
|
4
|
+
before. So every failure here is a disappointment and never a penalty, and the
|
|
5
|
+
command has to say which one it is rather than leave an operator with a file that
|
|
6
|
+
will not run.
|
|
7
|
+
|
|
8
|
+
Two of these are about a download that looks like it worked:
|
|
9
|
+
|
|
10
|
+
* **The wrong architecture.** The registry image is built for one architecture,
|
|
11
|
+
so it can only serve that one. A binary for another does not execute, and an
|
|
12
|
+
operator reads that as our download being broken. The registry says which it
|
|
13
|
+
serves and the CLI prints its words.
|
|
14
|
+
* **A short file.** A truncated or empty body written to disk and chmod +x is a
|
|
15
|
+
helper that fails at run time, and the operator finds out when a test does not
|
|
16
|
+
improve. The size is checked before anything is written.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
|
|
23
|
+
from types import SimpleNamespace
|
|
24
|
+
|
|
25
|
+
import typer
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
|
|
28
|
+
from cli.commands import miner as miner_mod
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Resp:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
payload=None,
|
|
35
|
+
status_code: int = 404,
|
|
36
|
+
content: bytes = b"",
|
|
37
|
+
headers: dict | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._payload = payload
|
|
40
|
+
self.status_code = status_code
|
|
41
|
+
self.content = content
|
|
42
|
+
self.text = ""
|
|
43
|
+
self.headers = headers or {}
|
|
44
|
+
|
|
45
|
+
def json(self):
|
|
46
|
+
if self._payload is None:
|
|
47
|
+
raise ValueError("not json")
|
|
48
|
+
return self._payload
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _RecordingClient:
|
|
52
|
+
"""Records every request, so "what it asked for" is an assertion."""
|
|
53
|
+
|
|
54
|
+
calls: list[tuple]
|
|
55
|
+
|
|
56
|
+
def __init__(self, response: _Resp) -> None:
|
|
57
|
+
self._response = response
|
|
58
|
+
self.calls = []
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "_RecordingClient":
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *args) -> None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def get(self, path: str, params=None) -> _Resp:
|
|
67
|
+
self.calls.append((path, params))
|
|
68
|
+
return self._response
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run(monkeypatch, response: _Resp, out: str, arch=None, machine="x86_64"):
|
|
72
|
+
console = Console(record=True, width=200)
|
|
73
|
+
monkeypatch.setattr(miner_mod, "console", console)
|
|
74
|
+
monkeypatch.setattr(miner_mod, "load_config", lambda: SimpleNamespace())
|
|
75
|
+
monkeypatch.setattr(miner_mod.platform, "machine", lambda: machine)
|
|
76
|
+
client = _RecordingClient(response)
|
|
77
|
+
monkeypatch.setattr(miner_mod, "RegistryClient", lambda config: client)
|
|
78
|
+
raised = None
|
|
79
|
+
try:
|
|
80
|
+
miner_mod.capacity_helper(out=out, arch=arch)
|
|
81
|
+
except typer.Exit as exc:
|
|
82
|
+
raised = exc
|
|
83
|
+
return " ".join(console.export_text().split()), client, raised
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_it_asks_for_this_machines_architecture_unless_told_otherwise(
|
|
87
|
+
monkeypatch, tmp_path
|
|
88
|
+
):
|
|
89
|
+
"""An operator on an arm box must not be sent an x86 binary because the
|
|
90
|
+
default was hardcoded."""
|
|
91
|
+
out = str(tmp_path / "capacity-helper")
|
|
92
|
+
_, client, _ = _run(monkeypatch, _Resp(status_code=503), out=out, machine="aarch64")
|
|
93
|
+
|
|
94
|
+
assert client.calls == [("/quality/capacity-helper", {"arch": "aarch64"})]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_an_explicit_arch_overrides_the_machines_own(monkeypatch, tmp_path):
|
|
98
|
+
out = str(tmp_path / "capacity-helper")
|
|
99
|
+
_, client, _ = _run(
|
|
100
|
+
monkeypatch, _Resp(status_code=503), out=out, arch="x86_64", machine="aarch64"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert client.calls == [("/quality/capacity-helper", {"arch": "x86_64"})]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_an_unserved_architecture_is_the_registrys_words_not_ours(
|
|
107
|
+
monkeypatch, tmp_path
|
|
108
|
+
):
|
|
109
|
+
"""Only the registry knows which architectures its build can serve. Being
|
|
110
|
+
told "not for your machine" is a different fact from "the download broke"."""
|
|
111
|
+
detail = (
|
|
112
|
+
"no capacity helper is served for aarch64; this build serves x86_64. "
|
|
113
|
+
"Your node is tested exactly as it is today without a helper, so "
|
|
114
|
+
"nothing is lost by not running one"
|
|
115
|
+
)
|
|
116
|
+
out = str(tmp_path / "capacity-helper")
|
|
117
|
+
printed, _, raised = _run(
|
|
118
|
+
monkeypatch,
|
|
119
|
+
_Resp({"detail": detail}, status_code=404),
|
|
120
|
+
out=out,
|
|
121
|
+
machine="aarch64",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
assert raised is not None and raised.exit_code == 1
|
|
125
|
+
assert "No capacity helper for aarch64" in printed
|
|
126
|
+
assert "this build serves x86_64" in printed
|
|
127
|
+
# AND IT SAYS THEY LOSE NOTHING. An operator who reads only this line must
|
|
128
|
+
# not think their node has been penalised for not having one.
|
|
129
|
+
assert "tested exactly as it is today" in printed
|
|
130
|
+
# Nothing was written, so there is no unrunnable file left behind.
|
|
131
|
+
assert not (tmp_path / "capacity-helper").exists()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_short_body_is_refused_rather_than_written_and_made_executable(
|
|
135
|
+
monkeypatch, tmp_path
|
|
136
|
+
):
|
|
137
|
+
"""THE ONE THAT BITES SILENTLY. A truncated body written and chmod +x is a
|
|
138
|
+
helper that fails at run time, and the operator finds out weeks later when
|
|
139
|
+
their score has not moved."""
|
|
140
|
+
out = str(tmp_path / "capacity-helper")
|
|
141
|
+
printed, _, raised = _run(
|
|
142
|
+
monkeypatch,
|
|
143
|
+
_Resp(status_code=200, content=b"not a binary"),
|
|
144
|
+
out=out,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
assert raised is not None and raised.exit_code == 1
|
|
148
|
+
assert "too small to be the helper" in printed
|
|
149
|
+
assert "rather than running it" in printed
|
|
150
|
+
assert not (tmp_path / "capacity-helper").exists(), (
|
|
151
|
+
"a body too small to be the helper must not reach the disk at all"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_a_body_that_does_not_match_its_checksum_is_refused(monkeypatch, tmp_path):
|
|
156
|
+
"""A size check only catches an empty file. A download that stopped early, or
|
|
157
|
+
was mangled by a proxy or a stale cache, is the right size and the wrong
|
|
158
|
+
bytes — and an operator finds out when their score does not move."""
|
|
159
|
+
out = str(tmp_path / "capacity-helper")
|
|
160
|
+
blob = b"\x7fELF" + b"\0" * 2_000_000
|
|
161
|
+
printed, _, raised = _run(
|
|
162
|
+
monkeypatch,
|
|
163
|
+
_Resp(
|
|
164
|
+
status_code=200,
|
|
165
|
+
content=blob,
|
|
166
|
+
headers={"X-Capacity-Helper-Sha256": "0" * 64},
|
|
167
|
+
),
|
|
168
|
+
out=out,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
assert raised is not None and raised.exit_code == 1
|
|
172
|
+
assert "does not match the checksum" in printed
|
|
173
|
+
assert "Nothing has been written" in printed
|
|
174
|
+
assert not (tmp_path / "capacity-helper").exists(), (
|
|
175
|
+
"a body that failed its checksum must not reach the disk"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_a_matching_checksum_is_verified_and_printed(monkeypatch, tmp_path):
|
|
180
|
+
out = str(tmp_path / "capacity-helper")
|
|
181
|
+
blob = b"\x7fELF" + b"\0" * 2_000_000
|
|
182
|
+
digest = hashlib.sha256(blob).hexdigest()
|
|
183
|
+
printed, _, raised = _run(
|
|
184
|
+
monkeypatch,
|
|
185
|
+
_Resp(
|
|
186
|
+
status_code=200,
|
|
187
|
+
content=blob,
|
|
188
|
+
headers={"X-Capacity-Helper-Sha256": digest},
|
|
189
|
+
),
|
|
190
|
+
out=out,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert raised is None
|
|
194
|
+
assert (tmp_path / "capacity-helper").read_bytes() == blob
|
|
195
|
+
assert digest in printed
|
|
196
|
+
assert "matches what was served" in printed
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_no_checksum_served_is_said_out_loud_rather_than_treated_as_a_pass(
|
|
200
|
+
monkeypatch, tmp_path
|
|
201
|
+
):
|
|
202
|
+
"""An older registry serves no digest. That is not a reason to refuse a binary
|
|
203
|
+
that is otherwise the right size, but the operator must not be told it was
|
|
204
|
+
verified when it was not."""
|
|
205
|
+
out = str(tmp_path / "capacity-helper")
|
|
206
|
+
blob = b"\x7fELF" + b"\0" * 2_000_000
|
|
207
|
+
printed, _, raised = _run(
|
|
208
|
+
monkeypatch, _Resp(status_code=200, content=blob), out=out
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
assert raised is None
|
|
212
|
+
assert (tmp_path / "capacity-helper").read_bytes() == blob
|
|
213
|
+
assert "no checksum was served" in printed
|
|
214
|
+
assert "matches what was served" not in printed
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_a_real_download_is_written_executable_and_says_how_to_run_it(
|
|
218
|
+
monkeypatch, tmp_path
|
|
219
|
+
):
|
|
220
|
+
out = str(tmp_path / "capacity-helper")
|
|
221
|
+
blob = b"\x7fELF" + b"\0" * 2_000_000
|
|
222
|
+
printed, _, raised = _run(
|
|
223
|
+
monkeypatch,
|
|
224
|
+
_Resp(
|
|
225
|
+
status_code=200,
|
|
226
|
+
content=blob,
|
|
227
|
+
headers={"X-Capacity-Helper-Arch": "x86_64"},
|
|
228
|
+
),
|
|
229
|
+
out=out,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
assert raised is None, "a good download must not exit non-zero"
|
|
233
|
+
written = tmp_path / "capacity-helper"
|
|
234
|
+
assert written.read_bytes() == blob
|
|
235
|
+
# Executable, or the next line an operator types fails with "permission
|
|
236
|
+
# denied" and reads as a broken download.
|
|
237
|
+
assert written.stat().st_mode & 0o111, "the helper has to be executable"
|
|
238
|
+
assert "2000004 bytes, x86_64" in printed
|
|
239
|
+
# The three things they now have to do, in the order they do them.
|
|
240
|
+
assert "CAPACITY_HELPER_SECRET" in printed
|
|
241
|
+
assert "--family evm" in printed
|
|
242
|
+
assert "ws://127.0.0.1:9944 --family substrate" in printed
|
|
243
|
+
assert "check --endpoint" in printed
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""What ``bm miner capacity-pack`` tells an operator when there is no pack.
|
|
2
|
+
|
|
3
|
+
Two of its three refusals used to be the same sentence — "No capacity run
|
|
4
|
+
stored for <chain> (<class>) yet. One appears after the next run of that
|
|
5
|
+
chain." — and in two of the three cases that sentence is false:
|
|
6
|
+
|
|
7
|
+
* **The full class has no pack and never will.** A full node is asked about
|
|
8
|
+
the newest blocks, so ``capacity-tester``'s run keeps a question set only
|
|
9
|
+
for the archive class (``run.rs``: "no manifest for the full class"). A
|
|
10
|
+
miner asked Ben on 21 Sep 2026 why his scored eth full node had no pack; the
|
|
11
|
+
CLI had told him to wait for a run that cannot produce one.
|
|
12
|
+
* **A stored set from before a format bump is stored, not missing.** Right
|
|
13
|
+
after ``CAPACITY_SET_FORMAT`` moved 9 → 10 on 23 Sep 2026, every chain's
|
|
14
|
+
newest set was one the shipped binary could not read. Ben hit it on tao.
|
|
15
|
+
|
|
16
|
+
So: the full class is refused here, before any request, and the registry's own
|
|
17
|
+
words are printed for every other 404 rather than replaced with ours.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from types import SimpleNamespace
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
import typer
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
|
|
28
|
+
from cli.commands import miner as miner_mod
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Resp:
|
|
32
|
+
def __init__(self, payload, status_code: int = 404, text: str = "") -> None:
|
|
33
|
+
self._payload = payload
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.text = text
|
|
36
|
+
|
|
37
|
+
def json(self):
|
|
38
|
+
if self._payload is None:
|
|
39
|
+
raise ValueError("not json")
|
|
40
|
+
return self._payload
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _RecordingClient:
|
|
44
|
+
"""Records every request, so "it never asked" is an assertion and not an
|
|
45
|
+
assumption."""
|
|
46
|
+
|
|
47
|
+
calls: list[tuple]
|
|
48
|
+
|
|
49
|
+
def __init__(self, response: _Resp) -> None:
|
|
50
|
+
self._response = response
|
|
51
|
+
self.calls = []
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> "_RecordingClient":
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *args) -> None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def get(self, path: str, params=None) -> _Resp:
|
|
60
|
+
self.calls.append((path, params))
|
|
61
|
+
return self._response
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run(monkeypatch, node_class: str, response: _Resp) -> tuple[str, _RecordingClient]:
|
|
65
|
+
console = Console(record=True, width=200)
|
|
66
|
+
monkeypatch.setattr(miner_mod, "console", console)
|
|
67
|
+
monkeypatch.setattr(miner_mod, "load_config", lambda: SimpleNamespace())
|
|
68
|
+
client = _RecordingClient(response)
|
|
69
|
+
monkeypatch.setattr(miner_mod, "RegistryClient", lambda config: client)
|
|
70
|
+
with pytest.raises(typer.Exit) as exc:
|
|
71
|
+
# `out` empty, not None: nothing is written on any of these paths, and
|
|
72
|
+
# the command's own annotation says `str`.
|
|
73
|
+
miner_mod.capacity_pack(chain="eth", node_class=node_class, out="")
|
|
74
|
+
assert exc.value.exit_code == 1
|
|
75
|
+
return " ".join(console.export_text().split()), client
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_the_full_class_is_refused_without_asking_and_is_never_told_to_wait(
|
|
79
|
+
monkeypatch,
|
|
80
|
+
):
|
|
81
|
+
out, client = _run(monkeypatch, "full", _Resp({"detail": "should never be read"}))
|
|
82
|
+
|
|
83
|
+
assert client.calls == [], (
|
|
84
|
+
"the full class has no pack on any chain, so the CLI must say so "
|
|
85
|
+
f"without making the request; it made {client.calls}"
|
|
86
|
+
)
|
|
87
|
+
assert "There is no self-test pack for full nodes" in out
|
|
88
|
+
assert "drawn fresh at the moment it's tested" in out
|
|
89
|
+
assert "bm miner capacity-pack --chain eth" in out
|
|
90
|
+
# The false promise, in either wording. This is the whole bug.
|
|
91
|
+
assert "next run" not in out
|
|
92
|
+
assert "yet" not in out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_a_404_reaches_the_operator_in_the_registrys_own_words(monkeypatch):
|
|
96
|
+
"""The registry knows which absence it is; the CLI does not. It prints what
|
|
97
|
+
it was told."""
|
|
98
|
+
detail = (
|
|
99
|
+
"This chain's stored test questions are from before the current test "
|
|
100
|
+
"version, so there's no pack to download yet. One is stored at the "
|
|
101
|
+
"chain's next capacity run, usually within a day."
|
|
102
|
+
)
|
|
103
|
+
out, client = _run(monkeypatch, "archive", _Resp({"detail": detail}))
|
|
104
|
+
|
|
105
|
+
assert [c[0] for c in client.calls] == ["/quality/capacity-bundle"]
|
|
106
|
+
assert detail in out
|
|
107
|
+
# Not overwritten with the old class-blind sentence.
|
|
108
|
+
assert "No capacity run stored for eth" not in out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_a_404_with_no_readable_body_still_says_something_true(monkeypatch):
|
|
112
|
+
"""An old registry, or a proxy's own 404 page, carries no ``detail``. The
|
|
113
|
+
fallback must still be a sentence, and must not invent a reason."""
|
|
114
|
+
out, _ = _run(monkeypatch, "archive", _Resp(None, text=""))
|
|
115
|
+
|
|
116
|
+
assert "No capacity pack for eth (archive)" in out
|
|
117
|
+
assert "Nothing is stored for that chain yet" in out
|
|
@@ -196,6 +196,30 @@ class TestDetailView:
|
|
|
196
196
|
assert "Last Tested" in out
|
|
197
197
|
assert "Next Re-test" in out and "2026-08-23 21:00:00" in out
|
|
198
198
|
|
|
199
|
+
def test_a_held_verdict_says_so_and_names_what_could_not_be_measured(
|
|
200
|
+
self, monkeypatch
|
|
201
|
+
):
|
|
202
|
+
"""#1465: a state carried through could-not-test rounds is the last
|
|
203
|
+
verdict that concluded, not this battery's — the view says how many
|
|
204
|
+
rounds, since when, and which checks could not be read, so an
|
|
205
|
+
operator does not read a held state as a fresh judgement."""
|
|
206
|
+
held = _elig(
|
|
207
|
+
could_not_test_streak=62,
|
|
208
|
+
could_not_test_since="2026-08-23T10:00:00+00:00",
|
|
209
|
+
)
|
|
210
|
+
out = _run(monkeypatch, [_node(eligibility=held)], "show")
|
|
211
|
+
assert "Held" in out
|
|
212
|
+
assert "62 battery round(s) since 2026-08-23 10:00:00" in out
|
|
213
|
+
assert "could not measure: mempool_feed" in out
|
|
214
|
+
|
|
215
|
+
# A fresh verdict (streak 0) or an older registry (no key): no row.
|
|
216
|
+
fresh = _run(monkeypatch, [_node(eligibility=_elig())], "show")
|
|
217
|
+
assert "Held" not in fresh
|
|
218
|
+
zero = _run(
|
|
219
|
+
monkeypatch, [_node(eligibility=_elig(could_not_test_streak=0))], "show"
|
|
220
|
+
)
|
|
221
|
+
assert "Held" not in zero
|
|
222
|
+
|
|
199
223
|
def test_prints_every_battery_check_with_its_outcome_and_detail(self, monkeypatch):
|
|
200
224
|
out = _run(monkeypatch, [_node(eligibility=_elig())], "show")
|
|
201
225
|
assert "archive_depth" in out
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Tests for the ETH tier display on the ``bm miner`` CLI surface.
|
|
2
|
+
|
|
3
|
+
The Erigon network-policy notice this file also used to cover is GONE
|
|
4
|
+
(#1517). It told an operator registering an Erigon ETH backend that
|
|
5
|
+
"Erigon backends currently serve only eth_getProof. Other ETH RPC
|
|
6
|
+
traffic will be routed to Reth backends in the network." That was true
|
|
7
|
+
only while Erigon was off ETH's audit allowlist; with Erigon on it,
|
|
8
|
+
Erigon backends serve the ordinary customer methods like any other
|
|
9
|
+
client and the notice was simply false. A false notice at registration
|
|
10
|
+
is worse than none: it tells an operator their box will not earn on the
|
|
11
|
+
traffic it is about to serve."""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from cli.commands.miner import _eth_tier
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _node(declared_class=None, **caps):
|
|
20
|
+
"""One row as ``GET /nodes`` returns it."""
|
|
21
|
+
node = {"capabilities": caps or None}
|
|
22
|
+
if declared_class is not None:
|
|
23
|
+
node["declared_class"] = declared_class
|
|
24
|
+
return node
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestEthTier:
|
|
28
|
+
def test_archive_proof_when_declared_archive_and_serving_proofs(self):
|
|
29
|
+
assert (
|
|
30
|
+
_eth_tier(_node("archive", is_archive=True, serves_proofs=True))
|
|
31
|
+
== "archive_proof"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def test_archive_when_only_history(self):
|
|
35
|
+
# Today's Reth-archive shape: history retained, no historical proofs.
|
|
36
|
+
assert (
|
|
37
|
+
_eth_tier(_node("archive", is_archive=True, serves_proofs=False))
|
|
38
|
+
== "archive"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def test_full_when_declared_full(self):
|
|
42
|
+
assert _eth_tier(_node("full", is_archive=False, serves_proofs=False)) == "full"
|
|
43
|
+
|
|
44
|
+
def test_the_tier_follows_the_declaration_not_the_sweep(self):
|
|
45
|
+
"""#1018. The operator must be shown the class they are PAID as. The
|
|
46
|
+
capabilities on both rows below say the opposite of the declaration
|
|
47
|
+
— the contradiction the card found on two live production rows — and
|
|
48
|
+
the tier has to follow the declaration in both directions."""
|
|
49
|
+
assert _eth_tier(_node("full", is_archive=True, serves_proofs=True)) == "full"
|
|
50
|
+
assert (
|
|
51
|
+
_eth_tier(_node("archive", is_archive=False, serves_proofs=False))
|
|
52
|
+
== "archive"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_dash_when_the_row_carries_no_declared_class(self):
|
|
56
|
+
"""A registry too old to send one. ``-`` is "we do not know", which
|
|
57
|
+
is the only honest answer; guessing from ``capabilities`` is the
|
|
58
|
+
defect."""
|
|
59
|
+
assert _eth_tier(None) == "-"
|
|
60
|
+
assert _eth_tier({}) == "-"
|
|
61
|
+
assert _eth_tier({"capabilities": {"is_archive": True}}) == "-"
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
"""Tests for ETH tier display + Erigon network-policy notice on the
|
|
2
|
-
``bm miner`` CLI surface."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
from unittest.mock import MagicMock
|
|
7
|
-
|
|
8
|
-
from cli.commands.miner import (
|
|
9
|
-
_eth_tier,
|
|
10
|
-
_maybe_print_eth_software_notice,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _node(declared_class=None, **caps):
|
|
15
|
-
"""One row as ``GET /nodes`` returns it."""
|
|
16
|
-
node = {"capabilities": caps or None}
|
|
17
|
-
if declared_class is not None:
|
|
18
|
-
node["declared_class"] = declared_class
|
|
19
|
-
return node
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class TestEthTier:
|
|
23
|
-
def test_archive_proof_when_declared_archive_and_serving_proofs(self):
|
|
24
|
-
assert (
|
|
25
|
-
_eth_tier(_node("archive", is_archive=True, serves_proofs=True))
|
|
26
|
-
== "archive_proof"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
def test_archive_when_only_history(self):
|
|
30
|
-
# Today's Reth-archive shape: history retained, no historical proofs.
|
|
31
|
-
assert (
|
|
32
|
-
_eth_tier(_node("archive", is_archive=True, serves_proofs=False))
|
|
33
|
-
== "archive"
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
def test_full_when_declared_full(self):
|
|
37
|
-
assert _eth_tier(_node("full", is_archive=False, serves_proofs=False)) == "full"
|
|
38
|
-
|
|
39
|
-
def test_the_tier_follows_the_declaration_not_the_sweep(self):
|
|
40
|
-
"""#1018. The operator must be shown the class they are PAID as. The
|
|
41
|
-
capabilities on both rows below say the opposite of the declaration
|
|
42
|
-
— the contradiction the card found on two live production rows — and
|
|
43
|
-
the tier has to follow the declaration in both directions."""
|
|
44
|
-
assert _eth_tier(_node("full", is_archive=True, serves_proofs=True)) == "full"
|
|
45
|
-
assert (
|
|
46
|
-
_eth_tier(_node("archive", is_archive=False, serves_proofs=False))
|
|
47
|
-
== "archive"
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
def test_dash_when_the_row_carries_no_declared_class(self):
|
|
51
|
-
"""A registry too old to send one. ``-`` is "we do not know", which
|
|
52
|
-
is the only honest answer; guessing from ``capabilities`` is the
|
|
53
|
-
defect."""
|
|
54
|
-
assert _eth_tier(None) == "-"
|
|
55
|
-
assert _eth_tier({}) == "-"
|
|
56
|
-
assert _eth_tier({"capabilities": {"is_archive": True}}) == "-"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class TestErigonSoftwareNotice:
|
|
60
|
-
def _fake_client(self, software_name: str | None) -> MagicMock:
|
|
61
|
-
client = MagicMock()
|
|
62
|
-
resp = MagicMock()
|
|
63
|
-
resp.status_code = 200
|
|
64
|
-
resp.json.return_value = {"software_name": software_name}
|
|
65
|
-
client.get.return_value = resp
|
|
66
|
-
return client
|
|
67
|
-
|
|
68
|
-
def test_erigon_prints_notice(self, capsys):
|
|
69
|
-
from cli.commands import miner as miner_mod
|
|
70
|
-
|
|
71
|
-
client = self._fake_client("erigon/v3.0.4")
|
|
72
|
-
# Stash the original console and replace with a capturing one.
|
|
73
|
-
original = miner_mod.console
|
|
74
|
-
from rich.console import Console
|
|
75
|
-
|
|
76
|
-
miner_mod.console = Console(force_terminal=False, no_color=True)
|
|
77
|
-
try:
|
|
78
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
79
|
-
captured = capsys.readouterr()
|
|
80
|
-
finally:
|
|
81
|
-
miner_mod.console = original
|
|
82
|
-
assert "Network policy notice" in captured.out
|
|
83
|
-
assert "eth_getProof" in captured.out
|
|
84
|
-
|
|
85
|
-
def test_reth_no_notice(self, capsys):
|
|
86
|
-
from cli.commands import miner as miner_mod
|
|
87
|
-
|
|
88
|
-
client = self._fake_client("reth/v1.5.1")
|
|
89
|
-
original = miner_mod.console
|
|
90
|
-
from rich.console import Console
|
|
91
|
-
|
|
92
|
-
miner_mod.console = Console(force_terminal=False, no_color=True)
|
|
93
|
-
try:
|
|
94
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
95
|
-
captured = capsys.readouterr()
|
|
96
|
-
finally:
|
|
97
|
-
miner_mod.console = original
|
|
98
|
-
assert "Network policy notice" not in captured.out
|
|
99
|
-
|
|
100
|
-
def test_non_eth_chain_no_notice(self, capsys):
|
|
101
|
-
from cli.commands import miner as miner_mod
|
|
102
|
-
|
|
103
|
-
client = self._fake_client("erigon/v3.0.4")
|
|
104
|
-
original = miner_mod.console
|
|
105
|
-
from rich.console import Console
|
|
106
|
-
|
|
107
|
-
miner_mod.console = Console(force_terminal=False, no_color=True)
|
|
108
|
-
try:
|
|
109
|
-
_maybe_print_eth_software_notice(client, "tao", "node-id")
|
|
110
|
-
captured = capsys.readouterr()
|
|
111
|
-
finally:
|
|
112
|
-
miner_mod.console = original
|
|
113
|
-
assert "Network policy notice" not in captured.out
|
|
114
|
-
# And we should not even have queried the registry.
|
|
115
|
-
client.get.assert_not_called()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class TestErigonNoticeBestEffort:
|
|
119
|
-
"""Codex P2: the follow-up GET must NOT propagate exceptions —
|
|
120
|
-
a transient registry error after a successful POST /nodes would
|
|
121
|
-
otherwise fail ``bm miner add`` and lead the operator into a
|
|
122
|
-
duplicate-node retry."""
|
|
123
|
-
|
|
124
|
-
def _swallowing_capture(self, capsys, runnable):
|
|
125
|
-
"""Helper: run ``runnable`` while console output is captured."""
|
|
126
|
-
from cli.commands import miner as miner_mod
|
|
127
|
-
from rich.console import Console
|
|
128
|
-
|
|
129
|
-
original = miner_mod.console
|
|
130
|
-
miner_mod.console = Console(force_terminal=False, no_color=True)
|
|
131
|
-
try:
|
|
132
|
-
runnable()
|
|
133
|
-
return capsys.readouterr().out
|
|
134
|
-
finally:
|
|
135
|
-
miner_mod.console = original
|
|
136
|
-
|
|
137
|
-
def test_get_raises_connection_error_swallowed(self, capsys):
|
|
138
|
-
client = MagicMock()
|
|
139
|
-
client.get.side_effect = ConnectionError("network down")
|
|
140
|
-
|
|
141
|
-
def run():
|
|
142
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
143
|
-
|
|
144
|
-
out = self._swallowing_capture(capsys, run)
|
|
145
|
-
assert "Network policy notice" not in out
|
|
146
|
-
|
|
147
|
-
def test_get_raises_timeout_swallowed(self, capsys):
|
|
148
|
-
client = MagicMock()
|
|
149
|
-
client.get.side_effect = TimeoutError("read timeout")
|
|
150
|
-
|
|
151
|
-
def run():
|
|
152
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
153
|
-
|
|
154
|
-
out = self._swallowing_capture(capsys, run)
|
|
155
|
-
assert "Network policy notice" not in out
|
|
156
|
-
|
|
157
|
-
def test_get_returns_500_swallowed(self, capsys):
|
|
158
|
-
client = MagicMock()
|
|
159
|
-
resp = MagicMock()
|
|
160
|
-
resp.status_code = 500
|
|
161
|
-
client.get.return_value = resp
|
|
162
|
-
|
|
163
|
-
def run():
|
|
164
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
165
|
-
|
|
166
|
-
out = self._swallowing_capture(capsys, run)
|
|
167
|
-
assert "Network policy notice" not in out
|
|
168
|
-
|
|
169
|
-
def test_json_decode_failure_swallowed(self, capsys):
|
|
170
|
-
client = MagicMock()
|
|
171
|
-
resp = MagicMock()
|
|
172
|
-
resp.status_code = 200
|
|
173
|
-
resp.json.side_effect = ValueError("invalid json")
|
|
174
|
-
client.get.return_value = resp
|
|
175
|
-
|
|
176
|
-
def run():
|
|
177
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
178
|
-
|
|
179
|
-
out = self._swallowing_capture(capsys, run)
|
|
180
|
-
assert "Network policy notice" not in out
|
|
181
|
-
|
|
182
|
-
def test_software_name_missing_swallowed(self, capsys):
|
|
183
|
-
"""Response body without ``software_name`` field — old registry
|
|
184
|
-
version. Falls through the ``or ""`` then skips the print."""
|
|
185
|
-
client = MagicMock()
|
|
186
|
-
resp = MagicMock()
|
|
187
|
-
resp.status_code = 200
|
|
188
|
-
resp.json.return_value = {}
|
|
189
|
-
client.get.return_value = resp
|
|
190
|
-
|
|
191
|
-
def run():
|
|
192
|
-
_maybe_print_eth_software_notice(client, "eth", "node-id")
|
|
193
|
-
|
|
194
|
-
out = self._swallowing_capture(capsys, run)
|
|
195
|
-
assert "Network policy notice" not in out
|
|
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
|