From db3df1f2712d4d0f26ffe2f41fbce538b64f4e21 Mon Sep 17 00:00:00 2001 From: "Dada (OpenClaw)" Date: Wed, 30 Sep 2026 21:00:18 +0000 Subject: [PATCH] Correct the Arbitrum fix: it was the RPC endpoint, not the block range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #20's max_block_range=40 was the wrong diagnosis. Measured again, more carefully: publicnode's free endpoint refuses eth_getLogs whenever toBlock is more than ~50-100 blocks behind its *live* head, regardless of range width (a 10-block, already-narrow query fails at 100 blocks behind head; a 1000-block query succeeds right at head). Since 'safe' is always confirmations(240) blocks behind head by design, every query against that endpoint was structurally unreachable — reducing the range couldn't have fixed it, and confirmed it still didn't (same error after the PR #20 deploy). Switched to Arbitrum's own public RPC (arb1.arbitrum.io/rpc), verified directly: no archive restriction, just a 10,000-matched-logs cap. max_block_range=500 measured at ~2,700 logs/1000 blocks for USDC on Arbitrum, comfortable margin. Also fixes the three house.py portfolio tests that share the same PRESETS entry: arb1.arbitrum.io's path is /rpc, not /, so the test fixtures needed a second mock registration. OPENCLAW_BRIEF.md's RPC reference table updated to match. Manually re-advanced the still-stuck ChainCursor row (chain_id=42161) via direct UPDATE to the new safe head — same nothing-to-backfill situation as before. 181 tests pass, ruff clean. Verified against the live endpoints, not just diagnosed. --- app/crypto_payments.py | 18 +++++++++++------- docs/OPENCLAW_BRIEF.md | 2 +- tests/test_crypto.py | 19 +++++++++++-------- tests/test_house.py | 8 ++++++-- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/app/crypto_payments.py b/app/crypto_payments.py index f1022de..4700e34 100644 --- a/app/crypto_payments.py +++ b/app/crypto_payments.py @@ -94,13 +94,17 @@ def public(self) -> dict: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", confirmations=12, max_block_range=500), "base": Network("base", "Base", 8453, "https://base-rpc.publicnode.com", "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", confirmations=10, max_block_range=1000), - # max_block_range=40: publicnode's free Arbitrum endpoint answers eth_getLogs for a - # range up to ~50-99 blocks but returns "Archive requests require a personal token" - # above that (measured directly against the live endpoint 2026-09-30) — nothing to do - # with confirmations or how far behind the cursor is. 5000 here meant every poll - # since deploy failed and the deposit watcher never advanced at all. - "arbitrum": Network("arbitrum", "Arbitrum One", 42161, "https://arbitrum-one-rpc.publicnode.com", - "0xaf88d065e77c8cc2239327c5edb3a432268e5831", confirmations=240, max_block_range=40), + # publicnode's free Arbitrum endpoint refuses eth_getLogs with "Archive requests + # require a personal token" for any query whose *toBlock* is more than roughly + # 50-100 blocks behind its live head — measured directly 2026-09-30, true regardless + # of range width. Since `safe` is always confirmations(240) blocks behind head by + # design, every query against that endpoint was structurally in "archive" territory; + # the deposit watcher had failed on every single poll since deploy and the cursor + # never advanced. Arbitrum's own public RPC has no such restriction (verified), just + # a 10,000-matched-logs cap per call — max_block_range=500 keeps comfortably under + # that at USDC's transfer volume (~2,700 logs/1000 blocks measured). + "arbitrum": Network("arbitrum", "Arbitrum One", 42161, "https://arb1.arbitrum.io/rpc", + "0xaf88d065e77c8cc2239327c5edb3a432268e5831", confirmations=240, max_block_range=500), "optimism": Network("optimism", "OP Mainnet", 10, "https://optimism-rpc.publicnode.com", "0x0b2c639c533813f4aa9d7837caf62653d097ff85", confirmations=10, max_block_range=1000), "polygon": Network("polygon", "Polygon PoS", 137, "https://polygon-bor-rpc.publicnode.com", diff --git a/docs/OPENCLAW_BRIEF.md b/docs/OPENCLAW_BRIEF.md index 639c582..96232ac 100644 --- a/docs/OPENCLAW_BRIEF.md +++ b/docs/OPENCLAW_BRIEF.md @@ -40,7 +40,7 @@ https://appp.tail1cb552.ts.net. |---|---| | ethereum | https://ethereum-rpc.publicnode.com | | base | https://base-rpc.publicnode.com | - | arbitrum | https://arbitrum-one-rpc.publicnode.com | + | arbitrum | https://arb1.arbitrum.io/rpc (publicnode's free tier refuses eth_getLogs more than ~50-100 blocks behind its head — confirmed 2026-09-30, broke deposit detection entirely) | | optimism | https://optimism-rpc.publicnode.com | | polygon | https://polygon-bor-rpc.publicnode.com | diff --git a/tests/test_crypto.py b/tests/test_crypto.py index cf321e9..f72d3fa 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -306,11 +306,14 @@ def test_network_presets_and_json_config(monkeypatch): assert [n.key for n in cp.configured_networks()] == ["ethereum"] # legacy single-network settings -def test_arbitrum_max_block_range_stays_under_the_free_rpcs_archive_threshold(): - """Regression guard: publicnode's free Arbitrum endpoint answers eth_getLogs up to - ~50-99 blocks, then refuses with "Archive requests require a personal token" above - that — measured directly against the live endpoint 2026-09-30. A range at or above - that threshold means the deposit watcher fails on every single poll and the cursor - never advances (confirmed: it sat ~455k blocks behind for as long as this had been - deployed). Keep meaningful margin below the measured ~50 floor.""" - assert cp.PRESETS["arbitrum"].max_block_range <= 45 +def test_arbitrum_uses_a_getlogs_endpoint_without_the_archive_restriction(): + """Regression guard: publicnode's free Arbitrum endpoint refuses eth_getLogs with + "Archive requests require a personal token" whenever toBlock is more than ~50-100 + blocks behind its live head — measured directly 2026-09-30. Since `safe` is always + confirmations(240) blocks behind head by design, every query against that endpoint + was structurally unreachable: the deposit watcher failed on every single poll and + the cursor sat ~455k blocks behind for as long as this had been deployed. Arbitrum's + own public RPC has no such restriction, just a 10,000-matched-logs cap per call.""" + net = cp.PRESETS["arbitrum"] + assert "publicnode.com" not in net.rpc_url + assert net.max_block_range <= 2000 # comfortably under the 10k-log cap at USDC volume diff --git a/tests/test_house.py b/tests/test_house.py index d4431ca..ddea8a5 100644 --- a/tests/test_house.py +++ b/tests/test_house.py @@ -241,6 +241,7 @@ def rpc(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload["id"], "result": result}) web.responses["/"] = rpc + web.responses["/rpc"] = rpc # arbitrum's PRESETS entry is arb1.arbitrum.io/rpc, not path "/" r = await invoke(client, house, "portfolio", {"address": address, "networks": ["base", "arbitrum"]}) @@ -265,7 +266,7 @@ def rpc(request: httpx.Request) -> httpx.Response: assert token["balance_units"] == 5_000_000 hosts = {host for host, _ in calls} - assert hosts == {"base-rpc.publicnode.com", "arbitrum-one-rpc.publicnode.com"} + assert hosts == {"base-rpc.publicnode.com", "arb1.arbitrum.io"} # native + USDC + WETH per network, exactly the capped allowlist, no more. assert len(calls) == 2 * (1 + len(app_house.TOKEN_ALLOWLIST["base"])) @@ -276,6 +277,7 @@ def rpc(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload["id"], "result": "0x0"}) web.responses["/"] = rpc + web.responses["/rpc"] = rpc # arbitrum's PRESETS entry is arb1.arbitrum.io/rpc, not path "/" r = await invoke(client, house, "portfolio", {"address": "0x" + "11" * 20}) @@ -288,12 +290,13 @@ async def test_portfolio_reports_per_asset_failure_without_5xx(client, house, we """One chain's dead RPC must not cost the buyer the rest of the portfolio.""" def rpc(request: httpx.Request) -> httpx.Response: - if request.url.host == "arbitrum-one-rpc.publicnode.com": + if request.url.host == "arb1.arbitrum.io": return httpx.Response(500, text="rpc down") payload = json.loads(request.content) return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload["id"], "result": "0x0"}) web.responses["/"] = rpc + web.responses["/rpc"] = rpc # arbitrum's PRESETS entry is arb1.arbitrum.io/rpc, not path "/" r = await invoke(client, house, "portfolio", {"address": "0x" + "22" * 20, "networks": ["base", "arbitrum"]}) @@ -349,6 +352,7 @@ def rpc(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload["id"], "result": "0x0"}) web.responses["/"] = rpc + web.responses["/rpc"] = rpc # arbitrum's PRESETS entry is arb1.arbitrum.io/rpc, not path "/" r1 = await invoke(client, house, "portfolio", {"address": address, "networks": ["base", "arbitrum"]}, call_id="order-c1") assert r1.status_code == 200, r1.text