Repository navigation
server.features should not be tied to peer discovery (returns -32603 when --electrum-public-hosts unset) #229
Description
Activity
For reference: mempool/electrs (the fork this repo's JSON-RPC 2.0 error objects were ported from, in mempool/electrs#103) has already implemented exactly this decoupling, so there's a proven reference rather than a novel design.
In their
src/electrum/server.rs,ServerFeaturesis stored as a field on the connection (built once at startup from config, withhoststaken fromelectrum_public_hosts), andserver.featuresjust returns it unconditionally:fn server_features(&self) -> Result<Value> { Ok(json!(self.server_features.as_ref())) }
It is always present in the dispatch match and never returns an error — so there's no
-32603"discovery is disabled" forserver.features.server.add_peerstays gated on theelectrum-discoveryfeature /DiscoveryManager(as proposed here):- built without
electrum-discovery→ falls through to the&_arm →-32601method not found - built with discovery but
electrum_public_hostsunset →-32603"discovery is disabled"
Cross-fork comparison:
Method romanz/electrs mempool/electrs Blockstream/electrs (current) server.features(no hosts)success success -32603"discovery is disabled"server.add_peer(no discovery)n/a (no such method) -32601/-32603-32603So the fix is essentially porting mempool's
server_featuresfield pattern — mirroring how mempool/electrs#103 was ported.- built without
Direct reference implementation: mempool/electrs has since landed this exact change in mempool/electrs#150 ("feat[electrum]: Support server.features on all feature combinations", closes mempool/electrs#149). It's a small diff (+22/-19) across
src/config.rsandsrc/electrum/server.rsand matches the proposal above:config.rs— moveelectrum_public_hostsout of#[cfg(feature = "electrum-discovery")]so the field always exists (defaults toNonewhen the feature is off).server.rs— build aserver_features: Arc<ServerFeatures>once at RPC startup withhosts = electrum_public_hosts.unwrap_or_default()(empty map when unconfigured), and thread it into eachConnection.server_features()becomesOk(json!(self.server_features.as_ref())), and the dispatch arm is ungated so it works in all build configurations.- Discovery still receives the same struct (
server_features.as_ref().clone());server.add_peerstays gated on the discovery feature.
So this is a ready-to-port reference, same as how the JSON-RPC 2.0 error objects were ported from mempool/electrs#103.
One nuance when porting: mempool/electrs#150 sets
server_versionfromVERSION_STRING, whereas this repo currently constructs it inline asformat!("electrs-esplora {}", ELECTRS_VERSION)— that existing string should be preserved rather than adopting mempool's value verbatim.Additional client-side impact, reproduced on regtest today: Electrum 4.8.2 cannot connect to a default build of this repo at all.
Since Electrum 4.7.0 (spesmilo/electrum#10281, "interface: check genesis hash on connection"), the client requests
server.featuresright afterserver.versionand disconnects if it fails (electrum/interface.py, L1063–1067 in the 4.8.2 tag). Against electrsnew-indexat cb7c72f built withoutelectrum-discovery, Electrum's log shows, in a retry loop:disconnecting due to GracefulDisconnect(RPCError(-32601, 'unknown method server.features'))and
getinfostays at"connected": false.server.versionitself succeeds (["electrs-esplora 0.4.1","1.4"]).Confirmed by execution: the same Electrum 4.8.2 connects to the same commit once electrs is built with
--features electrum-discoveryand started with--electrum-public-hosts.So beyond protocol correctness, porting mempool/electrs#150 would restore compatibility with current Electrum releases for anyone running a default build.
Is anyone already working on the port? If not, I'd be happy to open a PR following the plan above (keeping the existing
electrs-esplora {ELECTRS_VERSION}string), and verify it end-to-end with Electrum 4.8.2 on regtest.
Summary
server.featuresis currently wired through the optional peer-discovery subsystem, so it returns a JSON-RPC-32603("discovery is disabled") internal error whenever--electrum-public-hostsis not configured. It would be more protocol-correct (and consistent with upstream romanz/electrs) to serveserver.featuresindependently of discovery.Current behavior
server.featuresis gated behind theelectrum-discoveryfeature and reads its payload from theDiscoveryManager:When
electrum-discoveryis built but--electrum-public-hostsis unset,self.discoveryisNone, so a normal client callingserver.features(e.g. for a genesis-hash / protocol-version compatibility check) receives:-32603isInternalError, which is misleading for what is a valid, standard request.Why this is a problem
server.featuresis general server-info metadata, not a discovery operation — clients call it to check chain/protocol compatibility before anything else. Of the fields inServerFeatures, onlyhostsdepends on discovery config; everything else is always available:genesis_hashgenesis_hash(network_type)server_versionprotocol_min/protocol_maxPROTOCOL_VERSIONhash_function"sha256"pruningNonehosts--electrum-public-hostsThe Electrum protocol explicitly allows
hoststo be an empty object, soserver.featurescan return everything else with empty hosts when discovery is unconfigured. This is what upstream romanz/electrs does — it servesserver.featuresunconditionally.Proposed change
ServerFeaturespayload from config (currently built inline inside the discovery.map()), defaultinghoststo empty when--electrum-public-hostsis unset.server.featuresreturn that payload directly instead of going throughself.discovery, and ungate it from#[cfg(feature = "electrum-discovery")]so it works in plain Bitcoin builds.DiscoveryManager::new()so there is a single source of truth.server.add_peershould stay tied to discovery (it genuinely needs theDiscoveryManager). As a minor follow-up, when discovery is disabled it could return-32601(method not found) rather than-32603, since "this server does not accept peers" is closer to method-not-found than an internal error.