Merge pull request #427 from atoulme/more_logging

expose more logging and set up sane logging when using the client
diff --git a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformation.kt b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformation.kt
index 8e9686d..c222e0e 100644
--- a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformation.kt
+++ b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformation.kt
@@ -66,15 +66,6 @@
   fun forks(): List<Long>
 
   /**
-   * Get our latest known fork
-   *
-   * @return the latest fork number we know
-   */
-  fun getLatestFork(): Long? {
-    return if (forks().isEmpty()) null else forks()[forks().size - 1]
-  }
-
-  /**
    * Get all our fork hashes
    *
    * @param all fork hashes, sorted
@@ -83,9 +74,7 @@
     val crc = CRC32()
     crc.update(genesisHash().toArrayUnsafe())
     val forkHashes = ArrayList<Bytes>(listOf(Bytes.ofUnsignedInt(crc.value)))
-    val forks = mutableListOf<Long>()
-    forks.addAll(forks())
-    for (fork in forks) {
+    for (fork in forks()) {
       val byteRepresentationFork = UInt64.valueOf(fork).toBytes()
       crc.update(byteRepresentationFork.toArrayUnsafe(), 0, byteRepresentationFork.size())
       forkHashes.add(Bytes.ofUnsignedInt(crc.value))
@@ -93,19 +82,11 @@
     return forkHashes
   }
 
-  /**
-   * Get latest fork hash, if known
-   *
-   * @return the hash of the latest fork hash we know of
-   */
-  fun getLatestForkHash(): Bytes? {
-    val hashes = getForkHashes()
-    return if (hashes.isEmpty()) {
-      null
-    } else hashes[hashes.size - 1]
-  }
+  fun getLastestApplicableFork(number: Long): ForkInfo
 }
 
+data class ForkInfo(val next: Long, val hash: Bytes)
+
 /**
  * POJO - constant representation of the blockchain information
  *
@@ -116,15 +97,41 @@
  * @param genesisHash the genesis block hash
  * @param forks known forks
  */
-data class SimpleBlockchainInformation(
+class SimpleBlockchainInformation(
   val networkID: UInt256,
   val totalDifficulty: UInt256,
   val bestHash: Hash,
   val bestNumber: UInt256,
   val genesisHash: Hash,
-  val forks: List<Long>
+  possibleForks: List<Long>,
 ) : BlockchainInformation {
 
+  private val forkIds: List<ForkInfo>
+  private val forks: List<Long>
+
+  init {
+    this.forks = possibleForks.filter { it > 0 }.sorted().distinct()
+    val crc = CRC32()
+    crc.update(genesisHash.toArrayUnsafe())
+    val genesisHashCrc = Bytes.ofUnsignedInt(crc.value)
+    val forkHashes = mutableListOf(genesisHashCrc)
+    for (f in forks) {
+      val byteRepresentationFork = Bytes.ofUnsignedLong(f).toArrayUnsafe()
+      crc.update(byteRepresentationFork, 0, byteRepresentationFork.size)
+      forkHashes.add(Bytes.ofUnsignedInt(crc.value))
+    }
+    val mutableForkIds = mutableListOf<ForkInfo>()
+
+    // This loop is for all the fork hashes that have an associated "next fork"
+    for (i in forks.indices) {
+      mutableForkIds.add(ForkInfo(forks.get(i), forkHashes[i]))
+    }
+    if (forks.isNotEmpty()) {
+      mutableForkIds.add(ForkInfo(0, forkHashes.last()))
+    }
+    this.forkIds = mutableForkIds.toList()
+  }
+
   override fun networkID(): UInt256 = networkID
 
   override fun totalDifficulty(): UInt256 = totalDifficulty
@@ -136,4 +143,13 @@
   override fun genesisHash(): Hash = genesisHash
 
   override fun forks(): List<Long> = forks
+
+  override fun getLastestApplicableFork(number: Long): ForkInfo {
+    for (fork in forkIds) {
+      if (number < fork.next) {
+        return fork
+      }
+    }
+    return forkIds.last()
+  }
 }
diff --git a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler.kt b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler.kt
index 9290232..cfc328e 100644
--- a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler.kt
+++ b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler.kt
@@ -18,9 +18,11 @@
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
 import org.apache.tuweni.bytes.Bytes
 import org.apache.tuweni.concurrent.AsyncCompletion
 import org.apache.tuweni.concurrent.CompletableAsyncCompletion
+import org.apache.tuweni.concurrent.ExpiringMap
 import org.apache.tuweni.concurrent.coroutines.asyncCompletion
 import org.apache.tuweni.devp2p.eth.EthSubprotocol.Companion.ETH62
 import org.apache.tuweni.devp2p.eth.EthSubprotocol.Companion.ETH65
@@ -30,7 +32,6 @@
 import org.apache.tuweni.rlpx.wire.SubProtocolHandler
 import org.apache.tuweni.rlpx.wire.WireConnection
 import org.slf4j.LoggerFactory
-import java.util.WeakHashMap
 import kotlin.collections.ArrayList
 import kotlin.coroutines.CoroutineContext
 
@@ -41,7 +42,7 @@
   private val controller: EthController,
 ) : SubProtocolHandler, CoroutineScope {
 
-  private val pendingStatus = WeakHashMap<String, PeerInfo>()
+  private val pendingStatus = ExpiringMap<String, PeerInfo>(60000)
 
   companion object {
     val logger = LoggerFactory.getLogger(EthHandler::class.java)!!
@@ -227,26 +228,29 @@
     controller.addNewBlockHashes(message.hashes)
   }
 
-  override fun handleNewPeerConnection(connection: WireConnection): AsyncCompletion {
-    val newPeer = pendingStatus.computeIfAbsent(connection.uri()) { PeerInfo() }
-    val ethSubProtocol = connection.agreedSubprotocolVersion(EthSubprotocol.ETH65.name())
-    if (ethSubProtocol == null) {
-      newPeer.cancel()
-      return newPeer.ready
-    }
-    logger.info("Sending status message to ${connection.uri()}")
-    service.send(
-      ethSubProtocol, MessageType.Status.code, connection,
-      StatusMessage(
-        ethSubProtocol.version(),
-        blockchainInfo.networkID(), blockchainInfo.totalDifficulty(),
-        blockchainInfo.bestHash(), blockchainInfo.genesisHash(), blockchainInfo.getLatestForkHash(),
-        blockchainInfo.getLatestFork()
-      ).toBytes()
-    )
+  override fun handleNewPeerConnection(connection: WireConnection): AsyncCompletion =
+    runBlocking {
+      val newPeer = pendingStatus.computeIfAbsent(connection.uri()) { PeerInfo() }
+      val ethSubProtocol = connection.agreedSubprotocolVersion(EthSubprotocol.ETH65.name())
+      if (ethSubProtocol == null) {
+        newPeer.cancel()
+        return@runBlocking newPeer.ready
+      }
+      logger.info("Sending status message to ${connection.uri()}")
+      val forkId =
+        blockchainInfo.getLastestApplicableFork(controller.repository.retrieveChainHeadHeader().number.toLong())
+      service.send(
+        ethSubProtocol, MessageType.Status.code, connection,
+        StatusMessage(
+          ethSubProtocol.version(),
+          blockchainInfo.networkID(), blockchainInfo.totalDifficulty(),
+          blockchainInfo.bestHash(), blockchainInfo.genesisHash(), forkId.hash,
+          forkId.next
+        ).toBytes()
+      )
 
-    return newPeer.ready
-  }
+      return@runBlocking newPeer.ready
+    }
 
   override fun stop() = asyncCompletion {
   }
diff --git a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler66.kt b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler66.kt
index c45ad6b..d6553cf 100644
--- a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler66.kt
+++ b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHandler66.kt
@@ -18,8 +18,10 @@
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
 import org.apache.tuweni.bytes.Bytes
 import org.apache.tuweni.concurrent.AsyncCompletion
+import org.apache.tuweni.concurrent.ExpiringMap
 import org.apache.tuweni.concurrent.coroutines.asyncCompletion
 import org.apache.tuweni.concurrent.coroutines.await
 import org.apache.tuweni.eth.Hash
@@ -30,7 +32,6 @@
 import org.apache.tuweni.rlpx.wire.WireConnection
 import org.apache.tuweni.units.bigints.UInt64
 import org.slf4j.LoggerFactory
-import java.util.WeakHashMap
 import kotlin.collections.ArrayList
 import kotlin.coroutines.CoroutineContext
 
@@ -42,7 +43,7 @@
 ) : SubProtocolHandler, CoroutineScope {
 
   private val ethHandler = EthHandler(coroutineContext, blockchainInfo, service, controller)
-  private val pendingStatus = WeakHashMap<String, PeerInfo>()
+  private val pendingStatus = ExpiringMap<String, PeerInfo>(60000)
 
   companion object {
     val logger = LoggerFactory.getLogger(EthHandler66::class.java)!!
@@ -63,9 +64,9 @@
       val payload = pair.second
 
       when (messageType) {
-        MessageType.Status.code -> handleStatus(connection, StatusMessage.read(payload))
-        MessageType.NewBlockHashes.code -> handleNewBlockHashes(NewBlockHashes.read(payload))
-        MessageType.Transactions.code -> handleTransactions(Transactions.read(payload))
+        MessageType.Status.code -> handleStatus(connection, StatusMessage.read(message))
+        MessageType.NewBlockHashes.code -> handleNewBlockHashes(NewBlockHashes.read(message))
+        MessageType.Transactions.code -> handleTransactions(Transactions.read(message))
         MessageType.GetBlockHeaders.code -> handleGetBlockHeaders(
           connection,
           requestIdentifier,
@@ -78,13 +79,13 @@
           GetBlockBodies.read(payload)
         )
         MessageType.BlockBodies.code -> handleBlockBodies(connection, requestIdentifier, BlockBodies.read(payload))
-        MessageType.NewBlock.code -> handleNewBlock(NewBlock.read(payload))
+        MessageType.NewBlock.code -> handleNewBlock(NewBlock.read(message))
         MessageType.GetNodeData.code -> handleGetNodeData(connection, requestIdentifier, GetNodeData.read(payload))
         MessageType.NodeData.code -> handleNodeData(connection, requestIdentifier, NodeData.read(payload))
         MessageType.GetReceipts.code -> handleGetReceipts(connection, requestIdentifier, GetReceipts.read(payload))
         MessageType.Receipts.code -> handleReceipts(connection, requestIdentifier, Receipts.read(payload))
         MessageType.NewPooledTransactionHashes.code -> handleNewPooledTransactionHashes(
-          connection, NewPooledTransactionHashes.read(payload)
+          connection, NewPooledTransactionHashes.read(message)
         )
         MessageType.GetPooledTransactions.code -> handleGetPooledTransactions(
           connection, requestIdentifier,
@@ -272,32 +273,33 @@
     controller.addNewBlockHashes(message.hashes)
   }
 
-  override fun handleNewPeerConnection(connection: WireConnection): AsyncCompletion {
+  override fun handleNewPeerConnection(connection: WireConnection): AsyncCompletion = runBlocking {
     if (connection.agreedSubprotocolVersion(EthSubprotocol.ETH66.name()) != EthSubprotocol.ETH66) {
-      return ethHandler.handleNewPeerConnection(connection)
+      logger.debug("Downgrade connection from eth/66 to eth65")
+      return@runBlocking ethHandler.handleNewPeerConnection(connection)
     }
-    val newPeer = pendingStatus.computeIfAbsent(connection.uri()) { PeerInfo() }
+    val newPeer = pendingStatus.computeIfAbsent(connection.uri()) {
+      logger.debug("Register a new peer ${connection.uri()}")
+      PeerInfo()
+    }
     val ethSubProtocol = connection.agreedSubprotocolVersion(EthSubprotocol.ETH66.name())
     if (ethSubProtocol == null) {
       newPeer.cancel()
-      return newPeer.ready
+      return@runBlocking newPeer.ready
     }
+    val forkId =
+      blockchainInfo.getLastestApplicableFork(controller.repository.retrieveChainHeadHeader().number.toLong())
     service.send(
       ethSubProtocol, MessageType.Status.code, connection,
-      RLP.encodeList {
-        it.writeValue(UInt64.random().toBytes())
-        it.writeRLP(
-          StatusMessage(
-            ethSubProtocol.version(),
-            blockchainInfo.networkID(), blockchainInfo.totalDifficulty(),
-            blockchainInfo.bestHash(), blockchainInfo.genesisHash(), blockchainInfo.getLatestForkHash(),
-            blockchainInfo.getLatestFork()
-          ).toBytes()
-        )
-      }
+      StatusMessage(
+        ethSubProtocol.version(),
+        blockchainInfo.networkID(), blockchainInfo.totalDifficulty(),
+        blockchainInfo.bestHash(), blockchainInfo.genesisHash(), forkId.hash,
+        forkId.next
+      ).toBytes()
     )
 
-    return newPeer.ready
+    return@runBlocking newPeer.ready
   }
 
   override fun stop() = asyncCompletion {
diff --git a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHelloHandler.kt b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHelloHandler.kt
index da959b8..480937c 100644
--- a/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHelloHandler.kt
+++ b/devp2p-eth/src/main/kotlin/org/apache/tuweni/devp2p/eth/EthHelloHandler.kt
@@ -73,13 +73,15 @@
       newPeer.cancel()
       return newPeer.ready
     }
+    val forkId =
+      blockchainInfo.getLastestApplicableFork(0L)
     service.send(
       ethSubProtocol, MessageType.Status.code, connection,
       StatusMessage(
         ethSubProtocol.version(),
         blockchainInfo.networkID(), blockchainInfo.totalDifficulty(),
-        blockchainInfo.bestHash(), blockchainInfo.genesisHash(), blockchainInfo.getLatestForkHash(),
-        blockchainInfo.getLatestFork()
+        blockchainInfo.bestHash(), blockchainInfo.genesisHash(), forkId.hash,
+        forkId.next
       ).toBytes()
     )
 
diff --git a/devp2p-eth/src/test/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformationTest.kt b/devp2p-eth/src/test/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformationTest.kt
index 080cf2b..af8a532 100644
--- a/devp2p-eth/src/test/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformationTest.kt
+++ b/devp2p-eth/src/test/kotlin/org/apache/tuweni/devp2p/eth/BlockchainInformationTest.kt
@@ -42,7 +42,7 @@
     assertEquals(Bytes.fromHexString("0x3edd5b10"), info.getForkHashes()[4])
     assertEquals(Bytes.fromHexString("0xa00bc324"), info.getForkHashes()[5])
     assertEquals(Bytes.fromHexString("0x668db0af"), info.getForkHashes()[6])
-    assertEquals(Bytes.fromHexString("0xe029e991"), info.getLatestForkHash())
+    assertEquals(Bytes.fromHexString("0x879d6e30"), info.getForkHashes()[7])
   }
 
   @Test
@@ -54,6 +54,35 @@
       UInt256.valueOf(genesisFile.chainId.toLong()), genesisBlock.header.difficulty,
       genesisBlock.header.hash, UInt256.valueOf(42L), genesisBlock.header.hash, genesisFile.forks
     )
-    assertEquals(9200000L, info.getLatestFork())
+    assertEquals(1150000L, info.getLastestApplicableFork(0L).next)
+  }
+
+  @Test
+  fun testRopstenLatest() {
+    val contents = BlockchainInformationTest::class.java.getResourceAsStream("/genesis/ropsten.json").readAllBytes()
+    val genesisFile = GenesisFile.read(contents)
+    val genesisBlock = genesisFile.toBlock()
+    val info = SimpleBlockchainInformation(
+      UInt256.valueOf(genesisFile.chainId.toLong()), genesisBlock.header.difficulty,
+      genesisBlock.header.hash, UInt256.valueOf(42L), genesisBlock.header.hash, genesisFile.forks
+    )
+    assertEquals(Bytes.fromHexString("0x30c7ddbc"), info.getLastestApplicableFork(0L).hash)
+    assertEquals(10L, info.getLastestApplicableFork(0L).next)
+    assertEquals(Bytes.fromHexString("0x63760190"), info.getLastestApplicableFork(11L).hash)
+    assertEquals(1700000L, info.getLastestApplicableFork(11L).next)
+    assertEquals(Bytes.fromHexString("0x3ea159c7"), info.getLastestApplicableFork(1700001L).hash)
+    assertEquals(4230000L, info.getLastestApplicableFork(1700001L).next)
+    assertEquals(Bytes.fromHexString("0x97b544f3"), info.getLastestApplicableFork(4230000L).hash)
+    assertEquals(4939394, info.getLastestApplicableFork(4230000L).next)
+    assertEquals(Bytes.fromHexString("0xd6e2149b"), info.getLastestApplicableFork(4939394).hash)
+    assertEquals(6485846, info.getLastestApplicableFork(4939394).next)
+    assertEquals(Bytes.fromHexString("0x4bc66396"), info.getLastestApplicableFork(6485846).hash)
+    assertEquals(7117117, info.getLastestApplicableFork(6485846).next)
+    assertEquals(Bytes.fromHexString("0x6727ef90"), info.getLastestApplicableFork(7117117).hash)
+    assertEquals(9812189, info.getLastestApplicableFork(7117117).next)
+    assertEquals(Bytes.fromHexString("0xa157d377"), info.getLastestApplicableFork(9812189).hash)
+    assertEquals(10499401, info.getLastestApplicableFork(9812189).next)
+    assertEquals(Bytes.fromHexString("0x7119b6b3"), info.getLastestApplicableFork(10499401).hash)
+    assertEquals(0, info.getLastestApplicableFork(10499401).next)
   }
 }
diff --git a/eth-client-ui/build.gradle b/eth-client-ui/build.gradle
index 6f468ef..64a0bea 100644
--- a/eth-client-ui/build.gradle
+++ b/eth-client-ui/build.gradle
@@ -26,12 +26,16 @@
   implementation 'javax.servlet:javax.servlet-api'
   implementation 'javax.ws.rs:javax.ws.rs-api'
 
+  implementation project(':bytes')
   implementation project(':concurrent')
   implementation project(':config')
   implementation project(':concurrent-coroutines')
   implementation project(':crypto')
+  implementation project(':eth')
   implementation project(':eth-client')
+  implementation project(':eth-repository')
   implementation project(':peer-repository')
+  implementation project(':units')
 
   testImplementation project(':junit')
   testImplementation 'org.bouncycastle:bcprov-jdk15on'
diff --git a/eth-client-ui/src/integrationTest/kotlin/org/apache/tuweni/ethclientui/UIIntegrationTest.kt b/eth-client-ui/src/integrationTest/kotlin/org/apache/tuweni/ethclientui/UIIntegrationTest.kt
index 353fa3e..19f0e0b 100644
--- a/eth-client-ui/src/integrationTest/kotlin/org/apache/tuweni/ethclientui/UIIntegrationTest.kt
+++ b/eth-client-ui/src/integrationTest/kotlin/org/apache/tuweni/ethclientui/UIIntegrationTest.kt
@@ -17,22 +17,42 @@
 package org.apache.tuweni.ethclientui
 
 import io.vertx.core.Vertx
+import kotlinx.coroutines.runBlocking
 import org.apache.tuweni.ethclient.EthereumClient
 import org.apache.tuweni.ethclient.EthereumClientConfig
+import org.apache.tuweni.junit.BouncyCastleExtension
+import org.apache.tuweni.junit.TempDirectory
+import org.apache.tuweni.junit.TempDirectoryExtension
 import org.apache.tuweni.junit.VertxExtension
 import org.apache.tuweni.junit.VertxInstance
+import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Assertions.assertTrue
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
 import java.net.HttpURLConnection
 import java.net.URL
+import java.nio.file.Path
 
-@ExtendWith(VertxExtension::class)
+@ExtendWith(VertxExtension::class, BouncyCastleExtension::class, TempDirectoryExtension::class)
 class UIIntegrationTest {
 
   @Test
-  fun testServerComesUp(@VertxInstance vertx: Vertx) {
-    val ui = UI(client = EthereumClient(vertx, EthereumClientConfig.fromString("[storage.forui]\npath=\"data\"")))
+  fun testServerComesUp(@VertxInstance vertx: Vertx, @TempDirectory tempDir: Path) = runBlocking {
+    val ui = UI(
+      client = EthereumClient(
+        vertx,
+        EthereumClientConfig.fromString(
+          """[storage.default]
+path="${tempDir.toAbsolutePath()}"
+genesis="default"
+[genesis.default]
+path="classpath:/genesis/dev.json"
+[peerRepository.default]
+type="memory""""
+        )
+      )
+    )
+    ui.client.start()
     ui.start()
     val url = URL("http://localhost:" + ui.actualPort)
     val con = url.openConnection() as HttpURLConnection
@@ -45,6 +65,12 @@
     con2.requestMethod = "GET"
     val response2 = con2.inputStream.readAllBytes()
     assertTrue(response2.isNotEmpty())
+    val url3 = URL("http://localhost:" + ui.actualPort + "/rest/state")
+    val con3 = url3.openConnection() as HttpURLConnection
+    con3.requestMethod = "GET"
+    val response3 = con3.inputStream.readAllBytes()
+    assertTrue(response3.isNotEmpty())
+    assertEquals("""{"peerCounts":{"default":0},"bestBlocks":{"default":{"hash":"0xa08d1edb37ba1c62db764ef7c2566cbe368b850f5b3762c6c24114a3fd97b87f","number":"0x0000000000000000000000000000000000000000000000000000000000000000"}}}""", String(response3))
     ui.stop()
   }
 }
diff --git a/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/JSONProvider.kt b/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/JSONProvider.kt
new file mode 100644
index 0000000..365beb5
--- /dev/null
+++ b/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/JSONProvider.kt
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+package org.apache.tuweni.ethclientui
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import jakarta.ws.rs.Produces
+import jakarta.ws.rs.core.Feature
+import jakarta.ws.rs.core.FeatureContext
+import jakarta.ws.rs.core.MediaType
+import jakarta.ws.rs.ext.MessageBodyReader
+import jakarta.ws.rs.ext.MessageBodyWriter
+import jakarta.ws.rs.ext.Provider
+import org.apache.tuweni.eth.EthJsonModule
+import org.glassfish.jersey.jackson.internal.jackson.jaxrs.cfg.Annotations
+import org.glassfish.jersey.jackson.internal.jackson.jaxrs.json.JacksonJaxbJsonProvider
+
+@Provider
+@Produces(MediaType.APPLICATION_JSON)
+class JSONProvider : JacksonJaxbJsonProvider {
+
+  constructor() : super()
+  constructor(vararg annotationsToUse: Annotations?) : super(mapper, annotationsToUse)
+
+  companion object {
+    val mapper = ObjectMapper()
+
+    init {
+      mapper.registerModule(EthJsonModule())
+    }
+  }
+
+  init {
+    setMapper(mapper)
+  }
+}
+
+class MarshallingFeature : Feature {
+  override fun configure(context: FeatureContext): Boolean {
+    context.register(JSONProvider::class.java, MessageBodyReader::class.java, MessageBodyWriter::class.java)
+    return true
+  }
+}
diff --git a/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/StateService.kt b/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/StateService.kt
index f37461e..9eee076 100644
--- a/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/StateService.kt
+++ b/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/StateService.kt
@@ -23,6 +23,13 @@
 import jakarta.ws.rs.Produces
 import jakarta.ws.rs.core.Context
 import jakarta.ws.rs.core.MediaType
+import kotlinx.coroutines.runBlocking
+import org.apache.tuweni.bytes.Bytes32
+import org.apache.tuweni.units.bigints.UInt256
+
+data class BlockHashAndNumber(val hash: Bytes32, val number: UInt256)
+
+data class State(val peerCounts: Map<String, Long>, val bestBlocks: Map<String, BlockHashAndNumber>)
 
 @Path("state")
 class StateService {
@@ -32,12 +39,18 @@
 
   @GET
   @Produces(MediaType.APPLICATION_JSON)
-  fun get(): Map<String, Long> {
+  fun get(): State {
     val client = context!!.getAttribute("ethclient") as EthereumClient
 
-    val pairs = client.peerRepositories.entries.map {
+    val peerCounts = client.peerRepositories.entries.map {
       Pair(it.key, it.value.activeConnections().count())
     }
-    return mapOf(*pairs.toTypedArray())
+    val bestBlocks = client.storageRepositories.entries.map {
+      runBlocking {
+        val bestBlock = it.value.retrieveChainHeadHeader()
+        Pair(it.key, BlockHashAndNumber(bestBlock.hash, bestBlock.number))
+      }
+    }
+    return State(mapOf(*peerCounts.toTypedArray()), mapOf(*bestBlocks.toTypedArray()))
   }
 }
diff --git a/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/UI.kt b/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/UI.kt
index 4157090..fad640c 100644
--- a/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/UI.kt
+++ b/eth-client-ui/src/main/kotlin/org/apache/tuweni/ethclientui/UI.kt
@@ -45,7 +45,7 @@
     ctx.contextPath = path
     newServer.handler = ctx
 
-    val config = ResourceConfig().packages(true, "org.apache.tuweni.ethclientui")
+    val config = ResourceConfig().packages(true, "org.apache.tuweni.ethclientui").register(MarshallingFeature::class.java)
     val holder = ServletHolder(ServletContainer(config))
     holder.initOrder = 1
     ctx.addServlet(holder, "/rest/*")
diff --git a/eth-client/src/main/kotlin/org/apache/tuweni/ethclient/EthereumClient.kt b/eth-client/src/main/kotlin/org/apache/tuweni/ethclient/EthereumClient.kt
index 15bfbed..061b102 100644
--- a/eth-client/src/main/kotlin/org/apache/tuweni/ethclient/EthereumClient.kt
+++ b/eth-client/src/main/kotlin/org/apache/tuweni/ethclient/EthereumClient.kt
@@ -83,8 +83,8 @@
 
   private var metricsService: MetricsService? = null
   private val genesisFiles = mutableMapOf<String, GenesisFile>()
-  private val services = mutableMapOf<String, RLPxService>()
-  private val storageRepositories = mutableMapOf<String, BlockchainRepository>()
+  private val rlpxServices = mutableMapOf<String, RLPxService>()
+  val storageRepositories = mutableMapOf<String, BlockchainRepository>()
   val peerRepositories = mutableMapOf<String, EthereumPeerRepository>()
   private val dnsClients = mutableMapOf<String, DNSClient>()
   private val discoveryServices = mutableMapOf<String, DiscoveryService>()
@@ -198,6 +198,7 @@
       discoveryServices[it.getName()] = discoveryService
       logger.info("Started discovery service ${it.getName()}")
     }
+    val adapters = mutableMapOf<String, WireConnectionPeerRepositoryAdapter>()
 
     AsyncCompletion.allOf(
       config.rlpxServices().map { rlpxConfig ->
@@ -252,7 +253,8 @@
           meter,
           adapter
         )
-        services[rlpxConfig.getName()] = service
+        adapters[rlpxConfig.getName()] = adapter
+        rlpxServices[rlpxConfig.getName()] = service
         peerRepository.addIdentityListener {
           service.connectTo(
             it.publicKey(),
@@ -287,9 +289,9 @@
 
       for (sync in config.synchronizers()) {
         val syncRepository = storageRepositories[sync.getRepository()] ?: throw IllegalArgumentException("Repository ${sync.getRepository()} missing for synchronizer ${sync.getName()}")
-        val syncService = services[sync.getRlpxService()] ?: throw IllegalArgumentException("Service ${sync.getRlpxService()} missing for synchronizer ${sync.getName()}")
+        val syncService = rlpxServices[sync.getRlpxService()] ?: throw IllegalArgumentException("Service ${sync.getRlpxService()} missing for synchronizer ${sync.getName()}")
         val syncPeerRepository = peerRepositories[sync.getPeerRepository()] ?: throw IllegalArgumentException("Peer repository ${sync.getPeerRepository()} missing for synchronizer ${sync.getName()}")
-        val adapter = WireConnectionPeerRepositoryAdapter(syncPeerRepository)
+        val adapter = adapters[sync.getRlpxService()] ?: throw IllegalArgumentException("Service ${sync.getRlpxService()} missing for synchronizer ${sync.getName()}")
 
         when (sync.getType()) {
           SynchronizerType.best -> {
@@ -397,7 +399,7 @@
     managerHandler.forEach {
       it.stop()
     }
-    AsyncCompletion.allOf(services.values.map(RLPxService::stop)).await()
+    AsyncCompletion.allOf(rlpxServices.values.map(RLPxService::stop)).await()
     storageRepositories.values.forEach(BlockchainRepository::close)
     metricsService?.close()
     Unit
diff --git a/eth/src/main/java/org/apache/tuweni/eth/genesis/GenesisFile.java b/eth/src/main/java/org/apache/tuweni/eth/genesis/GenesisFile.java
index a49b474..bad79bf 100644
--- a/eth/src/main/java/org/apache/tuweni/eth/genesis/GenesisFile.java
+++ b/eth/src/main/java/org/apache/tuweni/eth/genesis/GenesisFile.java
@@ -94,14 +94,14 @@
     if (gasLimit == null) {
       throw new IllegalArgumentException("gasLimit must be provided");
     }
-    this.nonce = Bytes.fromHexString(nonce);
+    this.nonce = Bytes.fromHexStringLenient(nonce);
     this.difficulty = UInt256.fromHexString(difficulty);
     this.mixhash = Hash.fromHexString(mixhash);
     this.coinbase = Address.fromHexString(coinbase);
     this.timestamp = "0x0".equals(timestamp) ? Instant.ofEpochSecond(0)
-        : Instant.ofEpochSecond(Bytes.fromHexString(timestamp).toLong());
+        : Instant.ofEpochSecond(Bytes.fromHexStringLenient(timestamp).toLong());
     this.extraData = Bytes.fromHexString(extraData);
-    this.gasLimit = Gas.valueOf(Bytes.fromHexString(gasLimit).toLong());
+    this.gasLimit = Gas.valueOf(Bytes.fromHexStringLenient(gasLimit).toLong());
     this.allocs = new HashMap<>();
     for (Map.Entry<String, String> entry : allocs.entrySet()) {
       Address addr = null;
diff --git a/eth/src/main/resources/genesis/ropsten.json b/eth/src/main/resources/genesis/ropsten.json
index 939e0a3..3912705 100644
--- a/eth/src/main/resources/genesis/ropsten.json
+++ b/eth/src/main/resources/genesis/ropsten.json
@@ -10,6 +10,7 @@
     "muirGlacierBlock": 7117117,
     "berlinBlock": 9812189,
     "londonBlock": 10499401,
+    "terminalTotalDifficulty": 50000000000000000,
     "ethash": {
     },
     "discovery": {
@@ -20,6 +21,11 @@
         "enode://30b7ab30a01c124a6cceca36863ece12c4f5fa68e3ba9b0b51407ccc002eeed3b3102d20a88f1c1d3c3154e2449317b8ef95090e77b312d5cc39354f86d5d606@52.176.7.10:30303",
         "enode://865a63255b3bb68023b6bffd5095118fcc13e79dcf014fe4e47e065c350c7cc72af2e53eff895f11ba1bbb6a2b33271c1116ee870f266618eadfc2e78aa7349c@52.176.100.77:30303"
       ]
+    },
+    "checkpoint": {
+      "hash": "0x43de216f876d897e59b9757dd24186e5b53be28bc425ca6a966335b48daaa50c",
+      "number": 12200000,
+      "totalDifficulty": "0x928D05243C1CF4"
     }
   },
   "nonce": "0x0000000000000042",
@@ -885,4 +891,4 @@
       "balance": "1000000000000000000000000000000"
     }
   }
-}
\ No newline at end of file
+}
diff --git a/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DefaultWireConnection.java b/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DefaultWireConnection.java
index 73e1f8b..032efd3 100644
--- a/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DefaultWireConnection.java
+++ b/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DefaultWireConnection.java
@@ -170,9 +170,9 @@
       return;
     } else if (message.messageId() == 1) {
       DisconnectMessage disconnect = DisconnectMessage.read(message.content());
-      logger.debug("Received disconnect {}", disconnect);
+      logger.debug("Received disconnect {} {}", disconnect.disconnectReason(), uri());
       disconnectReceived = true;
-      disconnectReason = DisconnectReason.valueOf(disconnect.reason());
+      disconnectReason = disconnect.disconnectReason();
       disconnectHandler.run();
       if (!ready.isDone()) {
         ready.complete(this); // Return the connection as is.
@@ -315,7 +315,7 @@
   }
 
   public void sendMessage(SubProtocolIdentifier subProtocolIdentifier, int messageType, Bytes message) {
-    logger.trace("Sending sub-protocol message {} {}", messageType, message);
+    logger.trace("Sending sub-protocol message {} {} {}", messageType, message, uri());
     Integer offset = null;
     for (Map.Entry<Range<Integer>, SubProtocolIdentifier> entry : subprotocolRangeMap.asMapOfRanges().entrySet()) {
       if (entry.getValue().equals(subProtocolIdentifier)) {
diff --git a/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DisconnectMessage.java b/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DisconnectMessage.java
index ec5b65c..08634b4 100644
--- a/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DisconnectMessage.java
+++ b/rlpx/src/main/java/org/apache/tuweni/rlpx/wire/DisconnectMessage.java
@@ -37,19 +37,19 @@
     }
   }
 
-  private final int reason;
-
-  DisconnectMessage(DisconnectReason reason) {
-    this(reason.code);
-  }
+  private final DisconnectReason reason;
 
   DisconnectMessage(int reason) {
+    this(DisconnectReason.valueOf(reason));
+  }
+
+  DisconnectMessage(DisconnectReason reason) {
     this.reason = reason;
   }
 
   @Override
   public Bytes toBytes() {
-    return RLP.encodeList(writer -> writer.writeInt(reason));
+    return RLP.encodeList(writer -> writer.writeInt(reason.code));
   }
 
   @Override
@@ -58,11 +58,15 @@
   }
 
   int reason() {
+    return reason.code;
+  }
+
+  DisconnectReason disconnectReason() {
     return reason;
   }
 
   @Override
   public String toString() {
-    return "DisconnectMessage reason=" + DisconnectReason.valueOf(reason).text;
+    return "DisconnectMessage reason=" + reason.text;
   }
 }