robustrep 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {robustrep-0.1.1 → robustrep-0.2.0}/CHANGELOG.md +111 -0
  2. {robustrep-0.1.1/robustrep.egg-info → robustrep-0.2.0}/PKG-INFO +6 -2
  3. {robustrep-0.1.1 → robustrep-0.2.0}/README.md +4 -0
  4. {robustrep-0.1.1 → robustrep-0.2.0}/constraints.txt +1 -1
  5. {robustrep-0.1.1 → robustrep-0.2.0}/pyproject.toml +5 -2
  6. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/cli.py +7 -2
  7. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/config.py +24 -2
  8. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/provenance_keys.py +1 -0
  9. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/publish.py +20 -3
  10. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/render.py +212 -115
  11. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/base_erc8004.py +114 -16
  12. robustrep-0.2.0/robustrep/sources/log_fetcher.py +201 -0
  13. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/rpc.py +92 -7
  14. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/store.py +61 -0
  15. {robustrep-0.1.1 → robustrep-0.2.0/robustrep.egg-info}/PKG-INFO +6 -2
  16. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep.egg-info/SOURCES.txt +3 -0
  17. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep.egg-info/requires.txt +1 -1
  18. {robustrep-0.1.1 → robustrep-0.2.0}/tests/e2e/test_live.py +73 -27
  19. {robustrep-0.1.1 → robustrep-0.2.0}/tests/e2e/test_replay.py +4 -0
  20. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_base_erc8004.py +143 -1
  21. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_cli.py +65 -2
  22. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_live_guard.py +104 -12
  23. robustrep-0.2.0/tests/unit/test_log_fetcher.py +362 -0
  24. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_report.py +1 -13
  25. robustrep-0.2.0/tests/unit/test_report_log_sources.py +109 -0
  26. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_report_publish.py +51 -0
  27. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_rpc.py +167 -3
  28. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_schema.py +28 -0
  29. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_store.py +76 -1
  30. {robustrep-0.1.1 → robustrep-0.2.0}/LICENSE +0 -0
  31. {robustrep-0.1.1 → robustrep-0.2.0}/MANIFEST.in +0 -0
  32. {robustrep-0.1.1 → robustrep-0.2.0}/SECURITY.md +0 -0
  33. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/__init__.py +0 -0
  34. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/aggregate.py +0 -0
  35. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/evidence.py +0 -0
  36. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/explain.py +0 -0
  37. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/normalize.py +0 -0
  38. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/pipeline.py +0 -0
  39. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/__init__.py +0 -0
  40. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/adversarial.py +0 -0
  41. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/export.py +0 -0
  42. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/figures.py +0 -0
  43. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/report/sensitivity.py +0 -0
  44. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/schema.py +0 -0
  45. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/__init__.py +0 -0
  46. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/blockscout_util.py +0 -0
  47. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/evidence_batch.py +0 -0
  48. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/evidence_fetch.py +0 -0
  49. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/http_util.py +0 -0
  50. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sources/rater_profile.py +0 -0
  51. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep/sybil.py +0 -0
  52. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep.egg-info/dependency_links.txt +0 -0
  53. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep.egg-info/entry_points.txt +0 -0
  54. {robustrep-0.1.1 → robustrep-0.2.0}/robustrep.egg-info/top_level.txt +0 -0
  55. {robustrep-0.1.1 → robustrep-0.2.0}/setup.cfg +0 -0
  56. {robustrep-0.1.1 → robustrep-0.2.0}/tests/adversarial/test_attacks.py +0 -0
  57. {robustrep-0.1.1 → robustrep-0.2.0}/tests/conftest.py +0 -0
  58. {robustrep-0.1.1 → robustrep-0.2.0}/tests/e2e/fixtures/base_logs_2000.json +0 -0
  59. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_aggregate.py +0 -0
  60. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_blockscout_util.py +0 -0
  61. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_conn_guard.py +0 -0
  62. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_evidence.py +0 -0
  63. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_evidence_batch.py +0 -0
  64. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_evidence_fetch.py +0 -0
  65. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_explain.py +0 -0
  66. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_http_util.py +0 -0
  67. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_normalize.py +0 -0
  68. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_pipeline.py +0 -0
  69. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_rater_profile.py +0 -0
  70. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_sybil.py +0 -0
  71. {robustrep-0.1.1 → robustrep-0.2.0}/tests/unit/test_version.py +0 -0
@@ -4,6 +4,115 @@ All notable changes to robustrep are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project is pre-1.0,
5
5
  so a `0.x` bump may change published numbers without changing the API.
6
6
 
7
+ ## [0.2.0] — 2026-10-12
8
+
9
+ Adaptive `eth_getLogs` fetching. On 2026-10-12 mainnet.base.org began answering
10
+ every `eth_getLogs` with HTTP 429 (`-32011 request limit reached`), even for a
11
+ 100-block window on a first request. The remaining free endpoints each cap the
12
+ window differently (publicnode serves roughly the last 5,000 blocks, nodies
13
+ 50 blocks per call but deep history, drpc about 100), so fixed 2,000-block
14
+ chunks could not back-fill the roughly 1.2M blocks since the last report. `fetch`
15
+ now learns each endpoint's window at runtime and records which host served
16
+ what. Scoring is unchanged.
17
+
18
+ ### Added
19
+
20
+ - **`LogFetcher` (`robustrep/sources/log_fetcher.py`).** Sits between
21
+ `sync_feedback` and `RpcClient`: given a block range it returns all logs or
22
+ raises `RpcError`. It learns each endpoint's window cap (shrinks by at least
23
+ half on a range-too-large error, in one step to the cap when the server
24
+ names one), marks an endpoint dead for the run when it rejects the minimum
25
+ window (default 50 blocks), rotates past dead endpoints, and bounds invalid
26
+ rounds so it always terminates. `LogFetcher.from_config(rpc, cfg)` is the
27
+ construction path shared by the CLI and the live tests.
28
+ - **`sync_log` table and `Store.sync_sources()`.** One row per synced
29
+ sub-window of a chunk (`from_block, to_block, host, window, n_logs, fetched_at`), written by
30
+ delete-intersecting-then-insert so reruns are idempotent;
31
+ `sync_sources()` aggregates per host.
32
+ - **`rpc_sources` report provenance** (last in `PROVENANCE_KEYS`). `report.md`
33
+ gets a `Log sources:` line and a `Source recording starts at block ...`
34
+ line under Provenance (`Log sources: not recorded ...` for data fetched
35
+ before this version), and an always-present Limitations bullet, `Log sources
36
+ not cross-checked.` RPC endpoint hostnames appear in the public report and
37
+ `scores.json`; do not use an endpoint whose hostname embeds an account id or
38
+ key.
39
+ - **`Config` fields:** `rpc_window_hints` (tuple of `(host, cap)` pairs,
40
+ default empty), `rpc_min_window` (50) and `rpc_min_interval` (0.5 s between
41
+ call starts; this is the CLI default). The library default (`LogFetcher`'s
42
+ `min_interval`) is 0; `RpcClient` has no pacing.
43
+ - **`RpcRangeTooLargeError(endpoint, numbers)`**, raised immediately (no
44
+ rotation or backoff) on provider range-cap errors. Its message is fixed
45
+ text; only parsed integers leave the module. `RpcClient` also gains
46
+ `current_url` and `rotate()`.
47
+ - **`Store.failed_ranges()`**, a read-only view of the recorded failed
48
+ ranges, used by the live suite.
49
+
50
+ ### Changed
51
+
52
+ - **Default `rpc_urls` is now four endpoints, in rotation order:**
53
+ `https://mainnet.base.org`, `https://base-rpc.publicnode.com`,
54
+ `https://base-pokt.nodies.app`, `https://base.drpc.org`. `--rpc-url`
55
+ (repeatable) still replaces the whole list. Report provenance does not
56
+ record this list; it records `rpc_sources`, the hosts that actually served
57
+ each block range.
58
+ - **`sync_feedback(..., fetcher=None)`.** With a fetcher it records
59
+ `sync_log`; without one it behaves as before and records no sources. The
60
+ CLI always passes one.
61
+ - **Live suite: an unusable endpoint is a labelled failure, never a skip.**
62
+ `rpc_available()` and the new `check_failed_ranges()` report
63
+ `RPC unavailable: rate limited` / `transport failure: ...` with `pytest.fail`
64
+ instead of `pytest.skip`. A run that cannot reach the chain has checked
65
+ nothing, and a skip would have hidden the mainnet.base.org change for weeks;
66
+ the owner prefers the email. The label (never server-supplied text) says
67
+ whether to look at the endpoint list or at the data. Genuine data failures
68
+ propagate unchanged. Both live tests now go through `LogFetcher.from_config`.
69
+ - **`--chunk` smaller than the minimum window still works**; it caps the
70
+ window and is not raised to 50.
71
+
72
+ ### Fixed
73
+
74
+ - **Live suite saw a throttled `eth_getLogs` as a bare checkpoint failure.**
75
+ `sync_feedback` records a chunk whose fetch failed in `failed_ranges` and
76
+ carries on rather than raising, so the availability guard never saw the 429
77
+ and the test failed on `last_block` with no hint why (weekly run of
78
+ 2026-10-12). The test now reads the recorded errors through
79
+ `Store.failed_ranges()` and classifies them with the same markers; a
80
+ throttled sibling range can never hide a real one.
81
+ - **JSON-RPC error bodies carried by HTTP 400 are now classified.**
82
+ base.drpc.org answers an over-cap `eth_getLogs` with HTTP 400 and a JSON-RPC
83
+ error (`ranges over 10000 blocks are not supported on free plan`).
84
+ `RpcClient` raised on the status before reading the body, so the range error
85
+ was never recognised and drpc's window was never learned; every rotation to
86
+ it re-sent a full-width request that failed. A 400 whose body is a JSON
87
+ object with an `error` now goes through the same classification as a 200
88
+ with an error (range too large, non-retryable, or retry and rotate). A 400
89
+ without such a body, and every 403, 429 and 5xx, is still an HTTP error as
90
+ before.
91
+ - **Default CLI pacing is 0.5 s between `eth_getLogs` calls (was 0.2 s).** The
92
+ 2026-10-12 back-fill showed nodies throttling at about 5 calls per second
93
+ sustained (104 HTTP 429s). `LogFetcher`'s library default stays 0.
94
+
95
+ ### Rollback
96
+
97
+ To go back to 0.1.2 (no source recording, no automatic rotation), install the
98
+ old release first: `pip install 'robustrep==0.1.2'`, then
99
+ `robustrep fetch --db data/base.db --chunk 50 --rpc-url https://base-pokt.nodies.app`;
100
+ the database is compatible in both directions (0.1.2 ignores `sync_log`;
101
+ blocks fetched under 0.1.2 have no source rows).
102
+
103
+ ## [0.1.2] — 2026-10-03
104
+
105
+ A dependency security release. No API, CLI or scoring changes.
106
+
107
+ ### Security — Fixed
108
+
109
+ - **urllib3 floor raised to 2.8.** urllib3 2.6–2.7 can enter an infinite loop
110
+ on a chunked deflate stream (PYSEC-2026-4176) and buffer an unbounded
111
+ chunk-size line into memory (PYSEC-2026-4177). Both are reachable from
112
+ `fetch`, which reads responses from attacker-chosen evidence hosts, so a
113
+ hostile URI could hang or exhaust the fetcher. 2.8 also fixes HTTPS-proxy TLS
114
+ settings being ignored (PYSEC-2026-4175). The CI pin moves to 2.8.0 as well.
115
+
7
116
  ## [0.1.1] — 2026-09-15
8
117
 
9
118
  A security release: it closes every finding of an external security review of
@@ -114,5 +223,7 @@ no longer rescales a group by its own extremes.
114
223
  Initial release on PyPI: the scoring library, the Base ERC-8004 adapter, and the
115
224
  reproducible report.
116
225
 
226
+ [0.2.0]: https://github.com/iamdongwang/robustrep/compare/v0.1.2...v0.2.0
227
+ [0.1.2]: https://github.com/iamdongwang/robustrep/compare/v0.1.1...v0.1.2
117
228
  [0.1.1]: https://github.com/iamdongwang/robustrep/compare/v0.1.0...v0.1.1
118
229
  [0.1.0]: https://github.com/iamdongwang/robustrep/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: robustrep
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Robust, transport-agnostic reputation scoring for AI agents
5
5
  Author: Dong Wang
6
6
  License: Apache-2.0
@@ -25,7 +25,7 @@ Requires-Dist: numpy>=1.24
25
25
  Requires-Dist: eth-abi>=5.0
26
26
  Requires-Dist: eth-hash[pycryptodome]>=0.5
27
27
  Requires-Dist: requests>=2.31
28
- Requires-Dist: urllib3>=2.6
28
+ Requires-Dist: urllib3>=2.8
29
29
  Requires-Dist: tabulate>=0.9
30
30
  Requires-Dist: matplotlib>=3.7
31
31
  Requires-Dist: typer>=0.12
@@ -68,6 +68,10 @@ result = score(records, Config())
68
68
  robustrep score --db data/base.db --out scores.csv
69
69
  robustrep report --db data/base.db --out-dir reports
70
70
 
71
+ The default endpoint list is rotated automatically and the `eth_getLogs` window adapts per
72
+ endpoint; `--rpc-url` (repeatable) replaces the list and `--chunk` caps the window.
73
+ RPC endpoint hostnames appear in the public report and `scores.json`; do not use an endpoint whose hostname embeds an account id or key.
74
+
71
75
  Rater profiles: Blockscout (free, no key) by default; Etherscan V2 requires a paid plan for Base.
72
76
  Pick a source explicitly with `fetch --profile-source {auto,blockscout,etherscan,none}`.
73
77
 
@@ -29,6 +29,10 @@ result = score(records, Config())
29
29
  robustrep score --db data/base.db --out scores.csv
30
30
  robustrep report --db data/base.db --out-dir reports
31
31
 
32
+ The default endpoint list is rotated automatically and the `eth_getLogs` window adapts per
33
+ endpoint; `--rpc-url` (repeatable) replaces the list and `--chunk` caps the window.
34
+ RPC endpoint hostnames appear in the public report and `scores.json`; do not use an endpoint whose hostname embeds an account id or key.
35
+
32
36
  Rater profiles: Blockscout (free, no key) by default; Etherscan V2 requires a paid plan for Base.
33
37
  Pick a source explicitly with `fetch --profile-source {auto,blockscout,etherscan,none}`.
34
38
 
@@ -98,7 +98,7 @@ typer==0.27.2
98
98
  typing-inspection==0.4.4
99
99
  typing_extensions==4.16.0
100
100
  tzdata==2026.4
101
- urllib3==2.7.0
101
+ urllib3==2.8.0
102
102
  zipp==4.1.0
103
103
  # -- hand-added (not emitted by `pip freeze` on the dev box) -----------------
104
104
  # pip itself: freeze omits it. CI installs this version explicitly before the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "robustrep"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Robust, transport-agnostic reputation scoring for AI agents"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -31,7 +31,10 @@ dependencies = [
31
31
  # decompression bomb (`max_length` on urllib3's decoders) first exists in
32
32
  # 2.6.0. On 2.0-2.5 the same call decompresses the whole body before
33
33
  # truncating -- measured at a 455 MB peak against 0.8 MB on 2.7.
34
- "urllib3>=2.6",
34
+ # >=2.8: 2.6-2.7 can loop forever on a chunked deflate stream and buffer an
35
+ # unbounded chunk-size line (PYSEC-2026-4176/4177), both reachable from an
36
+ # attacker-chosen evidence host.
37
+ "urllib3>=2.8",
35
38
  "tabulate>=0.9",
36
39
  "matplotlib>=3.7",
37
40
  "typer>=0.12",
@@ -37,6 +37,7 @@ from .report.publish import (EVIDENCE_CAP_PER_URI_KEY, EVIDENCE_CAP_TOTAL_KEY, b
37
37
  from .report.sensitivity import sensitivity_table
38
38
  from .sources import base_erc8004 as base
39
39
  from .sources.evidence_batch import DEFAULT_MAX_TOTAL_LOOKUPS, classify_all
40
+ from .sources.log_fetcher import LogFetcher
40
41
  from .sources.rater_profile import DEFAULT_RPS, default_client, enrich_raters, estimate_seconds
41
42
  from .sources.rpc import RpcClient, RpcError
42
43
  from .store import Store
@@ -125,7 +126,8 @@ def _rpc_guard():
125
126
 
126
127
  def _step_sync(store: Store, rpc: RpcClient, cfg: Config, to_block: Optional[int]) -> str:
127
128
  """Sync feedback/revocation/response events up to ``to_block`` (or head)."""
128
- n = base.sync_feedback(store, rpc, chunk=cfg.chunk_blocks, end_block=to_block, confirmations=cfg.confirmations)
129
+ n = base.sync_feedback(store, rpc, chunk=cfg.chunk_blocks, end_block=to_block,
130
+ confirmations=cfg.confirmations, fetcher=LogFetcher.from_config(rpc, cfg))
129
131
  return f"feedback rows added: {n}"
130
132
 
131
133
 
@@ -530,6 +532,8 @@ def report(
530
532
  # Same reason: provenance publishes the caps the fetch run
531
533
  # recorded in *this* store, not this build's constants.
532
534
  evidence_caps = evidence_caps_for_provenance(store)
535
+ # And which RPC endpoints served the logs, from this store's sync_log.
536
+ sync_sources = store.sync_sources()
533
537
  block = int(last_block) if last_block is not None else 0
534
538
  # Inside the guard: sensitivity_table re-scores the whole dataset once
535
539
  # per variant, so any ValueError scoring can raise, it can raise too.
@@ -541,7 +545,8 @@ def report(
541
545
  adv = scenario_table()
542
546
  provenance = build_provenance(mode, cfg, records, result,
543
547
  n_lookup_budget_starved=n_lookup_budget_starved,
544
- evidence_caps=evidence_caps)
548
+ evidence_caps=evidence_caps,
549
+ sync_sources=sync_sources)
545
550
 
546
551
  block_dir = out_dir / str(block)
547
552
  # Guarded like the scoring above: `export_json` refuses an address-bearing
@@ -59,9 +59,25 @@ class Config:
59
59
  bootstrap_seed: int = 0
60
60
  ci_level: float = 0.95
61
61
  # data source
62
- rpc_urls: tuple[str, ...] = ("https://mainnet.base.org",)
62
+ # Measured 2026-10-12: mainnet.base.org answers every eth_getLogs with 429;
63
+ # publicnode serves only ~the last 5000 blocks; nodies caps at 50 blocks per
64
+ # call but serves deep history; drpc caps at ~100. RpcClient rotates on
65
+ # failure and sticks, LogFetcher learns each host's window.
66
+ rpc_urls: tuple[str, ...] = (
67
+ "https://mainnet.base.org",
68
+ "https://base-rpc.publicnode.com",
69
+ "https://base-pokt.nodies.app",
70
+ "https://base.drpc.org",
71
+ )
72
+ # (scheme://hostname, max block window) pairs; a tuple so Config stays hashable.
73
+ rpc_window_hints: tuple[tuple[str, int], ...] = ()
74
+ rpc_min_window: int = 50
75
+ # Seconds between eth_getLogs call starts (CLI default; LogFetcher's own is 0).
76
+ # nodies throttled at ~5 calls/s sustained during the 2026-10-12 back-fill
77
+ # (104 HTTP 429s), so 0.2 s was too tight.
78
+ rpc_min_interval: float = 0.5
63
79
  chunk_blocks: int = 2000
64
- user_agent: str = "robustrep/0.1 (+https://github.com/iamdongwang/robustrep)"
80
+ user_agent: str = "robustrep/0.2 (+https://github.com/iamdongwang/robustrep)"
65
81
  confirmations: int = DEFAULT_CONFIRMATIONS
66
82
 
67
83
  def __post_init__(self) -> None:
@@ -90,5 +106,11 @@ class Config:
90
106
  raise ValueError("chunk_blocks must be >= 1")
91
107
  if len(self.rpc_urls) < 1:
92
108
  raise ValueError("rpc_urls must have at least 1 entry")
109
+ if any(w < 1 for _, w in self.rpc_window_hints):
110
+ raise ValueError("rpc_window_hints values must be >= 1")
111
+ if self.rpc_min_window < 1:
112
+ raise ValueError("rpc_min_window must be >= 1")
113
+ if self.rpc_min_interval < 0:
114
+ raise ValueError("rpc_min_interval must be >= 0")
93
115
  if self.confirmations < 0:
94
116
  raise ValueError("confirmations must be >= 0")
@@ -31,4 +31,5 @@ PROVENANCE_KEYS = (
31
31
  "evidence_max_lookups_per_uri",
32
32
  "evidence_max_total_lookups",
33
33
  "evidence_lookup_starved_uris",
34
+ "rpc_sources",
34
35
  )
@@ -22,19 +22,20 @@ says about the run that made it is part of the artifact, not part of the CLI.
22
22
  """
23
23
  from __future__ import annotations
24
24
 
25
+ import dataclasses
25
26
  import logging
26
27
  import os
27
28
  import shutil
28
29
  import tempfile
29
30
  from pathlib import Path
30
- from typing import List, Optional
31
+ from typing import List, Optional, Union
31
32
 
32
33
  import pandas
33
34
 
34
35
  from ..config import Config
35
36
  from ..evidence import MAX_TX_LOOKUPS_PER_URI
36
37
  from ..sources.evidence_batch import DEFAULT_MAX_TOTAL_LOOKUPS
37
- from ..store import Store
38
+ from ..store import Store, SyncSources
38
39
  from .export import export_json, versions
39
40
  from .figures import fig_evidence, fig_mean_vs_robust, fig_rank_shift, fig_sybil_clusters
40
41
  from .provenance_keys import PROVENANCE_KEYS
@@ -173,10 +174,22 @@ def evidence_caps_for_provenance(store: Optional[Store]) -> dict:
173
174
  return caps
174
175
 
175
176
 
177
+ def _rpc_sources_for_provenance(sync_sources: Optional[SyncSources]) -> Union[str, dict]:
178
+ """`config["rpc_sources"]`: the store's per-host sync_log summary, or
179
+ `UNAVAILABLE` when there is no store or it recorded no source at all."""
180
+ if sync_sources is None or sync_sources.recorded_from_block is None:
181
+ return UNAVAILABLE
182
+ return {
183
+ "sources": [dataclasses.asdict(source) for source in sync_sources.sources],
184
+ "recorded_from_block": int(sync_sources.recorded_from_block),
185
+ }
186
+
187
+
176
188
  def build_provenance(mode: str, cfg: Config, records: pandas.DataFrame,
177
189
  result: Optional[pandas.DataFrame] = None,
178
190
  n_lookup_budget_starved: int = 0,
179
- evidence_caps: Optional[dict] = None) -> dict:
191
+ evidence_caps: Optional[dict] = None,
192
+ sync_sources: Optional[SyncSources] = None) -> dict:
180
193
  caps = evidence_caps if evidence_caps is not None else evidence_caps_for_provenance(None)
181
194
  config = dict(
182
195
  bootstrap_n=cfg.bootstrap_n,
@@ -204,6 +217,10 @@ def build_provenance(mode: str, cfg: Config, records: pandas.DataFrame,
204
217
  # caller with no store to count from (e.g. `build_provenance` called
205
218
  # directly on an in-memory `records` frame, as several tests do).
206
219
  evidence_lookup_starved_uris=n_lookup_budget_starved,
220
+ # Which RPC endpoints served the logs, per Store.sync_sources. Last,
221
+ # and UNAVAILABLE (not an empty list) when this store never recorded
222
+ # a source: "not recorded" and "recorded, nothing" are different claims.
223
+ rpc_sources=_rpc_sources_for_provenance(sync_sources),
207
224
  )
208
225
  # PROVENANCE_KEYS is the published order, shared with `report.render` so the
209
226
  # two artifacts cannot drift (a test pins them equal). Ordering through it