From b521f256cdfdd05cc5be5d5dbc6205f15cd81014 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 11:58:43 +1000 Subject: [PATCH 01/14] Simplify bootnode parsing and add v5Bootnodes to genesis configs Use discovery protocol version flag to determine bootnode format instead of prefix detection. Add discovery.v5Bootnodes section to mainnet.json and hoodi.json with ENR entries from eth-clients repos. Add DiscoveryOptions.getV5BootNodes() and unit tests. Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/BesuCommand.java | 61 +++++----- .../hyperledger/besu/cli/BesuCommandTest.java | 1 + .../besu/config/DiscoveryOptions.java | 27 +++++ config/src/main/resources/hoodi.json | 11 ++ config/src/main/resources/mainnet.json | 19 +++ .../besu/config/DiscoveryOptionsTest.java | 109 ++++++++++++++++++ 6 files changed, 198 insertions(+), 30 deletions(-) create mode 100644 config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 82e7d6ca74d..3522986338b 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -88,6 +88,7 @@ import org.hyperledger.besu.cli.util.VersionProvider; import org.hyperledger.besu.components.BesuComponent; import org.hyperledger.besu.config.CheckpointConfigOptions; +import org.hyperledger.besu.config.DiscoveryOptions; import org.hyperledger.besu.config.GenesisConfig; import org.hyperledger.besu.config.GenesisConfigOptions; import org.hyperledger.besu.config.JsonUtil; @@ -2546,46 +2547,46 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { discoveryDnsUrlFromGenesis.ifPresent(builder::setDnsDiscoveryUrl); } - List listBootNodes = null; + // Resolve bootnodes: CLI --bootnodes overrides genesis defaults. + // The discovery protocol version determines the expected format: + // V5 → ENR strings ("enr:..."), V4 → enode URLs ("enode://...") + final boolean isV5 = + unstableNetworkingOptions.toDomainObject().discoveryConfiguration().isDiscoveryV5Enabled(); + List rawBootnodes = null; if (p2PDiscoveryOptions.bootNodes != null) { try { - final List resolvedBootNodeArgs = - BootnodeResolver.resolve(p2PDiscoveryOptions.bootNodes); - if (!resolvedBootNodeArgs.isEmpty()) { - if (resolvedBootNodeArgs.getFirst().startsWith("enr:")) { - builder.setEnrBootNodes( - resolvedBootNodeArgs.stream().map(EthereumNodeRecord::fromEnr).toList()); - } else { - listBootNodes = buildEnodes(resolvedBootNodeArgs, getEnodeDnsConfiguration()); - } - } else { - listBootNodes = Collections.emptyList(); - } - + rawBootnodes = BootnodeResolver.resolve(p2PDiscoveryOptions.bootNodes); } catch (final BootnodeResolutionException e) { throw new ParameterException(commandLine, e.getMessage(), e); - - } catch (final IllegalArgumentException e) { - throw new ParameterException(commandLine, e.getMessage()); } } else { - final Optional> bootNodesFromGenesis = - genesisConfigOptionsSupplier.get().getDiscoveryOptions().getBootNodes(); - if (bootNodesFromGenesis.isPresent() && !bootNodesFromGenesis.get().isEmpty()) { - if (bootNodesFromGenesis.get().getFirst().startsWith("enr:")) { - builder.setEnrBootNodes( - bootNodesFromGenesis.get().stream().map(EthereumNodeRecord::fromEnr).toList()); - } else { - listBootNodes = buildEnodes(bootNodesFromGenesis.get(), getEnodeDnsConfiguration()); - } - } + final DiscoveryOptions discoveryOptions = + genesisConfigOptionsSupplier.get().getDiscoveryOptions(); + rawBootnodes = + isV5 + ? discoveryOptions.getV5BootNodes().orElse(null) + : discoveryOptions.getBootNodes().orElse(null); } - if (listBootNodes != null) { + + if (rawBootnodes != null && !rawBootnodes.isEmpty()) { if (!p2PDiscoveryOptions.peerDiscoveryEnabled) { logger.warn("Discovery disabled: bootnodes will be ignored."); } - DiscoveryConfiguration.assertValidBootnodes(listBootNodes); - builder.setEnodeBootNodes(listBootNodes); + try { + if (isV5) { + builder.setEnrBootNodes(rawBootnodes.stream().map(EthereumNodeRecord::fromEnr).toList()); + } else { + final List enodes = buildEnodes(rawBootnodes, getEnodeDnsConfiguration()); + DiscoveryConfiguration.assertValidBootnodes(enodes); + builder.setEnodeBootNodes(enodes); + } + } catch (final IllegalArgumentException e) { + throw new ParameterException(commandLine, e.getMessage()); + } + } else if (p2PDiscoveryOptions.bootNodes != null) { + // Explicitly empty --bootnodes clears all default bootnodes + builder.setEnodeBootNodes(Collections.emptyList()); + builder.setEnrBootNodes(Collections.emptyList()); } return builder.build(); } diff --git a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java index db3e7740abb..b91f1b201d6 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java @@ -958,6 +958,7 @@ public void callingWithBootnodesOptionButNoValueMustPassEmptyBootnodeList() { verify(mockRunnerBuilder).build(); assertThat(ethNetworkConfigArgumentCaptor.getValue().enodeBootNodes()).isEmpty(); + assertThat(ethNetworkConfigArgumentCaptor.getValue().enrBootNodes()).isEmpty(); assertThat(commandOutput.toString(UTF_8)).isEmpty(); assertThat(commandErrorOutput.toString(UTF_8)).isEmpty(); diff --git a/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java b/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java index 7347615f3f3..c9fd63b6ee2 100644 --- a/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java +++ b/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java @@ -28,6 +28,7 @@ public class DiscoveryOptions { new DiscoveryOptions(JsonUtil.createEmptyObjectNode()); private static final String ENODES_KEY = "bootnodes"; + private static final String V5_BOOTNODES_KEY = "v5Bootnodes"; private static final String DNS_KEY = "dns"; private final ObjectNode discoveryConfigRoot; @@ -67,6 +68,32 @@ public Optional> getBootNodes() { return Optional.of(bootNodes); } + /** + * Gets V5 boot nodes (ENR format). + * + * @return optional list of ENR boot node strings + */ + public Optional> getV5BootNodes() { + final Optional bootNodesArray = + JsonUtil.getArrayNode(discoveryConfigRoot, V5_BOOTNODES_KEY); + if (bootNodesArray.isEmpty()) { + return Optional.empty(); + } + final List bootNodes = new ArrayList<>(); + bootNodesArray + .get() + .elements() + .forEachRemaining( + bootNodeElement -> { + if (!bootNodeElement.isTextual()) { + throw new IllegalArgumentException( + V5_BOOTNODES_KEY + " does not contain a string: " + bootNodeElement); + } + bootNodes.add(bootNodeElement.asText()); + }); + return Optional.of(bootNodes); + } + /** * Gets discovery dns url. * diff --git a/config/src/main/resources/hoodi.json b/config/src/main/resources/hoodi.json index b9e435bae6c..18932b25fed 100644 --- a/config/src/main/resources/hoodi.json +++ b/config/src/main/resources/hoodi.json @@ -52,6 +52,17 @@ "enode://2112dd3839dd752813d4df7f40936f06829fc54c0e051a93967c26e5f5d27d99d886b57b4ffcc3c475e930ec9e79c56ef1dbb7d86ca5ee83a9d2ccf36e5c240c@134.209.138.84:30303", "enode://60203fcb3524e07c5df60a14ae1c9c5b24023ea5d47463dfae051d2c9f3219f309657537576090ca0ae641f73d419f53d8e8000d7a464319d4784acd7d2abc41@209.38.124.160:30303", "enode://8ae4a48101b2299597341263da0deb47cc38aa4d3ef4b7430b897d49bfa10eb1ccfe1655679b1ed46928ef177fbf21b86837bd724400196c508427a6f41602cd@134.199.184.23:30303" + ], + "v5Bootnodes": [ + "enr:-Mq4QLkmuSwbGBUph1r7iHopzRpdqE-gcm5LNZfcE-6T37OCZbRHi22bXZkaqnZ6XdIyEDTelnkmMEQB8w6NbnJUt9GGAZWaowaYh2F0dG5ldHOIABgAAAAAAACEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhNEmfKCEcXVpY4IyyIlzZWNwMjU2azGhA0hGa4jZJZYQAS-z6ZFK-m4GCFnWS8wfjO0bpSQn6hyEiHN5bmNuZXRzAIN0Y3CCIyiDdWRwgiMo", + "enr:-Ku4QLVumWTwyOUVS4ajqq8ZuZz2ik6t3Gtq0Ozxqecj0qNZWpMnudcvTs-4jrlwYRQMQwBS8Pvtmu4ZPP2Lx3i2t7YBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpBd9cEGEAAJEP__________gmlkgnY0gmlwhNEmfKCJc2VjcDI1NmsxoQLdRlI8aCa_ELwTJhVN8k7km7IDc3pYu-FMYBs5_FiigIN1ZHCCIyk", + "enr:-LK4QAYuLujoiaqCAs0-qNWj9oFws1B4iy-Hff1bRB7wpQCYSS-IIMxLWCn7sWloTJzC1SiH8Y7lMQ5I36ynGV1ASj4Eh2F0dG5ldHOIYAAAAAAAAACEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhIbRilSJc2VjcDI1NmsxoQOmI5MlAu3f5WEThAYOqoygpS2wYn0XS5NV2aYq7T0a04N0Y3CCIyiDdWRwgiMo", + "enr:-Ku4QIC89sMC0o-irosD4_23lJJ4qCGOvdUz7SmoShWx0k6AaxCFTKviEHa-sa7-EzsiXpDp0qP0xzX6nKdXJX3X-IQBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpBd9cEGEAAJEP__________gmlkgnY0gmlwhIbRilSJc2VjcDI1NmsxoQK_m0f1DzDc9Cjrspm36zuRa7072HSiMGYWLsKiVSbP34N1ZHCCIyk", + "enr:-Ku4QNkWjw5tNzo8DtWqKm7CnDdIq_y7xppD6c1EZSwjB8rMOkSFA1wJPLoKrq5UvA7wcxIotH6Usx3PAugEN2JMncIBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpBd9cEGEAAJEP__________gmlkgnY0gmlwhIbHuBeJc2VjcDI1NmsxoQP3FwrhFYB60djwRjAoOjttq6du94DtkQuaN99wvgqaIYN1ZHCCIyk", + "enr:-OS4QMJGE13xEROqvKN1xnnt7U-noc51VXyM6wFMuL9LMhQDfo1p1dF_zFdS4OsnXz_vIYk-nQWnqJMWRDKvkSK6_CwDh2F0dG5ldHOIAAAAADAAAACGY2xpZW502IpMaWdodGhvdXNljDcuMC4wLWJldGEuM4RldGgykNLxmX9gAAkQAAgAAAAAAACCaWSCdjSCaXCEhse4F4RxdWljgiMqiXNlY3AyNTZrMaECef77P8k5l3PC_raLw42OAzdXfxeQ-58BJriNaqiRGJSIc3luY25ldHMAg3RjcIIjKIN1ZHCCIyg", + "enr:-LK4QDwhXMitMbC8xRiNL-XGMhRyMSOnxej-zGifjv9Nm5G8EF285phTU-CAsMHRRefZimNI7eNpAluijMQP7NDC8kEMh2F0dG5ldHOIAAAAAAAABgCEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhAOIT_SJc2VjcDI1NmsxoQMoHWNL4MAvh6YpQeM2SUjhUrLIPsAVPB8nyxbmckC6KIN0Y3CCIyiDdWRwgiMo", + "enr:-LK4QPYl2HnMPQ7b1es6Nf_tFYkyya5bj9IqAKOEj2cmoqVkN8ANbJJJK40MX4kciL7pZszPHw6vLNyeC-O3HUrLQv8Mh2F0dG5ldHOIAAAAAAAAAMCEZXRoMpDS8Zl_YAAJEAAIAAAAAAAAgmlkgnY0gmlwhAMYRG-Jc2VjcDI1NmsxoQPQ35tjr6q1qUqwAnegQmYQyfqxC_6437CObkZneI9n34N0Y3CCIyiDdWRwgiMo", + "enr:-KG4QKRSUi4IOAIK_xt5ERrwW_J47wmNCLWFh7Jo0hFE69drZsiZ5Pb5CEcM_njFTTLlIR6SCf67HTcSV1g6hCXdhWkCgmlkgnY0gmlwhLkvrBODaXA2kCoGxcAWAAAYAAAAAAAAABCJc2VjcDI1NmsxoQPU7g2jQGTz8BYbB2vLTb39S_PrcZAehwMM0b3bWsM5rIN1ZHCCIyiEdWRwNoIjKA" ] } }, diff --git a/config/src/main/resources/mainnet.json b/config/src/main/resources/mainnet.json index 3ba94ccf767..46695f6fe10 100644 --- a/config/src/main/resources/mainnet.json +++ b/config/src/main/resources/mainnet.json @@ -54,6 +54,25 @@ "enode://22a8232c3abc76a16ae9d6c3b164f98775fe226f0917b0ca871128a74a8e9630b458460865bab457221f1d448dd9791d24c4e5d88786180ac185df813a68d4de@3.209.45.79:30303", "enode://2b252ab6a1d0f971d9722cb839a42cb81db019ba44c08754628ab4a823487071b5695317c8ccd085219c3a03af063495b2f1da8d18218da2d6a82981b45e6ffc@65.108.70.101:30303", "enode://4aeb4ab6c14b23e2c4cfdce879c04b0748a20d8e9b59e25ded2a08143e265c6c25936e74cbc8e641e3312ca288673d91f2f93f8e277de3cfa444ecdaaf982052@157.90.35.166:30303" + ], + "v5Bootnodes": [ + "enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo", + "enr:-Iu4QEDJ4Wa_UQNbK8Ay1hFEkXvd8psolVK6OhfTL9irqz3nbXxxWyKwEplPfkju4zduVQj6mMhUCm9R2Lc4YM5jPcIBgmlkgnY0gmlwhANrfESJc2VjcDI1NmsxoQJCYz2-nsqFpeEj6eov9HSi9QssIVIVNr0I89J1vXM9foN0Y3CCIyiDdWRwgiMo", + "enr:-Ku4QImhMc1z8yCiNJ1TyUxdcfNucje3BGwEHzodEZUan8PherEo4sF7pPHPSIB1NNuSg5fZy7qFsjmUKs2ea1Whi0EBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQOVphkDqal4QzPMksc5wnpuC3gvSC8AfbFOnZY_On34wIN1ZHCCIyg", + "enr:-Ku4QP2xDnEtUXIjzJ_DhlCRN9SN99RYQPJL92TMlSv7U5C1YnYLjwOQHgZIUXw6c-BvRg2Yc2QsZxxoS_pPRVe0yK8Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMeFF5GrS7UZpAH2Ly84aLK-TyvH-dRo0JM1i8yygH50YN1ZHCCJxA", + "enr:-Ku4QPp9z1W4tAO8Ber_NQierYaOStqhDqQdOPY3bB3jDgkjcbk6YrEnVYIiCBbTxuar3CzS528d2iE7TdJsrL-dEKoBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpD1pf1CAAAAAP__________gmlkgnY0gmlwhBLf22SJc2VjcDI1NmsxoQMw5fqqkw2hHC4F5HZZDPsNmPdB1Gi8JPQK7pRc9XHh-oN1ZHCCKvg", + "enr:-Le4QPUXJS2BTORXxyx2Ia-9ae4YqA_JWX3ssj4E_J-3z1A-HmFGrU8BpvpqhNabayXeOZ2Nq_sbeDgtzMJpLLnXFgAChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISsaa0Zg2lwNpAkAIkHAAAAAPA8kv_-awoTiXNlY3AyNTZrMaEDHAD2JKYevx89W0CcFJFiskdcEzkH_Wdv9iW42qLK79ODdWRwgiMohHVkcDaCI4I", + "enr:-Le4QLHZDSvkLfqgEo8IWGG96h6mxwe_PsggC20CL3neLBjfXLGAQFOPSltZ7oP6ol54OvaNqO02Rnvb8YmDR274uq8ChGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLosQxg2lwNpAqAX4AAAAAAPA8kv_-ax65iXNlY3AyNTZrMaEDBJj7_dLFACaxBfaI8KZTh_SSJUjhyAyfshimvSqo22WDdWRwgiMohHVkcDaCI4I", + "enr:-Le4QH6LQrusDbAHPjU_HcKOuMeXfdEB5NJyXgHWFadfHgiySqeDyusQMvfphdYWOzuSZO9Uq2AMRJR5O4ip7OvVma8BhGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLY9ncg2lwNpAkAh8AgQIBAAAAAAAAAAmXiXNlY3AyNTZrMaECDYCZTZEksF-kmgPholqgVt8IXr-8L7Nu7YrZ7HUpgxmDdWRwgiMohHVkcDaCI4I", + "enr:-Le4QIqLuWybHNONr933Lk0dcMmAB5WgvGKRyDihy1wHDIVlNuuztX62W51voT4I8qD34GcTEOTmag1bcdZ_8aaT4NUBhGV0aDKQtTA_KgEAAAAAIgEAAAAAAIJpZIJ2NIJpcISLY04ng2lwNpAkAh8AgAIBAAAAAAAAAA-fiXNlY3AyNTZrMaEDscnRV6n1m-D9ID5UsURk0jsoKNXt1TIrj8uKOGW6iluDdWRwgiMohHVkcDaCI4I", + "enr:-Ku4QHqVeJ8PPICcWk1vSn_XcSkjOkNiTg6Fmii5j6vUQgvzMc9L1goFnLKgXqBJspJjIsB91LTOleFmyWWrFVATGngBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhAMRHkWJc2VjcDI1NmsxoQKLVXFOhp2uX6jeT0DvvDpPcU8FWMjQdR4wMuORMhpX24N1ZHCCIyg", + "enr:-Ku4QG-2_Md3sZIAUebGYT6g0SMskIml77l6yR-M_JXc-UdNHCmHQeOiMLbylPejyJsdAPsTHJyjJB2sYGDLe0dn8uYBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhBLY-NyJc2VjcDI1NmsxoQORcM6e19T1T9gi7jxEZjk_sjVLGFscUNqAY9obgZaxbIN1ZHCCIyg", + "enr:-Ku4QPn5eVhcoF1opaFEvg1b6JNFD2rqVkHQ8HApOKK61OIcIXD127bKWgAtbwI7pnxx6cDyk_nI88TrZKQaGMZj0q0Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhDayLMaJc2VjcDI1NmsxoQK2sBOLGcUb4AwuYzFuAVCaNHA-dy24UuEKkeFNgCVCsIN1ZHCCIyg", + "enr:-Ku4QEWzdnVtXc2Q0ZVigfCGggOVB2Vc1ZCPEc6j21NIFLODSJbvNaef1g4PxhPwl_3kax86YPheFUSLXPRs98vvYsoBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhDZBrP2Jc2VjcDI1NmsxoQM6jr8Rb1ktLEsVcKAPa08wCsKUmvoQ8khiOl_SLozf9IN1ZHCCIyg", + "enr:-LK4QA8FfhaAjlb_BXsXxSfiysR7R52Nhi9JBt4F8SPssu8hdE1BXQQEtVDC3qStCW60LSO7hEsVHv5zm8_6Vnjhcn0Bh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhAN4aBKJc2VjcDI1NmsxoQJerDhsJ-KxZ8sHySMOCmTO6sHM3iCFQ6VMvLTe948MyYN0Y3CCI4yDdWRwgiOM", + "enr:-LK4QKWrXTpV9T78hNG6s8AM6IO4XH9kFT91uZtFg1GcsJ6dKovDOr1jtAAFPnS2lvNltkOGA9k29BUN7lFh_sjuc9QBh2F0dG5ldHOIAAAAAAAAAACEZXRoMpC1MD8qAAAAAP__________gmlkgnY0gmlwhANAdd-Jc2VjcDI1NmsxoQLQa6ai7y9PMN5hpLe5HmiJSlYzMuzP7ZhwRiwHvqNXdoN0Y3CCI4yDdWRwgiOM", + "enr:-IS4QPi-onjNsT5xAIAenhCGTDl4z-4UOR25Uq-3TmG4V3kwB9ljLTb_Kp1wdjHNj-H8VVLRBSSWVZo3GUe3z6k0E-IBgmlkgnY0gmlwhKB3_qGJc2VjcDI1NmsxoQMvAfgB4cJXvvXeM6WbCG86CstbSxbQBSGx31FAwVtOTYN1ZHCCIyg", + "enr:-KG4QPUf8-g_jU-KrwzG42AGt0wWM1BTnQxgZXlvCEIfTQ5hSmptkmgmMbRkpOqv6kzb33SlhPHJp7x4rLWWiVq5lSECgmlkgnY0gmlwhFPlR9KDaXA2kCoGxcAJAAAVAAAAAAAAABCJc2VjcDI1NmsxoQLdUv9Eo9sxCt0tc_CheLOWnX59yHJtkBSOL7kpxdJ6GYN1ZHCCIyiEdWRwNoIjKA" ] }, "checkpoint": { diff --git a/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java b/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java new file mode 100644 index 00000000000..ca7b8842b0b --- /dev/null +++ b/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java @@ -0,0 +1,109 @@ +/* + * Copyright contributors to Besu. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ +package org.hyperledger.besu.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +class DiscoveryOptionsTest { + + @Test + void defaultHasNoBootNodes() { + assertThat(DiscoveryOptions.DEFAULT.getBootNodes()).isEmpty(); + } + + @Test + void defaultHasNoV5BootNodes() { + assertThat(DiscoveryOptions.DEFAULT.getV5BootNodes()).isEmpty(); + } + + @Test + void defaultHasNoDnsUrl() { + assertThat(DiscoveryOptions.DEFAULT.getDiscoveryDnsUrl()).isEmpty(); + } + + @Test + void parsesBootNodes() { + final ObjectNode root = JsonUtil.createEmptyObjectNode(); + final ArrayNode bootnodes = root.putArray("bootnodes"); + bootnodes.add("enode://abc@127.0.0.1:30303"); + bootnodes.add("enode://def@127.0.0.1:30304"); + + final DiscoveryOptions options = new DiscoveryOptions(root); + assertThat(options.getBootNodes()) + .isPresent() + .hasValue(List.of("enode://abc@127.0.0.1:30303", "enode://def@127.0.0.1:30304")); + } + + @Test + void parsesV5BootNodes() { + final ObjectNode root = JsonUtil.createEmptyObjectNode(); + final ArrayNode v5Bootnodes = root.putArray("v5Bootnodes"); + v5Bootnodes.add("enr:-abc123"); + v5Bootnodes.add("enr:-def456"); + + final DiscoveryOptions options = new DiscoveryOptions(root); + assertThat(options.getV5BootNodes()) + .isPresent() + .hasValue(List.of("enr:-abc123", "enr:-def456")); + } + + @Test + void v5BootNodesEmptyWhenKeyAbsent() { + final ObjectNode root = JsonUtil.createEmptyObjectNode(); + root.putArray("bootnodes").add("enode://abc@127.0.0.1:30303"); + + final DiscoveryOptions options = new DiscoveryOptions(root); + assertThat(options.getV5BootNodes()).isEmpty(); + assertThat(options.getBootNodes()).isPresent(); + } + + @Test + void bootNodesAndV5BootNodesCanCoexist() { + final ObjectNode root = JsonUtil.createEmptyObjectNode(); + root.putArray("bootnodes").add("enode://abc@127.0.0.1:30303"); + root.putArray("v5Bootnodes").add("enr:-abc123"); + + final DiscoveryOptions options = new DiscoveryOptions(root); + assertThat(options.getBootNodes()).isPresent().hasValue(List.of("enode://abc@127.0.0.1:30303")); + assertThat(options.getV5BootNodes()).isPresent().hasValue(List.of("enr:-abc123")); + } + + @Test + void v5BootNodesThrowsOnNonStringElement() { + final ObjectNode root = JsonUtil.createEmptyObjectNode(); + root.putArray("v5Bootnodes").add(42); + + final DiscoveryOptions options = new DiscoveryOptions(root); + assertThatThrownBy(options::getV5BootNodes) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("v5Bootnodes does not contain a string"); + } + + @Test + void parsesDnsUrl() { + final ObjectNode root = JsonUtil.createEmptyObjectNode(); + root.put("dns", "enrtree://test@domain"); + + final DiscoveryOptions options = new DiscoveryOptions(root); + assertThat(options.getDiscoveryDnsUrl()).isPresent().hasValue("enrtree://test@domain"); + } +} From ad8b277de398ad9d09591566471c672bac39fbaa Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 12:18:05 +1000 Subject: [PATCH 02/14] Remove stale prefix detection and fix v5Bootnodes key normalization - EthNetworkConfig.getNetworkConfig() now reads bootnodes (V4) and v5Bootnodes (V5) separately instead of detecting format by prefix - Remove dead MAINNET fallback null-checks in RunnerBuilder (record constructor enforces non-null) - Clear both enode and ENR bootnode lists for custom genesis files - Fix DiscoveryOptions V5_BOOTNODES_KEY to lowercase (GenesisReader normalizeKeys lowercases all JSON keys) - Add enrBootNodes assertions to EthNetworkConfigTest Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/RunnerBuilder.java | 19 ++++------- .../org/hyperledger/besu/cli/BesuCommand.java | 1 + .../besu/cli/config/EthNetworkConfig.java | 32 +++++++++---------- .../besu/cli/config/EthNetworkConfigTest.java | 2 ++ .../besu/config/DiscoveryOptions.java | 2 +- .../besu/config/DiscoveryOptionsTest.java | 8 ++--- 6 files changed, 30 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/org/hyperledger/besu/RunnerBuilder.java b/app/src/main/java/org/hyperledger/besu/RunnerBuilder.java index f0d5e35c8be..47c815cd301 100644 --- a/app/src/main/java/org/hyperledger/besu/RunnerBuilder.java +++ b/app/src/main/java/org/hyperledger/besu/RunnerBuilder.java @@ -22,7 +22,6 @@ import org.hyperledger.besu.cli.config.EthNetworkConfig; import org.hyperledger.besu.cli.options.EthstatsOptions; -import org.hyperledger.besu.config.NetworkDefinition; import org.hyperledger.besu.controller.BesuController; import org.hyperledger.besu.cryptoservices.NodeKey; import org.hyperledger.besu.ethereum.ProtocolContext; @@ -672,17 +671,8 @@ public Runner build() { }); discoveryConfiguration.setPreferIpv6Outbound(preferIpv6Outbound); if (discoveryEnabled) { - final List bootstrap; - if (ethNetworkConfig.enodeBootNodes() == null) { - bootstrap = EthNetworkConfig.getNetworkConfig(NetworkDefinition.MAINNET).enodeBootNodes(); - } else { - bootstrap = ethNetworkConfig.enodeBootNodes(); - } - discoveryConfiguration.setEnodeBootnodes(bootstrap); - discoveryConfiguration.setEnrBootnodes( - ethNetworkConfig.enrBootNodes() == null - ? EthNetworkConfig.getNetworkConfig(NetworkDefinition.MAINNET).enrBootNodes() - : ethNetworkConfig.enrBootNodes()); + discoveryConfiguration.setEnodeBootnodes(ethNetworkConfig.enodeBootNodes()); + discoveryConfiguration.setEnrBootnodes(ethNetworkConfig.enrBootNodes()); discoveryConfiguration.setIncludeBootnodesOnPeerRefresh( besuController.getGenesisConfigOptions().isPoa() && poaDiscoveryRetryBootnodes); @@ -690,7 +680,10 @@ public Runner build() { "Resolved {} bootnodes.", discoveryConfiguration.getEnodeBootnodes().size() + discoveryConfiguration.getEnrBootnodes().size()); - LOG.debug("Bootnodes = {}", bootstrap); + LOG.debug( + "Bootnodes enode={}, enr={}", + discoveryConfiguration.getEnodeBootnodes(), + discoveryConfiguration.getEnrBootnodes()); discoveryConfiguration.setDnsDiscoveryURL(ethNetworkConfig.dnsDiscoveryUrl()); discoveryConfiguration.setDiscoveryV5Enabled( networkingConfiguration.discoveryConfiguration().isDiscoveryV5Enabled()); diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 3522986338b..7ff9c92cbaf 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -2525,6 +2525,7 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { if (p2PDiscoveryOptions.bootNodes == null) { builder.setEnodeBootNodes(new ArrayList<>()); + builder.setEnrBootNodes(new ArrayList<>()); } builder.setDnsDiscoveryUrl(null); } diff --git a/app/src/main/java/org/hyperledger/besu/cli/config/EthNetworkConfig.java b/app/src/main/java/org/hyperledger/besu/cli/config/EthNetworkConfig.java index 7a34f44fcb4..9845b727c37 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/config/EthNetworkConfig.java +++ b/app/src/main/java/org/hyperledger/besu/cli/config/EthNetworkConfig.java @@ -14,8 +14,8 @@ */ package org.hyperledger.besu.cli.config; +import org.hyperledger.besu.config.DiscoveryOptions; import org.hyperledger.besu.config.GenesisConfig; -import org.hyperledger.besu.config.GenesisConfigOptions; import org.hyperledger.besu.config.NetworkDefinition; import org.hyperledger.besu.ethereum.p2p.discovery.dns.EthereumNodeRecord; import org.hyperledger.besu.ethereum.p2p.peers.EnodeURLImpl; @@ -25,10 +25,8 @@ import java.math.BigInteger; import java.net.URL; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Optional; /** * The Eth network config. @@ -72,25 +70,27 @@ public record EthNetworkConfig( public static EthNetworkConfig getNetworkConfig(final NetworkDefinition networkDefinition) { final URL genesisSource = jsonConfigSource(networkDefinition.getGenesisFile()); final GenesisConfig genesisConfig = GenesisConfig.fromSource(genesisSource); - final GenesisConfigOptions genesisConfigOptions = genesisConfig.getConfigOptions(); - final Optional> rawBootNodes = - genesisConfigOptions.getDiscoveryOptions().getBootNodes(); - final List enodeBootNodes = new ArrayList<>(); - final List enrBootNodes = new ArrayList<>(); - if (rawBootNodes.isPresent() && !rawBootNodes.get().isEmpty()) { - if (rawBootNodes.get().getFirst().startsWith("enr:")) { - enrBootNodes.addAll(rawBootNodes.get().stream().map(EthereumNodeRecord::fromEnr).toList()); - } else { - enodeBootNodes.addAll(rawBootNodes.get().stream().map(EnodeURLImpl::fromString).toList()); - } - } + final DiscoveryOptions discoveryOptions = + genesisConfig.getConfigOptions().getDiscoveryOptions(); + + final List enodeBootNodes = + discoveryOptions + .getBootNodes() + .map(nodes -> nodes.stream().map(EnodeURLImpl::fromString).toList()) + .orElse(List.of()); + + final List enrBootNodes = + discoveryOptions + .getV5BootNodes() + .map(nodes -> nodes.stream().map(EthereumNodeRecord::fromEnr).toList()) + .orElse(List.of()); return new EthNetworkConfig( genesisConfig, networkDefinition.getNetworkId(), enodeBootNodes, enrBootNodes, - genesisConfigOptions.getDiscoveryOptions().getDiscoveryDnsUrl().orElse(null)); + discoveryOptions.getDiscoveryDnsUrl().orElse(null)); } private static URL jsonConfigSource(final String resourceName) { diff --git a/app/src/test/java/org/hyperledger/besu/cli/config/EthNetworkConfigTest.java b/app/src/test/java/org/hyperledger/besu/cli/config/EthNetworkConfigTest.java index 79990d5df4d..0ecac5a6846 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/config/EthNetworkConfigTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/config/EthNetworkConfigTest.java @@ -40,6 +40,7 @@ public void testDefaultMainnetConfig() { EthNetworkConfig config = EthNetworkConfig.getNetworkConfig(NetworkDefinition.MAINNET); assertThat(config.dnsDiscoveryUrl()).isEqualTo(MAINNET_DISCOVERY_URL); assertThat(config.enodeBootNodes()).isEqualTo(MAINNET_BOOTSTRAP_NODES); + assertThat(config.enrBootNodes()).isNotEmpty(); assertThat(config.networkId()).isEqualTo(BigInteger.ONE); } @@ -56,6 +57,7 @@ public void testDefaultHoodiConfig() { EthNetworkConfig config = EthNetworkConfig.getNetworkConfig(NetworkDefinition.HOODI); assertThat(config.dnsDiscoveryUrl()).isEqualTo(HOODI_DISCOVERY_URL); assertThat(config.enodeBootNodes()).isEqualTo(HOODI_BOOTSTRAP_NODES); + assertThat(config.enrBootNodes()).isNotEmpty(); assertThat(config.networkId()).isEqualTo(BigInteger.valueOf(560048)); } diff --git a/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java b/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java index c9fd63b6ee2..c4aae63f7e6 100644 --- a/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java +++ b/config/src/main/java/org/hyperledger/besu/config/DiscoveryOptions.java @@ -28,7 +28,7 @@ public class DiscoveryOptions { new DiscoveryOptions(JsonUtil.createEmptyObjectNode()); private static final String ENODES_KEY = "bootnodes"; - private static final String V5_BOOTNODES_KEY = "v5Bootnodes"; + private static final String V5_BOOTNODES_KEY = "v5bootnodes"; private static final String DNS_KEY = "dns"; private final ObjectNode discoveryConfigRoot; diff --git a/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java b/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java index ca7b8842b0b..05ef67f4696 100644 --- a/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java +++ b/config/src/test/java/org/hyperledger/besu/config/DiscoveryOptionsTest.java @@ -56,7 +56,7 @@ void parsesBootNodes() { @Test void parsesV5BootNodes() { final ObjectNode root = JsonUtil.createEmptyObjectNode(); - final ArrayNode v5Bootnodes = root.putArray("v5Bootnodes"); + final ArrayNode v5Bootnodes = root.putArray("v5bootnodes"); v5Bootnodes.add("enr:-abc123"); v5Bootnodes.add("enr:-def456"); @@ -80,7 +80,7 @@ void v5BootNodesEmptyWhenKeyAbsent() { void bootNodesAndV5BootNodesCanCoexist() { final ObjectNode root = JsonUtil.createEmptyObjectNode(); root.putArray("bootnodes").add("enode://abc@127.0.0.1:30303"); - root.putArray("v5Bootnodes").add("enr:-abc123"); + root.putArray("v5bootnodes").add("enr:-abc123"); final DiscoveryOptions options = new DiscoveryOptions(root); assertThat(options.getBootNodes()).isPresent().hasValue(List.of("enode://abc@127.0.0.1:30303")); @@ -90,12 +90,12 @@ void bootNodesAndV5BootNodesCanCoexist() { @Test void v5BootNodesThrowsOnNonStringElement() { final ObjectNode root = JsonUtil.createEmptyObjectNode(); - root.putArray("v5Bootnodes").add(42); + root.putArray("v5bootnodes").add(42); final DiscoveryOptions options = new DiscoveryOptions(root); assertThatThrownBy(options::getV5BootNodes) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("v5Bootnodes does not contain a string"); + .hasMessageContaining("v5bootnodes does not contain a string"); } @Test From a26f9cf0a4179bba7ba86b8c286c80274f4f2b3c Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 12:34:13 +1000 Subject: [PATCH 03/14] Clear unused bootnode list based on discovery protocol version Only one discovery protocol (V4 or V5) is active at a time. Clear the non-applicable bootnode list when setting bootnodes to avoid carrying stale defaults that would never be used. Signed-off-by: Usman Saleem --- app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 8919dc44255..f302a49c2a8 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -2576,10 +2576,12 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { try { if (isV5) { builder.setEnrBootNodes(rawBootnodes.stream().map(EthereumNodeRecord::fromEnr).toList()); + builder.setEnodeBootNodes(Collections.emptyList()); } else { final List enodes = buildEnodes(rawBootnodes, getEnodeDnsConfiguration()); DiscoveryConfiguration.assertValidBootnodes(enodes); builder.setEnodeBootNodes(enodes); + builder.setEnrBootNodes(Collections.emptyList()); } } catch (final IllegalArgumentException e) { throw new ParameterException(commandLine, e.getMessage()); From e484e48752912e1a4a8797981e83f1ad13d1cbf9 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 12:54:05 +1000 Subject: [PATCH 04/14] fix unit test Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/CascadingDefaultProviderTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java b/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java index 9fdeed43a37..47391ada866 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java @@ -132,6 +132,7 @@ public void overrideDefaultValuesIfKeyIsPresentInConfigFile(final @TempDir File .setNetworkId(BigInteger.valueOf(42)) .setGenesisConfig(GenesisConfig.fromConfig(encodeJsonGenesis(GENESIS_VALID_JSON))) .setEnodeBootNodes(nodes) + .setEnrBootNodes(Collections.emptyList()) .setDnsDiscoveryUrl(null) .build(); verify(mockControllerBuilder).dataDirectory(eq(dataFolder.toPath())); From 63734c6e5232b99c5962a70e4da8347c4ae46ef3 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 13:35:55 +1000 Subject: [PATCH 05/14] Clear unused bootnode list only on CLI override, use V5 flag in getBootnodeIdentifiers - Only clear the non-applicable bootnode list when --bootnodes is explicitly provided via CLI; genesis defaults keep both lists intact - Replace heuristic fallback in getBootnodeIdentifiers() with explicit discoveryV5Enabled flag check Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/BesuCommand.java | 15 +++++++++++---- .../p2p/config/DiscoveryConfiguration.java | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index f302a49c2a8..dde9ae81433 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -2554,7 +2554,8 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { final boolean isV5 = unstableNetworkingOptions.toDomainObject().discoveryConfiguration().isDiscoveryV5Enabled(); List rawBootnodes = null; - if (p2PDiscoveryOptions.bootNodes != null) { + final boolean cliBootnodesProvided = p2PDiscoveryOptions.bootNodes != null; + if (cliBootnodesProvided) { try { rawBootnodes = BootnodeResolver.resolve(p2PDiscoveryOptions.bootNodes); } catch (final BootnodeResolutionException e) { @@ -2576,17 +2577,23 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { try { if (isV5) { builder.setEnrBootNodes(rawBootnodes.stream().map(EthereumNodeRecord::fromEnr).toList()); - builder.setEnodeBootNodes(Collections.emptyList()); } else { final List enodes = buildEnodes(rawBootnodes, getEnodeDnsConfiguration()); DiscoveryConfiguration.assertValidBootnodes(enodes); builder.setEnodeBootNodes(enodes); - builder.setEnrBootNodes(Collections.emptyList()); + } + // CLI --bootnodes is a full override: clear the unused protocol's list + if (cliBootnodesProvided) { + if (isV5) { + builder.setEnodeBootNodes(Collections.emptyList()); + } else { + builder.setEnrBootNodes(Collections.emptyList()); + } } } catch (final IllegalArgumentException e) { throw new ParameterException(commandLine, e.getMessage()); } - } else if (p2PDiscoveryOptions.bootNodes != null) { + } else if (cliBootnodesProvided) { // Explicitly empty --bootnodes clears all default bootnodes builder.setEnodeBootNodes(Collections.emptyList()); builder.setEnrBootNodes(Collections.emptyList()); diff --git a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/config/DiscoveryConfiguration.java b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/config/DiscoveryConfiguration.java index dd0f18d4f62..ce941313ba7 100644 --- a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/config/DiscoveryConfiguration.java +++ b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/config/DiscoveryConfiguration.java @@ -110,7 +110,7 @@ public DiscoveryConfiguration setEnrBootnodes(final List enr } public List getBootnodeIdentifiers() { - return enodeBootnodes.isEmpty() ? enrBootnodes : enodeBootnodes; + return discoveryV5Enabled ? enrBootnodes : enodeBootnodes; } public boolean getIncludeBootnodesOnPeerRefresh() { From 8e09351ad85aded73eb9142a5b647da2176efafa Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 13:43:10 +1000 Subject: [PATCH 06/14] review suggestion Signed-off-by: Usman Saleem --- app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index dde9ae81433..b6c38fbd265 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -2558,7 +2558,7 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { if (cliBootnodesProvided) { try { rawBootnodes = BootnodeResolver.resolve(p2PDiscoveryOptions.bootNodes); - } catch (final BootnodeResolutionException e) { + } catch (final BootnodeResolutionException | IllegalArgumentException e) { throw new ParameterException(commandLine, e.getMessage(), e); } } else { From a89f3d0e8ded805dc80412b569dca44c4affb843 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 14:02:52 +1000 Subject: [PATCH 07/14] Fix BesuCommandTest to use EthNetworkConfig for mainnet defaults Now that mainnet.json includes v5Bootnodes, use getNetworkConfig(MAINNET) instead of hardcoding empty ENR bootnode list in test expectation. Signed-off-by: Usman Saleem --- .../java/org/hyperledger/besu/cli/BesuCommandTest.java | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java index b91f1b201d6..63386938784 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java @@ -307,13 +307,7 @@ public void callingBesuCommandWithoutOptionsMustSyncWithDefaultValues() { ArgumentCaptor.forClass(EthNetworkConfig.class); verify(mockRunnerBuilder).discoveryEnabled(eq(true)); verify(mockRunnerBuilder) - .ethNetworkConfig( - new EthNetworkConfig( - GenesisConfig.fromResource(MAINNET.getGenesisFile()), - MAINNET.getNetworkId(), - MAINNET_BOOTSTRAP_NODES, - Collections.emptyList(), - MAINNET_DISCOVERY_URL)); + .ethNetworkConfig(EthNetworkConfig.getNetworkConfig(MAINNET)); verify(mockRunnerBuilder).p2pAdvertisedHost(eq("127.0.0.1")); verify(mockRunnerBuilder).p2pListenPort(eq(30303)); verify(mockRunnerBuilder).jsonRpcConfiguration(eq(DEFAULT_JSON_RPC_CONFIGURATION)); From 67564076a80901fd26985f7f0df4efee63a2056d Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 15:16:52 +1000 Subject: [PATCH 08/14] Fix CascadingDefaultProviderTest for mainnet ENR bootnodes Use getNetworkConfig(MAINNET) instead of hardcoding empty ENR list. Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/BesuCommandTest.java | 3 +-- .../besu/cli/CascadingDefaultProviderTest.java | 11 +---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java index 63386938784..cb36b838fb3 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java @@ -306,8 +306,7 @@ public void callingBesuCommandWithoutOptionsMustSyncWithDefaultValues() { final ArgumentCaptor ethNetworkArg = ArgumentCaptor.forClass(EthNetworkConfig.class); verify(mockRunnerBuilder).discoveryEnabled(eq(true)); - verify(mockRunnerBuilder) - .ethNetworkConfig(EthNetworkConfig.getNetworkConfig(MAINNET)); + verify(mockRunnerBuilder).ethNetworkConfig(EthNetworkConfig.getNetworkConfig(MAINNET)); verify(mockRunnerBuilder).p2pAdvertisedHost(eq("127.0.0.1")); verify(mockRunnerBuilder).p2pListenPort(eq(30303)); verify(mockRunnerBuilder).jsonRpcConfiguration(eq(DEFAULT_JSON_RPC_CONFIGURATION)); diff --git a/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java b/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java index 47391ada866..28cc78a0907 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/CascadingDefaultProviderTest.java @@ -21,8 +21,6 @@ import static org.hyperledger.besu.config.NetworkDefinition.MAINNET; import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.ETH; import static org.hyperledger.besu.ethereum.api.jsonrpc.RpcApis.WEB3; -import static org.hyperledger.besu.ethereum.p2p.config.DefaultDiscoveryConfiguration.MAINNET_BOOTSTRAP_NODES; -import static org.hyperledger.besu.ethereum.p2p.config.DefaultDiscoveryConfiguration.MAINNET_DISCOVERY_URL; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @@ -166,14 +164,7 @@ public void noOverrideDefaultValuesIfKeyIsNotPresentInConfigFile() { final MetricsConfiguration metricsConfiguration = MetricsConfiguration.builder().build(); verify(mockRunnerBuilder).discoveryEnabled(eq(true)); - verify(mockRunnerBuilder) - .ethNetworkConfig( - new EthNetworkConfig( - GenesisConfig.fromResource(MAINNET.getGenesisFile()), - MAINNET.getNetworkId(), - MAINNET_BOOTSTRAP_NODES, - Collections.emptyList(), - MAINNET_DISCOVERY_URL)); + verify(mockRunnerBuilder).ethNetworkConfig(EthNetworkConfig.getNetworkConfig(MAINNET)); verify(mockRunnerBuilder).p2pAdvertisedHost(eq("127.0.0.1")); verify(mockRunnerBuilder).p2pListenPort(eq(30303)); verify(mockRunnerBuilder).jsonRpcConfiguration(eq(jsonRpcConfiguration)); From e7714638c2d700fb48f94c4cb884dafcad27fc07 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Thu, 5 Mar 2026 16:38:53 +1000 Subject: [PATCH 09/14] Fix ENR public key decompression to always use secp256k1 ENR identity scheme v4 always encodes public keys using secp256k1, regardless of the node's configured signature algorithm. Use a static SECP256K1 instance instead of SignatureAlgorithmFactory.getInstance() which returns the globally active algorithm (could be secp256r1). This fixes startup failure with "Invalid point compression" when Besu is configured with secp256r1 and mainnet ENR bootnodes are loaded. Signed-off-by: Usman Saleem --- .../p2p/discovery/dns/EthereumNodeRecord.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java index 49518751267..4e636a039c2 100644 --- a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java +++ b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java @@ -16,7 +16,8 @@ // Adapted from https://github.com/tmio/tuweni and licensed under Apache 2.0 package org.hyperledger.besu.ethereum.p2p.discovery.dns; -import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmType; import org.hyperledger.besu.ethereum.p2p.discovery.NodeIdentifier; import java.net.InetAddress; @@ -46,6 +47,10 @@ public record EthereumNodeRecord( NodeRecord nodeRecord) implements NodeIdentifier { + // ENR identity scheme v4 always uses secp256k1, independent of the node's signature algorithm + private static final SignatureAlgorithm SECP256K1 = + SignatureAlgorithmType.DEFAULT_SIGNATURE_ALGORITHM_TYPE.get(); + @SuppressWarnings( "MethodInputParametersMustBeFinal") // needed since record constructors are not yet supported public EthereumNodeRecord { @@ -97,8 +102,8 @@ static Bytes initPublicKeyBytes(final Map fields) { throw new IllegalArgumentException("Missing secp256k1 entry in ENR"); } // convert 33 bytes compressed public key to uncompressed using Bouncy Castle - var curve = SignatureAlgorithmFactory.getInstance().getCurve(); - var ecPoint = curve.getCurve().decodePoint(keyBytes.toArrayUnsafe()); + // ENR identity scheme v4 always uses secp256k1 for the public key + var ecPoint = SECP256K1.getCurve().getCurve().decodePoint(keyBytes.toArrayUnsafe()); // uncompressed public key is 65 bytes, first byte is 0x04. var encodedPubKey = ecPoint.getEncoded(false); return Bytes.of(Arrays.copyOfRange(encodedPubKey, 1, encodedPubKey.length)); From 7447039c242d983c8a454eee73459b371eedaf44 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Fri, 6 Mar 2026 12:07:33 +1000 Subject: [PATCH 10/14] Improve error handling for invalid ENR bootnodes and add V5 bootnode tests - Catch RuntimeException (including DecodeException from discovery lib) for invalid ENR parsing, in addition to IllegalArgumentException - Add "Invalid bootnode format:" prefix for non-IAE runtime errors - Add tests: valid single ENR, valid comma-separated ENRs, and parameterized invalid ENR values with V5 discovery enabled Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/BesuCommand.java | 2 + .../hyperledger/besu/cli/BesuCommandTest.java | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 0cde8439030..896449d14cb 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -2585,6 +2585,8 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { } } catch (final IllegalArgumentException e) { throw new ParameterException(commandLine, e.getMessage()); + } catch (final RuntimeException e) { + throw new ParameterException(commandLine, "Invalid bootnode format: " + e.getMessage(), e); } } else if (cliBootnodesProvided) { // Explicitly empty --bootnodes clears all default bootnodes diff --git a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java index cb36b838fb3..b2dc5fb9dc0 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java @@ -113,6 +113,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; @@ -1014,6 +1016,45 @@ public void callingWithInvalidBootnodeAndEqualSignMustDisplayError() { assertThat(commandErrorOutput.toString(UTF_8)).startsWith(expectedErrorOutputStart); } + private static final String VALID_ENR_1 = + "enr:-Iu4QLm7bZGdAt9NSeJG0cEnJohWcQTQaI9wFLu3Q7eHIDfrI4cwtzvEW3F3VbG9XdFXlrHyFGeXPn9snTCQJ9bnMRABgmlkgnY0gmlwhAOTJQCJc2VjcDI1NmsxoQIZdZD6tDYpkpEfVo5bgiU8MGRjhcOmHGD2nErK0UKRrIN0Y3CCIyiDdWRwgiMo"; + private static final String VALID_ENR_2 = + "enr:-Iu4QEDJ4Wa_UQNbK8Ay1hFEkXvd8psolVK6OhfTL9irqz3nbXxxWyKwEplPfkju4zduVQj6mMhUCm9R2Lc4YM5jPcIBgmlkgnY0gmlwhANrfESJc2VjcDI1NmsxoQJCYz2-nsqFpeEj6eov9HSi9QssIVIVNr0I89J1vXM9foN0Y3CCIyiDdWRwgiMo"; + + @Test + public void callingWithValidEnrBootnodeAndV5EnabledMustSucceed() { + parseCommand("--Xv5-discovery-enabled", "--bootnodes", VALID_ENR_1); + + verify(mockRunnerBuilder).ethNetworkConfig(ethNetworkConfigArgumentCaptor.capture()); + verify(mockRunnerBuilder).build(); + + assertThat(ethNetworkConfigArgumentCaptor.getValue().enrBootNodes()).hasSize(1); + assertThat(ethNetworkConfigArgumentCaptor.getValue().enodeBootNodes()).isEmpty(); + assertThat(commandOutput.toString(UTF_8)).isEmpty(); + assertThat(commandErrorOutput.toString(UTF_8)).isEmpty(); + } + + @Test + public void callingWithMultipleValidEnrBootnodesAndV5EnabledMustSucceed() { + parseCommand("--Xv5-discovery-enabled", "--bootnodes", VALID_ENR_1 + "," + VALID_ENR_2); + + verify(mockRunnerBuilder).ethNetworkConfig(ethNetworkConfigArgumentCaptor.capture()); + verify(mockRunnerBuilder).build(); + + assertThat(ethNetworkConfigArgumentCaptor.getValue().enrBootNodes()).hasSize(2); + assertThat(ethNetworkConfigArgumentCaptor.getValue().enodeBootNodes()).isEmpty(); + assertThat(commandOutput.toString(UTF_8)).isEmpty(); + assertThat(commandErrorOutput.toString(UTF_8)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"enr:-invalidenrdata", "enr:invalidvalue", "invalidvalue"}) + public void callingWithInvalidBootnodeAndV5EnabledMustDisplayError(final String bootnode) { + parseCommand("--Xv5-discovery-enabled", "--bootnodes", bootnode); + assertThat(commandOutput.toString(UTF_8)).isEmpty(); + assertThat(commandErrorOutput.toString(UTF_8)).isNotEmpty(); + } + @Test public void bootnodesOptionMustBeUsed() { parseCommand("--bootnodes", String.join(",", VALID_ENODE_STRINGS)); From 6000bbe03e247a7fbd17fe4da8a2e57723310cf0 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Mon, 9 Mar 2026 08:14:07 +1000 Subject: [PATCH 11/14] Improve error message for invalid ENR bootnodes Wrap individual ENR parsing with a clear error that includes the offending value and expected format, e.g.: Invalid ENR bootnode: 'extra'. ENR bootnodes must start with 'enr:'. Signed-off-by: Usman Saleem --- .../org/hyperledger/besu/cli/BesuCommand.java | 20 ++++++++++++++++++- .../hyperledger/besu/cli/BesuCommandTest.java | 3 ++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java index 896449d14cb..4e25953359d 100644 --- a/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java +++ b/app/src/main/java/org/hyperledger/besu/cli/BesuCommand.java @@ -2569,7 +2569,23 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { } try { if (isV5) { - builder.setEnrBootNodes(rawBootnodes.stream().map(EthereumNodeRecord::fromEnr).toList()); + builder.setEnrBootNodes( + rawBootnodes.stream() + .map( + enr -> { + try { + return EthereumNodeRecord.fromEnr(enr); + } catch (final Exception e) { + throw new ParameterException( + commandLine, + "Invalid ENR bootnode: '" + + enr + + "'. ENR bootnodes must start with 'enr:'. Error: " + + e.getMessage(), + e); + } + }) + .toList()); } else { final List enodes = buildEnodes(rawBootnodes, getEnodeDnsConfiguration()); DiscoveryConfiguration.assertValidBootnodes(enodes); @@ -2583,6 +2599,8 @@ private EthNetworkConfig updateNetworkConfig(final NetworkDefinition network) { builder.setEnrBootNodes(Collections.emptyList()); } } + } catch (final ParameterException e) { + throw e; // re-throw ParameterException from ENR parsing as-is } catch (final IllegalArgumentException e) { throw new ParameterException(commandLine, e.getMessage()); } catch (final RuntimeException e) { diff --git a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java index b2dc5fb9dc0..b4132438cfc 100644 --- a/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java +++ b/app/src/test/java/org/hyperledger/besu/cli/BesuCommandTest.java @@ -1052,7 +1052,8 @@ public void callingWithMultipleValidEnrBootnodesAndV5EnabledMustSucceed() { public void callingWithInvalidBootnodeAndV5EnabledMustDisplayError(final String bootnode) { parseCommand("--Xv5-discovery-enabled", "--bootnodes", bootnode); assertThat(commandOutput.toString(UTF_8)).isEmpty(); - assertThat(commandErrorOutput.toString(UTF_8)).isNotEmpty(); + assertThat(commandErrorOutput.toString(UTF_8)) + .contains("Invalid ENR bootnode: '" + bootnode + "'"); } @Test From f53a37609b8eeedeaff862d5a1447063f01947a6 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Tue, 10 Mar 2026 14:36:01 +1000 Subject: [PATCH 12/14] Fix DiscV5 ECDH key agreement and peer connection pipeline - Add calculateECDHKeyAgreementCompressed through the crypto stack (SignatureAlgorithm, SecurityModule, NodeKey) to return the full compressed EC point (33 bytes) required by DiscV5 HKDF key derivation. - Handle compressed public keys (33-byte 0x02/0x03 prefix) in the NodeKeySigner.deriveECDHKeyAgreement used by the discovery library. - Fix candidatePeers() to use isListening() instead of isReadyForConnections(), which required DiscV4 bonding status that is never set for DiscV5-discovered peers. - Filter out the local node record from candidate peers returned by streamLiveNodes(). Signed-off-by: Usman Saleem --- .../besu/crypto/AbstractSECP256.java | 12 +++++++++++ .../besu/crypto/SignatureAlgorithm.java | 15 +++++++++++++ .../cryptoservices/KeyPairSecurityModule.java | 14 +++++++++++++ .../besu/cryptoservices/NodeKey.java | 12 +++++++++++ .../discv5/PeerDiscoveryAgentFactoryV5.java | 17 +++++++++++++-- .../discv5/PeerDiscoveryAgentV5.java | 11 +++++++++- plugin-api/build.gradle | 2 +- .../securitymodule/SecurityModule.java | 21 +++++++++++++++++++ 8 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/AbstractSECP256.java b/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/AbstractSECP256.java index 2098dfeadc1..87a7d6bc753 100644 --- a/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/AbstractSECP256.java +++ b/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/AbstractSECP256.java @@ -156,6 +156,18 @@ public Bytes32 calculateECDHKeyAgreement( return UInt256.valueOf(agreed); } + @Override + public Bytes calculateECDHKeyAgreementCompressed( + final SECPPrivateKey privKey, final SECPPublicKey theirPubKey) { + checkArgument(privKey != null, "missing private key"); + checkArgument(theirPubKey != null, "missing remote public key"); + + final org.bouncycastle.math.ec.ECPoint point = + theirPubKey.asEcPoint(curve).multiply(privKey.getD()).normalize(); + checkArgument(!point.isInfinity(), "ECDH key agreement point is at infinity"); + return Bytes.wrap(point.getEncoded(true)); + } + @Override public SECPPrivateKey createPrivateKey(final BigInteger key) { return SECPPrivateKey.create(key, ALGORITHM); diff --git a/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/SignatureAlgorithm.java b/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/SignatureAlgorithm.java index 9e43ec40b8a..0e650e2e775 100644 --- a/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/SignatureAlgorithm.java +++ b/crypto/algorithms/src/main/java/org/hyperledger/besu/crypto/SignatureAlgorithm.java @@ -119,6 +119,21 @@ SECPSignature normaliseSignature( */ Bytes32 calculateECDHKeyAgreement(final SECPPrivateKey privKey, final SECPPublicKey theirPubKey); + /** + * Calculate ECDH key agreement returning the resulting EC point in compressed format. + * + *

Unlike {@link #calculateECDHKeyAgreement(SECPPrivateKey, SECPPublicKey)} which returns only + * the x-coordinate, this method returns the full compressed EC point (33 bytes: prefix byte + + * x-coordinate). This is required by protocols such as DiscV5 which use the compressed point as + * input to HKDF key derivation. + * + * @param privKey the private key + * @param theirPubKey the remote party's public key + * @return the compressed EC point (33 bytes) + */ + Bytes calculateECDHKeyAgreementCompressed( + final SECPPrivateKey privKey, final SECPPublicKey theirPubKey); + /** * Gets half curve order. * diff --git a/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/KeyPairSecurityModule.java b/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/KeyPairSecurityModule.java index 97759b54c60..8ea7583b5fb 100644 --- a/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/KeyPairSecurityModule.java +++ b/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/KeyPairSecurityModule.java @@ -89,6 +89,20 @@ public Bytes32 calculateECDHKeyAgreement(final PublicKey partyKey) } } + @Override + public Bytes calculateECDHKeyAgreementCompressed(final PublicKey partyKey) + throws SecurityModuleException { + try { + final Bytes encodedECPoint = ECPointUtil.getEncodedBytes(partyKey.getW()); + final SECPPublicKey secp256KPartyKey = signatureAlgorithm.createPublicKey(encodedECPoint); + return signatureAlgorithm.calculateECDHKeyAgreementCompressed( + keyPair.getPrivateKey(), secp256KPartyKey); + } catch (final Exception e) { + throw new SecurityModuleException( + "Unexpected error while calculating compressed ECDH Key Agreement", e); + } + } + private static class SignatureImpl implements Signature { private final SECPSignature signature; diff --git a/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/NodeKey.java b/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/NodeKey.java index 698336d3fe6..7f1cb25b185 100644 --- a/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/NodeKey.java +++ b/crypto/services/src/main/java/org/hyperledger/besu/cryptoservices/NodeKey.java @@ -22,6 +22,7 @@ import org.hyperledger.besu.plugin.services.securitymodule.SecurityModule; import org.hyperledger.besu.plugin.services.securitymodule.data.Signature; +import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; /** The Node key. */ @@ -72,4 +73,15 @@ public Bytes32 calculateECDHKeyAgreement(final SECPPublicKey partyKey) { return securityModule.calculateECDHKeyAgreement( () -> ECPointUtil.fromBouncyCastleECPoint(signatureAlgorithm.publicKeyAsEcPoint(partyKey))); } + + /** + * Calculate ECDH key agreement returning the compressed EC point. + * + * @param partyKey the party key + * @return the compressed EC point (33 bytes) + */ + public Bytes calculateECDHKeyAgreementCompressed(final SECPPublicKey partyKey) { + return securityModule.calculateECDHKeyAgreementCompressed( + () -> ECPointUtil.fromBouncyCastleECPoint(signatureAlgorithm.publicKeyAsEcPoint(partyKey))); + } } diff --git a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentFactoryV5.java b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentFactoryV5.java index ee02baa272a..68529c7963a 100644 --- a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentFactoryV5.java +++ b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentFactoryV5.java @@ -169,8 +169,21 @@ public NodeKeySigner(final NodeKey nodeKey) { */ @Override public Bytes deriveECDHKeyAgreement(final Bytes remotePubKey) { - SECPPublicKey publicKey = signatureAlgorithm.createPublicKey(remotePubKey); - return nodeKey.calculateECDHKeyAgreement(publicKey); + final Bytes uncompressedKey; + if (remotePubKey.size() == 33) { + // Compressed key (0x02/0x03 prefix) — decompress to the 64-byte format without prefix + final byte[] encoded = + signatureAlgorithm + .getCurve() + .getCurve() + .decodePoint(remotePubKey.toArrayUnsafe()) + .getEncoded(false); + uncompressedKey = Bytes.wrap(encoded, 1, 64); + } else { + uncompressedKey = remotePubKey; + } + final SECPPublicKey publicKey = signatureAlgorithm.createPublicKey(uncompressedKey); + return nodeKey.calculateECDHKeyAgreementCompressed(publicKey); } /** diff --git a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java index c4dbe2eb509..a3c23a552dd 100644 --- a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java +++ b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java @@ -40,6 +40,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import org.apache.tuweni.bytes.Bytes; import org.ethereum.beacon.discovery.MutableDiscoverySystem; import org.ethereum.beacon.discovery.schema.NodeRecord; import org.ethereum.beacon.discovery.storage.NodeRecordListener; @@ -445,13 +446,21 @@ private Stream candidatePeers(final Collection newPee return Stream.empty(); } + final Bytes localNodeId = + getLocalNodeRecord().map(NodeRecord::getNodeId).orElse(Bytes.EMPTY); + // Combine newly discovered peers with known peers and filter for suitability final Stream knownPeers = system.streamLiveNodes(); final List candidates = Stream.concat(newPeers.stream(), knownPeers) .distinct() + // Exclude the local node record that streamLiveNodes may include + .filter(nr -> !nr.getNodeId().equals(localNodeId)) .map(nr -> DiscoveryPeerFactory.fromNodeRecord(nr, preferIpv6Outbound)) - .filter(DiscoveryPeer::isReadyForConnections) + // Use isListening() instead of isReadyForConnections() because + // DiscoveryPeerV4.isReadyForConnections() requires DiscV4 bonding status, + // which is never set for DiscV5-discovered peers. + .filter(DiscoveryPeer::isListening) .filter(peer -> peer.getForkId().map(forkIdManager::peerCheck).orElse(true)) .toList(); if (LOG.isTraceEnabled() && !candidates.isEmpty()) { diff --git a/plugin-api/build.gradle b/plugin-api/build.gradle index 7ad3a7826bc..2cdff2912bd 100644 --- a/plugin-api/build.gradle +++ b/plugin-api/build.gradle @@ -71,7 +71,7 @@ Calculated : ${currentHash} tasks.register('checkAPIChanges', FileStateChecker) { description = "Checks that the API for the Plugin-API project does not change without deliberate thought" files = sourceSets.main.allJava.files - knownHash = 'Hqbx2morgfuBxVBLgq4bgvp/zy67rj2hGoEanLGB3CI=' + knownHash = 'E4o16paE9naGmSRH0HVPGpYfDQ1n+U44FSqy1x1Ar0U=' } check.dependsOn('checkAPIChanges') diff --git a/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/securitymodule/SecurityModule.java b/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/securitymodule/SecurityModule.java index 471be58f0d8..dba151334f0 100644 --- a/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/securitymodule/SecurityModule.java +++ b/plugin-api/src/main/java/org/hyperledger/besu/plugin/services/securitymodule/SecurityModule.java @@ -18,6 +18,7 @@ import org.hyperledger.besu.plugin.services.securitymodule.data.PublicKey; import org.hyperledger.besu.plugin.services.securitymodule.data.Signature; +import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; /** @@ -53,4 +54,24 @@ public interface SecurityModule { * @throws SecurityModuleException if calculateECDHKeyAgreement fails */ Bytes32 calculateECDHKeyAgreement(PublicKey partyKey) throws SecurityModuleException; + + /** + * Perform ECDH key agreement returning the compressed EC point. + * + *

Returns the full compressed EC point (33 bytes: prefix byte + x-coordinate) from the ECDH + * scalar multiplication. This is required by protocols such as DiscV5 which use the compressed + * point as input keying material for HKDF key derivation. + * + *

The default implementation throws {@link SecurityModuleException}. Implementations that need + * to support DiscV5 must override this method. + * + * @param partyKey the key with which an agreement is to be created. + * @return the compressed EC point (33 bytes) + * @throws SecurityModuleException if the operation is not supported or fails + */ + default Bytes calculateECDHKeyAgreementCompressed(final PublicKey partyKey) + throws SecurityModuleException { + throw new SecurityModuleException( + "Compressed ECDH key agreement is not supported by this security module"); + } } From 67f64ccfd29fab94a7fda19ef48447a32c61a4c0 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Tue, 10 Mar 2026 14:41:14 +1000 Subject: [PATCH 13/14] Apply spotless formatting Signed-off-by: Usman Saleem --- .../ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java index a3c23a552dd..659909e7da3 100644 --- a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java +++ b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/discv5/PeerDiscoveryAgentV5.java @@ -446,8 +446,7 @@ private Stream candidatePeers(final Collection newPee return Stream.empty(); } - final Bytes localNodeId = - getLocalNodeRecord().map(NodeRecord::getNodeId).orElse(Bytes.EMPTY); + final Bytes localNodeId = getLocalNodeRecord().map(NodeRecord::getNodeId).orElse(Bytes.EMPTY); // Combine newly discovered peers with known peers and filter for suitability final Stream knownPeers = system.streamLiveNodes(); From 3b3707e0c588a23ab41c1769bf74cf86f9e9ee71 Mon Sep 17 00:00:00 2001 From: Usman Saleem Date: Wed, 11 Mar 2026 14:36:43 +1000 Subject: [PATCH 14/14] Explicitly use secp256k1 for ENR identity scheme v4 Instead of relying on DEFAULT_SIGNATURE_ALGORITHM_TYPE, explicitly create a secp256k1 instance since ENR identity scheme v4 always uses secp256k1 regardless of the node's signature algorithm. Signed-off-by: Usman Saleem --- .../besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java index 4e636a039c2..9c03a7890df 100644 --- a/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java +++ b/ethereum/p2p/src/main/java/org/hyperledger/besu/ethereum/p2p/discovery/dns/EthereumNodeRecord.java @@ -49,7 +49,7 @@ public record EthereumNodeRecord( // ENR identity scheme v4 always uses secp256k1, independent of the node's signature algorithm private static final SignatureAlgorithm SECP256K1 = - SignatureAlgorithmType.DEFAULT_SIGNATURE_ALGORITHM_TYPE.get(); + SignatureAlgorithmType.create("secp256k1").getInstance(); @SuppressWarnings( "MethodInputParametersMustBeFinal") // needed since record constructors are not yet supported