From c803ddc8ebfb6468201f3878cac5032c4ea434ff Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 2 Jul 2026 05:34:31 +0000 Subject: [PATCH 01/10] feat(eth): allow rpcCall by whitelisted EVM method selectors Add _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 --- docs/env.md | 4 +- server/public_ethereumtype_test.go | 69 ++++++++++++ server/websocket.go | 90 +++++++++++++++- server/websocket_test.go | 168 +++++++++++++++++++++++++++++ 4 files changed, 325 insertions(+), 6 deletions(-) diff --git a/docs/env.md b/docs/env.md index f0b01df7c9..ba4a2bcb10 100644 --- a/docs/env.md +++ b/docs/env.md @@ -81,7 +81,9 @@ Blockbook reads these from its process environment. When installed from the Debi 3. `COINGECKO_API_KEY` Example: for Optimism, `network=OP` and `coin shortcut=ETH`, so `OP_COINGECKO_API_KEY` is preferred over `ETH_COINGECKO_API_KEY`. -- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. If omitted, `rpcCall` is enabled for all addresses. +- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. If omitted (and `ALLOWED_EVM_CALL_METHODS` is not set either), `rpcCall` is enabled for all addresses. + +- `_ALLOWED_EVM_CALL_METHODS` - EVM method selectors (first 4 bytes of the calldata, for example `0xdd62ed3e` for ERC-20 `allowance(address,address)`) that `rpcCall` websocket requests may invoke on any address, as a comma-separated list; the `0x` prefix is optional and matching is case-insensitive. Combines with `ALLOWED_RPC_CALL_TO`: a call is allowed when either its target address or its calldata selector is allowed. When only this variable is set, only calls with an allowed selector pass. Malformed calldata (missing `0x` prefix, invalid or odd-length hex, fewer than 4 bytes) never matches a selector. A malformed selector in the list, or a set variable that contains no selectors at all, is a configuration error and Blockbook fails on startup. - `_ALTERNATIVE_SENDTX_URLS` - Comma-separated list of alternative EVM `eth_sendRawTransaction` providers, used for private/MEV-protected transaction submission. The prefix is the configured `network` value when present (for example `OP`, `BASE`, `POL`, `BSC`, `ARB`, `AVAX`), otherwise the coin shortcut (for example `ETH`). If omitted, Blockbook sends transactions through the normal backend RPC. diff --git a/server/public_ethereumtype_test.go b/server/public_ethereumtype_test.go index 3fac995e55..ca6bd77442 100644 --- a/server/public_ethereumtype_test.go +++ b/server/public_ethereumtype_test.go @@ -361,6 +361,75 @@ func Test_PublicServer_EthereumType(t *testing.T) { httpTestsEthereumType(t, ts) runWebsocketTests(t, ts, websocketTestsEthereumType) } + +// allowance(owner, spender) calldata with the 0xdd62ed3e selector +const rpcCallAllowanceData = "0xdd62ed3e0000000000000000000000009ea3721b5bf3b64b4418c38b603154d2d597fae3000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829" + +var websocketTestsEthereumTypeRpcCallAllowlist = []websocketTest{ + { + name: "rpcCall to allowed address", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9", + Data: "0x4567", + }, + }, + want: `{"id":"0","data":{"data":"0x4567abcd"}}`, + }, + { + name: "rpcCall with allowed method selector to any address", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: rpcCallAllowanceData, + }, + }, + want: `{"id":"1","data":{"data":"` + rpcCallAllowanceData + `abcd"}}`, + }, + { + name: "rpcCall with not allowed address and method selector", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: "0x70a08231000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829", + }, + }, + want: `{"id":"2","data":{"error":{"message":"Not supported"}}}`, + }, + { + name: "rpcCall with allowed selector but malformed calldata", + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: rpcCallAllowanceData[2:], + }, + }, + want: `{"id":"3","data":{"error":{"message":"Not supported"}}}`, + }, +} + +func Test_PublicServer_EthereumType_RpcCallAllowlist(t *testing.T) { + timeNow = fixedTimeNow + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9") + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "0xdd62ed3e") + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + glog.Fatal("fakechain: ", err) + } + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + runWebsocketTests(t, ts, websocketTestsEthereumTypeRpcCallAllowlist) +} func TestENSResolution(t *testing.T) { parser := eth.NewEthereumParser(1, true) chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) diff --git a/server/websocket.go b/server/websocket.go index ea45cd33fc..621ebb9dba 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/hex" "encoding/json" "math/big" "net/http" @@ -106,7 +107,13 @@ type WebsocketServer struct { fiatRatesSubscriptionsLock sync.Mutex allowedOrigins map[string]struct{} allowedRpcCallTo map[string]struct{} - trustedProxyPrefixes []netip.Prefix + // allowedRpcCallMethods holds 4-byte EVM call selectors (lowercase hex, + // no 0x prefix) that rpcCall may invoke on any address; nil when + // unconfigured. Together with allowedRpcCallTo it forms the rpcCall + // allowlist: a call passes when either the target address or the calldata + // selector is allowed; if both are nil, rpcCall is unrestricted. + allowedRpcCallMethods map[string]struct{} + trustedProxyPrefixes []netip.Prefix // cloudflarePrefixes gates trust of the CF-Connecting-* headers: when // non-empty, those headers are honored only when the TCP peer is inside one // of these ranges (or a loopback/private proxy). Empty disables verification @@ -183,6 +190,15 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. } glog.Info("Support of rpcCall for these contracts: ", envRpcCall) } + methodsEnvName := strings.ToUpper(is.GetNetwork()) + "_ALLOWED_EVM_CALL_METHODS" + envRpcCallMethods := os.Getenv(methodsEnvName) + s.allowedRpcCallMethods, err = parseAllowedEvmCallMethods(methodsEnvName, envRpcCallMethods) + if err != nil { + return nil, err + } + if s.allowedRpcCallMethods != nil { + glog.Info("Support of rpcCall for these method selectors: ", envRpcCallMethods) + } clientIPCfg, err := readClientIPConfig(is.GetNetwork()) if err != nil { return nil, err @@ -240,6 +256,35 @@ func parseAllowedOrigins(originEnvName, envAllowedOrigins string) map[string]str return allowedOrigins } +// parseAllowedEvmCallMethods parses a comma-separated list of 4-byte EVM call +// selectors (optional 0x prefix, case-insensitive) into a set keyed by +// lowercase hex without the prefix. Returns nil when the variable is unset. +// A malformed selector or a set variable that parses to no selectors at all +// is a configuration error that aborts startup so a typo cannot silently +// disable the intended allowlist. +func parseAllowedEvmCallMethods(envName, envValue string) (map[string]struct{}, error) { + if envValue == "" { + return nil, nil + } + methods := make(map[string]struct{}) + for _, m := range strings.Split(envValue, ",") { + m = strings.TrimSpace(m) + if m == "" { + continue + } + selector := strings.TrimPrefix(strings.ToLower(m), "0x") + b, err := hex.DecodeString(selector) + if err != nil || len(b) != 4 { + return nil, errors.Errorf("Invalid EVM call method selector %q in %s, expecting 4 bytes in hex", m, envName) + } + methods[selector] = struct{}{} + } + if len(methods) == 0 { + return nil, errors.Errorf("%s is set but contains no method selectors", envName) + } + return methods, nil +} + func (s *WebsocketServer) checkOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { @@ -1201,13 +1246,48 @@ func (s *WebsocketServer) getBlockFiltersBatch(r *WsBlockFiltersBatchReq) (res i }, nil } -func (s *WebsocketServer) rpcCall(r *WsRpcCallReq) (*WsRpcCallRes, error) { +// evmCallSelector extracts the 4-byte function selector from hex-encoded EVM +// calldata as lowercase hex without the 0x prefix. It decodes the whole data +// string so malformed calldata (missing 0x prefix, odd length, non-hex +// characters, fewer than 4 bytes) never yields a selector and thus fails +// closed in the rpcCall allowlist check. +func evmCallSelector(data string) (string, bool) { + if len(data) < 2 || data[0] != '0' || (data[1] != 'x' && data[1] != 'X') { + return "", false + } + b, err := hex.DecodeString(data[2:]) + if err != nil || len(b) < 4 { + return "", false + } + return hex.EncodeToString(b[:4]), true +} + +// rpcCallAllowed reports whether a rpcCall request passes the allowlists. With +// no allowlist configured rpcCall is unrestricted; otherwise the call must +// target an allowed address or invoke an allowed method selector. +func (s *WebsocketServer) rpcCallAllowed(r *WsRpcCallReq) bool { + if s.allowedRpcCallTo == nil && s.allowedRpcCallMethods == nil { + return true + } if s.allowedRpcCallTo != nil { - _, ok := s.allowedRpcCallTo[strings.ToLower(r.To)] - if !ok { - return nil, errors.New("Not supported") + if _, ok := s.allowedRpcCallTo[strings.ToLower(r.To)]; ok { + return true } } + if s.allowedRpcCallMethods != nil { + if selector, ok := evmCallSelector(r.Data); ok { + if _, ok := s.allowedRpcCallMethods[selector]; ok { + return true + } + } + } + return false +} + +func (s *WebsocketServer) rpcCall(r *WsRpcCallReq) (*WsRpcCallRes, error) { + if !s.rpcCallAllowed(r) { + return nil, errors.New("Not supported") + } data, err := s.chain.EthereumTypeRpcCall(r.Data, r.To, r.From) if err != nil { return nil, err diff --git a/server/websocket_test.go b/server/websocket_test.go index fa98bbcef9..a9ccf9d7fc 100644 --- a/server/websocket_test.go +++ b/server/websocket_test.go @@ -167,6 +167,174 @@ func TestParseAllowedOrigins(t *testing.T) { } } +func TestParseAllowedEvmCallMethods(t *testing.T) { + tests := []struct { + name string + env string + want []string + wantErr bool + }{ + {name: "empty", env: "", want: nil}, + {name: "empty entries only", env: " , ,", wantErr: true}, + {name: "single selector", env: "0xdd62ed3e", want: []string{"dd62ed3e"}}, + {name: "without prefix", env: "dd62ed3e", want: []string{"dd62ed3e"}}, + {name: "uppercase", env: "0XDD62ED3E", want: []string{"dd62ed3e"}}, + {name: "multiple with spaces", env: " 0xdd62ed3e , 0x70a08231 ", want: []string{"dd62ed3e", "70a08231"}}, + {name: "empty entries skipped", env: "0xdd62ed3e,,0x70a08231", want: []string{"dd62ed3e", "70a08231"}}, + {name: "too short", env: "0xdd62ed", wantErr: true}, + {name: "too long", env: "0xdd62ed3e00", wantErr: true}, + {name: "odd length", env: "0xdd62ed3", wantErr: true}, + {name: "non hex", env: "0xdd62ed3g", wantErr: true}, + {name: "bare prefix", env: "0x", wantErr: true}, + {name: "one invalid among valid", env: "0xdd62ed3e,invalid", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAllowedEvmCallMethods("FAKE_ALLOWED_EVM_CALL_METHODS", tt.env) + if tt.wantErr { + if err == nil { + t.Fatalf("parseAllowedEvmCallMethods(%q) = nil err, want error", tt.env) + } + return + } + if err != nil { + t.Fatalf("parseAllowedEvmCallMethods(%q) unexpected error: %v", tt.env, err) + } + if tt.want == nil && got != nil { + t.Fatalf("parseAllowedEvmCallMethods(%q) = %v, want nil", tt.env, got) + } + if len(got) != len(tt.want) { + t.Fatalf("parseAllowedEvmCallMethods(%q) len = %d, want %d", tt.env, len(got), len(tt.want)) + } + for _, selector := range tt.want { + if _, ok := got[selector]; !ok { + t.Fatalf("parseAllowedEvmCallMethods(%q) missing %q", tt.env, selector) + } + } + }) + } +} + +func TestRpcCallAllowed(t *testing.T) { + // allowance(owner, spender) calldata with the 0xdd62ed3e selector + const allowanceData = "0xdd62ed3e0000000000000000000000009ea3721b5bf3b64b4418c38b603154d2d597fae3000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829" + const allowedTo = "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9" + const otherTo = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599" + allowedToSet := map[string]struct{}{strings.ToLower(allowedTo): {}} + allowanceSelectorSet := map[string]struct{}{"dd62ed3e": {}} + + tests := []struct { + name string + to map[string]struct{} + methods map[string]struct{} + req WsRpcCallReq + want bool + }{ + { + name: "no allowlists configured allows all", + req: WsRpcCallReq{To: otherTo, Data: "0x12345678"}, + want: true, + }, + { + name: "allowed address passes", + to: allowedToSet, + req: WsRpcCallReq{To: allowedTo, Data: "0x12345678"}, + want: true, + }, + { + name: "allowed address is case-insensitive", + to: allowedToSet, + req: WsRpcCallReq{To: strings.ToUpper(allowedTo), Data: "0x12345678"}, + want: true, + }, + { + name: "address not allowed without methods list", + to: allowedToSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData}, + want: false, + }, + { + name: "allowed address passes regardless of selector", + to: allowedToSet, + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: allowedTo, Data: "0x12345678"}, + want: true, + }, + { + name: "allowed selector passes to any address", + to: allowedToSet, + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData}, + want: true, + }, + { + name: "selector alone is enough for exact 4 byte calldata", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0xdd62ed3e"}, + want: true, + }, + { + name: "uppercase hex calldata matches", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0XDD62ED3E"}, + want: true, + }, + { + name: "only methods list set rejects other selectors", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0x70a08231000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829"}, + want: false, + }, + { + name: "not allowed address nor selector", + to: allowedToSet, + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0x12345678"}, + want: false, + }, + { + name: "missing 0x prefix fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData[2:]}, + want: false, + }, + { + name: "odd length calldata fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: allowanceData + "0"}, + want: false, + }, + { + name: "non hex tail after valid selector fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0xdd62ed3exx"}, + want: false, + }, + { + name: "calldata shorter than selector fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: "0xdd62ed"}, + want: false, + }, + { + name: "empty calldata fails closed", + methods: allowanceSelectorSet, + req: WsRpcCallReq{To: otherTo, Data: ""}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &WebsocketServer{allowedRpcCallTo: tt.to, allowedRpcCallMethods: tt.methods} + if got := s.rpcCallAllowed(&tt.req); got != tt.want { + t.Fatalf("rpcCallAllowed(to=%q, data=%q) = %v, want %v", tt.req.To, tt.req.Data, got, tt.want) + } + }) + } +} + func TestParseTrustedProxies(t *testing.T) { tests := []struct { name string From 7a1081f1f1aac9ed30ea2a81e37d913d4bee6a3b Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 2 Jul 2026 06:11:53 +0000 Subject: [PATCH 02/10] feat(server): make rpcCall allowlists runtime-configurable via admin API 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/ 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 --- common/internalstate.go | 50 +++ db/rocksdb_runtime_settings.go | 56 +++ db/rocksdb_runtime_settings_test.go | 97 +++++ docs/env.md | 16 +- server/internal.go | 34 +- server/internal_test.go | 2 +- server/public_ethereumtype_test.go | 114 ++++++ server/runtime_settings.go | 373 ++++++++++++++++++ server/runtime_settings_test.go | 292 ++++++++++++++ server/websocket.go | 70 +--- server/websocket_test.go | 12 +- static/internal_templates/index.html | 3 + .../internal_templates/runtime_settings.html | 38 ++ 13 files changed, 1086 insertions(+), 71 deletions(-) create mode 100644 db/rocksdb_runtime_settings.go create mode 100644 db/rocksdb_runtime_settings_test.go create mode 100644 server/runtime_settings.go create mode 100644 server/runtime_settings_test.go create mode 100644 static/internal_templates/runtime_settings.html diff --git a/common/internalstate.go b/common/internalstate.go index 1964d287c4..0d8a22617b 100644 --- a/common/internalstate.go +++ b/common/internalstate.go @@ -119,6 +119,56 @@ type InternalState struct { // shared /64. Guarded by its own mutex (consulted on every connection attempt). wsBlockMux sync.Mutex wsBlockedIPs map[string]*WsBlockedIP + + // rpcCallAllowlists is the effective websocket rpcCall allowlist snapshot, + // resolved from the DB runtime-setting overrides and the environment + // defaults (see server initRpcCallAllowlists). Unexported so it is never + // serialized by Pack; replaced wholesale via the accessors below, giving + // the rpcCall hot path a lock-free, consistent view. + rpcCallAllowlists atomic.Pointer[RpcCallAllowlists] +} + +// Sources of a runtime setting value, reported by the /admin runtime-settings +// interface. +const ( + RuntimeSettingSourceUnset = "unset" + RuntimeSettingSourceEnv = "env" + RuntimeSettingSourceDB = "db" +) + +// RpcCallAllowlists is an immutable snapshot of the websocket rpcCall +// allowlists. Readers must not mutate the maps; writers build a new snapshot +// and replace it via SetRpcCallAllowlists. +type RpcCallAllowlists struct { + // To and Methods are the parsed allowlists; a nil map means that dimension + // is unconfigured. With both nil, rpcCall is unrestricted. + To map[string]struct{} + Methods map[string]struct{} + // Raw comma-separated values and their sources (unset/env/db), kept for + // the admin interface and logging. + ToValue string + MethodsValue string + ToSource string + MethodsSource string +} + +// GetRpcCallAllowlists returns the current rpcCall allowlist snapshot, nil +// when not yet initialized. +func (is *InternalState) GetRpcCallAllowlists() *RpcCallAllowlists { + return is.rpcCallAllowlists.Load() +} + +// SetRpcCallAllowlists atomically replaces the rpcCall allowlist snapshot. +func (is *InternalState) SetRpcCallAllowlists(a *RpcCallAllowlists) { + is.rpcCallAllowlists.Store(a) +} + +// InitRpcCallAllowlists publishes the initial snapshot only when none exists +// yet and reports whether it did. The compare-and-swap keeps a snapshot that +// was already published — possibly updated by the admin interface in the +// meantime — intact when several server constructors initialize in any order. +func (is *InternalState) InitRpcCallAllowlists(a *RpcCallAllowlists) bool { + return is.rpcCallAllowlists.CompareAndSwap(nil, a) } // WsBlockedIP records a websocket client key (IPv4 address or full IPv6 /128) diff --git a/db/rocksdb_runtime_settings.go b/db/rocksdb_runtime_settings.go new file mode 100644 index 0000000000..50a7535c0a --- /dev/null +++ b/db/rocksdb_runtime_settings.go @@ -0,0 +1,56 @@ +package db + +import ( + "encoding/json" + "time" + + "github.com/juju/errors" +) + +// runtimeSettingKeyPrefix prefixes cfDefault keys holding runtime setting +// overrides written through the internal /admin interface. Each setting is +// stored under its own key, so a write or delete touches nothing else in the +// database (in particular it is independent of the periodically stored +// internalState blob) and the rows are ignored by older blockbook versions. +const runtimeSettingKeyPrefix = "runtimeSetting:" + +// runtimeSettingEnvelope wraps a stored runtime setting value. The envelope +// keeps a stored empty value distinguishable from a missing row and records +// when the value was last changed. +type runtimeSettingEnvelope struct { + Value string `json:"value"` + Updated time.Time `json:"updated"` +} + +// GetRuntimeSetting returns the stored override of the given runtime setting +// and whether an override exists. +func (d *RocksDB) GetRuntimeSetting(name string) (string, bool, error) { + val, err := d.db.GetCF(d.ro, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name)) + if err != nil { + return "", false, err + } + defer val.Free() + data := val.Data() + if len(data) == 0 { + return "", false, nil + } + var e runtimeSettingEnvelope + if err := json.Unmarshal(data, &e); err != nil { + return "", false, errors.Annotatef(err, "cannot unpack runtime setting %s", name) + } + return e.Value, true, nil +} + +// StoreRuntimeSetting persists an override of the given runtime setting. +func (d *RocksDB) StoreRuntimeSetting(name, value string) error { + buf, err := json.Marshal(&runtimeSettingEnvelope{Value: value, Updated: time.Now()}) + if err != nil { + return err + } + return d.db.PutCF(d.wo, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name), buf) +} + +// DeleteRuntimeSetting removes the override of the given runtime setting. +func (d *RocksDB) DeleteRuntimeSetting(name string) error { + return d.db.DeleteCF(d.wo, d.cfh[cfDefault], []byte(runtimeSettingKeyPrefix+name)) +} diff --git a/db/rocksdb_runtime_settings_test.go b/db/rocksdb_runtime_settings_test.go new file mode 100644 index 0000000000..ce22e5eb3f --- /dev/null +++ b/db/rocksdb_runtime_settings_test.go @@ -0,0 +1,97 @@ +//go:build unittest + +package db + +import ( + "os" + "testing" + + "github.com/trezor/blockbook/common" +) + +func TestRocksDB_RuntimeSettings(t *testing.T) { + parser := &testBitcoinParser{ + BitcoinParser: bitcoinTestnetParser(), + } + d := setupRocksDB(t, parser) + path := d.path + dClosed := false + defer func() { + if !dClosed { + if err := d.Close(); err != nil { + t.Error(err) + } + } + os.RemoveAll(path) + }() + + getSetting := func(db *RocksDB, name string) (string, bool) { + t.Helper() + value, found, err := db.GetRuntimeSetting(name) + if err != nil { + t.Fatalf("GetRuntimeSetting(%q) unexpected error: %v", name, err) + } + return value, found + } + + if value, found := getSetting(d, "MISSING"); found || value != "" { + t.Fatalf("GetRuntimeSetting(MISSING) = %q, %v, want empty, false", value, found) + } + + if err := d.StoreRuntimeSetting("ALLOWED_RPC_CALL_TO", "0xabcd,0xef01"); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_RPC_CALL_TO"); !found || value != "0xabcd,0xef01" { + t.Fatalf("GetRuntimeSetting after store = %q, %v, want 0xabcd,0xef01, true", value, found) + } + + // overwrite + if err := d.StoreRuntimeSetting("ALLOWED_RPC_CALL_TO", "0x1234"); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_RPC_CALL_TO"); !found || value != "0x1234" { + t.Fatalf("GetRuntimeSetting after overwrite = %q, %v, want 0x1234, true", value, found) + } + + // an explicitly stored empty value must stay distinguishable from a missing row + if err := d.StoreRuntimeSetting("ALLOWED_EVM_CALL_METHODS", ""); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_EVM_CALL_METHODS"); !found || value != "" { + t.Fatalf("GetRuntimeSetting of stored empty value = %q, %v, want empty, true", value, found) + } + + if err := d.DeleteRuntimeSetting("ALLOWED_EVM_CALL_METHODS"); err != nil { + t.Fatal(err) + } + if value, found := getSetting(d, "ALLOWED_EVM_CALL_METHODS"); found || value != "" { + t.Fatalf("GetRuntimeSetting after delete = %q, %v, want empty, false", value, found) + } + // deleting a missing row is not an error + if err := d.DeleteRuntimeSetting("ALLOWED_EVM_CALL_METHODS"); err != nil { + t.Fatal(err) + } + + // settings survive a database reopen + dClosed = true + if err := d.Close(); err != nil { + t.Fatal(err) + } + d2, err := NewRocksDB(path, 100000, -1, parser, nil, false) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := d2.Close(); err != nil { + t.Error(err) + } + }() + is, err := d2.LoadInternalState(&common.Config{CoinName: "coin-unittest"}) + if err != nil { + t.Fatal(err) + } + d2.SetInternalState(is) + if value, found := getSetting(d2, "ALLOWED_RPC_CALL_TO"); !found || value != "0x1234" { + t.Fatalf("GetRuntimeSetting after reopen = %q, %v, want 0x1234, true", value, found) + } +} diff --git a/docs/env.md b/docs/env.md index ba4a2bcb10..cdee3c7533 100644 --- a/docs/env.md +++ b/docs/env.md @@ -81,9 +81,9 @@ Blockbook reads these from its process environment. When installed from the Debi 3. `COINGECKO_API_KEY` Example: for Optimism, `network=OP` and `coin shortcut=ETH`, so `OP_COINGECKO_API_KEY` is preferred over `ETH_COINGECKO_API_KEY`. -- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. If omitted (and `ALLOWED_EVM_CALL_METHODS` is not set either), `rpcCall` is enabled for all addresses. +- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. Entries are trimmed and empty entries skipped; a set value that contains no addresses at all is a configuration error and Blockbook fails on startup. If omitted (and `ALLOWED_EVM_CALL_METHODS` is not set either), `rpcCall` is enabled for all addresses. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. -- `_ALLOWED_EVM_CALL_METHODS` - EVM method selectors (first 4 bytes of the calldata, for example `0xdd62ed3e` for ERC-20 `allowance(address,address)`) that `rpcCall` websocket requests may invoke on any address, as a comma-separated list; the `0x` prefix is optional and matching is case-insensitive. Combines with `ALLOWED_RPC_CALL_TO`: a call is allowed when either its target address or its calldata selector is allowed. When only this variable is set, only calls with an allowed selector pass. Malformed calldata (missing `0x` prefix, invalid or odd-length hex, fewer than 4 bytes) never matches a selector. A malformed selector in the list, or a set variable that contains no selectors at all, is a configuration error and Blockbook fails on startup. +- `_ALLOWED_EVM_CALL_METHODS` - EVM method selectors (first 4 bytes of the calldata, for example `0xdd62ed3e` for ERC-20 `allowance(address,address)`) that `rpcCall` websocket requests may invoke on any address, as a comma-separated list; the `0x` prefix is optional and matching is case-insensitive. Combines with `ALLOWED_RPC_CALL_TO`: a call is allowed when either its target address or its calldata selector is allowed. When only this variable is set, only calls with an allowed selector pass. Malformed calldata (missing `0x` prefix, invalid or odd-length hex, fewer than 4 bytes) never matches a selector. A malformed selector in the list, or a set variable that contains no selectors at all, is a configuration error and Blockbook fails on startup. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. - `_ALTERNATIVE_SENDTX_URLS` - Comma-separated list of alternative EVM `eth_sendRawTransaction` providers, used for private/MEV-protected transaction submission. The prefix is the configured `network` value when present (for example `OP`, `BASE`, `POL`, `BSC`, `ARB`, `AVAX`), otherwise the coin shortcut (for example `ETH`). If omitted, Blockbook sends transactions through the normal backend RPC. @@ -93,6 +93,18 @@ Blockbook reads these from its process environment. When installed from the Debi WebSocket `sendTransaction` can bypass the alternative provider for a single request by setting `disableAlternativeRPC` to `true`. +## Runtime settings + +The `rpcCall` allowlists can be changed at runtime, without a restart, through the internal server's authenticated admin API (see `BB_ADMIN_USER`/`BB_ADMIN_PASSWORD` above). An override is persisted in the Blockbook database, survives restarts and takes precedence over the corresponding environment variable, which serves only as the startup default. Every change is logged. The `/admin/runtime-settings` page shows the current values and their sources. + +`GET/POST/DELETE /admin/runtime-settings/` where `` is `ALLOWED_RPC_CALL_TO` or `ALLOWED_EVM_CALL_METHODS`: + +- `GET` returns the effective value and its source (`db` = stored override, `env` = environment default, `unset` = neither): `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e","source":"env"}`. +- `POST` (or `PUT`) with body `{"value":"0xdd62ed3e,0x70a08231"}` validates the value (invalid values are rejected with `400` and change nothing), stores it in the database and only then applies it to the live allowlists — a database failure returns `500` and leaves the live state unchanged. A `"value"` of exactly `""` is a valid override meaning "explicitly unconfigured" (that allowlist dimension is disabled, as if its environment variable was not set) — it is the only way to un-restrict at runtime while the environment variable has a value. A value containing only whitespace or separators is rejected with `400`, so a botched automation input cannot silently disable the allowlist. +- `DELETE` removes the stored override and reverts to the environment default. If the environment value is malformed the request is rejected with `400` and the override is kept, so a later restart cannot fail on it. + +Old Blockbook versions ignore the stored overrides (the environment applies again after a version rollback) and keep them intact, so rolling forward resumes the override. + ## Build-time variables - `BB_BUILD_ENV` - Selects the active RPC URL override family during package/config generation. Defaults to `dev`. diff --git a/server/internal.go b/server/internal.go index 30584f05a8..72c17565c7 100644 --- a/server/internal.go +++ b/server/internal.go @@ -14,6 +14,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/golang/glog" @@ -45,6 +46,12 @@ type InternalServer struct { adminAuthEnabled bool adminUserHash [32]byte adminPassHash [32]byte + // runtimeSettingsMux serializes runtime-setting writes (validate → store + // to DB → publish snapshot) so concurrent admin requests cannot publish a + // snapshot whose value lost the database write. runtimeSettings is the + // persistence backend of those writes (the RocksDB in production). + runtimeSettingsMux sync.Mutex + runtimeSettings runtimeSettingStore } // NewInternalServer creates new internal http interface to blockbook and returns its handle @@ -64,15 +71,16 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B htmlTemplates: htmlTemplates[InternalTemplateData]{ debug: true, }, - https: https, - certFiles: certFiles, - db: db, - txCache: txCache, - chain: chain, - chainParser: chain.GetChainParser(), - mempool: mempool, - is: is, - api: api, + https: https, + certFiles: certFiles, + db: db, + txCache: txCache, + chain: chain, + chainParser: chain.GetChainParser(), + mempool: mempool, + is: is, + api: api, + runtimeSettings: db, } s.htmlTemplates.newTemplateData = s.newTemplateData s.htmlTemplates.newTemplateDataWithError = s.newTemplateDataWithError @@ -104,9 +112,14 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B serveMux.HandleFunc(adminPath+"/", s.requireAdminAuth(s.adminSubtreeHandler(adminPath))) serveMux.HandleFunc(adminPath+"/ws-limit-exceeding-ips", s.requireAdminAuth(s.htmlTemplateHandler(s.wsLimitExceedingIPs))) if s.chainParser.GetChainType() == bchain.ChainEthereumType { + if err := initRpcCallAllowlists(db, is); err != nil { + return nil, err + } serveMux.HandleFunc(adminPath+"/internal-data-errors", s.requireAdminAuth(s.htmlTemplateHandler(s.internalDataErrors))) serveMux.HandleFunc(adminPath+"/contract-info", s.requireAdminAuth(s.htmlTemplateHandler(s.contractInfoPage))) serveMux.HandleFunc(adminPath+"/contract-info/", s.requireAdminAuth(s.jsonHandler(s.apiContractInfo, 0))) + serveMux.HandleFunc(adminPath+"/runtime-settings", s.requireAdminAuth(s.htmlTemplateHandler(s.runtimeSettingsPage))) + serveMux.HandleFunc(adminPath+"/runtime-settings/", s.requireAdminAuth(s.jsonHandler(s.apiRuntimeSetting, 0))) } return s, nil } @@ -219,6 +232,7 @@ const ( adminInternalErrorsTpl adminLimitExceedingIPSTpl adminContractInfoTpl + adminRuntimeSettingsTpl internalTplCount ) @@ -252,6 +266,7 @@ type InternalTemplateData struct { WsGetAccountInfoLimit int WsLimitExceedingIPs []WsLimitExceedingIP WsBlockedIPs []WsBlockedIPView + RuntimeSettings []RuntimeSettingView } func (s *InternalServer) newTemplateData(r *http.Request) *InternalTemplateData { @@ -287,6 +302,7 @@ func (s *InternalServer) parseTemplates() []*template.Template { t[adminInternalErrorsTpl] = createTemplate("./static/internal_templates/block_internal_data_errors.html", "./static/internal_templates/base.html") t[adminLimitExceedingIPSTpl] = createTemplate("./static/internal_templates/ws_limit_exceeding_ips.html", "./static/internal_templates/base.html") t[adminContractInfoTpl] = createTemplate("./static/internal_templates/contract_info.html", "./static/internal_templates/base.html") + t[adminRuntimeSettingsTpl] = createTemplate("./static/internal_templates/runtime_settings.html", "./static/internal_templates/base.html") return t } diff --git a/server/internal_test.go b/server/internal_test.go index a0ab85fb5c..6c753293d9 100644 --- a/server/internal_test.go +++ b/server/internal_test.go @@ -81,7 +81,7 @@ func TestAdminSubtreeIsGated(t *testing.T) { // Without credentials, every /admin path is gated (401) and never reaches the // unauthenticated index handler. - for _, p := range []string{"/admin", "/admin/", "/admin/unknown", "/admin/contract-info"} { + for _, p := range []string{"/admin", "/admin/", "/admin/unknown", "/admin/contract-info", "/admin/runtime-settings/ALLOWED_RPC_CALL_TO"} { t.Run("no-auth "+p, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, p, nil) w := httptest.NewRecorder() diff --git a/server/public_ethereumtype_test.go b/server/public_ethereumtype_test.go index ca6bd77442..725e92f8b1 100644 --- a/server/public_ethereumtype_test.go +++ b/server/public_ethereumtype_test.go @@ -4,9 +4,11 @@ package server import ( + "io" "net/http" "net/http/httptest" "reflect" + "strings" "testing" "time" @@ -412,6 +414,118 @@ var websocketTestsEthereumTypeRpcCallAllowlist = []websocketTest{ }, } +// Test_PublicServer_EthereumType_RuntimeSettingsLiveUpdate proves the full +// runtime-settings flow: a websocket rpcCall rejected under the env-seeded +// allowlist starts passing right after an authenticated admin POST (no +// restart), the override is resolved from the DB on a simulated restart with +// precedence over the environment, and DELETE reverts to the env default. +func Test_PublicServer_EthereumType_RuntimeSettingsLiveUpdate(t *testing.T) { + timeNow = fixedTimeNow + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "0xdd62ed3e") + t.Setenv("BB_ADMIN_USER", "admin") + t.Setenv("BB_ADMIN_PASSWORD", "password") + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + glog.Fatal("fakechain: ", err) + } + + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + s.ConnectFullPublicInterface() + ts := httptest.NewServer(s.https.Handler) + defer ts.Close() + + internal, err := NewInternalServer("localhost:12346", "", s.db, s.chain, s.mempool, s.txCache, metrics, s.is, s.fiatRates) + if err != nil { + t.Fatal(err) + } + tsAdmin := httptest.NewServer(internal.https.Handler) + defer tsAdmin.Close() + + const balanceOfData = "0x70a08231000000000000000000000000e4db1c5a1b709ce4d2ada6985d9d506e58f73829" + balanceOfCall := func(name, want string) []websocketTest { + return []websocketTest{{ + name: name, + req: websocketReq{ + Method: "rpcCall", + Params: WsRpcCallReq{ + To: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", + Data: balanceOfData, + }, + }, + want: want, + }} + } + rejected := `{"id":"0","data":{"error":{"message":"Not supported"}}}` + allowed := `{"id":"0","data":{"data":"` + balanceOfData + `abcd"}}` + + adminURL := tsAdmin.URL + "/admin/runtime-settings/ALLOWED_EVM_CALL_METHODS" + doAdmin := func(method, body string) (int, string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequest(method, adminURL, rdr) + if err != nil { + t.Fatal(err) + } + req.SetBasicAuth("admin", "password") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, strings.TrimSpace(string(b)) + } + + // the balanceOf selector is not in the env-seeded allowlist + runWebsocketTests(t, ts, balanceOfCall("rpcCall balanceOf rejected by env allowlist", rejected)) + + // the admin endpoint requires authentication + resp, err := http.Post(adminURL, "application/json", strings.NewReader(`{"value":"0x70a08231"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated POST status = %d, want 401", resp.StatusCode) + } + + // authenticated POST adds the selector and takes effect without a restart + code, body := doAdmin(http.MethodPost, `{"value":"0xdd62ed3e,0x70a08231"}`) + if code != http.StatusOK || body != `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e,0x70a08231","source":"db"}` { + t.Fatalf("admin POST = %d %s", code, body) + } + runWebsocketTests(t, ts, balanceOfCall("rpcCall balanceOf allowed after admin update", allowed)) + + // simulated restart: a fresh InternalState resolves the override from the + // DB with precedence over the environment variable + is2 := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(s.db, is2); err != nil { + t.Fatal(err) + } + a := is2.GetRpcCallAllowlists() + if a.MethodsSource != common.RuntimeSettingSourceDB || a.MethodsValue != "0xdd62ed3e,0x70a08231" { + t.Fatalf("after simulated restart got source %q value %q, want db override", a.MethodsSource, a.MethodsValue) + } + if _, ok := a.Methods["70a08231"]; !ok { + t.Fatal("after simulated restart the stored selector is missing") + } + + // DELETE reverts to the env default and the call is rejected again + code, body = doAdmin(http.MethodDelete, "") + if code != http.StatusOK || body != `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e","source":"env"}` { + t.Fatalf("admin DELETE = %d %s", code, body) + } + runWebsocketTests(t, ts, balanceOfCall("rpcCall balanceOf rejected after revert", rejected)) +} + func Test_PublicServer_EthereumType_RpcCallAllowlist(t *testing.T) { timeNow = fixedTimeNow t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9") diff --git a/server/runtime_settings.go b/server/runtime_settings.go new file mode 100644 index 0000000000..396ab76897 --- /dev/null +++ b/server/runtime_settings.go @@ -0,0 +1,373 @@ +package server + +import ( + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "strings" + + "github.com/golang/glog" + "github.com/juju/errors" + "github.com/trezor/blockbook/api" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/db" +) + +// Runtime settings are configuration values that can be changed at runtime +// through the internal /admin/runtime-settings interface. Each setting is +// named after the environment variable that provides its startup default +// (prefixed with the network name, e.g. ETH_ALLOWED_RPC_CALL_TO); an override +// written through the admin interface is persisted in the database, survives +// restarts and takes precedence over the environment variable. +const ( + runtimeSettingAllowedRpcCallTo = "ALLOWED_RPC_CALL_TO" + runtimeSettingAllowedEvmCallMethods = "ALLOWED_EVM_CALL_METHODS" +) + +// runtimeSettingDef describes one runtime setting: how to validate its raw +// value and how to read/write it in the RpcCallAllowlists snapshot. Future +// runtime settings plug in here. +type runtimeSettingDef struct { + key string + // parse validates the raw comma-separated value and returns the parsed + // set; name is used in error messages. + parse func(name, value string) (map[string]struct{}, error) + // apply writes the parsed value into a snapshot under construction. + apply func(a *common.RpcCallAllowlists, parsed map[string]struct{}, value, source string) + // get reads the raw value and its source from a snapshot. + get func(a *common.RpcCallAllowlists) (value, source string) +} + +var runtimeSettingDefs = []*runtimeSettingDef{ + { + key: runtimeSettingAllowedRpcCallTo, + parse: parseAllowedRpcCallTo, + apply: func(a *common.RpcCallAllowlists, parsed map[string]struct{}, value, source string) { + a.To, a.ToValue, a.ToSource = parsed, value, source + }, + get: func(a *common.RpcCallAllowlists) (string, string) { + return a.ToValue, a.ToSource + }, + }, + { + key: runtimeSettingAllowedEvmCallMethods, + parse: parseAllowedEvmCallMethods, + apply: func(a *common.RpcCallAllowlists, parsed map[string]struct{}, value, source string) { + a.Methods, a.MethodsValue, a.MethodsSource = parsed, value, source + }, + get: func(a *common.RpcCallAllowlists) (string, string) { + return a.MethodsValue, a.MethodsSource + }, + }, +} + +func runtimeSettingDefByKey(key string) *runtimeSettingDef { + for _, def := range runtimeSettingDefs { + if def.key == key { + return def + } + } + return nil +} + +func runtimeSettingKeys() string { + keys := make([]string, len(runtimeSettingDefs)) + for i, def := range runtimeSettingDefs { + keys[i] = def.key + } + return strings.Join(keys, ", ") +} + +// parseAllowedRpcCallTo parses a comma-separated list of contract addresses +// into a set keyed by the lowercase address string. Entries are trimmed and +// empty entries skipped; a set value that parses to no addresses at all is a +// configuration error — silently ignoring it would leave rpcCall unrestricted, +// and keeping an empty-string entry would allowlist rpcCall requests with an +// empty to field. +func parseAllowedRpcCallTo(name, value string) (map[string]struct{}, error) { + if value == "" { + return nil, nil + } + addresses := make(map[string]struct{}) + for _, a := range strings.Split(value, ",") { + a = strings.TrimSpace(a) + if a == "" { + continue + } + addresses[strings.ToLower(a)] = struct{}{} + } + if len(addresses) == 0 { + return nil, errors.Errorf("%s is set but contains no addresses", name) + } + return addresses, nil +} + +// parseAllowedEvmCallMethods parses a comma-separated list of 4-byte EVM call +// selectors (optional 0x prefix, case-insensitive) into a set keyed by +// lowercase hex without the prefix. Returns nil when the value is unset. +// A malformed selector or a set value that parses to no selectors at all is a +// configuration error so a typo cannot silently disable the intended +// allowlist. +func parseAllowedEvmCallMethods(name, value string) (map[string]struct{}, error) { + if value == "" { + return nil, nil + } + methods := make(map[string]struct{}) + for _, m := range strings.Split(value, ",") { + m = strings.TrimSpace(m) + if m == "" { + continue + } + selector := strings.TrimPrefix(strings.ToLower(m), "0x") + b, err := hex.DecodeString(selector) + if err != nil || len(b) != 4 { + return nil, errors.Errorf("Invalid EVM call method selector %q in %s, expecting 4 bytes in hex", m, name) + } + methods[selector] = struct{}{} + } + if len(methods) == 0 { + return nil, errors.Errorf("%s is set but contains no method selectors", name) + } + return methods, nil +} + +// runtimeSettingEnvName returns the environment variable that provides the +// startup default of the given runtime setting. +func runtimeSettingEnvName(network, key string) string { + return strings.ToUpper(network) + "_" + key +} + +// resolveRuntimeSetting returns the effective raw value of a runtime setting +// and its source: a stored override wins over the environment variable. +func resolveRuntimeSetting(d *db.RocksDB, network, key string) (string, string, error) { + if d != nil { + value, found, err := d.GetRuntimeSetting(key) + if err != nil { + return "", "", err + } + if found { + return value, common.RuntimeSettingSourceDB, nil + } + } + if value := os.Getenv(runtimeSettingEnvName(network, key)); value != "" { + return value, common.RuntimeSettingSourceEnv, nil + } + return "", common.RuntimeSettingSourceUnset, nil +} + +// buildRpcCallAllowlists resolves and parses all runtime settings into a new +// snapshot. An unparsable value — a malformed environment variable or a +// corrupted stored override (writes are validated, so it cannot get there +// through the admin interface) — is an error; falling back could silently +// widen access. +func buildRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) (*common.RpcCallAllowlists, error) { + network := is.GetNetwork() + a := &common.RpcCallAllowlists{} + for _, def := range runtimeSettingDefs { + value, source, err := resolveRuntimeSetting(d, network, def.key) + if err != nil { + return nil, err + } + var parsed map[string]struct{} + if value != "" { + name := def.key + if source == common.RuntimeSettingSourceEnv { + name = runtimeSettingEnvName(network, def.key) + } + parsed, err = def.parse(name, value) + if err != nil { + return nil, err + } + } + def.apply(a, parsed, value, source) + } + return a, nil +} + +// initRpcCallAllowlists resolves and publishes the rpcCall allowlist snapshot +// if none exists yet. It is called from both NewWebsocketServer and +// NewInternalServer so that each server gets correct allowlists regardless of +// which of them (or both, in any order) a deployment runs; the CAS in +// InitRpcCallAllowlists keeps an already published — and possibly already +// admin-updated — snapshot intact. +func initRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) error { + if is.GetRpcCallAllowlists() != nil { + return nil + } + a, err := buildRpcCallAllowlists(d, is) + if err != nil { + return err + } + if !is.InitRpcCallAllowlists(a) { + return nil + } + if a.To != nil { + glog.Info("Support of rpcCall for these contracts (source ", a.ToSource, "): ", a.ToValue) + } + if a.Methods != nil { + glog.Info("Support of rpcCall for these method selectors (source ", a.MethodsSource, "): ", a.MethodsValue) + } + return nil +} + +// runtimeSettingStore persists runtime setting overrides; *db.RocksDB in +// production, replaceable in tests to exercise storage failures. +type runtimeSettingStore interface { + StoreRuntimeSetting(name, value string) error + DeleteRuntimeSetting(name string) error +} + +// runtimeSettingResponse is the JSON shape returned by the +// /admin/runtime-settings/ endpoint. +type runtimeSettingResponse struct { + Key string `json:"key"` + Value string `json:"value"` + Source string `json:"source"` +} + +// apiRuntimeSetting handles GET/POST/PUT/DELETE of a single runtime setting at +// /admin/runtime-settings/. +func (s *InternalServer) apiRuntimeSetting(r *http.Request, apiVersion int) (interface{}, error) { + var key string + if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { + key = strings.ToUpper(strings.TrimSpace(r.URL.Path[i+1:])) + } + def := runtimeSettingDefByKey(key) + if def == nil { + return nil, api.NewAPIError("Unknown runtime setting, supported: "+runtimeSettingKeys(), true) + } + switch r.Method { + case http.MethodGet: + return s.getRuntimeSetting(def) + case http.MethodPost, http.MethodPut: + return s.updateRuntimeSetting(def, r) + case http.MethodDelete: + return s.deleteRuntimeSetting(def, r) + } + return nil, api.NewAPIError("Unsupported method "+r.Method, true) +} + +// currentRpcCallAllowlists returns the live snapshot; it is published by +// NewInternalServer before the routes are registered, so a nil snapshot means +// a broken test setup rather than a runtime condition. +func (s *InternalServer) currentRpcCallAllowlists() (*common.RpcCallAllowlists, error) { + a := s.is.GetRpcCallAllowlists() + if a == nil { + return nil, errors.New("runtime settings not initialized") + } + return a, nil +} + +func (s *InternalServer) getRuntimeSetting(def *runtimeSettingDef) (interface{}, error) { + a, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + value, source := def.get(a) + return &runtimeSettingResponse{Key: def.key, Value: value, Source: source}, nil +} + +// updateRuntimeSetting validates the new value, persists it to the database +// and only then publishes it to the live snapshot, so an admin never gets a +// success response for a change that would not survive a restart. A failed +// database write leaves the live allowlists untouched and returns HTTP 500; +// an invalid value returns HTTP 400. +func (s *InternalServer) updateRuntimeSetting(def *runtimeSettingDef, r *http.Request) (interface{}, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, api.NewAPIError("Cannot get request body", true) + } + var req struct { + Value *string `json:"value"` + } + if err := json.Unmarshal(body, &req); err != nil || req.Value == nil { + return nil, api.NewAPIError(`Body must be a JSON object {"value":"..."}`, true) + } + // A value of exactly "" is a valid override: it explicitly unconfigures + // the dimension (as if the environment variable was not set), which is + // the only way to un-restrict at runtime when the environment has a + // value. Anything else — including whitespace- or separator-only values — + // must parse to at least one entry, so a botched automation input cannot + // silently disable the allowlist. + value := strings.TrimSpace(*req.Value) + var parsed map[string]struct{} + if *req.Value != "" { + parsed, err = def.parse(def.key, *req.Value) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) + } + } + s.runtimeSettingsMux.Lock() + defer s.runtimeSettingsMux.Unlock() + old, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + if err := s.runtimeSettings.StoreRuntimeSetting(def.key, value); err != nil { + glog.Error("admin: storing runtime setting ", def.key, "=", value, " failed: ", err) + return nil, api.NewAPIError("Cannot store runtime setting "+def.key+": "+err.Error(), false) + } + oldValue, oldSource := def.get(old) + a := *old + def.apply(&a, parsed, value, common.RuntimeSettingSourceDB) + s.is.SetRpcCallAllowlists(&a) + glog.Infof("admin: runtime setting %s changed from %q (%s) to %q (%s), client %s", + def.key, oldValue, oldSource, value, common.RuntimeSettingSourceDB, r.RemoteAddr) + return &runtimeSettingResponse{Key: def.key, Value: value, Source: common.RuntimeSettingSourceDB}, nil +} + +// deleteRuntimeSetting removes the stored override and reverts the setting to +// its environment default. The environment fallback is validated before the +// row is deleted — deleting with a malformed environment value would leave +// the database and the live state divergent and fail the next restart. +func (s *InternalServer) deleteRuntimeSetting(def *runtimeSettingDef, r *http.Request) (interface{}, error) { + envName := runtimeSettingEnvName(s.is.GetNetwork(), def.key) + envValue := os.Getenv(envName) + var parsed map[string]struct{} + source := common.RuntimeSettingSourceUnset + if envValue != "" { + var err error + parsed, err = def.parse(envName, envValue) + if err != nil { + return nil, api.NewAPIError("Cannot remove override, fallback environment value is invalid: "+err.Error()+"; fix the environment or set a value instead", true) + } + source = common.RuntimeSettingSourceEnv + } + s.runtimeSettingsMux.Lock() + defer s.runtimeSettingsMux.Unlock() + old, err := s.currentRpcCallAllowlists() + if err != nil { + return nil, err + } + if err := s.runtimeSettings.DeleteRuntimeSetting(def.key); err != nil { + glog.Error("admin: deleting runtime setting ", def.key, " failed: ", err) + return nil, api.NewAPIError("Cannot delete runtime setting "+def.key+": "+err.Error(), false) + } + oldValue, oldSource := def.get(old) + a := *old + def.apply(&a, parsed, envValue, source) + s.is.SetRpcCallAllowlists(&a) + glog.Infof("admin: runtime setting %s override removed, reverted from %q (%s) to %q (%s), client %s", + def.key, oldValue, oldSource, envValue, source, r.RemoteAddr) + return &runtimeSettingResponse{Key: def.key, Value: envValue, Source: source}, nil +} + +// RuntimeSettingView is a single row of the admin runtime-settings page. +type RuntimeSettingView struct { + Key string + Value string + Source string +} + +func (s *InternalServer) runtimeSettingsPage(w http.ResponseWriter, r *http.Request) (tpl, *InternalTemplateData, error) { + data := s.newTemplateData(r) + if a := s.is.GetRpcCallAllowlists(); a != nil { + for _, def := range runtimeSettingDefs { + value, source := def.get(a) + data.RuntimeSettings = append(data.RuntimeSettings, RuntimeSettingView{Key: def.key, Value: value, Source: source}) + } + } + return adminRuntimeSettingsTpl, data, nil +} diff --git a/server/runtime_settings_test.go b/server/runtime_settings_test.go new file mode 100644 index 0000000000..288721089c --- /dev/null +++ b/server/runtime_settings_test.go @@ -0,0 +1,292 @@ +//go:build unittest +// +build unittest + +package server + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +func TestParseAllowedRpcCallTo(t *testing.T) { + tests := []struct { + name string + value string + want []string + wantErr bool + }{ + {name: "empty", value: "", want: nil}, + {name: "single address", value: "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9", want: []string{"0xcda9fc258358ecaa88845f19af595e908bb7efe9"}}, + {name: "multiple with spaces", value: " 0xABCD , 0xEF01 ", want: []string{"0xabcd", "0xef01"}}, + {name: "empty entries skipped", value: "0xabcd,,0xef01", want: []string{"0xabcd", "0xef01"}}, + {name: "separators only", value: ",", wantErr: true}, + {name: "whitespace entries only", value: " , ,", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAllowedRpcCallTo("FAKE_ALLOWED_RPC_CALL_TO", tt.value) + if tt.wantErr { + if err == nil { + t.Fatalf("parseAllowedRpcCallTo(%q) = nil err, want error", tt.value) + } + return + } + if err != nil { + t.Fatalf("parseAllowedRpcCallTo(%q) unexpected error: %v", tt.value, err) + } + if tt.want == nil && got != nil { + t.Fatalf("parseAllowedRpcCallTo(%q) = %v, want nil", tt.value, got) + } + if len(got) != len(tt.want) { + t.Fatalf("parseAllowedRpcCallTo(%q) len = %d, want %d", tt.value, len(got), len(tt.want)) + } + for _, a := range tt.want { + if _, ok := got[a]; !ok { + t.Fatalf("parseAllowedRpcCallTo(%q) missing %q", tt.value, a) + } + } + }) + } +} + +func doRuntimeSettingRequest(t *testing.T, handler http.HandlerFunc, method, key, body string) (int, map[string]string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + r := httptest.NewRequest(method, "/admin/runtime-settings/"+key, rdr) + w := httptest.NewRecorder() + handler(w, r) + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("%s %s: cannot unmarshal response %q: %v", method, key, w.Body.String(), err) + } + return w.Code, resp +} + +// failingRuntimeSettingStore fails every persistence call; it stands in for a +// broken database to exercise the store-before-publish error paths. +type failingRuntimeSettingStore struct{} + +func (failingRuntimeSettingStore) StoreRuntimeSetting(name, value string) error { + return errors.New("store failed") +} + +func (failingRuntimeSettingStore) DeleteRuntimeSetting(name string) error { + return errors.New("delete failed") +} + +// The subtests of TestRuntimeSettingsAPI intentionally share the DB and +// snapshot state and depend on running in order; run the whole test, not +// individual subtests. +func TestRuntimeSettingsAPI(t *testing.T) { + const envTo = "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9" + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", envTo) + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + t.Fatal(err) + } + d, is, path := setupRocksDB(parser, chain, t, false, &common.Config{CoinName: "Fakecoin", CoinShortcut: "FAKE"}) + defer func() { + if err := d.Close(); err != nil { + t.Error(err) + } + os.RemoveAll(path) + }() + if err := initRpcCallAllowlists(d, is); err != nil { + t.Fatal(err) + } + s := &InternalServer{ + htmlTemplates: htmlTemplates[InternalTemplateData]{debug: true}, + db: d, + is: is, + chainParser: parser, + runtimeSettings: d, + } + handler := s.jsonHandler(s.apiRuntimeSetting, 0) + + t.Run("GET unknown key", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "NO_SUCH_SETTING", "") + if code != http.StatusBadRequest || !strings.Contains(resp["error"], "Unknown runtime setting") { + t.Fatalf("got %d %v, want 400 unknown runtime setting", code, resp) + } + }) + + t.Run("GET env-sourced value", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "ALLOWED_RPC_CALL_TO", "") + if code != http.StatusOK || resp["value"] != envTo || resp["source"] != common.RuntimeSettingSourceEnv { + t.Fatalf("got %d %v, want 200 value %q source env", code, resp, envTo) + } + }) + + t.Run("GET unset value", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusOK || resp["value"] != "" || resp["source"] != common.RuntimeSettingSourceUnset { + t.Fatalf("got %d %v, want 200 empty value source unset", code, resp) + } + }) + + t.Run("key is case-insensitive", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "allowed_rpc_call_to", "") + if code != http.StatusOK || resp["key"] != runtimeSettingAllowedRpcCallTo { + t.Fatalf("got %d %v, want 200 key %s", code, resp, runtimeSettingAllowedRpcCallTo) + } + }) + + t.Run("POST invalid selector is rejected", func(t *testing.T) { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", `{"value":"0x12"}`) + if code != http.StatusBadRequest { + t.Fatalf("got %d, want 400", code) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); found { + t.Fatal("rejected POST must not create a DB row") + } + if a := is.GetRpcCallAllowlists(); a.Methods != nil || a.MethodsSource != common.RuntimeSettingSourceUnset { + t.Fatalf("rejected POST must not change the snapshot, got %+v", a) + } + }) + + t.Run("POST invalid to list is rejected", func(t *testing.T) { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_RPC_CALL_TO", `{"value":","}`) + if code != http.StatusBadRequest { + t.Fatalf("got %d, want 400", code) + } + }) + + t.Run("POST bad body is rejected", func(t *testing.T) { + for _, body := range []string{"not json", `{"other":"field"}`} { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", body) + if code != http.StatusBadRequest { + t.Fatalf("body %q: got %d, want 400", body, code) + } + } + }) + + t.Run("POST whitespace-only value is rejected", func(t *testing.T) { + // only a value of exactly "" is an explicit unconfigure; a + // whitespace- or separator-only value is a typo that must not + // silently un-restrict rpcCall + for _, body := range []string{`{"value":" "}`, `{"value":"\n"}`} { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", body) + if code != http.StatusBadRequest { + t.Fatalf("body %q: got %d, want 400", body, code) + } + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); found { + t.Fatal("rejected POST must not create a DB row") + } + }) + + t.Run("unsupported method is rejected", func(t *testing.T) { + code, _ := doRuntimeSettingRequest(t, handler, http.MethodPatch, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusBadRequest { + t.Fatalf("got %d, want 400", code) + } + }) + + t.Run("POST stores override and updates snapshot", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", `{"value":"0xdd62ed3e,0x70a08231"}`) + if code != http.StatusOK || resp["value"] != "0xdd62ed3e,0x70a08231" || resp["source"] != common.RuntimeSettingSourceDB { + t.Fatalf("got %d %v, want 200 source db", code, resp) + } + value, found, err := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods) + if err != nil || !found || value != "0xdd62ed3e,0x70a08231" { + t.Fatalf("DB row = %q, %v, %v, want stored value", value, found, err) + } + a := is.GetRpcCallAllowlists() + if _, ok := a.Methods["70a08231"]; !ok || a.MethodsSource != common.RuntimeSettingSourceDB { + t.Fatalf("snapshot not updated: %+v", a) + } + }) + + t.Run("POST empty value explicitly unconfigures", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_RPC_CALL_TO", `{"value":""}`) + if code != http.StatusOK || resp["value"] != "" || resp["source"] != common.RuntimeSettingSourceDB { + t.Fatalf("got %d %v, want 200 empty value source db", code, resp) + } + a := is.GetRpcCallAllowlists() + if a.To != nil || a.ToSource != common.RuntimeSettingSourceDB { + t.Fatalf("snapshot not cleared: %+v", a) + } + if value, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedRpcCallTo); !found || value != "" { + t.Fatalf("DB row = %q, %v, want stored empty value", value, found) + } + }) + + t.Run("stored empty override beats env after restart", func(t *testing.T) { + // simulated restart: the stored empty override must keep the + // dimension unconfigured even though the env var has a value + restarted := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(d, restarted); err != nil { + t.Fatal(err) + } + a := restarted.GetRpcCallAllowlists() + if a.To != nil || a.ToValue != "" || a.ToSource != common.RuntimeSettingSourceDB { + t.Fatalf("after restart got To %v value %q source %q, want unconfigured db override", a.To, a.ToValue, a.ToSource) + } + }) + + t.Run("DELETE reverts to env", func(t *testing.T) { + code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_RPC_CALL_TO", "") + if code != http.StatusOK || resp["value"] != envTo || resp["source"] != common.RuntimeSettingSourceEnv { + t.Fatalf("got %d %v, want 200 value %q source env", code, resp, envTo) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedRpcCallTo); found { + t.Fatal("DELETE must remove the DB row") + } + a := is.GetRpcCallAllowlists() + if _, ok := a.To[strings.ToLower(envTo)]; !ok || a.ToSource != common.RuntimeSettingSourceEnv { + t.Fatalf("snapshot not reverted to env: %+v", a) + } + }) + + t.Run("DELETE with malformed env fallback is rejected", func(t *testing.T) { + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "not-a-selector") + code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusBadRequest { + t.Fatalf("got %d %v, want 400", code, resp) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); !found { + t.Fatal("rejected DELETE must keep the DB row") + } + if a := is.GetRpcCallAllowlists(); a.MethodsSource != common.RuntimeSettingSourceDB { + t.Fatalf("rejected DELETE must not change the snapshot: %+v", a) + } + }) + + // proves the DB write happens before the snapshot swap: with a failing + // store the requests take the error branch (glog.Error + 500) and the + // live allowlists stay untouched + t.Run("failed store returns 500 and keeps snapshot", func(t *testing.T) { + s.runtimeSettings = failingRuntimeSettingStore{} + defer func() { s.runtimeSettings = d }() + before := is.GetRpcCallAllowlists() + code, resp := doRuntimeSettingRequest(t, handler, http.MethodPost, "ALLOWED_EVM_CALL_METHODS", `{"value":"0xa9059cbb"}`) + if code != http.StatusInternalServerError || !strings.Contains(resp["error"], "Cannot store runtime setting") { + t.Fatalf("got %d %v, want 500 store error", code, resp) + } + if is.GetRpcCallAllowlists() != before { + t.Fatal("failed DB write must not publish a new snapshot") + } + code, resp = doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusInternalServerError || !strings.Contains(resp["error"], "Cannot delete runtime setting") { + t.Fatalf("got %d %v, want 500 delete error", code, resp) + } + if is.GetRpcCallAllowlists() != before { + t.Fatal("failed DB delete must not publish a new snapshot") + } + }) +} diff --git a/server/websocket.go b/server/websocket.go index 621ebb9dba..3c7acc256a 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -106,14 +106,7 @@ type WebsocketServer struct { fiatRatesTokenSubscriptions map[*websocketChannel][]string fiatRatesSubscriptionsLock sync.Mutex allowedOrigins map[string]struct{} - allowedRpcCallTo map[string]struct{} - // allowedRpcCallMethods holds 4-byte EVM call selectors (lowercase hex, - // no 0x prefix) that rpcCall may invoke on any address; nil when - // unconfigured. Together with allowedRpcCallTo it forms the rpcCall - // allowlist: a call passes when either the target address or the calldata - // selector is allowed; if both are nil, rpcCall is unrestricted. - allowedRpcCallMethods map[string]struct{} - trustedProxyPrefixes []netip.Prefix + trustedProxyPrefixes []netip.Prefix // cloudflarePrefixes gates trust of the CF-Connecting-* headers: when // non-empty, those headers are honored only when the TCP peer is inside one // of these ranges (or a loopback/private proxy). Empty disables verification @@ -182,23 +175,9 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. } originEnvName := strings.ToUpper(is.GetNetwork()) + "_WS_ALLOWED_ORIGINS" s.allowedOrigins = parseAllowedOrigins(originEnvName, os.Getenv(originEnvName)) - envRpcCall := os.Getenv(strings.ToUpper(is.GetNetwork()) + "_ALLOWED_RPC_CALL_TO") - if envRpcCall != "" { - s.allowedRpcCallTo = make(map[string]struct{}) - for _, c := range strings.Split(envRpcCall, ",") { - s.allowedRpcCallTo[strings.ToLower(c)] = struct{}{} - } - glog.Info("Support of rpcCall for these contracts: ", envRpcCall) - } - methodsEnvName := strings.ToUpper(is.GetNetwork()) + "_ALLOWED_EVM_CALL_METHODS" - envRpcCallMethods := os.Getenv(methodsEnvName) - s.allowedRpcCallMethods, err = parseAllowedEvmCallMethods(methodsEnvName, envRpcCallMethods) - if err != nil { + if err := initRpcCallAllowlists(db, is); err != nil { return nil, err } - if s.allowedRpcCallMethods != nil { - glog.Info("Support of rpcCall for these method selectors: ", envRpcCallMethods) - } clientIPCfg, err := readClientIPConfig(is.GetNetwork()) if err != nil { return nil, err @@ -256,35 +235,6 @@ func parseAllowedOrigins(originEnvName, envAllowedOrigins string) map[string]str return allowedOrigins } -// parseAllowedEvmCallMethods parses a comma-separated list of 4-byte EVM call -// selectors (optional 0x prefix, case-insensitive) into a set keyed by -// lowercase hex without the prefix. Returns nil when the variable is unset. -// A malformed selector or a set variable that parses to no selectors at all -// is a configuration error that aborts startup so a typo cannot silently -// disable the intended allowlist. -func parseAllowedEvmCallMethods(envName, envValue string) (map[string]struct{}, error) { - if envValue == "" { - return nil, nil - } - methods := make(map[string]struct{}) - for _, m := range strings.Split(envValue, ",") { - m = strings.TrimSpace(m) - if m == "" { - continue - } - selector := strings.TrimPrefix(strings.ToLower(m), "0x") - b, err := hex.DecodeString(selector) - if err != nil || len(b) != 4 { - return nil, errors.Errorf("Invalid EVM call method selector %q in %s, expecting 4 bytes in hex", m, envName) - } - methods[selector] = struct{}{} - } - if len(methods) == 0 { - return nil, errors.Errorf("%s is set but contains no method selectors", envName) - } - return methods, nil -} - func (s *WebsocketServer) checkOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { @@ -1264,19 +1214,23 @@ func evmCallSelector(data string) (string, bool) { // rpcCallAllowed reports whether a rpcCall request passes the allowlists. With // no allowlist configured rpcCall is unrestricted; otherwise the call must -// target an allowed address or invoke an allowed method selector. +// target an allowed address or invoke an allowed method selector. The snapshot +// is nil only when initRpcCallAllowlists has not run (bare test servers); +// NewWebsocketServer and NewInternalServer fail construction when they cannot +// publish one. func (s *WebsocketServer) rpcCallAllowed(r *WsRpcCallReq) bool { - if s.allowedRpcCallTo == nil && s.allowedRpcCallMethods == nil { + a := s.is.GetRpcCallAllowlists() + if a == nil || (a.To == nil && a.Methods == nil) { return true } - if s.allowedRpcCallTo != nil { - if _, ok := s.allowedRpcCallTo[strings.ToLower(r.To)]; ok { + if a.To != nil { + if _, ok := a.To[strings.ToLower(r.To)]; ok { return true } } - if s.allowedRpcCallMethods != nil { + if a.Methods != nil { if selector, ok := evmCallSelector(r.Data); ok { - if _, ok := s.allowedRpcCallMethods[selector]; ok { + if _, ok := a.Methods[selector]; ok { return true } } diff --git a/server/websocket_test.go b/server/websocket_test.go index a9ccf9d7fc..f29ab9a390 100644 --- a/server/websocket_test.go +++ b/server/websocket_test.go @@ -18,6 +18,7 @@ import ( "github.com/trezor/blockbook/api" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/common" "github.com/trezor/blockbook/tests/dbtestdata" ) @@ -327,12 +328,21 @@ func TestRpcCallAllowed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := &WebsocketServer{allowedRpcCallTo: tt.to, allowedRpcCallMethods: tt.methods} + is := &common.InternalState{} + is.SetRpcCallAllowlists(&common.RpcCallAllowlists{To: tt.to, Methods: tt.methods}) + s := &WebsocketServer{is: is} if got := s.rpcCallAllowed(&tt.req); got != tt.want { t.Fatalf("rpcCallAllowed(to=%q, data=%q) = %v, want %v", tt.req.To, tt.req.Data, got, tt.want) } }) } + + t.Run("nil snapshot allows all", func(t *testing.T) { + s := &WebsocketServer{is: &common.InternalState{}} + if !s.rpcCallAllowed(&WsRpcCallReq{To: otherTo, Data: "0x12345678"}) { + t.Fatal("rpcCallAllowed with uninitialized snapshot = false, want true") + } + }) } func TestParseTrustedProxies(t *testing.T) { diff --git a/static/internal_templates/index.html b/static/internal_templates/index.html index 7a94bce8f0..196b0027e5 100644 --- a/static/internal_templates/index.html +++ b/static/internal_templates/index.html @@ -11,4 +11,7 @@ + {{end}}{{end}} diff --git a/static/internal_templates/runtime_settings.html b/static/internal_templates/runtime_settings.html new file mode 100644 index 0000000000..f3a73aa4f1 --- /dev/null +++ b/static/internal_templates/runtime_settings.html @@ -0,0 +1,38 @@ +{{define "specific"}} {{if eq .ChainType 1}} + + + + + + + + + + {{range .RuntimeSettings}} + + + + + + {{end}} + +
SettingValueSource
{{.Key}}{{.Value}}{{.Source}}
+
+ Runtime settings override their environment variable defaults, are stored in the database and survive restarts. + Use the /admin/runtime-settings/<KEY> endpoint to manage them. Examples: +
+
+            # read the current value and its source (db, env or unset)
+            curl -k -u user:password 'https://<internaladdress>/admin/runtime-settings/ALLOWED_EVM_CALL_METHODS'
+
+            # set an override (an empty value explicitly unconfigures the setting)
+            curl -k -u user:password -X POST 'https://<internaladdress>/admin/runtime-settings/ALLOWED_EVM_CALL_METHODS' \
+            -H 'Content-Type: application/json' \
+            --data '{"value":"0xdd62ed3e,0x70a08231"}'
+
+            # remove the override and revert to the environment default
+            curl -k -u user:password -X DELETE 'https://<internaladdress>/admin/runtime-settings/ALLOWED_EVM_CALL_METHODS'
+        
+
+
+{{else}} Not supported {{end}}{{end}} From a4bc1d5ac08097ae0ea43927012e5cbae58bce85 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 2 Jul 2026 09:36:33 +0000 Subject: [PATCH 03/10] perf(eth): decode only first 4 bytes in evmCallSelector 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. --- server/websocket.go | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/server/websocket.go b/server/websocket.go index 3c7acc256a..5f0b628743 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -1197,19 +1197,32 @@ func (s *WebsocketServer) getBlockFiltersBatch(r *WsBlockFiltersBatchReq) (res i } // evmCallSelector extracts the 4-byte function selector from hex-encoded EVM -// calldata as lowercase hex without the 0x prefix. It decodes the whole data -// string so malformed calldata (missing 0x prefix, odd length, non-hex -// characters, fewer than 4 bytes) never yields a selector and thus fails -// closed in the rpcCall allowlist check. +// calldata as lowercase hex without the 0x prefix. It validates the full +// calldata hex (odd length or non-hex characters fail closed) but decodes +// only the first 4 bytes (8 hex chars) so that arbitrarily long calldata +// does not cause a large allocation that is then discarded. func evmCallSelector(data string) (string, bool) { - if len(data) < 2 || data[0] != '0' || (data[1] != 'x' && data[1] != 'X') { + if len(data) < 10 || data[0] != '0' || (data[1] != 'x' && data[1] != 'X') { return "", false } - b, err := hex.DecodeString(data[2:]) - if err != nil || len(b) < 4 { + s := data[2:] + if len(s)&1 == 1 { return "", false } - return hex.EncodeToString(b[:4]), true + for i := 0; i < len(s); i++ { + switch { + case s[i] >= '0' && s[i] <= '9': + case s[i] >= 'a' && s[i] <= 'f': + case s[i] >= 'A' && s[i] <= 'F': + default: + return "", false + } + } + b, err := hex.DecodeString(s[:8]) + if err != nil { + return "", false + } + return hex.EncodeToString(b), true } // rpcCallAllowed reports whether a rpcCall request passes the allowlists. With From cdb08f25589d099b757edbba96e3fe366f2e6432 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 2 Jul 2026 10:20:35 +0000 Subject: [PATCH 04/10] refactor(server): unify admin contract-info and runtime-settings APIs 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/
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 --- db/rocksdb_contracts.go | 27 +++ db/rocksdb_contracts_test.go | 58 +++++ docs/env.md | 10 +- server/internal.go | 91 ++++++-- server/internal_contract_info_test.go | 212 +++++++++++++++++++ server/internal_test.go | 22 ++ server/runtime_settings.go | 5 +- static/internal_templates/contract_info.html | 16 +- static/internal_templates/index.html | 6 +- 9 files changed, 417 insertions(+), 30 deletions(-) create mode 100644 server/internal_contract_info_test.go diff --git a/db/rocksdb_contracts.go b/db/rocksdb_contracts.go index 62e62eadc6..5709be6750 100644 --- a/db/rocksdb_contracts.go +++ b/db/rocksdb_contracts.go @@ -2,6 +2,7 @@ package db import ( vlq "github.com/bsm/go-vlq" + "github.com/juju/errors" "github.com/linxGnu/grocksdb" "github.com/trezor/blockbook/bchain" ) @@ -186,3 +187,29 @@ func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchai } return nil } + +// DeleteContractInfoForAddress removes the stored contract metadata for the given +// address (and its in-memory cache entry) so the next read re-fetches it from the +// backend node. Returns whether a stored row existed. ERC-4626 protocol-detection +// rows in cfErcProtocols are kept: they are sync-owned, chain-derived state with +// their own lifecycle, merged on read — not cached backend metadata. +func (d *RocksDB) DeleteContractInfoForAddress(address string) (bool, error) { + contract, err := d.chainParser.GetAddrDescFromAddress(address) + if err != nil { + return false, err + } + if contract == nil { + return false, errors.Errorf("invalid address %s", address) + } + val, err := d.db.GetCF(d.ro, d.cfh[cfContracts], contract) + if err != nil { + return false, err + } + found := len(val.Data()) > 0 + val.Free() + if err := d.db.DeleteCF(d.wo, d.cfh[cfContracts], contract); err != nil { + return false, err + } + cachedContracts.delete(string(contract)) + return found, nil +} diff --git a/db/rocksdb_contracts_test.go b/db/rocksdb_contracts_test.go index 0df1b9bcc0..cd8aca68ce 100644 --- a/db/rocksdb_contracts_test.go +++ b/db/rocksdb_contracts_test.go @@ -7,8 +7,66 @@ import ( "testing" "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/tests/dbtestdata" ) +func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + address := "0x" + dbtestdata.EthAddr20 + ci := &bchain.ContractInfo{ + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Contract: address, + Name: "Test contract", + Symbol: "TCT", + Decimals: 18, + } + if err := d.StoreContractInfo(ci); err != nil { + t.Fatal(err) + } + // The get populates the in-memory cache, so a successful delete below also + // proves the cache entry is purged along with the DB row. + got, err := d.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if got == nil || got.Name != ci.Name { + t.Fatalf("GetContractInfoForAddress() = %+v, want stored contract", got) + } + + found, err := d.DeleteContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if !found { + t.Error("DeleteContractInfoForAddress() = false, want true for a stored row") + } + got, err = d.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Errorf("GetContractInfoForAddress() after delete = %+v, want nil", got) + } + + // Idempotent: deleting a missing row is not an error. + found, err = d.DeleteContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if found { + t.Error("DeleteContractInfoForAddress() = true, want false for a missing row") + } + + if _, err = d.DeleteContractInfoForAddress("not-an-address"); err == nil { + t.Error("DeleteContractInfoForAddress() with invalid address: expected error") + } +} + // packContractInfo only carries the sync-owned core fields. ERC4626 detection // data lives in the cfErcProtocols column family and is exercised // separately in rocksdb_protocols_test.go. diff --git a/docs/env.md b/docs/env.md index cdee3c7533..56feea784b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -95,7 +95,7 @@ Blockbook reads these from its process environment. When installed from the Debi ## Runtime settings -The `rpcCall` allowlists can be changed at runtime, without a restart, through the internal server's authenticated admin API (see `BB_ADMIN_USER`/`BB_ADMIN_PASSWORD` above). An override is persisted in the Blockbook database, survives restarts and takes precedence over the corresponding environment variable, which serves only as the startup default. Every change is logged. The `/admin/runtime-settings` page shows the current values and their sources. +The `rpcCall` allowlists can be changed at runtime, without a restart, through the internal server's authenticated admin API (see `BB_ADMIN_USER`/`BB_ADMIN_PASSWORD` above). An override is persisted in the Blockbook database, survives restarts and takes precedence over the corresponding environment variable, which serves only as the startup default. Every change is logged. The `/admin/runtime-settings` page shows the current values and their sources. The endpoint and the page are registered on every chain type — the runtime-settings mechanism is chain-generic; the currently defined settings only affect EVM `rpcCall` and are simply unset elsewhere. `GET/POST/DELETE /admin/runtime-settings/` where `` is `ALLOWED_RPC_CALL_TO` or `ALLOWED_EVM_CALL_METHODS`: @@ -105,6 +105,14 @@ The `rpcCall` allowlists can be changed at runtime, without a restart, through t Old Blockbook versions ignore the stored overrides (the environment applies again after a version rollback) and keep them intact, so rolling forward resumes the override. +## Contract-info admin endpoint + +On EVM chains the internal server also exposes `/admin/contract-info/` (same Basic auth) to manage the contract metadata Blockbook caches from the backend node: + +- `GET /admin/contract-info/
` returns the contract's metadata as a `ContractInfo` JSON object (fetching it from the backend node and storing it if not cached yet). +- `POST` (or `PUT`) `/admin/contract-info/` with a JSON array body `[{ContractInfo},…]` updates the stored metadata of the listed contracts; the response is `{"updated":N}`. The write targets the collection path — a `POST` to an address path is rejected with `400`. +- `DELETE /admin/contract-info/
` purges the cached metadata of one contract so it is re-fetched from the backend node on the next read; the response is `{"contract":"
","deleted":true|false}` (`false` when nothing was stored — the delete is idempotent). + ## Build-time variables - `BB_BUILD_ENV` - Selects the active RPC URL override family during package/config generation. Defaults to `dev`. diff --git a/server/internal.go b/server/internal.go index 72c17565c7..e2e0038f88 100644 --- a/server/internal.go +++ b/server/internal.go @@ -12,13 +12,11 @@ import ( "os" "path/filepath" "sort" - "strconv" "strings" "sync" "time" "github.com/golang/glog" - "github.com/juju/errors" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/trezor/blockbook/api" "github.com/trezor/blockbook/bchain" @@ -111,15 +109,19 @@ func NewInternalServer(binding, certFiles string, db *db.RocksDB, chain bchain.B serveMux.HandleFunc(adminPath, s.requireAdminAuth(s.htmlTemplateHandler(s.adminIndex))) serveMux.HandleFunc(adminPath+"/", s.requireAdminAuth(s.adminSubtreeHandler(adminPath))) serveMux.HandleFunc(adminPath+"/ws-limit-exceeding-ips", s.requireAdminAuth(s.htmlTemplateHandler(s.wsLimitExceedingIPs))) + // Runtime settings are chain-generic (initRpcCallAllowlists already runs + // unconditionally in NewWebsocketServer); the currently defined settings only + // affect EVM rpcCall and are simply unset on other chains. The init must stay + // before the route registration — currentRpcCallAllowlists relies on it. + if err := initRpcCallAllowlists(db, is); err != nil { + return nil, err + } + serveMux.HandleFunc(adminPath+"/runtime-settings", s.requireAdminAuth(s.htmlTemplateHandler(s.runtimeSettingsPage))) + serveMux.HandleFunc(adminPath+"/runtime-settings/", s.requireAdminAuth(s.jsonHandler(s.apiRuntimeSetting, 0))) if s.chainParser.GetChainType() == bchain.ChainEthereumType { - if err := initRpcCallAllowlists(db, is); err != nil { - return nil, err - } serveMux.HandleFunc(adminPath+"/internal-data-errors", s.requireAdminAuth(s.htmlTemplateHandler(s.internalDataErrors))) serveMux.HandleFunc(adminPath+"/contract-info", s.requireAdminAuth(s.htmlTemplateHandler(s.contractInfoPage))) serveMux.HandleFunc(adminPath+"/contract-info/", s.requireAdminAuth(s.jsonHandler(s.apiContractInfo, 0))) - serveMux.HandleFunc(adminPath+"/runtime-settings", s.requireAdminAuth(s.htmlTemplateHandler(s.runtimeSettingsPage))) - serveMux.HandleFunc(adminPath+"/runtime-settings/", s.requireAdminAuth(s.jsonHandler(s.apiRuntimeSetting, 0))) } return s, nil } @@ -151,6 +153,20 @@ func (s *InternalServer) adminSubtreeHandler(adminPath string) http.HandlerFunc } } +// urlPathSegment returns the last segment of the request path with surrounding +// whitespace trimmed, or "" when the path ends in "/" or has no sub-segment +// below a registered subtree (a root-level path like "/admin" yields ""). Callers +// apply their own normalization on top (runtime-setting keys are uppercased, +// contract addresses are left to the chain parser, the authority on case and +// checksum handling). +func urlPathSegment(r *http.Request) string { + var seg string + if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { + seg = r.URL.Path[i+1:] + } + return strings.TrimSpace(seg) +} + // requireAdminAuth wraps an internal-server handler so it is reachable only with // valid HTTP Basic credentials. The admin surface is fail-closed: when the // credentials are not configured the endpoints return 503 rather than serving @@ -372,20 +388,44 @@ func (s *InternalServer) contractInfoPage(w http.ResponseWriter, r *http.Request return adminContractInfoTpl, data, nil } +// contractInfoUpdateResponse is the JSON shape returned by POST /admin/contract-info/. +type contractInfoUpdateResponse struct { + Updated int `json:"updated"` +} + +// contractInfoDeleteResponse is the JSON shape returned by +// DELETE /admin/contract-info/
. +type contractInfoDeleteResponse struct { + Contract string `json:"contract"` + Deleted bool `json:"deleted"` +} + +// apiContractInfo handles GET/POST/PUT/DELETE of cached contract metadata at +// /admin/contract-info/
(POST/PUT write the collection path +// /admin/contract-info/ with a JSON array body). func (s *InternalServer) apiContractInfo(r *http.Request, apiVersion int) (interface{}, error) { - if r.Method == http.MethodPost { + address := urlPathSegment(r) + switch r.Method { + case http.MethodGet: + return s.getContractInfo(address) + case http.MethodPost, http.MethodPut: + // The bulk write addresses each contract in its body; reject a POST to + // an address path instead of silently ignoring the address segment. + if address != "" { + return nil, api.NewAPIError("POST updates the collection; use /admin/contract-info/ with a JSON array body", true) + } return s.updateContracts(r) + case http.MethodDelete: + return s.deleteContractInfo(address) } - var contractAddress string - i := strings.LastIndexByte(r.URL.Path, '/') - if i > 0 { - contractAddress = r.URL.Path[i+1:] - } - if len(contractAddress) == 0 { + return nil, api.NewAPIError("Unsupported method "+r.Method, true) +} + +func (s *InternalServer) getContractInfo(address string) (interface{}, error) { + if address == "" { return nil, api.NewAPIError("Missing contract address", true) } - - contractInfo, valid, err := s.api.GetContractInfo(contractAddress, bchain.UnknownTokenStandard) + contractInfo, valid, err := s.api.GetContractInfo(address, bchain.UnknownTokenStandard) if err != nil { return nil, api.NewAPIError(err.Error(), true) } @@ -403,7 +443,7 @@ func (s *InternalServer) updateContracts(r *http.Request) (interface{}, error) { var contractInfos []bchain.ContractInfo err = json.Unmarshal(data, &contractInfos) if err != nil { - return nil, errors.Annotatef(err, "Cannot unmarshal body to array of ContractInfo objects") + return nil, api.NewAPIError("Cannot unmarshal body to array of ContractInfo objects: "+err.Error(), true) } for i := range contractInfos { c := &contractInfos[i] @@ -413,5 +453,20 @@ func (s *InternalServer) updateContracts(r *http.Request) (interface{}, error) { } } - return "{\"success\":\"Updated " + strconv.Itoa(len(contractInfos)) + " contracts\"}", nil + return &contractInfoUpdateResponse{Updated: len(contractInfos)}, nil +} + +// deleteContractInfo purges the cached metadata of one contract so the next +// read re-fetches it from the backend node. Deleting is idempotent: a missing +// row reports deleted=false rather than an error, matching the runtime-settings +// DELETE semantics. +func (s *InternalServer) deleteContractInfo(address string) (interface{}, error) { + if address == "" { + return nil, api.NewAPIError("Missing contract address", true) + } + found, err := s.db.DeleteContractInfoForAddress(address) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) + } + return &contractInfoDeleteResponse{Contract: address, Deleted: found}, nil } diff --git a/server/internal_contract_info_test.go b/server/internal_contract_info_test.go new file mode 100644 index 0000000000..7842e92945 --- /dev/null +++ b/server/internal_contract_info_test.go @@ -0,0 +1,212 @@ +//go:build unittest + +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/golang/glog" + "github.com/trezor/blockbook/bchain" + "github.com/trezor/blockbook/bchain/coins/eth" + "github.com/trezor/blockbook/tests/dbtestdata" +) + +// newInternalTestServer builds a full internal server (including the api.Worker +// the contract-info GET path needs) on top of the public-server test harness. +// The caller must have set BB_ADMIN_USER/BB_ADMIN_PASSWORD beforehand — the +// credentials are read in NewInternalServer. +func newInternalTestServer(t *testing.T, s *PublicServer) *httptest.Server { + t.Helper() + internal, err := NewInternalServer("localhost:12346", "", s.db, s.chain, s.mempool, s.txCache, metrics, s.is, s.fiatRates) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(internal.https.Handler) + t.Cleanup(ts.Close) + return ts +} + +func adminRequest(t *testing.T, ts *httptest.Server, method, path, body string) (int, string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequest(method, ts.URL+path, rdr) + if err != nil { + t.Fatal(err) + } + req.SetBasicAuth("admin", "password") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return resp.StatusCode, strings.TrimSpace(string(b)) +} + +// TestContractInfoAdminAPI exercises the /admin/contract-info/ JSON API +// end-to-end: GET of one contract, bulk POST on the collection path (the +// response must decode as a JSON object — a regression check for the formerly +// double-encoded string body), DELETE with idempotent semantics, and the +// request-validation failure paths. +func TestContractInfoAdminAPI(t *testing.T) { + t.Setenv("BB_ADMIN_USER", "admin") + t.Setenv("BB_ADMIN_PASSWORD", "password") + parser := eth.NewEthereumParser(1, true) + chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) + if err != nil { + glog.Fatal("fakechain: ", err) + } + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + ts := newInternalTestServer(t, s) + + address := "0x" + dbtestdata.EthAddr20 + + t.Run("GET missing address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/", "") + if code != http.StatusBadRequest || !strings.Contains(body, "Missing contract address") { + t.Fatalf("GET = %d %s, want 400 Missing contract address", code, body) + } + }) + + t.Run("GET address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/"+address, "") + if code != http.StatusOK { + t.Fatalf("GET = %d %s, want 200", code, body) + } + var ci bchain.ContractInfo + if err := json.Unmarshal([]byte(body), &ci); err != nil { + t.Fatalf("GET body %q does not decode to ContractInfo: %v", body, err) + } + if ci.Contract == "" || ci.Name == "" || ci.Standard != bchain.ERC20TokenStandard { + t.Fatalf("GET returned unexpected contract info: %+v", ci) + } + }) + + t.Run("POST collection", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/", + `[{"contract":"`+address+`","name":"Renamed","symbol":"RNM","standard":"ERC20","decimals":18}]`) + if code != http.StatusOK { + t.Fatalf("POST = %d %s, want 200", code, body) + } + // The old handler returned a pre-serialized string that jsonHandler + // encoded again; decoding into a typed object fails on that shape. + var resp contractInfoUpdateResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("POST body %q does not decode to an object: %v", body, err) + } + if resp.Updated != 1 { + t.Fatalf("POST updated = %d, want 1", resp.Updated) + } + stored, err := s.db.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if stored == nil || stored.Name != "Renamed" { + t.Fatalf("stored contract = %+v, want name Renamed", stored) + } + }) + + t.Run("POST with address segment", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/"+address, `[]`) + if code != http.StatusBadRequest || !strings.Contains(body, "collection") { + t.Fatalf("POST = %d %s, want 400 pointing to the collection path", code, body) + } + }) + + t.Run("POST non-array body", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/", `{"contract":"x"}`) + if code != http.StatusBadRequest { + t.Fatalf("POST = %d %s, want 400", code, body) + } + }) + + t.Run("unsupported method", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodPatch, "/admin/contract-info/"+address, "") + if code != http.StatusBadRequest || !strings.Contains(body, "Unsupported method") { + t.Fatalf("PATCH = %d %s, want 400 Unsupported method", code, body) + } + }) + + t.Run("DELETE", func(t *testing.T) { + // stored by the POST subtest above + code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/"+address, "") + if code != http.StatusOK || body != `{"contract":"`+address+`","deleted":true}` { + t.Fatalf("DELETE = %d %s, want deleted:true", code, body) + } + // assert on the DB directly — the GET endpoint would re-fetch from the + // backend and re-store the row + stored, err := s.db.GetContractInfoForAddress(address) + if err != nil { + t.Fatal(err) + } + if stored != nil { + t.Fatalf("stored contract after DELETE = %+v, want nil", stored) + } + // idempotent: deleting a missing row reports deleted:false, not an error + code, body = adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/"+address, "") + if code != http.StatusOK || body != `{"contract":"`+address+`","deleted":false}` { + t.Fatalf("repeated DELETE = %d %s, want deleted:false", code, body) + } + }) + + t.Run("DELETE invalid address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/not-an-address", "") + if code != http.StatusBadRequest { + t.Fatalf("DELETE = %d %s, want 400", code, body) + } + }) + + t.Run("DELETE missing address", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/", "") + if code != http.StatusBadRequest || !strings.Contains(body, "Missing contract address") { + t.Fatalf("DELETE = %d %s, want 400 Missing contract address", code, body) + } + }) +} + +// TestInternalServerRouteGatingNonEVM verifies that the chain-generic +// runtime-settings admin routes are registered on a non-EVM chain while the +// EVM-specific contract-info routes stay gated (404 via the authenticated +// catch-all). +func TestInternalServerRouteGatingNonEVM(t *testing.T) { + t.Setenv("BB_ADMIN_USER", "admin") + t.Setenv("BB_ADMIN_PASSWORD", "password") + parser, chain := setupChain(t) + s, dbpath := setupPublicHTTPServer(parser, chain, t, false) + defer closeAndDestroyPublicServer(t, s, dbpath) + ts := newInternalTestServer(t, s) + + code, body := adminRequest(t, ts, http.MethodGet, "/admin/runtime-settings/ALLOWED_RPC_CALL_TO", "") + if code != http.StatusOK { + t.Fatalf("GET runtime setting = %d %s, want 200 on a non-EVM chain", code, body) + } + var resp runtimeSettingResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("runtime setting body %q does not decode: %v", body, err) + } + if resp.Key != "ALLOWED_RPC_CALL_TO" || resp.Source != "unset" { + t.Fatalf("runtime setting = %+v, want unset ALLOWED_RPC_CALL_TO", resp) + } + + code, _ = adminRequest(t, ts, http.MethodGet, "/admin/runtime-settings", "") + if code != http.StatusOK { + t.Fatalf("GET runtime-settings page = %d, want 200 on a non-EVM chain", code) + } + + code, _ = adminRequest(t, ts, http.MethodGet, "/admin/contract-info/0x20cd153de35d469ba46127a0c8f18626b59a256a", "") + if code != http.StatusNotFound { + t.Fatalf("GET contract-info = %d, want 404 on a non-EVM chain", code) + } +} diff --git a/server/internal_test.go b/server/internal_test.go index 6c753293d9..4a3776a4cb 100644 --- a/server/internal_test.go +++ b/server/internal_test.go @@ -65,6 +65,28 @@ func TestRequireAdminAuth(t *testing.T) { } } +func Test_urlPathSegment(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"/admin/contract-info/0xAbC123", "0xAbC123"}, // case preserved + {"/admin/runtime-settings/allowed_rpc_call_to", "allowed_rpc_call_to"}, + {"/admin/contract-info/ 0xabc \t", "0xabc"}, // whitespace trimmed + {"/admin/contract-info/", ""}, // collection path + {"/admin", ""}, // root-level path has no sub-segment + {"/", ""}, + {"", ""}, + } + for _, tt := range tests { + r := httptest.NewRequest(http.MethodGet, "/x", nil) + r.URL.Path = tt.path // bypass NewRequest path validation to test raw shapes + if got := urlPathSegment(r); got != tt.want { + t.Errorf("urlPathSegment(%q) = %q, want %q", tt.path, got, tt.want) + } + } +} + // TestAdminSubtreeIsGated guards against the ServeMux fall-through where // trailing-slash or unknown /admin paths would otherwise reach the unauthenticated // index handler. It mirrors the route registration in NewInternalServer. diff --git a/server/runtime_settings.go b/server/runtime_settings.go index 396ab76897..513a61b0fa 100644 --- a/server/runtime_settings.go +++ b/server/runtime_settings.go @@ -230,10 +230,7 @@ type runtimeSettingResponse struct { // apiRuntimeSetting handles GET/POST/PUT/DELETE of a single runtime setting at // /admin/runtime-settings/. func (s *InternalServer) apiRuntimeSetting(r *http.Request, apiVersion int) (interface{}, error) { - var key string - if i := strings.LastIndexByte(r.URL.Path, '/'); i > 0 { - key = strings.ToUpper(strings.TrimSpace(r.URL.Path[i+1:])) - } + key := strings.ToUpper(urlPathSegment(r)) def := runtimeSettingDefByKey(key) if def == nil { return nil, api.NewAPIError("Unknown runtime setting, supported: "+runtimeSettingKeys(), true) diff --git a/static/internal_templates/contract_info.html b/static/internal_templates/contract_info.html index 57cbfece24..1f640e263d 100644 --- a/static/internal_templates/contract_info.html +++ b/static/internal_templates/contract_info.html @@ -26,13 +26,21 @@
- To update contract, use POST request to /admin/contract-info/ endpoint. Example: + Use the /admin/contract-info/ endpoint to manage cached contract metadata. Examples:
-            curl -k -v  \
-            'https://<internaladdress>/admin/contract-info/' \
+            # read the cached metadata of one contract
+            curl -k -u user:password 'https://<internaladdress>/admin/contract-info/<address>'
+
+            # update contracts; POST targets the collection path with a JSON array body,
+            # the response is {"updated":N}
+            curl -k -u user:password -X POST 'https://<internaladdress>/admin/contract-info/' \
             -H 'Content-Type: application/json' \
-            --data '[{ContractInfo},{ContractInfo},...]'        
+            --data '[{ContractInfo},{ContractInfo},...]'
+
+            # purge the cached metadata of one contract so it is re-fetched from the
+            # backend node on the next read; the response is {"contract":"...","deleted":true|false}
+            curl -k -u user:password -X DELETE 'https://<internaladdress>/admin/contract-info/<address>'
         
diff --git a/static/internal_templates/index.html b/static/internal_templates/index.html index 196b0027e5..166a68278d 100644 --- a/static/internal_templates/index.html +++ b/static/internal_templates/index.html @@ -4,6 +4,9 @@ IP addresses that exceeded websocket usage limit + {{if eq .ChainType 1}}
@@ -11,7 +14,4 @@ - {{end}}{{end}} From 253118356d003215da456406b42902d103a8a055 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Thu, 2 Jul 2026 10:49:12 +0000 Subject: [PATCH 05/10] fix(server,db): address review nits and harden the admin endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- common/internalstate.go | 11 ++ db/rocksdb_contracts.go | 39 +++-- db/rocksdb_contracts_test.go | 38 +++-- docs/env.md | 6 +- server/internal.go | 32 ++-- server/internal_contract_info_test.go | 24 ++- server/runtime_settings.go | 49 ++++-- server/runtime_settings_test.go | 35 +++- static/internal_templates/contract_info.html | 7 +- .../internal_templates/runtime_settings.html | 149 +++++++++++++++--- 10 files changed, 311 insertions(+), 79 deletions(-) diff --git a/common/internalstate.go b/common/internalstate.go index 0d8a22617b..ef6c537f7b 100644 --- a/common/internalstate.go +++ b/common/internalstate.go @@ -142,6 +142,17 @@ const ( type RpcCallAllowlists struct { // To and Methods are the parsed allowlists; a nil map means that dimension // is unconfigured. With both nil, rpcCall is unrestricted. + // + // Each dimension's key format must match what the rpcCall check looks up + // (server rpcCallAllowed), and the two deliberately differ: + // - To keys are the configured entries trimmed and lowercased verbatim — + // in practice 0x-prefixed hex addresses, because they are matched + // against the lowercased `to` field of the request as sent by clients. + // - Methods keys are 4-byte selectors as 8 lowercase hex characters + // without the 0x prefix, because they are matched against selectors + // hex-decoded from the request calldata (server evmCallSelector). + // A future dimension should document its key format here and keep the + // parser (server runtimeSettingDefs) and the rpcCall lookup in lockstep. To map[string]struct{} Methods map[string]struct{} // Raw comma-separated values and their sources (unset/env/db), kept for diff --git a/db/rocksdb_contracts.go b/db/rocksdb_contracts.go index 5709be6750..962c58153c 100644 --- a/db/rocksdb_contracts.go +++ b/db/rocksdb_contracts.go @@ -190,26 +190,45 @@ func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchai // DeleteContractInfoForAddress removes the stored contract metadata for the given // address (and its in-memory cache entry) so the next read re-fetches it from the -// backend node. Returns whether a stored row existed. ERC-4626 protocol-detection -// rows in cfErcProtocols are kept: they are sync-owned, chain-derived state with -// their own lifecycle, merged on read — not cached backend metadata. -func (d *RocksDB) DeleteContractInfoForAddress(address string) (bool, error) { +// backend node. It returns the purged record (nil when no row was stored): the +// whole row is discarded, including the sync-owned CreatedInBlock and +// DestructedInBlock, which a backend re-fetch cannot restore — only a reindex or +// storing the returned record back can. ERC-4626 protocol-detection rows in +// cfErcProtocols are kept: they live in their own column family with their own +// lifecycle and are merged on read. +func (d *RocksDB) DeleteContractInfoForAddress(address string) (*bchain.ContractInfo, error) { contract, err := d.chainParser.GetAddrDescFromAddress(address) if err != nil { - return false, err + return nil, err } if contract == nil { - return false, errors.Errorf("invalid address %s", address) + return nil, errors.Errorf("invalid address %s", address) } val, err := d.db.GetCF(d.ro, d.cfh[cfContracts], contract) if err != nil { - return false, err + return nil, err + } + buf := val.Data() + var purged *bchain.ContractInfo + if len(buf) > 0 { + purged, _ = unpackContractInfo(buf) + addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(contract) + if len(addresses) > 0 { + purged.Contract = addresses[0] + } } - found := len(val.Data()) > 0 val.Free() + if purged == nil { + return nil, nil + } if err := d.db.DeleteCF(d.wo, d.cfh[cfContracts], contract); err != nil { - return false, err + return nil, err } + // Bump before the cache delete: a concurrent GetContractInfo that already + // sampled the old protocolGen and is about to re-add the just-deleted row + // will mismatch on the next read and miss, even if its add lands after our + // delete clears the slot (same idiom as SetErcProtocol). + d.protocolGen.Add(1) cachedContracts.delete(string(contract)) - return found, nil + return purged, nil } diff --git a/db/rocksdb_contracts_test.go b/db/rocksdb_contracts_test.go index cd8aca68ce..3132c13a53 100644 --- a/db/rocksdb_contracts_test.go +++ b/db/rocksdb_contracts_test.go @@ -18,12 +18,13 @@ func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) { address := "0x" + dbtestdata.EthAddr20 ci := &bchain.ContractInfo{ - Standard: bchain.ERC20TokenStandard, - Type: bchain.ERC20TokenStandard, - Contract: address, - Name: "Test contract", - Symbol: "TCT", - Decimals: 18, + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Contract: address, + Name: "Test contract", + Symbol: "TCT", + Decimals: 18, + CreatedInBlock: 1234567, } if err := d.StoreContractInfo(ci); err != nil { t.Fatal(err) @@ -38,12 +39,18 @@ func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) { t.Fatalf("GetContractInfoForAddress() = %+v, want stored contract", got) } - found, err := d.DeleteContractInfoForAddress(address) + genBefore := d.protocolGen.Load() + purged, err := d.DeleteContractInfoForAddress(address) if err != nil { t.Fatal(err) } - if !found { - t.Error("DeleteContractInfoForAddress() = false, want true for a stored row") + if purged == nil || purged.Name != ci.Name || purged.CreatedInBlock != ci.CreatedInBlock { + t.Errorf("DeleteContractInfoForAddress() = %+v, want the stored record", purged) + } + // The generation bump protects against a concurrent GetContractInfo + // re-inserting the deleted row into the cache (see SetErcProtocol). + if d.protocolGen.Load() != genBefore+1 { + t.Error("DeleteContractInfoForAddress() did not bump protocolGen") } got, err = d.GetContractInfoForAddress(address) if err != nil { @@ -53,13 +60,18 @@ func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) { t.Errorf("GetContractInfoForAddress() after delete = %+v, want nil", got) } - // Idempotent: deleting a missing row is not an error. - found, err = d.DeleteContractInfoForAddress(address) + // Idempotent: deleting a missing row is not an error and does not bump + // the generation (nothing a concurrent reader could re-insert). + genBefore = d.protocolGen.Load() + purged, err = d.DeleteContractInfoForAddress(address) if err != nil { t.Fatal(err) } - if found { - t.Error("DeleteContractInfoForAddress() = true, want false for a missing row") + if purged != nil { + t.Errorf("DeleteContractInfoForAddress() = %+v, want nil for a missing row", purged) + } + if d.protocolGen.Load() != genBefore { + t.Error("DeleteContractInfoForAddress() of a missing row must not bump protocolGen") } if _, err = d.DeleteContractInfoForAddress("not-an-address"); err == nil { diff --git a/docs/env.md b/docs/env.md index 56feea784b..3f012f5291 100644 --- a/docs/env.md +++ b/docs/env.md @@ -81,9 +81,9 @@ Blockbook reads these from its process environment. When installed from the Debi 3. `COINGECKO_API_KEY` Example: for Optimism, `network=OP` and `coin shortcut=ETH`, so `OP_COINGECKO_API_KEY` is preferred over `ETH_COINGECKO_API_KEY`. -- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. Entries are trimmed and empty entries skipped; a set value that contains no addresses at all is a configuration error and Blockbook fails on startup. If omitted (and `ALLOWED_EVM_CALL_METHODS` is not set either), `rpcCall` is enabled for all addresses. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. +- `_ALLOWED_RPC_CALL_TO` - Addresses to which `rpcCall` websocket requests can be made, as a comma-separated list. The value and its entries are trimmed and empty entries skipped; a set value that contains no addresses at all (a whitespace-only value included) is a configuration error and Blockbook fails on startup. If omitted (and `ALLOWED_EVM_CALL_METHODS` is not set either), `rpcCall` is enabled for all addresses. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. -- `_ALLOWED_EVM_CALL_METHODS` - EVM method selectors (first 4 bytes of the calldata, for example `0xdd62ed3e` for ERC-20 `allowance(address,address)`) that `rpcCall` websocket requests may invoke on any address, as a comma-separated list; the `0x` prefix is optional and matching is case-insensitive. Combines with `ALLOWED_RPC_CALL_TO`: a call is allowed when either its target address or its calldata selector is allowed. When only this variable is set, only calls with an allowed selector pass. Malformed calldata (missing `0x` prefix, invalid or odd-length hex, fewer than 4 bytes) never matches a selector. A malformed selector in the list, or a set variable that contains no selectors at all, is a configuration error and Blockbook fails on startup. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. +- `_ALLOWED_EVM_CALL_METHODS` - EVM method selectors (first 4 bytes of the calldata, for example `0xdd62ed3e` for ERC-20 `allowance(address,address)`) that `rpcCall` websocket requests may invoke on any address, as a comma-separated list; the `0x` prefix is optional and matching is case-insensitive. Combines with `ALLOWED_RPC_CALL_TO`: a call is allowed when either its target address or its calldata selector is allowed. When only this variable is set, only calls with an allowed selector pass. Malformed calldata (missing `0x` prefix, invalid or odd-length hex, fewer than 4 bytes) never matches a selector. A malformed selector in the list, or a set variable that contains no selectors at all (a whitespace-only value included), is a configuration error and Blockbook fails on startup. This is the startup default of a [runtime setting](#runtime-settings): an override stored through the admin API takes precedence. - `_ALTERNATIVE_SENDTX_URLS` - Comma-separated list of alternative EVM `eth_sendRawTransaction` providers, used for private/MEV-protected transaction submission. The prefix is the configured `network` value when present (for example `OP`, `BASE`, `POL`, `BSC`, `ARB`, `AVAX`), otherwise the coin shortcut (for example `ETH`). If omitted, Blockbook sends transactions through the normal backend RPC. @@ -111,7 +111,7 @@ On EVM chains the internal server also exposes `/admin/contract-info/` (same Bas - `GET /admin/contract-info/
` returns the contract's metadata as a `ContractInfo` JSON object (fetching it from the backend node and storing it if not cached yet). - `POST` (or `PUT`) `/admin/contract-info/` with a JSON array body `[{ContractInfo},…]` updates the stored metadata of the listed contracts; the response is `{"updated":N}`. The write targets the collection path — a `POST` to an address path is rejected with `400`. -- `DELETE /admin/contract-info/
` purges the cached metadata of one contract so it is re-fetched from the backend node on the next read; the response is `{"contract":"
","deleted":true|false}` (`false` when nothing was stored — the delete is idempotent). +- `DELETE /admin/contract-info/
` purges the stored metadata of one contract so it is re-fetched from the backend node on the next read; the response is `{"contract":"
","deleted":true|false,"purged":{ContractInfo}}` (`deleted` is `false` and `purged` absent when nothing was stored — the delete is idempotent). Note that the whole record is discarded: the backend re-fetch restores only name/symbol/decimals, not the sync-owned `createdInBlock`/`destructedInBlock` fields, which are otherwise recoverable only by a reindex. The `purged` record in the response (also logged) can be `POST`ed back to restore them. ## Build-time variables diff --git a/server/internal.go b/server/internal.go index e2e0038f88..87a7c47f16 100644 --- a/server/internal.go +++ b/server/internal.go @@ -394,10 +394,14 @@ type contractInfoUpdateResponse struct { } // contractInfoDeleteResponse is the JSON shape returned by -// DELETE /admin/contract-info/
. +// DELETE /admin/contract-info/
. Purged carries the removed record so +// the operator can restore it verbatim with a POST — the row includes the +// sync-owned createdInBlock/destructedInBlock fields, which the backend +// re-fetch on the next read cannot recover. type contractInfoDeleteResponse struct { - Contract string `json:"contract"` - Deleted bool `json:"deleted"` + Contract string `json:"contract"` + Deleted bool `json:"deleted"` + Purged *bchain.ContractInfo `json:"purged,omitempty"` } // apiContractInfo handles GET/POST/PUT/DELETE of cached contract metadata at @@ -416,7 +420,7 @@ func (s *InternalServer) apiContractInfo(r *http.Request, apiVersion int) (inter } return s.updateContracts(r) case http.MethodDelete: - return s.deleteContractInfo(address) + return s.deleteContractInfo(address, r) } return nil, api.NewAPIError("Unsupported method "+r.Method, true) } @@ -456,17 +460,23 @@ func (s *InternalServer) updateContracts(r *http.Request) (interface{}, error) { return &contractInfoUpdateResponse{Updated: len(contractInfos)}, nil } -// deleteContractInfo purges the cached metadata of one contract so the next -// read re-fetches it from the backend node. Deleting is idempotent: a missing -// row reports deleted=false rather than an error, matching the runtime-settings -// DELETE semantics. -func (s *InternalServer) deleteContractInfo(address string) (interface{}, error) { +// deleteContractInfo purges the stored metadata of one contract so the next +// read re-fetches it from the backend node. The whole row is discarded — the +// backend re-fetch restores only name/symbol/decimals, not the sync-owned +// createdInBlock/destructedInBlock, so the purged record is logged and +// returned for a POST restore. Deleting is idempotent: a missing row reports +// deleted=false rather than an error, matching the runtime-settings DELETE +// semantics. +func (s *InternalServer) deleteContractInfo(address string, r *http.Request) (interface{}, error) { if address == "" { return nil, api.NewAPIError("Missing contract address", true) } - found, err := s.db.DeleteContractInfoForAddress(address) + purged, err := s.db.DeleteContractInfoForAddress(address) if err != nil { return nil, api.NewAPIError(err.Error(), true) } - return &contractInfoDeleteResponse{Contract: address, Deleted: found}, nil + if purged != nil { + glog.Infof("admin: contract info %s purged (%+v), client %s", address, *purged, r.RemoteAddr) + } + return &contractInfoDeleteResponse{Contract: address, Deleted: purged != nil, Purged: purged}, nil } diff --git a/server/internal_contract_info_test.go b/server/internal_contract_info_test.go index 7842e92945..4466467b87 100644 --- a/server/internal_contract_info_test.go +++ b/server/internal_contract_info_test.go @@ -142,8 +142,17 @@ func TestContractInfoAdminAPI(t *testing.T) { t.Run("DELETE", func(t *testing.T) { // stored by the POST subtest above code, body := adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/"+address, "") - if code != http.StatusOK || body != `{"contract":"`+address+`","deleted":true}` { - t.Fatalf("DELETE = %d %s, want deleted:true", code, body) + if code != http.StatusOK { + t.Fatalf("DELETE = %d %s, want 200", code, body) + } + var resp contractInfoDeleteResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("DELETE body %q does not decode: %v", body, err) + } + // the purged record is returned so the operator can restore it (incl. + // the sync-owned fields the backend re-fetch cannot recover) via POST + if !resp.Deleted || resp.Contract != address || resp.Purged == nil || resp.Purged.Name != "Renamed" { + t.Fatalf("DELETE = %+v, want deleted:true with the purged record", resp) } // assert on the DB directly — the GET endpoint would re-fetch from the // backend and re-store the row @@ -156,8 +165,15 @@ func TestContractInfoAdminAPI(t *testing.T) { } // idempotent: deleting a missing row reports deleted:false, not an error code, body = adminRequest(t, ts, http.MethodDelete, "/admin/contract-info/"+address, "") - if code != http.StatusOK || body != `{"contract":"`+address+`","deleted":false}` { - t.Fatalf("repeated DELETE = %d %s, want deleted:false", code, body) + if code != http.StatusOK { + t.Fatalf("repeated DELETE = %d %s, want 200", code, body) + } + resp = contractInfoDeleteResponse{} + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("repeated DELETE body %q does not decode: %v", body, err) + } + if resp.Deleted || resp.Purged != nil { + t.Fatalf("repeated DELETE = %+v, want deleted:false without a purged record", resp) } }) diff --git a/server/runtime_settings.go b/server/runtime_settings.go index 513a61b0fa..3c8c1742f4 100644 --- a/server/runtime_settings.go +++ b/server/runtime_settings.go @@ -123,7 +123,7 @@ func parseAllowedEvmCallMethods(name, value string) (map[string]struct{}, error) selector := strings.TrimPrefix(strings.ToLower(m), "0x") b, err := hex.DecodeString(selector) if err != nil || len(b) != 4 { - return nil, errors.Errorf("Invalid EVM call method selector %q in %s, expecting 4 bytes in hex", m, name) + return nil, errors.Errorf("invalid EVM call method selector %q in %s, expecting 4 bytes in hex", m, name) } methods[selector] = struct{}{} } @@ -139,11 +139,26 @@ func runtimeSettingEnvName(network, key string) string { return strings.ToUpper(network) + "_" + key } +// runtimeSettingEnvValue returns the trimmed value of the given environment +// variable, so a stray space or newline in an env file does not leak into the +// value (admin POSTs are trimmed the same way). A set but whitespace-only +// value is a configuration error rather than unset: treating it as unset +// would silently un-restrict rpcCall, unlike every other malformed value, +// which fails loudly. +func runtimeSettingEnvValue(envName string) (string, error) { + raw := os.Getenv(envName) + value := strings.TrimSpace(raw) + if raw != "" && value == "" { + return "", errors.Errorf("%s contains only whitespace", envName) + } + return value, nil +} + // resolveRuntimeSetting returns the effective raw value of a runtime setting // and its source: a stored override wins over the environment variable. -func resolveRuntimeSetting(d *db.RocksDB, network, key string) (string, string, error) { - if d != nil { - value, found, err := d.GetRuntimeSetting(key) +func resolveRuntimeSetting(store runtimeSettingStore, network, key string) (string, string, error) { + if store != nil { + value, found, err := store.GetRuntimeSetting(key) if err != nil { return "", "", err } @@ -151,7 +166,11 @@ func resolveRuntimeSetting(d *db.RocksDB, network, key string) (string, string, return value, common.RuntimeSettingSourceDB, nil } } - if value := os.Getenv(runtimeSettingEnvName(network, key)); value != "" { + value, err := runtimeSettingEnvValue(runtimeSettingEnvName(network, key)) + if err != nil { + return "", "", err + } + if value != "" { return value, common.RuntimeSettingSourceEnv, nil } return "", common.RuntimeSettingSourceUnset, nil @@ -163,10 +182,16 @@ func resolveRuntimeSetting(d *db.RocksDB, network, key string) (string, string, // through the admin interface) — is an error; falling back could silently // widen access. func buildRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) (*common.RpcCallAllowlists, error) { + // the explicit nil check avoids a typed-nil interface on which the store + // methods would be called + var store runtimeSettingStore + if d != nil { + store = d + } network := is.GetNetwork() a := &common.RpcCallAllowlists{} for _, def := range runtimeSettingDefs { - value, source, err := resolveRuntimeSetting(d, network, def.key) + value, source, err := resolveRuntimeSetting(store, network, def.key) if err != nil { return nil, err } @@ -212,9 +237,11 @@ func initRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) error { return nil } -// runtimeSettingStore persists runtime setting overrides; *db.RocksDB in -// production, replaceable in tests to exercise storage failures. +// runtimeSettingStore reads and persists runtime setting overrides; +// *db.RocksDB in production, replaceable in tests to exercise storage +// failures. type runtimeSettingStore interface { + GetRuntimeSetting(name string) (value string, found bool, err error) StoreRuntimeSetting(name, value string) error DeleteRuntimeSetting(name string) error } @@ -321,11 +348,13 @@ func (s *InternalServer) updateRuntimeSetting(def *runtimeSettingDef, r *http.Re // the database and the live state divergent and fail the next restart. func (s *InternalServer) deleteRuntimeSetting(def *runtimeSettingDef, r *http.Request) (interface{}, error) { envName := runtimeSettingEnvName(s.is.GetNetwork(), def.key) - envValue := os.Getenv(envName) + envValue, err := runtimeSettingEnvValue(envName) + if err != nil { + return nil, api.NewAPIError("Cannot remove override, fallback environment value is invalid: "+err.Error()+"; fix the environment or set a value instead", true) + } var parsed map[string]struct{} source := common.RuntimeSettingSourceUnset if envValue != "" { - var err error parsed, err = def.parse(envName, envValue) if err != nil { return nil, api.NewAPIError("Cannot remove override, fallback environment value is invalid: "+err.Error()+"; fix the environment or set a value instead", true) diff --git a/server/runtime_settings_test.go b/server/runtime_settings_test.go index 288721089c..9684198db6 100644 --- a/server/runtime_settings_test.go +++ b/server/runtime_settings_test.go @@ -60,6 +60,17 @@ func TestParseAllowedRpcCallTo(t *testing.T) { } } +// A whitespace-only environment value must fail startup resolution the same +// way a value with no parseable entries does — treating it as unset would +// silently leave rpcCall unrestricted. +func TestRuntimeSettingWhitespaceOnlyEnvFailsInit(t *testing.T) { + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", " \n") + is := &common.InternalState{CoinShortcut: "FAKE"} + if err := initRpcCallAllowlists(nil, is); err == nil || !strings.Contains(err.Error(), "only whitespace") { + t.Fatalf("initRpcCallAllowlists() err = %v, want whitespace configuration error", err) + } +} + func doRuntimeSettingRequest(t *testing.T, handler http.HandlerFunc, method, key, body string) (int, map[string]string) { t.Helper() var rdr io.Reader @@ -76,10 +87,14 @@ func doRuntimeSettingRequest(t *testing.T, handler http.HandlerFunc, method, key return w.Code, resp } -// failingRuntimeSettingStore fails every persistence call; it stands in for a +// failingRuntimeSettingStore fails every store call; it stands in for a // broken database to exercise the store-before-publish error paths. type failingRuntimeSettingStore struct{} +func (failingRuntimeSettingStore) GetRuntimeSetting(name string) (string, bool, error) { + return "", false, errors.New("get failed") +} + func (failingRuntimeSettingStore) StoreRuntimeSetting(name, value string) error { return errors.New("store failed") } @@ -93,7 +108,9 @@ func (failingRuntimeSettingStore) DeleteRuntimeSetting(name string) error { // individual subtests. func TestRuntimeSettingsAPI(t *testing.T) { const envTo = "0xcdA9FC258358EcaA88845f19Af595e908bb7EfE9" - t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", envTo) + // the surrounding whitespace must never surface: the env value is trimmed + // wherever it is resolved (startup and DELETE fallback alike) + t.Setenv("FAKE_ALLOWED_RPC_CALL_TO", " "+envTo+"\n") parser := eth.NewEthereumParser(1, true) chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) if err != nil { @@ -267,6 +284,20 @@ func TestRuntimeSettingsAPI(t *testing.T) { } }) + t.Run("DELETE with whitespace-only env fallback is rejected", func(t *testing.T) { + // a whitespace-only env value is a configuration error, not unset — + // reverting to it would silently un-restrict rpcCall and the next + // restart would resolve the same error + t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", " \n") + code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_EVM_CALL_METHODS", "") + if code != http.StatusBadRequest || !strings.Contains(resp["error"], "only whitespace") { + t.Fatalf("got %d %v, want 400 whitespace error", code, resp) + } + if _, found, _ := d.GetRuntimeSetting(runtimeSettingAllowedEvmCallMethods); !found { + t.Fatal("rejected DELETE must keep the DB row") + } + }) + // proves the DB write happens before the snapshot swap: with a failing // store the requests take the error branch (glog.Error + 500) and the // live allowlists stay untouched diff --git a/static/internal_templates/contract_info.html b/static/internal_templates/contract_info.html index 1f640e263d..e9656cdc88 100644 --- a/static/internal_templates/contract_info.html +++ b/static/internal_templates/contract_info.html @@ -38,8 +38,11 @@ -H 'Content-Type: application/json' \ --data '[{ContractInfo},{ContractInfo},...]' - # purge the cached metadata of one contract so it is re-fetched from the - # backend node on the next read; the response is {"contract":"...","deleted":true|false} + # purge the stored metadata of one contract so it is re-fetched from the + # backend node on the next read; the response + # {"contract":"...","deleted":true|false,"purged":{ContractInfo}} + # returns the removed record — POST it back to restore the sync-owned + # createdInBlock/destructedInBlock fields the re-fetch cannot recover curl -k -u user:password -X DELETE 'https://<internaladdress>/admin/contract-info/<address>'
diff --git a/static/internal_templates/runtime_settings.html b/static/internal_templates/runtime_settings.html index f3a73aa4f1..43d1e21d97 100644 --- a/static/internal_templates/runtime_settings.html +++ b/static/internal_templates/runtime_settings.html @@ -1,38 +1,139 @@ -{{define "specific"}} {{if eq .ChainType 1}} - +{{define "specific"}} + +
+
+ - + {{range .RuntimeSettings}} - - - - + + + + + {{end}}
Setting Value SourceActions
{{.Key}}{{.Value}}{{.Source}}
{{.Key}}{{.Value}}{{.Source}} + + +
-
- Runtime settings override their environment variable defaults, are stored in the database and survive restarts. - Use the /admin/runtime-settings/<KEY> endpoint to manage them. Examples: -
-
-            # read the current value and its source (db, env or unset)
-            curl -k -u user:password 'https://<internaladdress>/admin/runtime-settings/ALLOWED_EVM_CALL_METHODS'
+
+{{end}}

From 20af430966201806766be65d56bb3b2d8188890a Mon Sep 17 00:00:00 2001
From: pragmaxim 
Date: Fri, 3 Jul 2026 04:36:58 +0000
Subject: [PATCH 06/10] feat(server): list all runtime settings, warn on
 env/override drift
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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 
---
 docs/env.md                     |  4 ++-
 server/runtime_settings.go      | 47 ++++++++++++++++++++++++++++++-
 server/runtime_settings_test.go | 49 +++++++++++++++++++++++++++++++++
 3 files changed, 98 insertions(+), 2 deletions(-)

diff --git a/docs/env.md b/docs/env.md
index 3f012f5291..134ef47445 100644
--- a/docs/env.md
+++ b/docs/env.md
@@ -99,12 +99,14 @@ The `rpcCall` allowlists can be changed at runtime, without a restart, through t
 
 `GET/POST/DELETE /admin/runtime-settings/` where `` is `ALLOWED_RPC_CALL_TO` or `ALLOWED_EVM_CALL_METHODS`:
 
--   `GET` returns the effective value and its source (`db` = stored override, `env` = environment default, `unset` = neither): `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e","source":"env"}`.
+-   `GET` returns the effective value and its source (`db` = stored override, `env` = environment default, `unset` = neither): `{"key":"ALLOWED_EVM_CALL_METHODS","value":"0xdd62ed3e","source":"env"}`. A `GET` of the bare collection path `/admin/runtime-settings/` returns all settings as a JSON array.
 -   `POST` (or `PUT`) with body `{"value":"0xdd62ed3e,0x70a08231"}` validates the value (invalid values are rejected with `400` and change nothing), stores it in the database and only then applies it to the live allowlists — a database failure returns `500` and leaves the live state unchanged. A `"value"` of exactly `""` is a valid override meaning "explicitly unconfigured" (that allowlist dimension is disabled, as if its environment variable was not set) — it is the only way to un-restrict at runtime while the environment variable has a value. A value containing only whitespace or separators is rejected with `400`, so a botched automation input cannot silently disable the allowlist.
 -   `DELETE` removes the stored override and reverts to the environment default. If the environment value is malformed the request is rejected with `400` and the override is kept, so a later restart cannot fail on it.
 
 Old Blockbook versions ignore the stored overrides (the environment applies again after a version rollback) and keep them intact, so rolling forward resumes the override.
 
+The two sources play distinct roles in a replicated deployment. The environment variable is the deploy-managed baseline: it is shipped identically to every replica with the deployment's env file, applies from the first second of the process (before the admin port is even reachable) and — unlike the database, which is wiped when a replica is resynced — survives a database rebuild, so a freshly synced replica never starts with the allowlists silently unconfigured. The stored override is the runtime layer on top: it takes effect without a restart and persists across restarts until the deployment ships an updated environment and the override is removed. Because an override shadows the environment value, the two can drift (a replica that missed an admin update, or an env change rolled out while an override exists); the drift is visible in the `source` field and Blockbook logs a warning at startup when a stored override shadows a different environment value.
+
 ## Contract-info admin endpoint
 
 On EVM chains the internal server also exposes `/admin/contract-info/` (same Basic auth) to manage the contract metadata Blockbook caches from the backend node:
diff --git a/server/runtime_settings.go b/server/runtime_settings.go
index 3c8c1742f4..20f845ed0b 100644
--- a/server/runtime_settings.go
+++ b/server/runtime_settings.go
@@ -234,9 +234,35 @@ func initRpcCallAllowlists(d *db.RocksDB, is *common.InternalState) error {
 	if a.Methods != nil {
 		glog.Info("Support of rpcCall for these method selectors (source ", a.MethodsSource, "): ", a.MethodsValue)
 	}
+	warnShadowedRuntimeSettingEnv(a, is.GetNetwork())
 	return nil
 }
 
+// warnShadowedRuntimeSettingEnv logs a warning for every stored override that
+// shadows a different environment value, so drift between the deployed env
+// file and the database (for example a replica that missed an admin update,
+// or an env change rolled out while an override exists) is visible at
+// startup. It only compares — a shadowed environment value is never parsed or
+// validated, so it cannot fail the start.
+func warnShadowedRuntimeSettingEnv(a *common.RpcCallAllowlists, network string) {
+	for _, def := range runtimeSettingDefs {
+		value, source := def.get(a)
+		if source != common.RuntimeSettingSourceDB {
+			continue
+		}
+		envName := runtimeSettingEnvName(network, def.key)
+		envValue, err := runtimeSettingEnvValue(envName)
+		if err != nil {
+			glog.Warning("runtime setting ", def.key, ": stored override shadows a malformed environment value: ", err)
+			continue
+		}
+		if envValue != "" && envValue != value {
+			glog.Warningf("runtime setting %s: stored override %q shadows a different environment value %q (%s); the override wins until it is removed",
+				def.key, value, envValue, envName)
+		}
+	}
+}
+
 // runtimeSettingStore reads and persists runtime setting overrides;
 // *db.RocksDB in production, replaceable in tests to exercise storage
 // failures.
@@ -255,9 +281,13 @@ type runtimeSettingResponse struct {
 }
 
 // apiRuntimeSetting handles GET/POST/PUT/DELETE of a single runtime setting at
-// /admin/runtime-settings/.
+// /admin/runtime-settings/; a GET of the bare collection path returns all
+// settings, so a management tool can read the whole state in one request.
 func (s *InternalServer) apiRuntimeSetting(r *http.Request, apiVersion int) (interface{}, error) {
 	key := strings.ToUpper(urlPathSegment(r))
+	if key == "" && r.Method == http.MethodGet {
+		return s.listRuntimeSettings()
+	}
 	def := runtimeSettingDefByKey(key)
 	if def == nil {
 		return nil, api.NewAPIError("Unknown runtime setting, supported: "+runtimeSettingKeys(), true)
@@ -293,6 +323,21 @@ func (s *InternalServer) getRuntimeSetting(def *runtimeSettingDef) (interface{},
 	return &runtimeSettingResponse{Key: def.key, Value: value, Source: source}, nil
 }
 
+// listRuntimeSettings returns the effective value and source of every runtime
+// setting as a JSON array.
+func (s *InternalServer) listRuntimeSettings() (interface{}, error) {
+	a, err := s.currentRpcCallAllowlists()
+	if err != nil {
+		return nil, err
+	}
+	settings := make([]runtimeSettingResponse, len(runtimeSettingDefs))
+	for i, def := range runtimeSettingDefs {
+		value, source := def.get(a)
+		settings[i] = runtimeSettingResponse{Key: def.key, Value: value, Source: source}
+	}
+	return settings, nil
+}
+
 // updateRuntimeSetting validates the new value, persists it to the database
 // and only then publishes it to the live snapshot, so an admin never gets a
 // success response for a change that would not survive a restart. A failed
diff --git a/server/runtime_settings_test.go b/server/runtime_settings_test.go
index 9684198db6..23f935b74b 100644
--- a/server/runtime_settings_test.go
+++ b/server/runtime_settings_test.go
@@ -156,6 +156,40 @@ func TestRuntimeSettingsAPI(t *testing.T) {
 		}
 	})
 
+	t.Run("GET all settings", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/admin/runtime-settings/", nil)
+		w := httptest.NewRecorder()
+		handler(w, r)
+		if w.Code != http.StatusOK {
+			t.Fatalf("got %d %s, want 200", w.Code, w.Body.String())
+		}
+		var settings []runtimeSettingResponse
+		if err := json.Unmarshal(w.Body.Bytes(), &settings); err != nil {
+			t.Fatalf("cannot unmarshal response %q: %v", w.Body.String(), err)
+		}
+		want := map[string]runtimeSettingResponse{
+			runtimeSettingAllowedRpcCallTo:      {Key: runtimeSettingAllowedRpcCallTo, Value: envTo, Source: common.RuntimeSettingSourceEnv},
+			runtimeSettingAllowedEvmCallMethods: {Key: runtimeSettingAllowedEvmCallMethods, Value: "", Source: common.RuntimeSettingSourceUnset},
+		}
+		if len(settings) != len(want) {
+			t.Fatalf("got %d settings %v, want %d", len(settings), settings, len(want))
+		}
+		for _, s := range settings {
+			if s != want[s.Key] {
+				t.Fatalf("setting %s = %+v, want %+v", s.Key, s, want[s.Key])
+			}
+		}
+	})
+
+	t.Run("empty key accepts only GET", func(t *testing.T) {
+		for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
+			code, resp := doRuntimeSettingRequest(t, handler, method, "", `{"value":""}`)
+			if code != http.StatusBadRequest || !strings.Contains(resp["error"], "Unknown runtime setting") {
+				t.Fatalf("%s: got %d %v, want 400 unknown runtime setting", method, code, resp)
+			}
+		}
+	})
+
 	t.Run("key is case-insensitive", func(t *testing.T) {
 		code, resp := doRuntimeSettingRequest(t, handler, http.MethodGet, "allowed_rpc_call_to", "")
 		if code != http.StatusOK || resp["key"] != runtimeSettingAllowedRpcCallTo {
@@ -256,6 +290,21 @@ func TestRuntimeSettingsAPI(t *testing.T) {
 		}
 	})
 
+	t.Run("stored override shadows malformed env without failing init", func(t *testing.T) {
+		// drift between a stored override and the environment must not fail a
+		// restart — the shadowed env value is never parsed, the override wins
+		// and the mismatch is only logged
+		t.Setenv("FAKE_ALLOWED_EVM_CALL_METHODS", "not-a-selector")
+		restarted := &common.InternalState{CoinShortcut: "FAKE"}
+		if err := initRpcCallAllowlists(d, restarted); err != nil {
+			t.Fatal(err)
+		}
+		a := restarted.GetRpcCallAllowlists()
+		if a.MethodsSource != common.RuntimeSettingSourceDB || a.MethodsValue != "0xdd62ed3e,0x70a08231" {
+			t.Fatalf("got source %q value %q, want the db override", a.MethodsSource, a.MethodsValue)
+		}
+	})
+
 	t.Run("DELETE reverts to env", func(t *testing.T) {
 		code, resp := doRuntimeSettingRequest(t, handler, http.MethodDelete, "ALLOWED_RPC_CALL_TO", "")
 		if code != http.StatusOK || resp["value"] != envTo || resp["source"] != common.RuntimeSettingSourceEnv {

From 14e19308a860aba20c8c8cbefd4c611d0917ea06 Mon Sep 17 00:00:00 2001
From: pragmaxim 
Date: Fri, 3 Jul 2026 04:43:28 +0000
Subject: [PATCH 07/10] feat(server,db): paginated list endpoint for admin
 contract-info
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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":"
"} 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 --- db/rocksdb_contracts.go | 47 ++++++++++++++++ db/rocksdb_contracts_test.go | 56 ++++++++++++++++++++ docs/env.md | 1 + server/internal.go | 42 +++++++++++++-- server/internal_contract_info_test.go | 39 ++++++++++++-- static/internal_templates/contract_info.html | 5 ++ 6 files changed, 183 insertions(+), 7 deletions(-) diff --git a/db/rocksdb_contracts.go b/db/rocksdb_contracts.go index 962c58153c..483ff4bd00 100644 --- a/db/rocksdb_contracts.go +++ b/db/rocksdb_contracts.go @@ -188,6 +188,53 @@ func (d *RocksDB) storeContractInfo(wb *grocksdb.WriteBatch, contractInfo *bchai return nil } +// ListContractInfos returns up to limit stored contract records ordered by +// address descriptor, starting at the optional from address (inclusive), and +// the address to pass as from to fetch the next page ("" when the listing is +// complete). A page limit is mandatory: the rows are sync-populated (every +// contract creation when internal data processing is enabled), so the full +// set can run into millions on a busy chain. +func (d *RocksDB) ListContractInfos(from string, limit int) ([]bchain.ContractInfo, string, error) { + var start bchain.AddressDescriptor + if from != "" { + var err error + start, err = d.chainParser.GetAddrDescFromAddress(from) + if err != nil { + return nil, "", err + } + if start == nil { + return nil, "", errors.Errorf("invalid address %s", from) + } + } + it := d.db.NewIteratorCF(d.ro, d.cfh[cfContracts]) + defer it.Close() + if start != nil { + it.Seek(start) + } else { + it.SeekToFirst() + } + contracts := make([]bchain.ContractInfo, 0, limit) + for ; it.Valid(); it.Next() { + addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(it.Key().Data()) + if len(contracts) == limit { + // one more row exists — its address is the cursor of the next page + if len(addresses) > 0 { + return contracts, addresses[0], nil + } + return contracts, "", nil + } + contractInfo, err := unpackContractInfo(it.Value().Data()) + if err != nil { + return nil, "", err + } + if len(addresses) > 0 { + contractInfo.Contract = addresses[0] + } + contracts = append(contracts, *contractInfo) + } + return contracts, "", nil +} + // DeleteContractInfoForAddress removes the stored contract metadata for the given // address (and its in-memory cache entry) so the next read re-fetches it from the // backend node. It returns the purged record (nil when no row was stored): the diff --git a/db/rocksdb_contracts_test.go b/db/rocksdb_contracts_test.go index 3132c13a53..322714c6ff 100644 --- a/db/rocksdb_contracts_test.go +++ b/db/rocksdb_contracts_test.go @@ -4,12 +4,68 @@ package db import ( "reflect" + "strconv" + "strings" "testing" "github.com/trezor/blockbook/bchain" "github.com/trezor/blockbook/tests/dbtestdata" ) +func TestRocksDB_ListContractInfos(t *testing.T) { + d := setupRocksDB(t, &testEthereumParser{ + EthereumParser: ethereumTestnetParser(), + }) + defer closeAndDestroyRocksDB(t, d) + + // ordered by address descriptor: 0x20… < 0x4b… < 0x55… + addresses := []string{"0x" + dbtestdata.EthAddr20, "0x" + dbtestdata.EthAddr4b, "0x" + dbtestdata.EthAddr55} + for i, a := range addresses { + if err := d.StoreContractInfo(&bchain.ContractInfo{ + Standard: bchain.ERC20TokenStandard, + Type: bchain.ERC20TokenStandard, + Contract: a, + Name: "Contract " + strconv.Itoa(i), + Decimals: 18, + }); err != nil { + t.Fatal(err) + } + } + + contracts, next, err := d.ListContractInfos("", 10) + if err != nil { + t.Fatal(err) + } + if len(contracts) != 3 || next != "" { + t.Fatalf("ListContractInfos() = %d rows, next %q, want 3 rows and no next", len(contracts), next) + } + for i, c := range contracts { + if !strings.EqualFold(c.Contract, addresses[i]) { + t.Errorf("row %d = %s, want %s", i, c.Contract, addresses[i]) + } + } + + // paging: a full first page and a next cursor pointing at the third row + contracts, next, err = d.ListContractInfos("", 2) + if err != nil { + t.Fatal(err) + } + if len(contracts) != 2 || !strings.EqualFold(next, addresses[2]) { + t.Fatalf("ListContractInfos(limit 2) = %d rows, next %q, want 2 rows and next %s", len(contracts), next, addresses[2]) + } + contracts, next, err = d.ListContractInfos(next, 2) + if err != nil { + t.Fatal(err) + } + if len(contracts) != 1 || next != "" || !strings.EqualFold(contracts[0].Contract, addresses[2]) { + t.Fatalf("ListContractInfos(from next) = %+v next %q, want only the third row", contracts, next) + } + + if _, _, err = d.ListContractInfos("not-an-address", 2); err == nil { + t.Error("ListContractInfos() with invalid from: expected error") + } +} + func TestRocksDB_DeleteContractInfoForAddress(t *testing.T) { d := setupRocksDB(t, &testEthereumParser{ EthereumParser: ethereumTestnetParser(), diff --git a/docs/env.md b/docs/env.md index 134ef47445..1e2643d379 100644 --- a/docs/env.md +++ b/docs/env.md @@ -112,6 +112,7 @@ The two sources play distinct roles in a replicated deployment. The environment On EVM chains the internal server also exposes `/admin/contract-info/` (same Basic auth) to manage the contract metadata Blockbook caches from the backend node: - `GET /admin/contract-info/
` returns the contract's metadata as a `ContractInfo` JSON object (fetching it from the backend node and storing it if not cached yet). +- `GET /admin/contract-info/` (bare collection path) lists the stored records page by page as `{"contracts":[{ContractInfo},…],"next":"
"}`. Unlike runtime settings, the collection is unbounded (sync stores a record per contract creation, millions on a busy chain), so the page size is limited: `?limit=<1..10000>` (default `1000`) and `?from=
` continue from a cursor; `next` (present only when more records exist) is the `from` of the next page. - `POST` (or `PUT`) `/admin/contract-info/` with a JSON array body `[{ContractInfo},…]` updates the stored metadata of the listed contracts; the response is `{"updated":N}`. The write targets the collection path — a `POST` to an address path is rejected with `400`. - `DELETE /admin/contract-info/
` purges the stored metadata of one contract so it is re-fetched from the backend node on the next read; the response is `{"contract":"
","deleted":true|false,"purged":{ContractInfo}}` (`deleted` is `false` and `purged` absent when nothing was stored — the delete is idempotent). Note that the whole record is discarded: the backend re-fetch restores only name/symbol/decimals, not the sync-owned `createdInBlock`/`destructedInBlock` fields, which are otherwise recoverable only by a reindex. The `purged` record in the response (also logged) can be `POST`ed back to restore them. diff --git a/server/internal.go b/server/internal.go index 87a7c47f16..726dcf0c96 100644 --- a/server/internal.go +++ b/server/internal.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -393,6 +394,18 @@ type contractInfoUpdateResponse struct { Updated int `json:"updated"` } +// contractInfoListResponse is the JSON shape returned by GET /admin/contract-info/. +// Next, when present, is the from parameter of the next page. +type contractInfoListResponse struct { + Contracts []bchain.ContractInfo `json:"contracts"` + Next string `json:"next,omitempty"` +} + +const ( + contractInfoListDefaultLimit = 1000 + contractInfoListMaxLimit = 10000 +) + // contractInfoDeleteResponse is the JSON shape returned by // DELETE /admin/contract-info/
. Purged carries the removed record so // the operator can restore it verbatim with a POST — the row includes the @@ -406,11 +419,15 @@ type contractInfoDeleteResponse struct { // apiContractInfo handles GET/POST/PUT/DELETE of cached contract metadata at // /admin/contract-info/
(POST/PUT write the collection path -// /admin/contract-info/ with a JSON array body). +// /admin/contract-info/ with a JSON array body; a GET of the collection path +// lists the stored records page by page). func (s *InternalServer) apiContractInfo(r *http.Request, apiVersion int) (interface{}, error) { address := urlPathSegment(r) switch r.Method { case http.MethodGet: + if address == "" { + return s.listContractInfos(r) + } return s.getContractInfo(address) case http.MethodPost, http.MethodPut: // The bulk write addresses each contract in its body; reject a POST to @@ -425,10 +442,27 @@ func (s *InternalServer) apiContractInfo(r *http.Request, apiVersion int) (inter return nil, api.NewAPIError("Unsupported method "+r.Method, true) } -func (s *InternalServer) getContractInfo(address string) (interface{}, error) { - if address == "" { - return nil, api.NewAPIError("Missing contract address", true) +// listContractInfos returns one page of the stored contract records. Unlike +// the runtime-settings list, the collection is unbounded (sync stores a row +// per contract creation), so the page size is limited and the response +// carries a next cursor. +func (s *InternalServer) listContractInfos(r *http.Request) (interface{}, error) { + limit := contractInfoListDefaultLimit + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > contractInfoListMaxLimit { + return nil, api.NewAPIError("Invalid limit, expecting a number in 1.."+strconv.Itoa(contractInfoListMaxLimit), true) + } + limit = n + } + contracts, next, err := s.db.ListContractInfos(r.URL.Query().Get("from"), limit) + if err != nil { + return nil, api.NewAPIError(err.Error(), true) } + return &contractInfoListResponse{Contracts: contracts, Next: next}, nil +} + +func (s *InternalServer) getContractInfo(address string) (interface{}, error) { contractInfo, valid, err := s.api.GetContractInfo(address, bchain.UnknownTokenStandard) if err != nil { return nil, api.NewAPIError(err.Error(), true) diff --git a/server/internal_contract_info_test.go b/server/internal_contract_info_test.go index 4466467b87..7f8d917f65 100644 --- a/server/internal_contract_info_test.go +++ b/server/internal_contract_info_test.go @@ -73,10 +73,23 @@ func TestContractInfoAdminAPI(t *testing.T) { address := "0x" + dbtestdata.EthAddr20 - t.Run("GET missing address", func(t *testing.T) { + t.Run("GET collection lists stored contracts", func(t *testing.T) { code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/", "") - if code != http.StatusBadRequest || !strings.Contains(body, "Missing contract address") { - t.Fatalf("GET = %d %s, want 400 Missing contract address", code, body) + if code != http.StatusOK { + t.Fatalf("GET = %d %s, want 200", code, body) + } + var resp contractInfoListResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("GET body %q does not decode: %v", body, err) + } + }) + + t.Run("GET collection with invalid limit", func(t *testing.T) { + for _, q := range []string{"limit=0", "limit=-1", "limit=abc", "limit=10001"} { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/?"+q, "") + if code != http.StatusBadRequest { + t.Fatalf("%s: GET = %d %s, want 400", q, code, body) + } } }) @@ -118,6 +131,26 @@ func TestContractInfoAdminAPI(t *testing.T) { } }) + t.Run("GET collection contains the updated contract", func(t *testing.T) { + code, body := adminRequest(t, ts, http.MethodGet, "/admin/contract-info/?limit=10", "") + if code != http.StatusOK { + t.Fatalf("GET = %d %s, want 200", code, body) + } + var resp contractInfoListResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("GET body %q does not decode: %v", body, err) + } + found := false + for _, c := range resp.Contracts { + if c.Name == "Renamed" { + found = true + } + } + if !found { + t.Fatalf("list %+v does not contain the contract stored by POST", resp.Contracts) + } + }) + t.Run("POST with address segment", func(t *testing.T) { code, body := adminRequest(t, ts, http.MethodPost, "/admin/contract-info/"+address, `[]`) if code != http.StatusBadRequest || !strings.Contains(body, "collection") { diff --git a/static/internal_templates/contract_info.html b/static/internal_templates/contract_info.html index e9656cdc88..f98813d932 100644 --- a/static/internal_templates/contract_info.html +++ b/static/internal_templates/contract_info.html @@ -32,6 +32,11 @@ # read the cached metadata of one contract curl -k -u user:password 'https://<internaladdress>/admin/contract-info/<address>' + # list the stored records page by page; the response + # {"contracts":[...],"next":"<address>"} carries the from cursor of the + # next page (the collection can hold millions of sync-stored records) + curl -k -u user:password 'https://<internaladdress>/admin/contract-info/?limit=1000&from=<address>' + # update contracts; POST targets the collection path with a JSON array body, # the response is {"updated":N} curl -k -u user:password -X POST 'https://<internaladdress>/admin/contract-info/' \ From 9bb0d7b99cc808b56f6eda4ff601f6700b983626 Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 7 Jul 2026 08:42:49 +0000 Subject: [PATCH 08/10] ci: idempotent admin API e2e tests against the deployed dev instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-deploy step in the e2e-tests job exercising the admin API (/admin/runtime-settings/, /admin/contract-info/) of the deployed base_archive dev instance, leaving its database and live rpcCall policy exactly as found — at the end of a run and at every instant during it: - runtime settings use a zero-policy-impact roundtrip: the POSTed value is always the current effective value (or the explicit-empty override when unset), so the parsed allowlist never changes; the final DELETE restores the original source; db-sourced settings are only rewritten in place, since their env fallback is unknown from outside - the contract roundtrip uses a reserved never-real address, pre-cleans it (healing a crashed run) and deletes it at the end; the cleanup and restore flags are raised before the POSTs, so a response lost after a server-side commit still gets cleaned up - negative tests: 401 without credentials (503 = admin disabled fails loudly), unknown key, invalid values proven to change nothing, POST to an address path, unsupported method, list pagination limits - target URL derives from BB_DEV_API_URL_HTTP_ plus the coin config's ports.blockbook_internal (BB_ADMIN_E2E_URL overrides for local runs); the coin gate compares against coins_csv deploy aliases (test_coins_csv carries test names and would never match); skips are annotated (::warning:: for a missing BB_RUNTIME_ENV, ::notice:: for coin-not-deployed) so a dead signal cannot look like a pass - credentials are parsed from BB_ENV inside the script (surrounding quotes stripped like systemd EnvironmentFile) and never printed; transient network errors are retried once, then fail with a clean pipeline error; helpers reuse runner.py and wait_for_sync.py Verified live against blockbook-dev3: two consecutive runs green via the derived URL, instance state identical before and after. Co-Authored-By: Claude Fable 5 --- .github/scripts/admin_api_e2e.py | 380 ++++++++++++++++++++++++++ .github/scripts/admin_api_e2e_test.py | 87 ++++++ .github/workflows/deploy.yml | 16 ++ 3 files changed, 483 insertions(+) create mode 100644 .github/scripts/admin_api_e2e.py create mode 100644 .github/scripts/admin_api_e2e_test.py diff --git a/.github/scripts/admin_api_e2e.py b/.github/scripts/admin_api_e2e.py new file mode 100644 index 0000000000..a2221a4fcc --- /dev/null +++ b/.github/scripts/admin_api_e2e.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""E2E tests of the blockbook admin API (/admin/runtime-settings/, +/admin/contract-info/) against a deployed dev instance. + +The tests are idempotent and leave the instance's database and its live +rpcCall policy exactly as they were found: + +* runtime settings are exercised with a zero-policy-impact roundtrip — the + POSTed value is always the *current effective value* (or the explicit-empty + override when unset), so the parsed allowlist is identical at every instant, + and the final DELETE restores the original source; +* the contract-info roundtrip uses a reserved never-real address and deletes + its record at the end (and before starting, which heals a crashed run). + +Configuration (environment): + BB_ADMIN_E2E_COIN coin this test targets (default base_archive) + BB_ADMIN_E2E_URL explicit internal-server base URL; when unset, the host + is derived from the BB_DEV_API_URL_HTTP_ repo + variable and the port from the coin config's + ports.blockbook_internal, so a host move is a one-place + repo-variable change + DEPLOYED_COINS comma-separated deploy config names of this deploy run; + when set and the target coin is absent, the test skips + itself + BB_ENV content of blockbook.env; BB_ADMIN_USER and + BB_ADMIN_PASSWORD are extracted from it (never printed) + BB_ADMIN_USER/BB_ADMIN_PASSWORD direct credentials, override BB_ENV + SYNC_CA_FILE/SYNC_TLS_INSECURE TLS verification, as in wait_for_sync.py +""" + +import base64 +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from runner import LOG_PREFIX, fail, log +from wait_for_sync import build_ssl_context, resolve_http_base + +REPO_ROOT = Path(__file__).resolve().parents[2] + +SETTING_KEYS = ("ALLOWED_RPC_CALL_TO", "ALLOWED_EVM_CALL_METHODS") +# Invalid values per setting; each must be rejected with 400 and change nothing. +INVALID_SETTING_VALUES = { + "ALLOWED_RPC_CALL_TO": ",", + "ALLOWED_EVM_CALL_METHODS": "0x12", +} +# Reserved, never-real address the contract roundtrip may create and delete. +TEST_CONTRACT_ADDRESS = "0xE2Ee00000000000000000000000000000000e2eE" + + +def strip_matching_quotes(value: str) -> str: + """Remove one pair of surrounding quotes, the way systemd's + EnvironmentFile does before the value reaches the server process.""" + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def parse_env_credentials(blob: str) -> tuple[str, str]: + """Extract BB_ADMIN_USER/BB_ADMIN_PASSWORD from KEY=value lines.""" + values = {} + for line in blob.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key in ("BB_ADMIN_USER", "BB_ADMIN_PASSWORD"): + values[key] = strip_matching_quotes(value.strip()) + return values.get("BB_ADMIN_USER", ""), values.get("BB_ADMIN_PASSWORD", "") + + +def coin_deployed(deployed_csv: str, coin: str) -> bool: + """Whether coin is in the comma-separated deploy list; an empty list means + the caller did not restrict the run (local use) and the test proceeds.""" + csv = deployed_csv.strip() + if not csv: + return True + coins = {part.strip().lower() for part in csv.split(",") if part.strip()} + return coin.strip().lower() in coins + + +def resolve_admin_url(coin: str) -> str: + """Resolve the internal-server base URL: an explicit BB_ADMIN_E2E_URL + wins; otherwise the host follows the same BB_DEV_API_URL_HTTP_ repo + variable every other post-deploy check uses, with the internal port from + the coin config. The internal server always serves HTTPS (the packaged + self-signed certificate).""" + explicit = os.environ.get("BB_ADMIN_E2E_URL", "").strip() + if explicit: + return explicit.rstrip("/") + host = urllib.parse.urlparse(resolve_http_base(coin)).hostname + config_path = REPO_ROOT / "configs" / "coins" / (coin + ".json") + try: + with open(config_path, encoding="utf-8") as f: + port = json.load(f)["ports"]["blockbook_internal"] + except (OSError, ValueError, KeyError) as exc: + fail(f"cannot read ports.blockbook_internal from {config_path}: {exc}") + return f"https://{host}:{port}" + + +def skip(message: str, annotation: str) -> None: + """Exit 0 with a visible workflow annotation, so a skip can never be + mistaken for a pass in the run summary.""" + print(f"::{annotation}::admin API e2e tests skipped: {message}") + log(f"{message}; skipping admin API e2e tests") + raise SystemExit(0) + + +def plan_settings_roundtrip(value: str, source: str) -> list[tuple[str, str | None, str, str]]: + """Return the (method, body value, expected value, expected source) steps + that exercise POST/GET/DELETE for a setting currently at (value, source) + while keeping the parsed policy identical at every instant and ending in + the starting state. A db-sourced setting is only rewritten in place: its + env fallback is unknown, so a DELETE could change policy.""" + if source == "db": + return [("POST", value, value, "db")] + if source == "env": + return [("POST", value, value, "db"), ("DELETE", None, value, "env")] + if source == "unset": + # the explicit-empty override behaves exactly like unset + return [("POST", "", "", "db"), ("DELETE", None, "", "unset")] + raise ValueError(f"unknown setting source {source!r}") + + +class AdminClient: + # one retry absorbs a transient connection error without masking a real + # outage; every request in this test is idempotent, so retrying is safe + RETRIES = 1 + RETRY_DELAY_SECONDS = 3 + + def __init__(self, base_url: str, user: str, password: str, timeout: int, context: ssl.SSLContext): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.context = context + token = base64.b64encode(f"{user}:{password}".encode()).decode() + self.auth_header = "Basic " + token + + def request(self, method: str, path: str, body: object = None, auth: bool = True) -> tuple[int, object]: + data = None + headers = {} + if body is not None: + data = json.dumps(body).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(self.base_url + path, data=data, method=method, headers=headers) + if auth: + req.add_header("Authorization", self.auth_header) + for attempt in range(self.RETRIES + 1): + try: + with urllib.request.urlopen(req, timeout=self.timeout, context=self.context) as resp: + return resp.getcode(), self._parse(resp.read()) + except urllib.error.HTTPError as exc: + return exc.code, self._parse(exc.read()) + except OSError as exc: # URLError, TLS errors, timeouts + if attempt < self.RETRIES: + log(f"{method} {path} failed ({exc}), retrying in {self.RETRY_DELAY_SECONDS}s") + time.sleep(self.RETRY_DELAY_SECONDS) + continue + fail(f"{method} {self.base_url + path} failed after {attempt + 1} attempts: {exc}") + + @staticmethod + def _parse(raw: bytes) -> object: + try: + return json.loads(raw) + except (json.JSONDecodeError, UnicodeDecodeError): + return {"raw": raw.decode("utf-8", "replace")} + + +def check(condition: bool, what: str, actual: object) -> None: + if not condition: + fail(f"{what}: got {actual!r}") + + +def get_setting(client: AdminClient, key: str) -> dict: + status, resp = client.request("GET", f"/admin/runtime-settings/{key}") + check(status == 200 and isinstance(resp, dict), f"GET {key} expected 200 object", (status, resp)) + return resp + + +def check_auth_gating(client: AdminClient) -> None: + status, resp = client.request("GET", "/admin/runtime-settings/", auth=False) + if status == 503: + fail("admin interface is disabled on the instance (503) — BB_ADMIN_USER/BB_ADMIN_PASSWORD not configured there") + check(status == 401, "unauthenticated GET expected 401", (status, resp)) + log("PASS auth: admin API rejects unauthenticated requests with 401") + + +def check_settings_list(client: AdminClient) -> dict[str, dict]: + status, resp = client.request("GET", "/admin/runtime-settings/") + check(status == 200 and isinstance(resp, list), "GET runtime-settings list expected 200 array", (status, resp)) + settings = {entry.get("key"): entry for entry in resp if isinstance(entry, dict)} + for key in SETTING_KEYS: + check(key in settings, f"runtime-settings list must contain {key}", sorted(settings)) + entry = settings[key] + check(entry.get("source") in ("db", "env", "unset"), f"{key} has a valid source", entry) + log("PASS settings: list returns all settings " + json.dumps([settings[k] for k in SETTING_KEYS])) + return settings + + +def check_settings_negative(client: AdminClient, before: dict[str, dict]) -> None: + status, resp = client.request("GET", "/admin/runtime-settings/NO_SUCH_SETTING") + check(status == 400, "GET unknown setting expected 400", (status, resp)) + for key, invalid in INVALID_SETTING_VALUES.items(): + status, resp = client.request("POST", f"/admin/runtime-settings/{key}", {"value": invalid}) + check(status == 400, f"POST invalid value to {key} expected 400", (status, resp)) + after = get_setting(client, key) + check(after == before[key], f"rejected POST must not change {key}", (before[key], after)) + log("PASS settings: unknown key and invalid values are rejected without effect") + + +def settings_roundtrip(client: AdminClient, key: str, current: dict) -> None: + value, source = current.get("value", ""), current.get("source", "") + steps = plan_settings_roundtrip(value, source) + # If the run dies between POST and DELETE, still try to restore the + # original source. The flag is raised before the POST is attempted: a + # spurious restore DELETE of a missing override is a harmless no-op that + # reverts to the env value, i.e. the state the failed POST left in place. + restore_delete = False + try: + for method, body_value, want_value, want_source in steps: + if method == "POST" and source != "db": + restore_delete = True + body = {"value": body_value} if method == "POST" else None + status, resp = client.request(method, f"/admin/runtime-settings/{key}", body) + want = {"key": key, "value": want_value, "source": want_source} + check(status == 200 and resp == want, f"{method} {key} expected {want}", (status, resp)) + after = get_setting(client, key) + check(after == want, f"GET {key} after {method} expected {want}", after) + if method == "DELETE": + restore_delete = False + finally: + if restore_delete: + try: + status, resp = client.request("DELETE", f"/admin/runtime-settings/{key}") + log(f"restore: DELETE {key} -> {status} {json.dumps(resp)}") + except Exception as exc: # do not mask the original failure + print(f"{LOG_PREFIX} restore DELETE {key} failed: {exc}", file=sys.stderr) + log(f"PASS settings: {key} roundtrip from source={source!r} left state unchanged") + + +def contract_roundtrip(client: AdminClient) -> None: + address = TEST_CONTRACT_ADDRESS + path = f"/admin/contract-info/{address}" + + # pre-clean: heals a previously crashed run; deleted:false is the norm + status, resp = client.request("DELETE", path) + check(status == 200 and isinstance(resp, dict), "pre-clean DELETE expected 200", (status, resp)) + if resp.get("deleted"): + log(f"pre-clean removed a leftover test record: {json.dumps(resp)}") + + # list pagination + status, resp = client.request("GET", "/admin/contract-info/?limit=1") + check( + status == 200 and isinstance(resp, dict) and isinstance(resp.get("contracts"), list), + "GET contract list expected 200 with contracts array", + (status, resp), + ) + check(len(resp["contracts"]) <= 1, "contract list must honor limit=1", len(resp["contracts"])) + if resp.get("next"): + status, page2 = client.request("GET", f"/admin/contract-info/?limit=1&from={resp['next']}") + check( + status == 200 and isinstance(page2, dict) and isinstance(page2.get("contracts"), list), + "GET contract list next page expected 200", + (status, page2), + ) + status, resp = client.request("GET", "/admin/contract-info/?limit=0") + check(status == 400, "GET contract list with limit=0 expected 400", (status, resp)) + + # negative writes + status, resp = client.request("POST", path, []) + check(status == 400, "POST to an address path expected 400", (status, resp)) + status, resp = client.request("PATCH", "/admin/contract-info/") + check(status == 400, "PATCH expected 400", (status, resp)) + + # create -> read -> list from cursor -> delete -> delete again + record = { + "contract": address, + "name": "TestToken-e2e", + "symbol": "TSTE2E", + "decimals": 18, + "standard": "ERC20", + "type": "ERC20", + } + # raised before the POST is attempted: the server may commit even when the + # response is lost, and a spurious cleanup DELETE of a missing row is a + # harmless no-op (same reasoning as the settings restore flag) + created = False + try: + created = True + status, resp = client.request("POST", "/admin/contract-info/", [record]) + check(status == 200 and resp == {"updated": 1}, "POST test contract expected {'updated': 1}", (status, resp)) + + status, resp = client.request("GET", path) + check(status == 200 and isinstance(resp, dict), "GET test contract expected 200", (status, resp)) + got = {k: resp.get(k) for k in ("name", "symbol", "decimals", "standard")} + want = {k: record[k] for k in ("name", "symbol", "decimals", "standard")} + check(got == want, f"GET test contract expected {want}", got) + check( + str(resp.get("contract", "")).lower() == address.lower(), + "GET test contract address echo", + resp.get("contract"), + ) + + status, resp = client.request("GET", f"/admin/contract-info/?limit=1&from={address}") + check( + status == 200 + and isinstance(resp, dict) + and len(resp.get("contracts", [])) == 1 + and str(resp["contracts"][0].get("contract", "")).lower() == address.lower(), + "list from= must start with the test record", + (status, resp), + ) + + status, resp = client.request("DELETE", path) + check( + status == 200 + and isinstance(resp, dict) + and resp.get("deleted") is True + and resp.get("purged", {}).get("name") == record["name"], + "DELETE must report deleted:true with the purged record", + (status, resp), + ) + created = False + status, resp = client.request("DELETE", path) + check( + status == 200 and isinstance(resp, dict) and resp.get("deleted") is False and "purged" not in resp, + "repeated DELETE must report deleted:false", + (status, resp), + ) + finally: + if created: # a failure after the POST must not leave the record behind + try: + status, resp = client.request("DELETE", path) + log(f"cleanup: DELETE test contract -> {status} {json.dumps(resp)}") + except Exception as exc: # do not mask the original failure + print(f"{LOG_PREFIX} cleanup DELETE {address} failed: {exc}", file=sys.stderr) + log("PASS contracts: list/POST/GET/DELETE roundtrip left no record behind") + + +def main() -> None: + coin = os.environ.get("BB_ADMIN_E2E_COIN", "base_archive") + if not coin_deployed(os.environ.get("DEPLOYED_COINS", ""), coin): + skip(f"{coin} is not part of this deploy run", "notice") + + user = os.environ.get("BB_ADMIN_USER", "").strip() + password = os.environ.get("BB_ADMIN_PASSWORD", "").strip() + if not (user and password): + blob = os.environ.get("BB_ENV", "") + if not blob.strip(): + # a warning, not a notice: in CI the secret is expected to exist, + # so this skip usually means a lost/renamed BB_RUNTIME_ENV + skip("BB_RUNTIME_ENV secret not set", "warning") + user, password = parse_env_credentials(blob) + if not (user and password): + fail("BB_ENV does not contain BB_ADMIN_USER and BB_ADMIN_PASSWORD") + + base_url = resolve_admin_url(coin) + ssl_context, tls_mode = build_ssl_context() + log(f"TLS certificate verification: {tls_mode}") + timeout = int(os.environ.get("ADMIN_E2E_REQUEST_TIMEOUT_SECONDS", "20")) + client = AdminClient(base_url, user, password, timeout, ssl_context) + log(f"Running admin API e2e tests against {base_url} ({coin})") + + check_auth_gating(client) + settings = check_settings_list(client) + check_settings_negative(client, settings) + for key in SETTING_KEYS: + settings_roundtrip(client, key, settings[key]) + contract_roundtrip(client) + log("All admin API e2e tests passed; instance state is unchanged.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/admin_api_e2e_test.py b/.github/scripts/admin_api_e2e_test.py new file mode 100644 index 0000000000..605c896135 --- /dev/null +++ b/.github/scripts/admin_api_e2e_test.py @@ -0,0 +1,87 @@ +import unittest + +from admin_api_e2e import coin_deployed, parse_env_credentials, plan_settings_roundtrip + + +class ParseEnvCredentialsTest(unittest.TestCase): + def test_plain(self) -> None: + blob = "ETH_ALLOWED_RPC_CALL_TO=0xabc\nBB_ADMIN_USER=admin\nBB_ADMIN_PASSWORD=s3cr3t\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "s3cr3t")) + + def test_whitespace_and_cr(self) -> None: + blob = " BB_ADMIN_USER = admin \r\nBB_ADMIN_PASSWORD=s3cr3t \r\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "s3cr3t")) + + def test_value_containing_equals(self) -> None: + blob = "BB_ADMIN_USER=admin\nBB_ADMIN_PASSWORD=pa=ss=word\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "pa=ss=word")) + + def test_surrounding_quotes_stripped_like_systemd(self) -> None: + # systemd's EnvironmentFile strips one pair of quotes before the value + # reaches the server, so the script must send the same unquoted value + blob = "BB_ADMIN_USER=\"admin\"\nBB_ADMIN_PASSWORD='s3c r3t'\n" + self.assertEqual(parse_env_credentials(blob), ("admin", "s3c r3t")) + + def test_inner_and_unmatched_quotes_kept(self) -> None: + blob = "BB_ADMIN_USER=ad\"min\nBB_ADMIN_PASSWORD=\"s3cr3t\n" + self.assertEqual(parse_env_credentials(blob), ('ad"min', '"s3cr3t')) + + def test_comments_and_missing_keys(self) -> None: + blob = "# BB_ADMIN_USER=commented\nOTHER=x\n" + self.assertEqual(parse_env_credentials(blob), ("", "")) + + +class CoinDeployedTest(unittest.TestCase): + def test_empty_list_means_unrestricted(self) -> None: + self.assertTrue(coin_deployed("", "base_archive")) + self.assertTrue(coin_deployed(" ", "base_archive")) + + def test_membership(self) -> None: + self.assertTrue(coin_deployed("bitcoin, base_archive ,zcash", "base_archive")) + self.assertTrue(coin_deployed("Base_Archive", "base_archive")) + self.assertFalse(coin_deployed("bitcoin,zcash", "base_archive")) + + +class PlanSettingsRoundtripTest(unittest.TestCase): + """The planner must keep the parsed policy identical at every instant and + end in the starting state (except db-sourced settings, which are only + rewritten in place because their env fallback is unknown).""" + + def test_env_sourced(self) -> None: + self.assertEqual( + plan_settings_roundtrip("0xabc,0xdef", "env"), + [ + ("POST", "0xabc,0xdef", "0xabc,0xdef", "db"), + ("DELETE", None, "0xabc,0xdef", "env"), + ], + ) + + def test_unset(self) -> None: + self.assertEqual( + plan_settings_roundtrip("", "unset"), + [("POST", "", "", "db"), ("DELETE", None, "", "unset")], + ) + + def test_db_sourced_is_rewritten_in_place_only(self) -> None: + self.assertEqual( + plan_settings_roundtrip("0xabc", "db"), + [("POST", "0xabc", "0xabc", "db")], + ) + + def test_every_step_preserves_the_effective_value(self) -> None: + for value, source in (("0xabc", "env"), ("", "unset"), ("0xdd62ed3e", "db")): + for _, _, expected_value, _ in plan_settings_roundtrip(value, source): + self.assertEqual(expected_value, value, (value, source)) + + def test_end_state_matches_start_state(self) -> None: + for value, source in (("0xabc", "env"), ("", "unset")): + _, _, final_value, final_source = plan_settings_roundtrip(value, source)[-1] + self.assertEqual((final_value, final_source), (value, source)) + + def test_unknown_source_raises(self) -> None: + with self.assertRaises(ValueError): + plan_settings_roundtrip("x", "bogus") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c1e2177174..c95dba94a8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -267,3 +267,19 @@ jobs: name: openapi-e2e-report path: tests/openapi/reports/ if-no-files-found: warn + + # Idempotent roundtrip of the internal-server admin API (runtime settings + # + contract info) against the deployed dev instance. The target URL is + # derived from the BB_DEV_API_URL_HTTP_ repo variable (exported + # above) plus the coin config's internal port. The script skips itself — + # with a visible annotation — when the target coin is not part of this + # deploy run (DEPLOYED_COINS carries deploy config names, hence + # coins_csv, not test_coins_csv) or the BB_RUNTIME_ENV secret is not + # set; credentials are parsed inside the script from BB_ENV, which is + # intentionally not exported to other steps. + - name: Run admin API e2e tests + env: + BB_ENV: ${{ secrets.BB_RUNTIME_ENV }} + BB_ADMIN_E2E_COIN: base_archive + DEPLOYED_COINS: ${{ needs.prepare_deploy.outputs.coins_csv }} + run: python3 ./.github/scripts/admin_api_e2e.py From 15bcfcf16417e1aa5343194b2d067d8bb10882df Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Tue, 7 Jul 2026 09:06:24 +0000 Subject: [PATCH 09/10] fix(ci): resolve the admin e2e URL variable by test coin name The BB_DEV_API_URL_HTTP_ repo variables follow the tests.json test-name convention (BB_DEV_API_URL_HTTP_base for base_archive), so resolving them by the deploy alias failed with "missing BB_DEV_API_URL_HTTP_base_archive". Map the deploy alias through runner.load_test_coin_name (the same helper deploy_plan.py uses) before the lookup; the coin gate keeps comparing deploy aliases. Unit test pins the mapping against the real base_archive.json. Co-Authored-By: Claude Fable 5 --- .github/scripts/admin_api_e2e.py | 17 ++++++++++------ .github/scripts/admin_api_e2e_test.py | 28 +++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/.github/scripts/admin_api_e2e.py b/.github/scripts/admin_api_e2e.py index a2221a4fcc..e851dbc475 100644 --- a/.github/scripts/admin_api_e2e.py +++ b/.github/scripts/admin_api_e2e.py @@ -39,7 +39,7 @@ import urllib.request from pathlib import Path -from runner import LOG_PREFIX, fail, log +from runner import LOG_PREFIX, ValidationError, fail, load_test_coin_name, log from wait_for_sync import build_ssl_context, resolve_http_base REPO_ROOT = Path(__file__).resolve().parents[2] @@ -88,15 +88,20 @@ def coin_deployed(deployed_csv: str, coin: str) -> bool: def resolve_admin_url(coin: str) -> str: """Resolve the internal-server base URL: an explicit BB_ADMIN_E2E_URL - wins; otherwise the host follows the same BB_DEV_API_URL_HTTP_ repo - variable every other post-deploy check uses, with the internal port from - the coin config. The internal server always serves HTTPS (the packaged - self-signed certificate).""" + wins; otherwise the host follows the same repo variable every other + post-deploy check uses — keyed by the coin's *test name* (tests.json + convention, e.g. BB_DEV_API_URL_HTTP_base for base_archive), not the + deploy alias — with the internal port from the coin config. The internal + server always serves HTTPS (the packaged self-signed certificate).""" explicit = os.environ.get("BB_ADMIN_E2E_URL", "").strip() if explicit: return explicit.rstrip("/") - host = urllib.parse.urlparse(resolve_http_base(coin)).hostname config_path = REPO_ROOT / "configs" / "coins" / (coin + ".json") + try: + test_coin = load_test_coin_name(config_path) + except ValidationError as exc: + fail(str(exc)) + host = urllib.parse.urlparse(resolve_http_base(test_coin)).hostname try: with open(config_path, encoding="utf-8") as f: port = json.load(f)["ports"]["blockbook_internal"] diff --git a/.github/scripts/admin_api_e2e_test.py b/.github/scripts/admin_api_e2e_test.py index 605c896135..3ecaf9b2ce 100644 --- a/.github/scripts/admin_api_e2e_test.py +++ b/.github/scripts/admin_api_e2e_test.py @@ -1,6 +1,30 @@ +import os import unittest - -from admin_api_e2e import coin_deployed, parse_env_credentials, plan_settings_roundtrip +from unittest import mock + +from admin_api_e2e import ( + coin_deployed, + parse_env_credentials, + plan_settings_roundtrip, + resolve_admin_url, +) + + +class ResolveAdminUrlTest(unittest.TestCase): + def test_explicit_url_wins(self) -> None: + with mock.patch.dict(os.environ, {"BB_ADMIN_E2E_URL": "https://example.test:9999/"}, clear=False): + self.assertEqual(resolve_admin_url("base_archive"), "https://example.test:9999") + + def test_derives_from_test_name_variable_and_config_port(self) -> None: + # the URL repo variables follow the tests.json test-name convention + # (BB_DEV_API_URL_HTTP_base for base_archive, not ..._base_archive); + # the port comes from the real coin config's ports.blockbook_internal + env = { + "BB_ADMIN_E2E_URL": "", + "BB_DEV_API_URL_HTTP_base": "https://blockbook-dev.test:9311", + } + with mock.patch.dict(os.environ, env, clear=False): + self.assertEqual(resolve_admin_url("base_archive"), "https://blockbook-dev.test:9211") class ParseEnvCredentialsTest(unittest.TestCase): From 60ea05a788778bd24f381535a7adbc4c34e20b3b Mon Sep 17 00:00:00 2001 From: pragmaxim Date: Wed, 8 Jul 2026 20:50:06 +0000 Subject: [PATCH 10/10] fix(db): fail loudly on undecodable key in ListContractInfos The paginated contract listing discarded the error from GetAddressesFromAddrDesc and fell through to return next="" for a boundary row whose key did not decode to an address. That silent truncation is indistinguishable from a completed listing and drops the remaining rows; in-page rows with no decoded address were likewise returned with an empty Contract. A corrupt key now returns an error, matching the unpackContractInfo error handling in the same loop. Unreachable for well-formed EVM/Tron contract descriptors, but a visible failure beats a confusing partial page. Co-Authored-By: Claude Opus 4.8 (1M context) --- db/rocksdb_contracts.go | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/db/rocksdb_contracts.go b/db/rocksdb_contracts.go index 483ff4bd00..ce30ebadb8 100644 --- a/db/rocksdb_contracts.go +++ b/db/rocksdb_contracts.go @@ -215,21 +215,28 @@ func (d *RocksDB) ListContractInfos(from string, limit int) ([]bchain.ContractIn } contracts := make([]bchain.ContractInfo, 0, limit) for ; it.Valid(); it.Next() { - addresses, _, _ := d.chainParser.GetAddressesFromAddrDesc(it.Key().Data()) + // The key is the contract's address descriptor. A key that fails to + // decode to an address is a corrupt row: fail loudly (as the + // unpackContractInfo error below does) rather than fall through to + // return next="", which a caller cannot tell apart from a completed + // listing and which would silently drop the boundary row's cursor — + // or return an in-page record with an empty Contract. + addresses, _, err := d.chainParser.GetAddressesFromAddrDesc(it.Key().Data()) + if err != nil { + return nil, "", err + } + if len(addresses) == 0 { + return nil, "", errors.Errorf("no address for contract descriptor %x", it.Key().Data()) + } if len(contracts) == limit { // one more row exists — its address is the cursor of the next page - if len(addresses) > 0 { - return contracts, addresses[0], nil - } - return contracts, "", nil + return contracts, addresses[0], nil } contractInfo, err := unpackContractInfo(it.Value().Data()) if err != nil { return nil, "", err } - if len(addresses) > 0 { - contractInfo.Contract = addresses[0] - } + contractInfo.Contract = addresses[0] contracts = append(contracts, *contractInfo) } return contracts, "", nil