From 0fe806ba3563620c25ec5c1fac404d31ba8d76ce Mon Sep 17 00:00:00 2001 From: Brok Malkotsis Date: Tue, 11 Aug 2026 04:07:59 +0000 Subject: [PATCH 1/2] feat(#869): retry-safe Base RPC failover with chain-id validation Extend shared JSON-RPC transport with ordered HTTPS endpoints, Base chain ID 8453 validation, bounded 429/5xx retries, and offline coverage for failover, wrong chain, rpc error, and exhaust. Migrate inventory and activation read readiness paths to the shared failover helper. --- scripts/_shared/rpc.py | 180 ++++++++++++++- .../check_routed_v3_activation_readiness.py | 18 ++ scripts/rehearse_autonomous_activation.py | 19 +- scripts/rehearse_canonical_child_verifier.py | 18 +- scripts/test_shared_rpc.py | 212 +++++++++++++++++- 5 files changed, 432 insertions(+), 15 deletions(-) diff --git a/scripts/_shared/rpc.py b/scripts/_shared/rpc.py index efda230b..13bfee4a 100644 --- a/scripts/_shared/rpc.py +++ b/scripts/_shared/rpc.py @@ -1,14 +1,67 @@ -"""JSON-RPC transport shared by local fork rehearsal scripts.""" +"""JSON-RPC transport with retry-safe Base RPC failover and chain-id validation.""" from __future__ import annotations import json -from typing import Any -from urllib.error import URLError +import time +from typing import Any, Sequence +from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen +# Ordered Base mainnet HTTPS endpoints (no credentials in URLs). +BASE_RPC_ENDPOINTS: Sequence[str] = ( + "https://mainnet.base.org", + "https://base.llamarpc.com", + "https://base-rpc.publicnode.com", + "https://1rpc.io/base", + "https://base.drpc.org", +) + +BASE_CHAIN_ID = 8453 +MAX_RETRIES = 3 +INITIAL_BACKOFF_SECONDS = 1.0 + + +class TransportError(RuntimeError): + """Retryable transport / HTTP 429 / HTTP 5xx failure.""" + + def __init__(self, message: str, *, retryable: bool = True) -> None: + super().__init__(message) + self.retryable = retryable + + +class RpcError(RuntimeError): + """Non-retryable JSON-RPC execution error.""" + + +# Public aliases used by tests and callers. +_TransportError = TransportError +_RpcError = RpcError + + +def _backoff(attempt: int) -> float: + """Deterministic exponential backoff: 1s, 2s, 4s, ...""" + return INITIAL_BACKOFF_SECONDS * (2 ** max(attempt - 1, 0)) + + +def _retryable_status(code: int) -> bool: + return code == 429 or 500 <= code < 600 + + +def _redact_endpoint(endpoint: str) -> str: + """Never surface credentials if an endpoint URL ever carries them.""" + if "@" not in endpoint: + return endpoint + try: + scheme, rest = endpoint.split("://", 1) + host = rest.split("@", 1)[-1] + return f"{scheme}://{host}" + except Exception: # noqa: BLE001 + return "" + def rpc(url: str, method: str, params: list[Any], request_id: int = 1) -> Any: + """Single-endpoint JSON-RPC call (preserved for backward compatibility).""" payload = json.dumps( {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} ).encode("utf-8") @@ -19,5 +72,124 @@ def rpc(url: str, method: str, params: list[Any], request_id: int = 1) -> Any: except URLError as error: raise RuntimeError(f"RPC transport failed for {method}: {error}") from error if body.get("error"): - raise RuntimeError(f"RPC {method} failed: {json.dumps(body['error'], sort_keys=True)}") + raise RuntimeError( + f"RPC {method} failed: {json.dumps(body['error'], sort_keys=True)}" + ) + return body.get("result") + + +def _rpc_call(endpoint: str, method: str, params: list[Any], request_id: int) -> Any: + """Low-level call with transport vs execution error separation.""" + payload = json.dumps( + {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params} + ).encode("utf-8") + request = Request( + endpoint, data=payload, headers={"content-type": "application/json"} + ) + try: + with urlopen(request, timeout=30) as response: + status = getattr(response, "status", None) + if status is None and hasattr(response, "getcode"): + try: + status = response.getcode() + except Exception: # noqa: BLE001 + status = 200 + if status is None: + status = 200 + body = json.load(response) + except HTTPError as error: + code = int(getattr(error, "code", 0) or 0) + if _retryable_status(code): + raise TransportError( + f"HTTP {code} from {_redact_endpoint(endpoint)}", + retryable=True, + ) from error + raise TransportError( + f"HTTP {code} from {_redact_endpoint(endpoint)}", + retryable=False, + ) from error + except URLError as error: + raise TransportError( + f"RPC transport failed for {method}: {error}", + retryable=True, + ) from error + + if _retryable_status(int(status or 0)): + raise TransportError( + f"HTTP {status} from {_redact_endpoint(endpoint)}", + retryable=True, + ) + + if body.get("error"): + raise RpcError( + f"RPC {method} failed: {json.dumps(body['error'], sort_keys=True)}" + ) return body.get("result") + + +def _validate_chain(endpoint: str) -> int | None: + """Return chain id when endpoint reports Base (8453); otherwise None.""" + try: + result = _rpc_call(endpoint, "eth_chainId", [], 1) + if isinstance(result, str): + chain_id = int(result, 16) if result.startswith("0x") else int(result) + else: + chain_id = int(result) + if chain_id == BASE_CHAIN_ID: + return chain_id + except Exception: # noqa: BLE001 - skip bad endpoints + return None + return None + + +def rpc_failover( + method: str, + params: list[Any], + request_id: int = 1, + endpoints: Sequence[str] | None = None, + max_retries: int = MAX_RETRIES, +) -> Any: + """JSON-RPC with ordered HTTPS failover, chain-id gate, and bounded retries. + + Retries only transport failures, HTTP 429, and HTTP 5xx. JSON-RPC execution + errors propagate immediately. Endpoint credentials are never logged. + """ + if endpoints is None: + endpoints = BASE_RPC_ENDPOINTS + if not endpoints: + raise RuntimeError("RPC failover exhausted: no endpoints configured") + + last_error: Exception | None = None + for endpoint in endpoints: + if not str(endpoint).lower().startswith("https://"): + last_error = RuntimeError( + f"refusing non-HTTPS endpoint {_redact_endpoint(str(endpoint))}" + ) + continue + chain_id = _validate_chain(endpoint) + if chain_id != BASE_CHAIN_ID: + last_error = RuntimeError( + f"wrong chain on {_redact_endpoint(endpoint)}" + ) + continue + + for attempt in range(1, max_retries + 1): + try: + # After chain validation, perform the requested method. + if method == "eth_chainId": + return hex(BASE_CHAIN_ID) + return _rpc_call(endpoint, method, params, request_id) + except RpcError: + raise + except TransportError as error: + last_error = error + if not error.retryable or attempt >= max_retries: + break + time.sleep(_backoff(attempt)) + except Exception as error: # noqa: BLE001 + last_error = error + break + + raise RuntimeError( + f"RPC failover exhausted for {method}: {last_error}" + ) from last_error diff --git a/scripts/check_routed_v3_activation_readiness.py b/scripts/check_routed_v3_activation_readiness.py index 95ba65cf..0d24dece 100644 --- a/scripts/check_routed_v3_activation_readiness.py +++ b/scripts/check_routed_v3_activation_readiness.py @@ -9,10 +9,28 @@ import activate_routed_v3_dynamic as dynamic import activate_routed_v3_replacements as activation +from _shared.rpc import BASE_RPC_ENDPOINTS, rpc_failover + + +def _prefer_failover_base_rpc(rpc_url: str) -> str: + """Prefer HTTPS Base endpoints with chain-id validation before inventory reads.""" + preferred = (rpc_url or "").strip() + endpoints: list[str] = [] + if preferred.startswith("https://"): + endpoints.append(preferred) + for endpoint in BASE_RPC_ENDPOINTS: + if endpoint not in endpoints: + endpoints.append(endpoint) + try: + rpc_failover("eth_chainId", [], endpoints=endpoints, max_retries=2) + return preferred if preferred.startswith("https://") else endpoints[0] + except Exception: + return preferred def inspect(rpc_url: str, cast_bin: str) -> dict[str, object]: try: + rpc_url = _prefer_failover_base_rpc(rpc_url) cast = activation.Cast(cast_bin, rpc_url) deployment = dynamic.discover_deployment(cast) state = activation.policy_state(cast, deployment) diff --git a/scripts/rehearse_autonomous_activation.py b/scripts/rehearse_autonomous_activation.py index b039cf9b..e2bd3465 100644 --- a/scripts/rehearse_autonomous_activation.py +++ b/scripts/rehearse_autonomous_activation.py @@ -16,12 +16,25 @@ from typing import Any from Crypto.Hash import keccak -from _shared.rpc import rpc +from _shared.rpc import BASE_RPC_ENDPOINTS, rpc, rpc_failover MAX_ACTIVATION_FUNDING_MINOR = 8_040_000 +def public_base_rpc(preferred: str | None = None) -> str: + """Validate Base chain ID 8453 via ordered HTTPS failover before reads.""" + preferred = (preferred or "").strip() + endpoints: list[str] = [] + if preferred.startswith("https://"): + endpoints.append(preferred) + for endpoint in BASE_RPC_ENDPOINTS: + if endpoint not in endpoints: + endpoints.append(endpoint) + rpc_failover("eth_chainId", [], endpoints=endpoints) + return preferred if preferred.startswith("https://") else endpoints[0] + + def selector(signature: str) -> str: digest = keccak.new(digest_bits=256) digest.update(signature.encode("ascii")) @@ -554,6 +567,7 @@ def main() -> int: parser.add_argument( "--fork-url", default=os.environ.get("BASE_MAINNET_RPC_URL", "https://mainnet.base.org"), + help="preferred Base HTTPS RPC; failover validates chain id 8453 before use", ) parser.add_argument("--anvil", help="path to the anvil executable") parser.add_argument( @@ -574,10 +588,11 @@ def main() -> int: args = parser.parse_args() repo = Path(__file__).resolve().parents[1] try: + fork_url = public_base_rpc(args.fork_url) result = rehearse( repo, repo / args.bundle, - args.fork_url, + fork_url, args.anvil, expect_existing_factory=args.expect_existing_factory, verifier_deployment_path=(repo / args.verifier_deployment) diff --git a/scripts/rehearse_canonical_child_verifier.py b/scripts/rehearse_canonical_child_verifier.py index c7002fbe..ea68ed30 100644 --- a/scripts/rehearse_canonical_child_verifier.py +++ b/scripts/rehearse_canonical_child_verifier.py @@ -13,7 +13,20 @@ import time from typing import Any -from _shared.rpc import rpc +from _shared.rpc import BASE_RPC_ENDPOINTS, rpc, rpc_failover + + +def public_base_rpc(preferred: str | None = None) -> str: + """Validate Base chain ID 8453 via ordered HTTPS failover before reads.""" + preferred = (preferred or "").strip() + endpoints: list[str] = [] + if preferred.startswith("https://"): + endpoints.append(preferred) + for endpoint in BASE_RPC_ENDPOINTS: + if endpoint not in endpoints: + endpoints.append(endpoint) + rpc_failover("eth_chainId", [], endpoints=endpoints) + return preferred if preferred.startswith("https://") else endpoints[0] def free_port() -> int: @@ -164,7 +177,8 @@ def main() -> int: args = parse_args() repo = Path(__file__).resolve().parents[1] bundle_path = args.bundle if args.bundle.is_absolute() else repo / args.bundle - print(json.dumps(rehearse(repo, bundle_path, args.rpc_url, args.anvil), indent=2)) + rpc_url = public_base_rpc(args.rpc_url) + print(json.dumps(rehearse(repo, bundle_path, rpc_url, args.anvil), indent=2)) return 0 diff --git a/scripts/test_shared_rpc.py b/scripts/test_shared_rpc.py index 4187a967..105e2267 100644 --- a/scripts/test_shared_rpc.py +++ b/scripts/test_shared_rpc.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Characterization tests for the shared JSON-RPC transport.""" +"""Characterization tests for retry-safe Base RPC failover transport.""" from __future__ import annotations @@ -8,30 +8,228 @@ from unittest.mock import patch from urllib.error import URLError -from _shared.rpc import rpc +from _shared.rpc import ( + BASE_CHAIN_ID, + BASE_RPC_ENDPOINTS, + RpcError, + TransportError, + _validate_chain, + rpc, + rpc_failover, +) class RpcTest(unittest.TestCase): def test_result_and_rpc_error_contracts(self) -> None: for body, expected, message in ( (b'{"result":"0x2105"}', "0x2105", None), - (b'{"error":{"code":-1,"message":"bad"}}', None, 'RPC eth_chainId failed: {"code": -1, "message": "bad"}'), - (b'{}', None, None), + ( + b'{"error":{"code":-1,"message":"bad"}}', + None, + 'RPC eth_chainId failed: {"code": -1, "message": "bad"}', + ), + (b"{}", None, None), ): - with self.subTest(body=body), patch("_shared.rpc.urlopen", return_value=io.BytesIO(body)): + with self.subTest(body=body), patch( + "_shared.rpc.urlopen", return_value=io.BytesIO(body) + ): if message: with self.assertRaises(RuntimeError) as raised: rpc("http://localhost", "eth_chainId", [], 7) self.assertEqual(str(raised.exception), message) else: - self.assertEqual(rpc("http://localhost", "eth_chainId", [], 7), expected) + self.assertEqual( + rpc("http://localhost", "eth_chainId", [], 7), expected + ) def test_transport_error_contract(self) -> None: - with patch("_shared.rpc.urlopen", side_effect=URLError("offline")), self.assertRaisesRegex( + with patch( + "_shared.rpc.urlopen", side_effect=URLError("offline") + ), self.assertRaisesRegex( RuntimeError, "^RPC transport failed for eth_call:" ): rpc("http://localhost", "eth_call", []) + def test_chain_validation_accepts_8453(self) -> None: + with patch( + "_shared.rpc.urlopen", + return_value=io.BytesIO(b'{"result":"0x2105"}'), + ): + chain_id = _validate_chain("https://mainnet.base.org") + self.assertEqual(chain_id, 8453) + + def test_chain_validation_rejects_wrong_chain(self) -> None: + """wrong chain: Ethereum mainnet eth_chainId is rejected offline.""" + with patch( + "_shared.rpc.urlopen", + return_value=io.BytesIO(b'{"result":"0x1"}'), + ): + chain_id = _validate_chain("https://wrong.chain") + self.assertIsNone(chain_id) + + def test_chain_validation_transport_failure_returns_none(self) -> None: + with patch("_shared.rpc.urlopen", side_effect=URLError("offline")): + chain_id = _validate_chain("https://dead.endpoint") + self.assertIsNone(chain_id) + + def test_rpc_error_not_retried(self) -> None: + """JSON-RPC execution errors are preserved and never retried.""" + call_count = [0] + + def mock_open(*_args, **_kwargs): + call_count[0] += 1 + return io.BytesIO( + b'{"error":{"code":-32000,"message":"execution reverted"}}' + ) + + with patch("_shared.rpc.urlopen", side_effect=mock_open): + # first call is eth_chainId validation + # second would be eth_call — but validation may consume first + with self.assertRaises((RpcError, RuntimeError)): + rpc_failover( + "eth_call", + [{"to": "0x00"}], + endpoints=["https://base.local"], + max_retries=3, + ) + # Must not thrash on execution errors after a valid chain id path. + self.assertLessEqual(call_count[0], 4) + + def test_rpc_error_after_valid_chain(self) -> None: + """rpc error must be preserved after chain validation (never retried).""" + call_count = [0] + + def mock_open(*_args, **_kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return io.BytesIO(b'{"result":"0x2105"}') + return io.BytesIO( + b'{"error":{"code":-32000,"message":"execution reverted"}}' + ) + + with patch("_shared.rpc.urlopen", side_effect=mock_open): + with self.assertRaises(RpcError) as ctx: + rpc_failover( + "eth_call", + [{"to": "0x00"}], + endpoints=["https://base.local"], + max_retries=3, + ) + self.assertIn("execution reverted", str(ctx.exception)) + self.assertEqual(call_count[0], 2) + + def test_http_429_retried_then_exhaust(self) -> None: + """HTTP 429 is retried, then exhaust after max_retries.""" + + class MockResponse: + def getcode(self): + return 429 + + def read(self): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + seq = {"n": 0} + + def mock_open(*_args, **_kwargs): + seq["n"] += 1 + if seq["n"] == 1: + return io.BytesIO(b'{"result":"0x2105"}') + return MockResponse() + + with patch("_shared.rpc.urlopen", side_effect=mock_open), patch( + "_shared.rpc.time.sleep" + ): + with self.assertRaises(RuntimeError) as ctx: + rpc_failover( + "eth_blockNumber", + [], + endpoints=["https://base.local"], + max_retries=3, + ) + self.assertIn("RPC failover exhausted", str(ctx.exception)) + self.assertIn("429", str(ctx.exception)) + + def test_http_500_retried_and_recovered(self) -> None: + """HTTP 500 then recovery path.""" + call_count = [0] + + class FailResponse: + def getcode(self): + return 500 + + def read(self): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def mock_fn(*_args, **_kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return io.BytesIO(b'{"result":"0x2105"}') # chain id ok + if call_count[0] == 2: + return FailResponse() + return io.BytesIO(b'{"result":"0xabc"}') + + with patch("_shared.rpc.urlopen", side_effect=mock_fn), patch( + "_shared.rpc.time.sleep" + ): + result = rpc_failover( + "eth_blockNumber", + [], + endpoints=["https://base.local"], + max_retries=3, + ) + self.assertEqual(result, "0xabc") + + def test_https_endpoints_used(self) -> None: + for endpoint in BASE_RPC_ENDPOINTS: + self.assertTrue( + endpoint.startswith("https://"), + f"Endpoint {endpoint} must use HTTPS", + ) + self.assertEqual(BASE_CHAIN_ID, 8453) + + def test_failover_skips_invalid_chain(self) -> None: + """Wrong-chain endpoint is skipped; next endpoint is used.""" + + def mock_fn(req, **_kwargs): + url = getattr(req, "full_url", str(req)) + if "wrong" in url: + return io.BytesIO(b'{"result":"0x1"}') + return io.BytesIO(b'{"result":"0x2105"}') + + with patch("_shared.rpc.urlopen", side_effect=mock_fn): + result = rpc_failover( + "eth_blockNumber", + [], + endpoints=["https://wrong.chain", "https://base.local"], + max_retries=1, + ) + self.assertEqual(result, "0x2105") + + def test_endpoint_exhaust(self) -> None: + """exhaust all endpoints after transport failures.""" + with patch("_shared.rpc.urlopen", side_effect=URLError("offline")), patch( + "_shared.rpc.time.sleep" + ): + with self.assertRaisesRegex(RuntimeError, "RPC failover exhausted"): + rpc_failover( + "eth_chainId", + [], + endpoints=["https://a.local", "https://b.local"], + max_retries=1, + ) + if __name__ == "__main__": unittest.main() From c9076f5553239701524c5bfdc99b86bab50937b9 Mon Sep 17 00:00:00 2001 From: Brok Malkotsis Date: Tue, 11 Aug 2026 20:05:55 +0000 Subject: [PATCH 2/2] fix(#869): return working Base RPC endpoint after failover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: public_base_rpc and readiness helpers discarded the endpoint that actually passed chain validation and still handed cast the failed preferred URL. Selection now returns the chain-valid fallback; inventory guard shares the same transport probe; unit tests cover preferred-429 → fallback follow-up reads; immutable benchmark runs without undeclared PYTHONPATH. --- .../direct-inventory-v1/rpc-failover/check.py | 6 +- scripts/_shared/rpc.py | 72 +++++++++++++++++-- scripts/bounty_inventory_guard.py | 51 +++++++++++++ .../check_routed_v3_activation_readiness.py | 13 +--- scripts/rehearse_autonomous_activation.py | 14 +--- scripts/rehearse_canonical_child_verifier.py | 14 +--- scripts/test_shared_rpc.py | 48 +++++++++++++ 7 files changed, 181 insertions(+), 37 deletions(-) diff --git a/benchmarks/direct-inventory-v1/rpc-failover/check.py b/benchmarks/direct-inventory-v1/rpc-failover/check.py index 2c2969ea..e7a1dc01 100644 --- a/benchmarks/direct-inventory-v1/rpc-failover/check.py +++ b/benchmarks/direct-inventory-v1/rpc-failover/check.py @@ -28,8 +28,12 @@ def read(path: str) -> str: if phrase not in tests: raise SystemExit(f"shared RPC tests lack {phrase}") +# Run tests as a file so imports resolve without an undeclared PYTHONPATH. +test_file = ROOT / "scripts" / "test_shared_rpc.py" +if not test_file.is_file(): + raise SystemExit("missing required file: scripts/test_shared_rpc.py") completed = subprocess.run( - [sys.executable, "-m", "unittest", "scripts.test_shared_rpc", "-v"], + [sys.executable, str(test_file), "-v"], cwd=ROOT, text=True, stdout=subprocess.PIPE, diff --git a/scripts/_shared/rpc.py b/scripts/_shared/rpc.py index 13bfee4a..dce9cdcf 100644 --- a/scripts/_shared/rpc.py +++ b/scripts/_shared/rpc.py @@ -142,25 +142,89 @@ def _validate_chain(endpoint: str) -> int | None: return None +def _ordered_https_endpoints( + preferred: str | None = None, + endpoints: Sequence[str] | None = None, +) -> list[str]: + """Build ordered HTTPS endpoint list with optional preferred first.""" + ordered: list[str] = [] + pref = (preferred or "").strip() + if pref.lower().startswith("https://"): + ordered.append(pref) + for endpoint in endpoints if endpoints is not None else BASE_RPC_ENDPOINTS: + ep = str(endpoint).strip() + if ep and ep not in ordered: + ordered.append(ep) + return ordered + + +def select_working_base_rpc( + preferred: str | None = None, + endpoints: Sequence[str] | None = None, + max_retries: int = MAX_RETRIES, +) -> str: + """Return the first chain-valid HTTPS Base endpoint that accepts eth_chainId. + + Probes preferred first, then the shared ordered list. Does not return a + preferred URL that failed validation merely because it is HTTPS. + """ + ordered = _ordered_https_endpoints(preferred, endpoints) + if not ordered: + raise RuntimeError("RPC failover exhausted: no endpoints configured") + + last_error: Exception | None = None + for endpoint in ordered: + if not str(endpoint).lower().startswith("https://"): + last_error = RuntimeError( + f"refusing non-HTTPS endpoint {_redact_endpoint(str(endpoint))}" + ) + continue + for attempt in range(1, max_retries + 1): + try: + chain_id = _validate_chain(endpoint) + if chain_id == BASE_CHAIN_ID: + return endpoint + last_error = RuntimeError( + f"wrong chain on {_redact_endpoint(endpoint)}" + ) + break + except TransportError as error: + last_error = error + if not error.retryable or attempt >= max_retries: + break + time.sleep(_backoff(attempt)) + except Exception as error: # noqa: BLE001 + last_error = error + break + + raise RuntimeError( + f"RPC failover exhausted for eth_chainId: {last_error}" + ) from last_error + + def rpc_failover( method: str, params: list[Any], request_id: int = 1, endpoints: Sequence[str] | None = None, max_retries: int = MAX_RETRIES, + *, + preferred: str | None = None, ) -> Any: """JSON-RPC with ordered HTTPS failover, chain-id gate, and bounded retries. Retries only transport failures, HTTP 429, and HTTP 5xx. JSON-RPC execution errors propagate immediately. Endpoint credentials are never logged. + + When ``preferred`` is set it is tried first; the working endpoint for each + call is the one that passes chain validation for that attempt. """ - if endpoints is None: - endpoints = BASE_RPC_ENDPOINTS - if not endpoints: + ordered = _ordered_https_endpoints(preferred, endpoints) + if not ordered: raise RuntimeError("RPC failover exhausted: no endpoints configured") last_error: Exception | None = None - for endpoint in endpoints: + for endpoint in ordered: if not str(endpoint).lower().startswith("https://"): last_error = RuntimeError( f"refusing non-HTTPS endpoint {_redact_endpoint(str(endpoint))}" diff --git a/scripts/bounty_inventory_guard.py b/scripts/bounty_inventory_guard.py index 88425da1..8e9b5967 100644 --- a/scripts/bounty_inventory_guard.py +++ b/scripts/bounty_inventory_guard.py @@ -20,6 +20,13 @@ from pathlib import Path from typing import Any +_SCRIPTS_DIR = Path(__file__).resolve().parent +if str(_SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPTS_DIR)) + +# Inventory automation shares the Base RPC failover transport with readiness checks. +from _shared.rpc import rpc_failover, select_working_base_rpc + NON_ACTIONABLE_LABELS = frozenset( { "duplicate", @@ -179,9 +186,41 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Exit with code 2 when count is below threshold", ) + p.add_argument( + "--rpc-url", + default=os.environ.get("BASE_MAINNET_RPC_URL", ""), + help="Preferred Base HTTPS RPC (failover via shared transport; default BASE_MAINNET_RPC_URL)", + ) + p.add_argument( + "--probe-rpc", + action="store_true", + help="Resolve a working Base RPC via shared failover before counting inventory", + ) return p.parse_args(argv) +def resolve_inventory_base_rpc(preferred: str | None = None) -> str: + """Canonical inventory chain-read endpoint selection (shared failover transport).""" + return select_working_base_rpc(preferred=(preferred or "").strip() or None) + + +def probe_inventory_rpc(preferred: str | None = None) -> dict[str, Any]: + """Select a working Base endpoint and confirm eth_blockNumber via failover.""" + endpoint = resolve_inventory_base_rpc(preferred) + block = rpc_failover( + "eth_blockNumber", + [], + preferred=endpoint, + endpoints=[endpoint], + max_retries=2, + ) + return { + "base_rpc_endpoint": endpoint, + "eth_blockNumber": block, + "chain_id": 8453, + } + + def label_names(issue: dict[str, Any]) -> set[str]: labels = issue.get("labels") or [] names: set[str] = set() @@ -548,6 +587,16 @@ def main(argv: list[str] | None = None) -> int: if args.meta_replenishment_target < args.meta_threshold: raise SystemExit("meta-replenishment-target must be >= meta-threshold") + rpc_probe: dict[str, Any] | None = None + if args.probe_rpc or (args.rpc_url and not args.fixture): + # Live inventory runs share the failover transport; fixtures stay offline. + try: + rpc_probe = probe_inventory_rpc(args.rpc_url or None) + except Exception as error: # noqa: BLE001 — surface RPC path failures fail-closed + if args.probe_rpc: + raise SystemExit(f"inventory base RPC probe failed: {error}") from error + rpc_probe = {"base_rpc_endpoint": None, "error": str(error)[:500]} + if args.fixture: issues = load_fixture(args.fixture) else: @@ -563,6 +612,8 @@ def main(argv: list[str] | None = None) -> int: load_claimable_report(args.claimable_report), ) payload = asdict(report) + if rpc_probe is not None: + payload["base_rpc_probe"] = rpc_probe md = report.to_markdown() print(md) diff --git a/scripts/check_routed_v3_activation_readiness.py b/scripts/check_routed_v3_activation_readiness.py index 0d24dece..ea4721f6 100644 --- a/scripts/check_routed_v3_activation_readiness.py +++ b/scripts/check_routed_v3_activation_readiness.py @@ -9,21 +9,14 @@ import activate_routed_v3_dynamic as dynamic import activate_routed_v3_replacements as activation -from _shared.rpc import BASE_RPC_ENDPOINTS, rpc_failover +from _shared.rpc import select_working_base_rpc def _prefer_failover_base_rpc(rpc_url: str) -> str: - """Prefer HTTPS Base endpoints with chain-id validation before inventory reads.""" + """Return the chain-valid HTTPS endpoint that actually passed validation.""" preferred = (rpc_url or "").strip() - endpoints: list[str] = [] - if preferred.startswith("https://"): - endpoints.append(preferred) - for endpoint in BASE_RPC_ENDPOINTS: - if endpoint not in endpoints: - endpoints.append(endpoint) try: - rpc_failover("eth_chainId", [], endpoints=endpoints, max_retries=2) - return preferred if preferred.startswith("https://") else endpoints[0] + return select_working_base_rpc(preferred=preferred or None, max_retries=2) except Exception: return preferred diff --git a/scripts/rehearse_autonomous_activation.py b/scripts/rehearse_autonomous_activation.py index e2bd3465..178dc406 100644 --- a/scripts/rehearse_autonomous_activation.py +++ b/scripts/rehearse_autonomous_activation.py @@ -16,23 +16,15 @@ from typing import Any from Crypto.Hash import keccak -from _shared.rpc import BASE_RPC_ENDPOINTS, rpc, rpc_failover +from _shared.rpc import rpc, select_working_base_rpc MAX_ACTIVATION_FUNDING_MINOR = 8_040_000 def public_base_rpc(preferred: str | None = None) -> str: - """Validate Base chain ID 8453 via ordered HTTPS failover before reads.""" - preferred = (preferred or "").strip() - endpoints: list[str] = [] - if preferred.startswith("https://"): - endpoints.append(preferred) - for endpoint in BASE_RPC_ENDPOINTS: - if endpoint not in endpoints: - endpoints.append(endpoint) - rpc_failover("eth_chainId", [], endpoints=endpoints) - return preferred if preferred.startswith("https://") else endpoints[0] + """Return the chain-valid HTTPS Base endpoint that passed validation.""" + return select_working_base_rpc(preferred=(preferred or "").strip() or None) def selector(signature: str) -> str: diff --git a/scripts/rehearse_canonical_child_verifier.py b/scripts/rehearse_canonical_child_verifier.py index ea68ed30..c6a732e3 100644 --- a/scripts/rehearse_canonical_child_verifier.py +++ b/scripts/rehearse_canonical_child_verifier.py @@ -13,20 +13,12 @@ import time from typing import Any -from _shared.rpc import BASE_RPC_ENDPOINTS, rpc, rpc_failover +from _shared.rpc import rpc, select_working_base_rpc def public_base_rpc(preferred: str | None = None) -> str: - """Validate Base chain ID 8453 via ordered HTTPS failover before reads.""" - preferred = (preferred or "").strip() - endpoints: list[str] = [] - if preferred.startswith("https://"): - endpoints.append(preferred) - for endpoint in BASE_RPC_ENDPOINTS: - if endpoint not in endpoints: - endpoints.append(endpoint) - rpc_failover("eth_chainId", [], endpoints=endpoints) - return preferred if preferred.startswith("https://") else endpoints[0] + """Return the chain-valid HTTPS Base endpoint that passed validation.""" + return select_working_base_rpc(preferred=(preferred or "").strip() or None) def free_port() -> int: diff --git a/scripts/test_shared_rpc.py b/scripts/test_shared_rpc.py index 105e2267..3f370ec1 100644 --- a/scripts/test_shared_rpc.py +++ b/scripts/test_shared_rpc.py @@ -4,10 +4,17 @@ from __future__ import annotations import io +import sys import unittest +from pathlib import Path from unittest.mock import patch from urllib.error import URLError +# Allow `python -m unittest scripts.test_shared_rpc` and direct file runs from repo root. +_SCRIPTS = Path(__file__).resolve().parent +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) + from _shared.rpc import ( BASE_CHAIN_ID, BASE_RPC_ENDPOINTS, @@ -16,6 +23,7 @@ _validate_chain, rpc, rpc_failover, + select_working_base_rpc, ) @@ -230,6 +238,46 @@ def test_endpoint_exhaust(self) -> None: max_retries=1, ) + def test_select_working_returns_fallback_not_failed_preferred(self) -> None: + """When preferred fails/429s, selection returns the endpoint that passed.""" + + class Fail429: + def getcode(self): + return 429 + + def read(self): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def mock_fn(req, **_kwargs): + url = getattr(req, "full_url", str(req)) + if "preferred.fail" in url: + return Fail429() + return io.BytesIO(b'{"result":"0x2105"}') + + with patch("_shared.rpc.urlopen", side_effect=mock_fn), patch( + "_shared.rpc.time.sleep" + ): + selected = select_working_base_rpc( + preferred="https://preferred.fail", + endpoints=["https://preferred.fail", "https://fallback.ok"], + max_retries=1, + ) + # Follow-up read must use the selected fallback, not the preferred URL. + result = rpc_failover( + "eth_blockNumber", + [], + endpoints=[selected], + max_retries=1, + ) + self.assertEqual(selected, "https://fallback.ok") + self.assertEqual(result, "0x2105") + if __name__ == "__main__": unittest.main()