The RPC said Base was healthy. The first contract read still failed.
This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry. Project Overview Agent Bounties is an open-source, payment-first bounty network. One of its GitHub Actions turns an exact GitHub identity and wallet into a participant record on Base before that participant can enter certain funded bounty flows. That registration path is deliberately fail-closed: if the action cannot prove the right chain, registry, attester, identity, transaction receipt, and final eligibility state, it must stop rather than pretending registration succeeded. While using the real registration workflow, I found a narrower but consequential reliability bug. The action considered an RPC endpoint healthy after only an eth_chainId check. The endpoint correctly reported Base mainnet, but refused the first participant-registry archive read with HTTP 403. Because selection had already committed to that endpoint, the ordered fallbacks were never tried. The result was not a cosmetic error. Two exact GitHub-hosted runs, from different runner regions, failed before broadcasting a transaction, blocking the participant registration that the bounty path required. Bug Fix or Performance Improvement The old health check answered only this question: Does this endpoint say it is Base mainnet? The workflow actually needed a stronger answer: Does this endpoint say it is Base mainnet and successfully perform the immutable registry read that registration depends on? That difference matters because an RPC can serve a cheap chain-ID response while rate-limiting, gating, or rejecting historical contract reads. The failure looked like this: configured RPC └─ eth_chainId == 8453 ✓ selected └─ registry attester() ✗ HTTP 403 └─ registration ✗ fail closed fallback RPCs never reached I changed endpoint selection to probe the real capability before committing: def select_base_rpc(cast: str, configured: str, registry: str) -> str: """Probe ordered RPC fallbacks and commit to one usable Base endpoint.""" endpoints = parse_rpc_urls(configured) for endpoint in endpoints: try: if run([cast, "chain-id", "--rpc-url", endpoint]) != BASE_CHAIN_ID: continue attester = run( [cast, "call", "--rpc-url", endpoint, registry, "attester()(address)"] ).lower() if ADDRESS.fullmatch(attester): return endpoint except RegistrationError: continue raise RegistrationError("no usable Base mainnet RPC endpoint") The workflow now has three ordered candidates: The operator-configured endpoint, preserving the existing preference. https://mainnet.base.org. https://base.drpc.org. An endpoint is selected only after it returns Base chain ID 8453 and a valid address from the registry's attester() call. If none can do both, the workflow still fails closed. I also fixed a related consistency error: the final eligibleAt(...) confirmation had been passed the original comma-separated configuration rather than the single endpoint selected by the probe. Every chain operation now uses the same proven endpoint. Code Upstream pull request #808 Immutable fix commit f58dbbb Second production failure, including the successful contract test job and fail-closed registration job Incident evidence and runner-region comparison The PR changes three files: .github/workflows/participant-registration.yml | 2 +- scripts/register_participant.py | 20 +++- scripts/test_register_participant.py | 133 +++++++++++++++++++++++-- 3 files changed, 141 insertions(+), 14 deletions(-) The upstream PR is open and currently mergeable, with its automated claimed-work check green. The challenge FAQ says an OSS contribution does not need to be merged upstream, so the public fork commit above is the stable, reviewable implementation while maintainer review proceeds independently. My Improvements 1. I reproduced the failure in the real workflow This was not inferred from a synthetic timeout. Two authorized registration attempts failed on the same provider from different GitHub runner regions: ORD runner: the endpoint passed chain ID, then rejected the registry read. SJC runner: the same sequence failed again on a later retry. Direct workstation probes between the two attempts showed that all three candidates could answer both calls at that moment. That contrast isolated the issue: a developer-side smoke test was not a reliable proxy for the GitHub runner's ability to perform the actual contract read. Both workflow attempts stopped before a transaction. That is the correct safety outcome, but the wrong availability outcome when healthy fallbacks exist. 2. I made the probe match the operation The core fix is intentionally small: test the cheapest real dependency before selecting a provider. attester() is read-only, stable, inexpensive, and exercises the same registry access class the registration flow requires. This avoids a dangerous overcorrection. The change does not weaken receipt checks, bypass eligibility, retry a transaction blindly, or alter any signing authority. It improves endpoint selection while preserving every existing payment and identity invariant. 3. I added deterministic regression coverage The focused suite now covers: an unavailable endpoint; an endpoint on the wrong chain; an endpoint that passes chain-id but refuses the registry read; ordered fallback selection; safe parsing of the endpoint list; an end-to-end fixture proving every chain operation uses the selected endpoint; preservation of transaction evidence after a post-receipt error; strict identity, repository, PR, registration-record, and eligibility checks. Current verification: $ python3 scripts/test_register_participant.py -v Ran 10 tests in 0.002s OK $ bash scripts/preflight.sh core preflight=core ok $ git diff --check # clean 4. I preserved the failure boundary If no endpoint both reports Base mainnet and reads the expected registry, the action stops. No transaction is attempted. The change does not modify the contract, registry address, attester or keeper authority, participant identity derivation, validity window, or settlement behavior. That was the most important design constraint: failover should improve availability without turning uncertainty into an on-chain side effect. Result The fixed path now distinguishes "this server knows Base's chain ID" from "this server can actually support participant registration." A degraded configured provider no longer prevents the workflow from trying its healthy fallbacks, and the selected endpoint is used consistently through the final eligibility check. The broader lesson is simple: health checks should prove the capability the next operation needs, not merely the cheapest fact a service can return.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to