feat(eth): whitelist EVM method selectors for rpcCall, runtime-configurable via admin API#1597
Conversation
Add <network>_ALLOWED_EVM_CALL_METHODS, a comma-separated list of 4-byte method selectors that permits websocket rpcCall requests to any address when the calldata starts with an allowed selector (e.g. 0xdd62ed3e for ERC-20 allowance). Complements ALLOWED_RPC_CALL_TO: a call passes when either its target address or its selector is allowed; with neither set rpcCall stays unrestricted. Malformed calldata never matches (full hex decode, fail closed) and malformed or empty selector config fails startup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Turn ALLOWED_RPC_CALL_TO and ALLOWED_EVM_CALL_METHODS into runtime settings: the environment variables remain the startup defaults, while an override written through the authenticated internal API GET/POST/DELETE /admin/runtime-settings/<KEY> is persisted in RocksDB (own cfDefault rows, independent of the periodically stored internalState blob and invisible to older versions), survives restarts, takes precedence over the environment and takes effect on websocket rpcCall immediately, without a restart. The live allowlists are an immutable snapshot behind an atomic pointer on InternalState, so the rpcCall hot path stays lock-free and both servers (public websocket, internal admin) share one view regardless of which of them a deployment runs. Writes are strictly ordered: validate (400 on invalid input) -> store to DB (500 and untouched live state on failure) -> publish snapshot -> log the change with old/new value, source and client address. POST of an exactly empty value explicitly unconfigures a dimension; whitespace- or separator-only values are rejected so a botched input cannot silently un-restrict rpcCall; DELETE reverts to the environment default and validates it first so a malformed environment cannot brick the next restart. The TO-list parser is unified with the selector parser: entries are trimmed, empty entries skipped and a set-but-empty value fails startup (previously "," allowlisted the empty string, which matched rpcCall requests with an empty to field). A minimal /admin/runtime-settings page lists the current values and their sources with curl hints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Avoids allocating a decoded copy of the full calldata (up to 4 MiB) on every rpcCall allowlist check. The full hex encoding is still validated byte-by-byte to preserve the existing fail-closed behavior for malformed calldata.
|
API unification review:
|
| Endpoint | Body |
|---|---|
POST /admin/contract-info/ |
Raw []ContractInfo |
POST /admin/runtime-settings/<KEY> |
{"value":"..."} |
The contract-info body IS the data; the runtime-settings body wraps it in a value key. If there is no strong reason for the wrapper, either:
- Unwrap
runtime-settingsto accept the raw value string as the body, or - Add a thin abstraction so all future admin mutation endpoints use a consistent body shape.
3. No DELETE in the old API
contract-info has no way to remove a single entry. The old approach is "overwrite by posting an empty array". The new endpoint introduces a proper DELETE verb with env-var fallback semantics. Consider adding DELETE support to contract-info for symmetry, or document the divergence.
4. Key normalization differs
| Endpoint | Key extraction |
|---|---|
contract-info/<address> |
r.URL.Path[i+1:] — raw, no normalization |
runtime-settings/<KEY> |
strings.ToUpper(strings.TrimSpace(...)) — normalized |
A user interacting with both APIs would see different behavior for the same path segment input. Recommend either:
- Normalize the
contract-infopath segment too (lowercase the address), or - Extract a common helper
urlPathSegment(r *http.Request) stringused by both.
5. Route registration is gated inconsistently within the codebase
Both routes are registered inside a ChainEthereumType guard in internal.go, but the runtime-settings handlers (apiRuntimeSetting, runtimeSettingsPage) are fully generic — they contain no EVM-specific logic. The only EVM-specific part is the parseAllowedEvmCallMethods definition in runtimeSettingDefs, which would simply never produce matches on non-EVM coins (no EVM calldata to check). Meanwhile, initRpcCallAllowlists is called unconditionally in websocket.go:178 but conditionally in internal.go:114.
Fix: Move initRpcCallAllowlists and the two runtime-settings routes outside the ChainEthereumType guard in internal.go. Keep internal-data-errors and contract-info gated (those are genuinely EVM-specific).
6. Admin nav link gating mismatch
The "Runtime Settings" link in static/internal_templates/index.html is inside {{if eq .ChainType 1}}, matching the route registration guard. If fix #5 is applied, this if must be removed from the runtime-settings link (but kept for internal-data-errors and contract-info).
Summary of actionable items
| # | What | Where | Notes |
|---|---|---|---|
| 1 | Return a typed struct from updateContracts |
server/internal.go |
Fixes double-encoding bug |
| 2 | Align write body format | server/runtime_settings.go or internal.go |
Optional — pick one convention |
| 3 | Add DELETE to contract-info |
server/internal.go |
Optional, for symmetry |
| 4 | Normalize contract-info path segment |
server/internal.go |
Or extract shared helper |
| 5 | Move routes outside ChainEthereumType guard |
server/internal.go |
Low risk, handlers are generic |
| 6 | Remove ChainType guard from runtime-settings nav link |
static/internal_templates/index.html |
Must follow #5 |
Addresses the API unification review on PR #1597: - POST /admin/contract-info/ returns a typed {"updated":N} object instead of a pre-serialized string that jsonHandler double-encoded on the wire; a malformed body is now a 400 (public APIError) rather than a 500 - apiContractInfo dispatches on method like apiRuntimeSetting: POST/PUT write the collection path only (an address segment is rejected instead of silently ignored), unknown methods are a 400 instead of falling into the GET path - new DELETE /admin/contract-info/<address> purges cached contract metadata (DB row + LRU entry) so it is re-fetched from the backend on the next read; idempotent, missing row reports deleted:false - shared urlPathSegment helper unifies path-segment extraction; contract addresses get no case normalization (the chain parser is the authority), runtime-setting keys keep their uppercasing at the call site - runtime-settings routes and initRpcCallAllowlists move out of the ChainEthereumType guard (the mechanism is chain-generic and the init already runs unconditionally in NewWebsocketServer); contract-info and internal-data-errors stay EVM-gated, nav links follow the same split Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Smaller items from the PR #1597 review: - lowercase the "invalid EVM call method selector" parse error to match the sibling parser's message convention - trim environment values wherever they are resolved (startup and the DELETE fallback), so stray whitespace from an env file never surfaces in values; a set but whitespace-only env value is a configuration error rather than unset — treating it as unset would silently un-restrict rpcCall - document the differing To/Methods map key formats on RpcCallAllowlists so a future allowlist dimension picks a convention deliberately - runtimeSettingStore gains GetRuntimeSetting; resolveRuntimeSetting reads through the interface instead of requiring *db.RocksDB Admin runtime-settings page gets an inline editing UI (edit/save/delete per setting, replacing the curl-only instructions) and the page/nav/ routes are consistently available on all chain types. Hardening found by review of the new DELETE /admin/contract-info/: - bump protocolGen before the cache purge in DeleteContractInfoForAddress so a concurrent GetContractInfo cannot re-insert the deleted row into the LRU (same idiom as SetErcProtocol); without it the purge could be silently undone until eviction or restart - DELETE returns (and logs) the purged record: the backend re-fetch restores only name/symbol/decimals, not the sync-owned createdInBlock/destructedInBlock, so the response gives the operator a POST-back restore path and the docs now state exactly what is discarded - a failed save in the admin UI keeps the row in edit state instead of stranding an orphaned input, and error responses are unwrapped to their message Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supports the bbctl counterpart (trezor/bbctl#10) of the runtime-settings admin API in a two-replica deployment where each replica owns its DB: - GET /admin/runtime-settings/ (bare collection path) returns every setting with its effective value and source as a JSON array, so a management tool reads the whole state per replica in one request; other methods on the bare path stay a 400 - initRpcCallAllowlists warns at startup when a stored override shadows a different (or malformed) environment value, making env/DB drift — a replica that missed an admin update, or an env change rolled out while an override exists — visible; the shadowed value is only compared, never parsed, so drift cannot fail a restart - docs/env.md explains the deployment roles of the two sources: the env var is the deploy-managed baseline that survives a replica resync (the DB is wiped, and unset allowlists would silently un-restrict rpcCall), the stored override is the runtime layer that survives restarts until the next deploy ships an updated environment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /admin/contract-info/ (bare collection path) lists the stored
records, mirroring the runtime-settings list convention. Unlike runtime
settings the collection is unbounded — sync stores a record per contract
creation, millions on a busy chain — so the list is paginated:
?limit=<1..10000> (default 1000) with a from cursor, and the response
{"contracts":[...],"next":"<address>"} carries the from of the next
page. ListContractInfos iterates cfContracts in address-descriptor
order (the CF holds only plain addrDesc keys; protocol rows live in
their own CF).
The former 400 for GET of the bare path moves to the list; DELETE and
POST semantics on the bare path are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #1593.
Motivation
Trezor Suite (Yield, Trading) needs to read ERC-20
allowance(address owner, address spender)across many different token contracts before showing or performing approve/deposit flows.<network>_ALLOWED_RPC_CALL_TOalone cannot express that — it would require whitelisting every token contract address individually.What this PR does
1. Method-selector allowlist for
rpcCallNew option
ALLOWED_EVM_CALL_METHODS: a comma-separated list of 4-byte EVM method selectors (optional0xprefix, case-insensitive) that websocketrpcCallrequests may invoke on any address when the calldata starts with an allowed selector — e.g.0xdd62ed3eforallowance(address,address).ALLOWED_RPC_CALL_TO: a call is allowed when either its target address or its calldata selector is allowed; with neither configured,rpcCallstays unrestricted (unchanged behavior).datafield is strictly hex-decoded (0x/0Xprefix required, even length, ≥ 4 bytes), matching the backend node's own parsing — malformed calldata never matches a selector, so it cannot bypass the allowlist.2. Both allowlists are dynamic runtime settings
Rather than remaining static env vars read once at startup, both
ALLOWED_RPC_CALL_TOandALLOWED_EVM_CALL_METHODSare implemented as runtime settings, manageable through the internal server's authenticated admin API (existing HTTP Basic auth,BB_ADMIN_USER/BB_ADMIN_PASSWORD; a bearer-token scheme for a future JS admin UI can later wrap the same middleware):cfDefaultkeys (JSON envelope with an updated-timestamp), independent of the periodically storedinternalStateblob. They survive restarts and take precedence over the env vars, which act as startup defaults only. Older Blockbook versions ignore the rows (env applies again after a rollback) and keep them intact.InternalState— therpcCallhot path stays lock-free, and changes apply to the next call with no restart. Initialization runs in both the public-websocket and internal-server constructors (CAS-guarded), so any deployment topology gets correct values.400and change nothing; a DB write failure returns500and leaves the live allowlists untouched, so a200always means the change is both durable and live. Every change is logged with old/new value, source and client address.POST {"value":""}(exactly empty) explicitly unconfigures a dimension — the only way to un-restrict at runtime while the env var has a value. Whitespace/separator-only values are rejected, so a botched automation input cannot silently disable the allowlist.DELETEvalidates the env fallback before removing the override, so a malformed environment cannot brick the next restart./admin/runtime-settingspage shows the current values and their sources, with curl hints.Behavior change (documented in
docs/env.md)The
ALLOWED_RPC_CALL_TOparser is unified with the selector parser: entries are trimmed, empty entries skipped, and a set-but-empty value now fails startup. Previously","silently allowlisted the empty string — which matchedrpcCallrequests with an emptytofield — and"a, b"stored" b", which never matched.Testing
500with untouched live state (fault-injection seam), restart resolution of a stored-empty override beating a set env var.rpcCallrejected under the env-seeded allowlist starts passing right after an authenticated admin POST (no restart), the override wins over env on a simulated restart, and DELETE reverts it.go vetclean; full-tags unittestsuite passes (728 tests acrossdb,common,server).🤖 Generated with Claude Code