diff --git a/harnesses/gas-estimation/cmd/script/config.go b/harnesses/gas-estimation/cmd/script/config.go index 49349a05..66dc49a7 100644 --- a/harnesses/gas-estimation/cmd/script/config.go +++ b/harnesses/gas-estimation/cmd/script/config.go @@ -133,7 +133,10 @@ func chains() []Chain { Slug: "arbitrum", ChainID: 42161, RealizedRPC: envDefault("GAS_REALIZED_RPC_ARBITRUM", "https://arbitrum-one-rpc.publicnode.com"), - VerifyRPC: envDefault("GAS_REALIZED_RPC_VERIFY_ARBITRUM", "https://arbitrum.drpc.org"), + // dRPC's arbitrum public routes to 1rpc.io which rate-limits within 30s. + // arb1.arbitrum.io is Offchain Labs' own hosted RPC — genuinely different + // upstream from PublicNode (Node Fleet), sustains 5+ req/s in tests. + VerifyRPC: envDefault("GAS_REALIZED_RPC_VERIFY_ARBITRUM", "https://arb1.arbitrum.io/rpc"), OwlracleSlug: "arb", BlockTimeSec: 1, // Arb One has sub-second nominal block time; realizer catch-up loop handles the burst. SupportedSet: []Oracle{OraclePublicNode, OracleOwlracle, OracleEtherscan, OracleMetaMask}, diff --git a/harnesses/gas-estimation/cmd/script/oracles.go b/harnesses/gas-estimation/cmd/script/oracles.go index d733a1b3..8fa1b0e3 100644 --- a/harnesses/gas-estimation/cmd/script/oracles.go +++ b/harnesses/gas-estimation/cmd/script/oracles.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "os" "strconv" "strings" "sync" @@ -21,6 +23,39 @@ const ( httpTimeout = 8 * time.Second ) +// proxyClient is a rotating-IP HTTP client built at process start +// from HTTPS_PROXY / HTTP_PROXY. Used only by pollers that need to +// evade per-IP throttles (currently: Owlracle, whose 10 req/h guest +// ceiling per IP made every 60 s poll from a single VPS collapse +// into HTTP 403 within an hour). Keep-alives are disabled so every +// request opens a fresh TCP conn and the rotating proxy (webshare) +// assigns a new upstream IP per request. If no proxy env is set, +// proxyClient stays nil and pollers fall back to the default client. +var proxyClient *http.Client + +func init() { + raw := os.Getenv("HTTPS_PROXY") + if raw == "" { + raw = os.Getenv("HTTP_PROXY") + } + if raw == "" { + return + } + parsed, err := url.Parse(raw) + if err != nil { + fmt.Printf("[proxy] parse %q: %v\n", raw, err) + return + } + proxyClient = &http.Client{ + Timeout: httpTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsed), + DisableKeepAlives: true, + }, + } + fmt.Printf("[proxy] wired via %s\n", parsed.Host) +} + // pollResult is what every oracle client returns. TargetBlock is the // block these predictions apply to (oracle-specific: Blocknative // gives an explicit next-block number, feeHistory returns the @@ -228,7 +263,12 @@ type owlResp struct { func pollOwlracle(ctx context.Context, ep OracleEndpoint) pollResult { req, _ := http.NewRequestWithContext(ctx, "GET", ep.URL, nil) - body, status, err := httpDo(ctx, req) + // Owlracle's guest tier is 10 req/h per IP. Our 4 chains × 60s + // poll = 240 req/h from a single VPS IP, which collapses to a + // permanent HTTP 403 within an hour. Route through the rotating + // proxy pool so each request gets a fresh upstream IP and stays + // under the per-IP guest ceiling. + body, status, err := httpDoProxied(ctx, req) if err != nil { return pollResult{Err: err} } @@ -491,6 +531,27 @@ func httpDo(ctx context.Context, req *http.Request) ([]byte, int, error) { return raw, resp.StatusCode, nil } +// httpDoProxied routes the request through proxyClient if a proxy is +// configured (webshare rotating pool via HTTPS_PROXY env). Falls +// back to httpDo when no proxy is set — useful for local runs +// without proxy access. +func httpDoProxied(ctx context.Context, req *http.Request) ([]byte, int, error) { + if proxyClient == nil { + return httpDo(ctx, req) + } + req.Header.Set("User-Agent", "OpenChainBench/1.0 (+https://openchainbench.com)") + resp, err := proxyClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return raw, resp.StatusCode, nil +} + func parseHexU64(s string) (uint64, error) { s = strings.TrimPrefix(s, "0x") if s == "" { diff --git a/harnesses/gas-estimation/cmd/script/realized.go b/harnesses/gas-estimation/cmd/script/realized.go index 52cbd35c..2e51cfe6 100644 --- a/harnesses/gas-estimation/cmd/script/realized.go +++ b/harnesses/gas-estimation/cmd/script/realized.go @@ -217,14 +217,26 @@ func runRealizer(ctx context.Context, buf *Buffer, chain Chain) { lastObserved = head return } - // Catch up at most 5 blocks per tick to bound the per-tick - // work even if the realizer lagged behind. On Polygon / - // Avalanche (2s block time, 12s realizer cadence) we expect - // to see 6 new blocks per tick on average, so the catch-up - // loop intentionally trails reality by a block or two — - // that's fine because the buffer holds predictions for - // pendingTTLBlocks worth of blocks anyway. - for n := lastObserved + 1; n <= head && n <= lastObserved+5; n++ { + // Per-chain catch-up cap. ETH (12s blocks) needs 5, Polygon + // and Avalanche (2s blocks) need ~10, Arbitrum L2 (sub-second + // blocks) sees ~48 new blocks per 12s realizer tick and falls + // hopelessly behind at cap=5 (buffers spike to 99 predictions + // per oracle, most get evicted before grading). Cap scales as + // `max(5, ceil(realizedPollInterval / BlockTimeSec) + 5)` — + // one tick of blocks plus a small slack. Hard ceiling of 60 + // so a stalled realizer can never fill the tick window with + // fetches on the busiest chain. + catchupCap := uint64(5) + if chain.BlockTimeSec > 0 { + c := uint64(int(realizedPollInterval.Seconds())/chain.BlockTimeSec + 5) + if c > catchupCap { + catchupCap = c + } + } + if catchupCap > 60 { + catchupCap = 60 + } + for n := lastObserved + 1; n <= head && n <= lastObserved+catchupCap; n++ { processBlock(ctx, buf, n, chain) lastObserved = n }