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.
Files changed (37) hide show
  1. {blockmachine-0.22.0 → blockmachine-0.22.1}/PKG-INFO +1 -1
  2. {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/PKG-INFO +1 -1
  3. {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/SOURCES.txt +2 -0
  4. {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/epoch.py +48 -4
  5. {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/leaderboard.py +1 -1
  6. {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/miner.py +158 -44
  7. {blockmachine-0.22.0 → blockmachine-0.22.1}/pyproject.toml +1 -1
  8. {blockmachine-0.22.0 → blockmachine-0.22.1}/settings.py +1 -0
  9. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_epoch_payout_cli.py +96 -0
  10. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_add_class.py +0 -1
  11. blockmachine-0.22.1/tests/test_miner_capacity_helper.py +243 -0
  12. blockmachine-0.22.1/tests/test_miner_capacity_pack.py +117 -0
  13. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_eligibility.py +24 -0
  14. blockmachine-0.22.1/tests/test_miner_eth_tier.py +61 -0
  15. blockmachine-0.22.0/tests/test_miner_eth_tier.py +0 -195
  16. {blockmachine-0.22.0 → blockmachine-0.22.1}/__init__.py +0 -0
  17. {blockmachine-0.22.0 → blockmachine-0.22.1}/auth_config.py +0 -0
  18. {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/dependency_links.txt +0 -0
  19. {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/entry_points.txt +0 -0
  20. {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/requires.txt +0 -0
  21. {blockmachine-0.22.0 → blockmachine-0.22.1}/blockmachine.egg-info/top_level.txt +0 -0
  22. {blockmachine-0.22.0 → blockmachine-0.22.1}/client.py +0 -0
  23. {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/__init__.py +0 -0
  24. {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/auth.py +0 -0
  25. {blockmachine-0.22.0 → blockmachine-0.22.1}/commands/validator.py +0 -0
  26. {blockmachine-0.22.0 → blockmachine-0.22.1}/config.py +0 -0
  27. {blockmachine-0.22.0 → blockmachine-0.22.1}/endpoints.py +0 -0
  28. {blockmachine-0.22.0 → blockmachine-0.22.1}/main.py +0 -0
  29. {blockmachine-0.22.0 → blockmachine-0.22.1}/setup.cfg +0 -0
  30. {blockmachine-0.22.0 → blockmachine-0.22.1}/testing.py +0 -0
  31. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_cli_config.py +0 -0
  32. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_cli_endpoints_and_testing.py +0 -0
  33. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_leaderboard_cli.py +0 -0
  34. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_ban_line.py +0 -0
  35. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_capacity.py +0 -0
  36. {blockmachine-0.22.0 → blockmachine-0.22.1}/tests/test_miner_compat_reason.py +0 -0
  37. {blockmachine-0.22.0 → blockmachine-0.22.1}/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blockmachine
3
- Version: 0.22.0
3
+ Version: 0.22.1
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.22.0
3
+ Version: 0.22.1
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
@@ -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"{int(miner.get('cu_total') or 0):,} CU)"
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
- lines.append(f" {'chain/class':<18} {'CU':>15} {'alpha':>9} {'USD':>9}")
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} {int(e.get('cu') or 0):>15,} "
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
- node_id = _create_node(
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", "--class", help="archive or full (default 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 run stored for {chain} ({node_class}) yet.[/yellow] "
2001
- "One appears after the next run of that chain."
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "blockmachine"
7
- version = "0.22.0"
7
+ version = "0.22.1"
8
8
  description = "Blockmachine CLI - Miner and validator operator interface"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -98,6 +98,7 @@ EVM_CHAINS = (
98
98
  "optimism",
99
99
  "arbitrum",
100
100
  "avalanche",
101
+ "arc",
101
102
  "scroll",
102
103
  "mantle",
103
104
  "robinhood",
@@ -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