Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions app/crypto_payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/OPENCLAW_BRIEF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
19 changes: 11 additions & 8 deletions tests/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions tests/test_house.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]})

Expand All @@ -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"]))

Expand All @@ -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})

Expand All @@ -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"]})

Expand Down Expand Up @@ -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
Expand Down
Loading