From ffe80138754523f43d2810f97bfa0d87ab29a13d Mon Sep 17 00:00:00 2001 From: Gaurav Ahuja Date: Mon, 1 Jun 2026 17:53:05 -0400 Subject: [PATCH 1/5] feat(coordinator): add debug_executionWitness JSON-RPC client Introduce ExecutionWitnessClient in jvm-libs with a coordinator implementation that calls Besu's debug_executionWitness RPC. Extend BlockParameter with BlockHash for hash-based block references aligned with Besu's string param parsing. --- .../execution-witness-client/README.md | 55 +++++ .../execution-witness-client/build.gradle | 16 ++ .../BlockParameterExtensions.kt | 11 + .../ExecutionWitnessJsonRpcClient.kt | 69 ++++++ .../ExecutionWitnessResponseParser.kt | 63 ++++++ .../ExecutionWitnessJsonRpcClientTest.kt | 204 ++++++++++++++++++ .../linea/clients/interfaces/build.gradle | 1 + .../extensions/EthApiBlockClientExtensions.kt | 17 +- .../executionwitness/ExecutionWitness.kt | 41 ++++ .../ExecutionWitnessClient.kt | 12 ++ .../kotlin/linea/ethapi/FakeEthApiClient.kt | 4 + .../FakeExecutionWitnessClient.kt | 25 +++ .../kotlin/linea/domain/BlockParameter.kt | 50 ++++- .../kotlin/linea/domain/BlockParameterTest.kt | 24 +++ .../web3j/domain/BlockParameterExtensions.kt | 2 + settings.gradle | 1 + 16 files changed, 580 insertions(+), 15 deletions(-) create mode 100644 coordinator/clients/execution-witness-client/README.md create mode 100644 coordinator/clients/execution-witness-client/build.gradle create mode 100644 coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt create mode 100644 coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt create mode 100644 coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt create mode 100644 coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt create mode 100644 jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt create mode 100644 jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt create mode 100644 jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt diff --git a/coordinator/clients/execution-witness-client/README.md b/coordinator/clients/execution-witness-client/README.md new file mode 100644 index 00000000000..08cc5edd2c1 --- /dev/null +++ b/coordinator/clients/execution-witness-client/README.md @@ -0,0 +1,55 @@ +# Execution witness JSON-RPC client + +Kotlin client for Besu's `debug_executionWitness` RPC (provided by [besu-zkevm-plugin](https://github.com/Consensys/besu-zkevm-plugin)). + +## Interface + +[`ExecutionWitnessClient`](../../../jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt) in `jvm-libs:linea:clients:interfaces`. + +Implementation: [`ExecutionWitnessJsonRpcClient`](src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt). + +## RPC + +**Request** + +```json +{ + "jsonrpc": "2.0", + "method": "debug_executionWitness", + "params": [""], + "id": 1 +} +``` + +**Response** (`result` object, or `null` if witness unavailable): + +```json +{ + "state": ["..."], + "keys": ["..."], + "codes": ["..."], + "headers": ["..."] +} +``` + +Hex strings may be with or without a `0x` prefix. + +## Block parameter mapping + +| `BlockParameter` | RPC `params[0]` | +|------------------|-----------------| +| `Tag.LATEST` (etc.) | tag string | +| `BlockNumber(n)` | decimal `n.toString()` | +| `BlockHash(h)` | `0x` + hex (32 bytes) | + +## Node prerequisites + +- `besu-zkevm-plugin` loaded +- Trie-log plugin enabled +- `--rpc-http-api=DEBUG` + +## Tests + +```bash +./gradlew :coordinator:clients:execution-witness-client:test +``` diff --git a/coordinator/clients/execution-witness-client/build.gradle b/coordinator/clients/execution-witness-client/build.gradle new file mode 100644 index 00000000000..abf2ef10625 --- /dev/null +++ b/coordinator/clients/execution-witness-client/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'net.consensys.zkevm.kotlin-library-conventions' +} + +dependencies { + implementation project(':jvm-libs:linea:clients:interfaces') + implementation project(':jvm-libs:generic:extensions:futures') + implementation project(':jvm-libs:generic:extensions:kotlin') + implementation project(':jvm-libs:generic:json-rpc') + implementation project(':jvm-libs:generic:errors') + api "io.vertx:vertx-core" + + testImplementation "io.vertx:vertx-junit5" + testImplementation project(':jvm-libs:linea:metrics:micrometer') + testImplementation "org.wiremock:wiremock:${libs.versions.wiremock.get()}" +} diff --git a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt new file mode 100644 index 00000000000..50d5babb98e --- /dev/null +++ b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt @@ -0,0 +1,11 @@ +package linea.coordinator.clients.executionwitness + +import linea.domain.BlockParameter +import linea.kotlin.encodeHex + +internal fun BlockParameter.toDebugExecutionWitnessRpcParam(): String = + when (this) { + is BlockParameter.Tag -> getTag() + is BlockParameter.BlockNumber -> getNumber().toString() + is BlockParameter.BlockHash -> getHash().encodeHex(prefix = true) + } diff --git a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt new file mode 100644 index 00000000000..122b18e52b3 --- /dev/null +++ b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt @@ -0,0 +1,69 @@ +package linea.coordinator.clients.executionwitness + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.fold +import io.vertx.core.Vertx +import linea.domain.BlockParameter +import linea.error.ErrorResponse +import linea.executionwitness.ExecutionWitness +import linea.executionwitness.ExecutionWitnessClient +import linea.executionwitness.ExecutionWitnessError +import net.consensys.linea.async.toSafeFuture +import net.consensys.linea.jsonrpc.JsonRpcRequestListParams +import net.consensys.linea.jsonrpc.client.JsonRpcClient +import net.consensys.linea.jsonrpc.client.JsonRpcRequestRetryer +import net.consensys.linea.jsonrpc.client.RequestRetryConfig +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import tech.pegasys.teku.infrastructure.async.SafeFuture +import java.util.concurrent.atomic.AtomicInteger + +class ExecutionWitnessJsonRpcClient( + private val rpcClient: JsonRpcClient, +) : ExecutionWitnessClient { + + constructor( + vertx: Vertx, + rpcClient: JsonRpcClient, + retryConfig: RequestRetryConfig, + log: Logger = LogManager.getLogger(ExecutionWitnessJsonRpcClient::class.java), + ) : this( + JsonRpcRequestRetryer( + vertx, + rpcClient, + config = JsonRpcRequestRetryer.Config( + methodsToRetry = retryableMethods, + requestRetry = retryConfig, + ), + log = log, + ), + ) + + private val requestId = AtomicInteger(0) + + override fun getExecutionWitness( + block: BlockParameter, + ): SafeFuture>> { + val jsonRequest = JsonRpcRequestListParams( + jsonrpc = "2.0", + id = requestId.incrementAndGet(), + method = "debug_executionWitness", + params = listOf(block.toDebugExecutionWitnessRpcParam()), + ) + + return rpcClient + .makeRequest(jsonRequest) + .toSafeFuture() + .thenApply { responseResult -> + responseResult.fold( + { success -> ExecutionWitnessResponseParser.parseExecutionWitness(success) }, + { error -> Err(ExecutionWitnessResponseParser.mapRpcError(error)) }, + ) + } + } + + companion object { + internal val retryableMethods = setOf("debug_executionWitness") + } +} diff --git a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt new file mode 100644 index 00000000000..25aa07bac01 --- /dev/null +++ b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt @@ -0,0 +1,63 @@ +package linea.coordinator.clients.executionwitness + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import linea.error.ErrorResponse +import linea.executionwitness.ExecutionWitness +import linea.executionwitness.ExecutionWitnessError +import linea.kotlin.decodeHex +import net.consensys.linea.jsonrpc.JsonRpcErrorResponse +import net.consensys.linea.jsonrpc.JsonRpcSuccessResponse + +object ExecutionWitnessResponseParser { + + fun mapRpcError(jsonRpcErrorResponse: JsonRpcErrorResponse): ErrorResponse { + return ErrorResponse( + ExecutionWitnessError.RPC_ERROR, + "${jsonRpcErrorResponse.error.code}: ${jsonRpcErrorResponse.error.message}", + ) + } + + fun parseExecutionWitness( + jsonRpcResponse: JsonRpcSuccessResponse, + ): Result> { + if (jsonRpcResponse.result == null) { + return Err( + ErrorResponse( + ExecutionWitnessError.NULL_RESULT, + "debug_executionWitness returned null (witness unavailable for block)", + ), + ) + } + + return try { + val json = jsonRpcResponse.result as JsonObject + Ok( + ExecutionWitness( + state = parseHexList(json, "state"), + keys = parseHexList(json, "keys"), + codes = parseHexList(json, "codes"), + headers = parseHexList(json, "headers"), + ), + ) + } catch (throwable: Throwable) { + Err( + ErrorResponse( + ExecutionWitnessError.PARSE_ERROR, + throwable.message ?: "failed to parse execution witness", + ), + ) + } + } + + private fun parseHexList(json: JsonObject, field: String): List { + val array = json.getValue(field) as? JsonArray + ?: throw IllegalArgumentException("missing or invalid field: $field") + return array.map { element -> + (element as String).decodeHex() + } + } +} diff --git a/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt b/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt new file mode 100644 index 00000000000..f2dfc28465d --- /dev/null +++ b/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt @@ -0,0 +1,204 @@ +package linea.coordinator.clients.executionwitness + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.containing +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.vertx.core.Vertx +import io.vertx.core.json.JsonObject +import io.vertx.junit5.VertxExtension +import linea.domain.BlockParameter +import linea.executionwitness.ExecutionWitnessError +import linea.kotlin.decodeHex +import linea.kotlin.encodeHex +import net.consensys.linea.async.get +import net.consensys.linea.jsonrpc.client.RequestRetryConfig +import net.consensys.linea.jsonrpc.client.VertxHttpJsonRpcClientFactory +import net.consensys.linea.metrics.micrometer.MicrometerMetricsFacade +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.net.URI +import java.net.URL +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@ExtendWith(VertxExtension::class) +class ExecutionWitnessJsonRpcClientTest { + private lateinit var wiremock: WireMockServer + private lateinit var client: ExecutionWitnessJsonRpcClient + private lateinit var serverUri: URL + + private val sampleWitnessJson = """ + { + "state": ["f902"], + "keys": ["f844"], + "codes": ["608060"], + "headers": ["f902"] + } + """.trimIndent() + + @BeforeEach + fun setup(vertx: Vertx) { + wiremock = WireMockServer(options().dynamicPort()) + wiremock.start() + serverUri = URI("http://127.0.0.1:${wiremock.port()}").toURL() + + val metricsFacade = MicrometerMetricsFacade(SimpleMeterRegistry(), "linea") + val rpcClient = VertxHttpJsonRpcClientFactory(vertx, metricsFacade).createWithRetries( + serverUri, + methodsToRetry = ExecutionWitnessJsonRpcClient.retryableMethods, + retryConfig = RequestRetryConfig( + maxRetries = 2u, + timeout = 10.seconds, + backoffDelay = 10.milliseconds, + failuresWarningThreshold = 1u, + ), + ) + client = ExecutionWitnessJsonRpcClient(rpcClient) + } + + @AfterEach + fun tearDown() { + wiremock.stop() + } + + @Test + fun `getExecutionWitness returns parsed witness for block number`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .withRequestBody(containing("\"method\":\"debug_executionWitness\"")) + .withRequestBody(containing("\"params\":[\"42\"]")) + .willReturn( + ok( + JsonObject.of( + "jsonrpc", + "2.0", + "id", + 1, + "result", + JsonObject(sampleWitnessJson), + ).encode(), + ), + ), + ) + + val result = client.getExecutionWitness(BlockParameter.BlockNumber(42UL)).get() + + assertThat(result).isEqualTo( + Ok( + linea.executionwitness.ExecutionWitness( + state = listOf("f902".decodeHex()), + keys = listOf("f844".decodeHex()), + codes = listOf("608060".decodeHex()), + headers = listOf("f902".decodeHex()), + ), + ), + ) + wiremock.verify(postRequestedFor(urlEqualTo("/"))) + } + + @Test + fun `getExecutionWitness returns parsed witness for block hash`() { + val hash = ByteArray(32) { 0xab.toByte() } + val hashParam = hash.encodeHex(prefix = true) + + wiremock.stubFor( + post(urlEqualTo("/")) + .withRequestBody(containing("\"params\":[\"$hashParam\"]")) + .willReturn( + ok( + JsonObject.of( + "jsonrpc", + "2.0", + "id", + 1, + "result", + JsonObject(sampleWitnessJson), + ).encode(), + ), + ), + ) + + val result = client.getExecutionWitness(BlockParameter.BlockHash(hash)).get() + + assertThat(result).isInstanceOf(Ok::class.java) + wiremock.verify( + postRequestedFor(urlEqualTo("/")) + .withRequestBody(containing(hashParam)), + ) + } + + @Test + fun `getExecutionWitness returns NULL_RESULT when result is null`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .willReturn( + ok( + JsonObject.of( + "jsonrpc", + "2.0", + "id", + 1, + "result", + null, + ).encode(), + ), + ), + ) + + val result = client.getExecutionWitness(BlockParameter.Tag.LATEST).get() + + assertThat(result).isEqualTo( + Err( + linea.error.ErrorResponse( + ExecutionWitnessError.NULL_RESULT, + "debug_executionWitness returned null (witness unavailable for block)", + ), + ), + ) + } + + @Test + fun `getExecutionWitness returns RPC_ERROR on json-rpc error`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .willReturn( + ok( + JsonObject.of( + "jsonrpc", + "2.0", + "id", + 1, + "error", + JsonObject.of( + "code", + -32603, + "message", + "Internal error", + ), + ).encode(), + ), + ), + ) + + val result = client.getExecutionWitness(BlockParameter.Tag.LATEST).get() + + assertThat(result).isEqualTo( + Err( + linea.error.ErrorResponse( + ExecutionWitnessError.RPC_ERROR, + "-32603: Internal error", + ), + ), + ) + } +} diff --git a/jvm-libs/linea/clients/interfaces/build.gradle b/jvm-libs/linea/clients/interfaces/build.gradle index a14545addad..13340393c27 100644 --- a/jvm-libs/linea/clients/interfaces/build.gradle +++ b/jvm-libs/linea/clients/interfaces/build.gradle @@ -8,6 +8,7 @@ description="Interfaces for interaction with Linea Smart Contract" dependencies { api project(':jvm-libs:linea:core:domain-models') api project(':jvm-libs:linea:core:long-running-service') + api project(':jvm-libs:generic:errors') api project(':jvm-libs:generic:extensions:futures') api project(':jvm-libs:generic:extensions:kotlin') diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/extensions/EthApiBlockClientExtensions.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/extensions/EthApiBlockClientExtensions.kt index 83cfea24149..edcb3ce3099 100644 --- a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/extensions/EthApiBlockClientExtensions.kt +++ b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/extensions/EthApiBlockClientExtensions.kt @@ -5,13 +5,16 @@ import linea.ethapi.EthApiBlockClient import tech.pegasys.teku.infrastructure.async.SafeFuture fun EthApiBlockClient.getBlockParameterNumber(blockParameter: BlockParameter): SafeFuture { - return if (blockParameter is BlockParameter.BlockNumber) { - SafeFuture.completedFuture(blockParameter.getNumber()) - } else if (blockParameter == BlockParameter.Tag.EARLIEST) { - SafeFuture.completedFuture(0UL) - } else { - this.ethGetBlockByNumberTxHashes(blockParameter) - .thenApply { block -> block.number } + return when (blockParameter) { + is BlockParameter.BlockNumber -> SafeFuture.completedFuture(blockParameter.getNumber()) + BlockParameter.Tag.EARLIEST -> SafeFuture.completedFuture(0UL) + is BlockParameter.BlockHash -> + throw UnsupportedOperationException( + "Block hash resolution requires ethGetBlockByHash; blockParameter=$blockParameter", + ) + else -> + this.ethGetBlockByNumberTxHashes(blockParameter) + .thenApply { block -> block.number } } } diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt new file mode 100644 index 00000000000..279474bb0ec --- /dev/null +++ b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt @@ -0,0 +1,41 @@ +package linea.executionwitness + +data class ExecutionWitness( + val state: List, + val keys: List, + val codes: List, + val headers: List, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as ExecutionWitness + return byteArrayListsEqual(state, other.state) && + byteArrayListsEqual(keys, other.keys) && + byteArrayListsEqual(codes, other.codes) && + byteArrayListsEqual(headers, other.headers) + } + + override fun hashCode(): Int { + var result = byteArrayListHashCode(state) + result = 31 * result + byteArrayListHashCode(keys) + result = 31 * result + byteArrayListHashCode(codes) + result = 31 * result + byteArrayListHashCode(headers) + return result + } + + private fun byteArrayListsEqual(a: List, b: List): Boolean { + if (a.size != b.size) return false + return a.indices.all { i -> a[i].contentEquals(b[i]) } + } + + private fun byteArrayListHashCode(list: List): Int { + return list.fold(0) { acc, bytes -> 31 * acc + bytes.contentHashCode() } + } +} + +enum class ExecutionWitnessError { + NULL_RESULT, + RPC_ERROR, + PARSE_ERROR, +} diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt new file mode 100644 index 00000000000..2936a4b54ff --- /dev/null +++ b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt @@ -0,0 +1,12 @@ +package linea.executionwitness + +import com.github.michaelbull.result.Result +import linea.domain.BlockParameter +import linea.error.ErrorResponse +import tech.pegasys.teku.infrastructure.async.SafeFuture + +interface ExecutionWitnessClient { + fun getExecutionWitness( + block: BlockParameter, + ): SafeFuture>> +} diff --git a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt index fa5a0609243..baf13059058 100644 --- a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt +++ b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt @@ -298,6 +298,10 @@ class FakeEthApiClient( ?: throw IllegalArgumentException("Invalid blockParameter=$blockParameter") is BlockParameter.BlockNumber -> blockParameter.getNumber() + + is BlockParameter.BlockHash -> + blocksDb.values.firstOrNull { it.hash.contentEquals(blockParameter.getHash()) }?.number + ?: throw IllegalArgumentException("Block hash not found in fake client: $blockParameter") } } diff --git a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt new file mode 100644 index 00000000000..1849ddc1a3b --- /dev/null +++ b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt @@ -0,0 +1,25 @@ +package linea.executionwitness + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import linea.domain.BlockParameter +import linea.error.ErrorResponse +import tech.pegasys.teku.infrastructure.async.SafeFuture + +class FakeExecutionWitnessClient( + private val witnessesByBlock: Map = emptyMap(), +) : ExecutionWitnessClient { + + override fun getExecutionWitness( + block: BlockParameter, + ): SafeFuture>> { + val witness = witnessesByBlock[block] + ?: return SafeFuture.completedFuture( + Err( + ErrorResponse(ExecutionWitnessError.NULL_RESULT, "no witness configured for block=$block"), + ), + ) + return SafeFuture.completedFuture(Ok(witness)) + } +} diff --git a/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt b/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt index 058f477b759..d3dd9dc4f24 100644 --- a/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt +++ b/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt @@ -1,11 +1,16 @@ package linea.domain +import linea.kotlin.decodeHex +import linea.kotlin.encodeHex + sealed interface BlockParameter { fun getTag(): String fun getNumber(): ULong companion object { + private const val BLOCK_HASH_HEX_LENGTH = 64 + fun fromNumber(blockNumber: Number): BlockNumber { require(blockNumber.toLong() >= 0) { "block number must be greater or equal than 0, value=$blockNumber" } return BlockNumber(blockNumber.toLong().toULong()) @@ -13,18 +18,26 @@ sealed interface BlockParameter { fun fromNumber(blockNumber: ULong): BlockNumber = BlockNumber(blockNumber) + fun fromHash(blockHash: ByteArray): BlockHash = BlockHash(blockHash) + + fun fromHash(blockHashHex: String): BlockHash = BlockHash(blockHashHex.decodeHex()) + fun parse(input: String): BlockParameter { return try { - // Try to parse the input as a tag Tag.fromString(input) } catch (e: IllegalArgumentException) { - // If it's not a valid tag, try to parse it as a block number - val blockNumber = if (input.startsWith("0x")) { - input.drop(2).toULongOrNull(radix = 16) - } else { - input.toULongOrNull(radix = 10) - } ?: throw IllegalArgumentException("Invalid BlockParameter: $input") - + val normalized = input.lowercase() + if (normalized.startsWith("0x")) { + val hexBody = normalized.drop(2) + if (hexBody.length == BLOCK_HASH_HEX_LENGTH) { + return BlockHash("0x$hexBody".decodeHex()) + } + val blockNumber = hexBody.toULongOrNull(radix = 16) + ?: throw IllegalArgumentException("Invalid BlockParameter: $input") + return BlockNumber(blockNumber) + } + val blockNumber = input.toULongOrNull(radix = 10) + ?: throw IllegalArgumentException("Invalid BlockParameter: $input") blockNumber.toBlockParameter() } } @@ -74,4 +87,25 @@ sealed interface BlockParameter { return parameter.toString() } } + + @JvmInline + value class BlockHash(private val hash: ByteArray) : BlockParameter { + init { + require(hash.size == 32) { "block hash must be 32 bytes, got ${hash.size}" } + } + + fun getHash(): ByteArray = hash + + override fun getTag(): String { + throw UnsupportedOperationException("getTag isn't supported on BlockHash!") + } + + override fun getNumber(): ULong { + throw UnsupportedOperationException("getNumber isn't supported on BlockHash!") + } + + override fun toString(): String { + return hash.encodeHex(prefix = true) + } + } } diff --git a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt index 2a9a3731dd9..f12e97b3813 100644 --- a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt +++ b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt @@ -1,5 +1,7 @@ package linea.domain +import linea.kotlin.decodeHex +import linea.kotlin.encodeHex import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test @@ -23,6 +25,28 @@ class BlockParameterTest { assertThat(BlockParameter.parse("0x78")).isEqualTo(BlockParameter.BlockNumber(120UL)) } + @Test + fun `parse should parse block hash`() { + val hashHex = "0x" + "ab".repeat(32) + val expectedHash = hashHex.decodeHex() + val parsed = BlockParameter.parse(hashHex) as BlockParameter.BlockHash + assertThat(parsed.getHash()).isEqualTo(expectedHash) + } + + @Test + fun `fromHash should accept bytes and hex string`() { + val hash = ByteArray(32) { 1 } + assertThat(BlockParameter.fromHash(hash).getHash()).isEqualTo(hash) + assertThat(BlockParameter.fromHash(hash.encodeHex(prefix = true)).getHash()).isEqualTo(hash) + } + + @Test + fun `fromHash should reject invalid hash length`() { + assertThatThrownBy { BlockParameter.fromHash(ByteArray(31)) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("32 bytes") + } + @Test fun `parse should throw InvalidArgument when invalid`() { assertThatThrownBy { BlockParameter.parse("invalid") } diff --git a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt index 8fcbb3c23d1..fe1539e262d 100644 --- a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt +++ b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt @@ -1,6 +1,7 @@ package linea.web3j.domain import linea.domain.BlockParameter +import linea.kotlin.encodeHex import linea.kotlin.toBigInteger import org.web3j.protocol.core.DefaultBlockParameter @@ -8,5 +9,6 @@ fun BlockParameter.toWeb3j(): DefaultBlockParameter { return when (this) { is BlockParameter.Tag -> DefaultBlockParameter.valueOf(this.getTag()) is BlockParameter.BlockNumber -> DefaultBlockParameter.valueOf(this.getNumber().toBigInteger()) + is BlockParameter.BlockHash -> DefaultBlockParameter.valueOf(this.getHash().encodeHex(prefix = true)) } } diff --git a/settings.gradle b/settings.gradle index 1cf3775a2ae..ca91d8454a5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -52,6 +52,7 @@ include 'coordinator:clients:shomei-client' include 'coordinator:clients:smart-contract-client' include 'coordinator:clients:web3signer-client' include 'coordinator:clients:forced-transactions-client' +include 'coordinator:clients:execution-witness-client' include 'coordinator:ethereum:models-helper' include 'coordinator:ethereum:finalization-monitor' include 'coordinator:ethereum:forced-transactions' From 702c568da0ddbd360cdfef94892f9a3e1f39fb47 Mon Sep 17 00:00:00 2001 From: Gaurav Ahuja Date: Mon, 1 Jun 2026 21:44:29 -0400 Subject: [PATCH 2/5] fix(state-recovery): fix build --- .../staterecovery/clients/ExecutionLayerInProcessClient.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt b/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt index 14a5dd8a270..caff4090247 100644 --- a/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt +++ b/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt @@ -10,6 +10,8 @@ import linea.staterecovery.StateRecoveryStatus import linea.staterecovery.plugin.BlockImporter import linea.staterecovery.plugin.RecoveryModeManager import org.apache.logging.log4j.LogManager +import org.apache.tuweni.bytes.Bytes32 +import org.hyperledger.besu.datatypes.Hash import org.hyperledger.besu.plugin.data.BlockHeader import org.hyperledger.besu.plugin.services.BlockSimulationService import org.hyperledger.besu.plugin.services.BlockchainService @@ -67,6 +69,11 @@ class ExecutionLayerInProcessClient( .getBlockByNumber(blockParameter.getNumber().toLong()) .map { it.blockHeader } .getOrNull() + + is BlockParameter.BlockHash -> + blockchainService + .getBlockHeaderByHash(Hash.wrap(Bytes32.wrap(blockParameter.getHash()))) + .getOrNull() } return blockHeader From ab8aa98ddb182eb2f785c3f9469fac75e3b77e43 Mon Sep 17 00:00:00 2001 From: Gaurav Ahuja Date: Mon, 1 Jun 2026 22:17:44 -0400 Subject: [PATCH 3/5] chore(misc): address cursorbot comments --- .../ExecutionWitnessJsonRpcClientTest.kt | 2 +- .../FakeExecutionWitnessClientTest.kt | 28 +++++++++++++++++++ .../kotlin/linea/domain/BlockParameter.kt | 11 ++++++-- .../kotlin/linea/domain/BlockParameterTest.kt | 10 +++++++ .../web3j/domain/BlockParameterExtensions.kt | 6 ++-- .../domain/BlockParameterExtensionsTest.kt | 27 ++++++++++++++++++ 6 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt create mode 100644 jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/domain/BlockParameterExtensionsTest.kt diff --git a/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt b/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt index f2dfc28465d..b6892747bd0 100644 --- a/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt +++ b/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt @@ -128,7 +128,7 @@ class ExecutionWitnessJsonRpcClientTest { ), ) - val result = client.getExecutionWitness(BlockParameter.BlockHash(hash)).get() + val result = client.getExecutionWitness(BlockParameter.fromHash(hash)).get() assertThat(result).isInstanceOf(Ok::class.java) wiremock.verify( diff --git a/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt b/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt new file mode 100644 index 00000000000..3a47beba7ab --- /dev/null +++ b/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt @@ -0,0 +1,28 @@ +package linea.executionwitness + +import com.github.michaelbull.result.getOrElse +import linea.domain.BlockParameter +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class FakeExecutionWitnessClientTest { + + @Test + fun `getExecutionWitness should find witness by block hash key`() { + val blockHash = BlockParameter.fromHash(ByteArray(32) { 3 }) + val witness = ExecutionWitness( + state = listOf(byteArrayOf(1)), + keys = emptyList(), + codes = emptyList(), + headers = emptyList(), + ) + val client = FakeExecutionWitnessClient( + witnessesByBlock = mapOf(blockHash to witness), + ) + + val result = client.getExecutionWitness(BlockParameter.fromHash(blockHash.getHash().copyOf())).get() + .getOrElse { error("unexpected error: $it") } + + assertThat(result).isEqualTo(witness) + } +} diff --git a/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt b/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt index d3dd9dc4f24..6a543f13f95 100644 --- a/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt +++ b/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt @@ -88,8 +88,7 @@ sealed interface BlockParameter { } } - @JvmInline - value class BlockHash(private val hash: ByteArray) : BlockParameter { + data class BlockHash(private val hash: ByteArray) : BlockParameter { init { require(hash.size == 32) { "block hash must be 32 bytes, got ${hash.size}" } } @@ -104,6 +103,14 @@ sealed interface BlockParameter { throw UnsupportedOperationException("getNumber isn't supported on BlockHash!") } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BlockHash) return false + return hash.contentEquals(other.hash) + } + + override fun hashCode(): Int = hash.contentHashCode() + override fun toString(): String { return hash.encodeHex(prefix = true) } diff --git a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt index f12e97b3813..bd419c83536 100644 --- a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt +++ b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt @@ -40,6 +40,16 @@ class BlockParameterTest { assertThat(BlockParameter.fromHash(hash.encodeHex(prefix = true)).getHash()).isEqualTo(hash) } + @Test + fun `BlockHash should use content-based equality`() { + val hashBytes = ByteArray(32) { 7 } + val a = BlockParameter.fromHash(hashBytes) + val b = BlockParameter.fromHash(hashBytes.copyOf()) + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + assertThat(BlockParameter.fromHash(ByteArray(32) { 8 })).isNotEqualTo(a) + } + @Test fun `fromHash should reject invalid hash length`() { assertThatThrownBy { BlockParameter.fromHash(ByteArray(31)) } diff --git a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt index fe1539e262d..8c4374ade7d 100644 --- a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt +++ b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt @@ -1,7 +1,6 @@ package linea.web3j.domain import linea.domain.BlockParameter -import linea.kotlin.encodeHex import linea.kotlin.toBigInteger import org.web3j.protocol.core.DefaultBlockParameter @@ -9,6 +8,9 @@ fun BlockParameter.toWeb3j(): DefaultBlockParameter { return when (this) { is BlockParameter.Tag -> DefaultBlockParameter.valueOf(this.getTag()) is BlockParameter.BlockNumber -> DefaultBlockParameter.valueOf(this.getNumber().toBigInteger()) - is BlockParameter.BlockHash -> DefaultBlockParameter.valueOf(this.getHash().encodeHex(prefix = true)) + is BlockParameter.BlockHash -> + throw UnsupportedOperationException( + "Web3j DefaultBlockParameter does not support block hash; blockParameter=$this", + ) } } diff --git a/jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/domain/BlockParameterExtensionsTest.kt b/jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/domain/BlockParameterExtensionsTest.kt new file mode 100644 index 00000000000..43f23661a34 --- /dev/null +++ b/jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/domain/BlockParameterExtensionsTest.kt @@ -0,0 +1,27 @@ +package linea.web3j.domain + +import linea.domain.BlockParameter +import linea.kotlin.toBigInteger +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.web3j.protocol.core.DefaultBlockParameter +import org.web3j.protocol.core.DefaultBlockParameterName + +class BlockParameterExtensionsTest { + + @Test + fun `toWeb3j should map tags and block numbers`() { + assertThat(BlockParameter.Tag.LATEST.toWeb3j()).isEqualTo(DefaultBlockParameterName.LATEST) + assertThat(BlockParameter.BlockNumber(42UL).toWeb3j().getValue()) + .isEqualTo(DefaultBlockParameter.valueOf(42.toBigInteger()).getValue()) + } + + @Test + fun `toWeb3j should reject block hash`() { + val blockHash = BlockParameter.fromHash(ByteArray(32) { 1 }) + assertThatThrownBy { blockHash.toWeb3j() } + .isInstanceOf(UnsupportedOperationException::class.java) + .hasMessageContaining("does not support block hash") + } +} From 04645549cc82bbe6252ac374d7ba53dfacde4bd1 Mon Sep 17 00:00:00 2001 From: Gaurav Ahuja Date: Tue, 2 Jun 2026 09:10:04 -0400 Subject: [PATCH 4/5] chore(misc): add BlockParameter parse test --- .../src/test/kotlin/linea/domain/BlockParameterTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt index bd419c83536..2f1c54175b7 100644 --- a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt +++ b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt @@ -33,6 +33,14 @@ class BlockParameterTest { assertThat(parsed.getHash()).isEqualTo(expectedHash) } + @Test + fun `parse should parse block hash from encoded byte array`() { + val expectedHash = ByteArray(32) { index -> (index + 1).toByte() } + val hashHex = expectedHash.encodeHex(prefix = true) + val parsed = BlockParameter.parse(hashHex) as BlockParameter.BlockHash + assertThat(parsed.getHash()).isEqualTo(expectedHash) + } + @Test fun `fromHash should accept bytes and hex string`() { val hash = ByteArray(32) { 1 } From b1b057084145b60c5ccbe087e2f75cf03fe9a1a0 Mon Sep 17 00:00:00 2001 From: Gaurav Ahuja Date: Sun, 7 Jun 2026 22:31:04 -0400 Subject: [PATCH 5/5] chore(misc): address review comments --- .../execution-witness-client/README.md | 55 ----- .../execution-witness-client/build.gradle | 16 -- .../BlockParameterExtensions.kt | 11 - .../ExecutionWitnessJsonRpcClient.kt | 69 ------ .../ExecutionWitnessResponseParser.kt | 63 ------ .../ExecutionWitnessJsonRpcClientTest.kt | 204 ------------------ .../main/kotlin/linea/ethapi/EthApiClient.kt | 3 +- .../linea/ethapi/ExecutionWitnessClient.kt | 48 +++++ .../executionwitness/ExecutionWitness.kt | 41 ---- .../ExecutionWitnessClient.kt | 12 -- .../linea/ethapi/FakeEthApiClientTest.kt | 19 ++ .../FakeExecutionWitnessClientTest.kt | 28 --- .../kotlin/linea/ethapi/FakeEthApiClient.kt | 15 +- .../FakeExecutionWitnessClient.kt | 25 --- .../kotlin/linea/domain/BlockParameter.kt | 37 ++-- .../kotlin/linea/domain/BlockParameterTest.kt | 14 +- jvm-libs/linea/web3j-extensions/build.gradle | 3 + .../ethapi/ExecutionWitnessResponseParser.kt | 38 ++++ .../linea/web3j/ethapi/Web3jEthApiClient.kt | 4 +- .../ethapi/Web3jEthApiClientWithRetries.kt | 7 + .../ethapi/Web3jExecutionWitnessClient.kt | 73 +++++++ .../ethapi/Web3jExecutionWitnessClientTest.kt | 181 ++++++++++++++++ .../clients/ExecutionLayerInProcessClient.kt | 2 +- settings.gradle | 1 - 24 files changed, 420 insertions(+), 549 deletions(-) delete mode 100644 coordinator/clients/execution-witness-client/README.md delete mode 100644 coordinator/clients/execution-witness-client/build.gradle delete mode 100644 coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt delete mode 100644 coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt delete mode 100644 coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt delete mode 100644 coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt create mode 100644 jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/ExecutionWitnessClient.kt delete mode 100644 jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt delete mode 100644 jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt delete mode 100644 jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt delete mode 100644 jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt create mode 100644 jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/ExecutionWitnessResponseParser.kt create mode 100644 jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClient.kt create mode 100644 jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClientTest.kt diff --git a/coordinator/clients/execution-witness-client/README.md b/coordinator/clients/execution-witness-client/README.md deleted file mode 100644 index 08cc5edd2c1..00000000000 --- a/coordinator/clients/execution-witness-client/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Execution witness JSON-RPC client - -Kotlin client for Besu's `debug_executionWitness` RPC (provided by [besu-zkevm-plugin](https://github.com/Consensys/besu-zkevm-plugin)). - -## Interface - -[`ExecutionWitnessClient`](../../../jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt) in `jvm-libs:linea:clients:interfaces`. - -Implementation: [`ExecutionWitnessJsonRpcClient`](src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt). - -## RPC - -**Request** - -```json -{ - "jsonrpc": "2.0", - "method": "debug_executionWitness", - "params": [""], - "id": 1 -} -``` - -**Response** (`result` object, or `null` if witness unavailable): - -```json -{ - "state": ["..."], - "keys": ["..."], - "codes": ["..."], - "headers": ["..."] -} -``` - -Hex strings may be with or without a `0x` prefix. - -## Block parameter mapping - -| `BlockParameter` | RPC `params[0]` | -|------------------|-----------------| -| `Tag.LATEST` (etc.) | tag string | -| `BlockNumber(n)` | decimal `n.toString()` | -| `BlockHash(h)` | `0x` + hex (32 bytes) | - -## Node prerequisites - -- `besu-zkevm-plugin` loaded -- Trie-log plugin enabled -- `--rpc-http-api=DEBUG` - -## Tests - -```bash -./gradlew :coordinator:clients:execution-witness-client:test -``` diff --git a/coordinator/clients/execution-witness-client/build.gradle b/coordinator/clients/execution-witness-client/build.gradle deleted file mode 100644 index abf2ef10625..00000000000 --- a/coordinator/clients/execution-witness-client/build.gradle +++ /dev/null @@ -1,16 +0,0 @@ -plugins { - id 'net.consensys.zkevm.kotlin-library-conventions' -} - -dependencies { - implementation project(':jvm-libs:linea:clients:interfaces') - implementation project(':jvm-libs:generic:extensions:futures') - implementation project(':jvm-libs:generic:extensions:kotlin') - implementation project(':jvm-libs:generic:json-rpc') - implementation project(':jvm-libs:generic:errors') - api "io.vertx:vertx-core" - - testImplementation "io.vertx:vertx-junit5" - testImplementation project(':jvm-libs:linea:metrics:micrometer') - testImplementation "org.wiremock:wiremock:${libs.versions.wiremock.get()}" -} diff --git a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt deleted file mode 100644 index 50d5babb98e..00000000000 --- a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/BlockParameterExtensions.kt +++ /dev/null @@ -1,11 +0,0 @@ -package linea.coordinator.clients.executionwitness - -import linea.domain.BlockParameter -import linea.kotlin.encodeHex - -internal fun BlockParameter.toDebugExecutionWitnessRpcParam(): String = - when (this) { - is BlockParameter.Tag -> getTag() - is BlockParameter.BlockNumber -> getNumber().toString() - is BlockParameter.BlockHash -> getHash().encodeHex(prefix = true) - } diff --git a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt deleted file mode 100644 index 122b18e52b3..00000000000 --- a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClient.kt +++ /dev/null @@ -1,69 +0,0 @@ -package linea.coordinator.clients.executionwitness - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.fold -import io.vertx.core.Vertx -import linea.domain.BlockParameter -import linea.error.ErrorResponse -import linea.executionwitness.ExecutionWitness -import linea.executionwitness.ExecutionWitnessClient -import linea.executionwitness.ExecutionWitnessError -import net.consensys.linea.async.toSafeFuture -import net.consensys.linea.jsonrpc.JsonRpcRequestListParams -import net.consensys.linea.jsonrpc.client.JsonRpcClient -import net.consensys.linea.jsonrpc.client.JsonRpcRequestRetryer -import net.consensys.linea.jsonrpc.client.RequestRetryConfig -import org.apache.logging.log4j.LogManager -import org.apache.logging.log4j.Logger -import tech.pegasys.teku.infrastructure.async.SafeFuture -import java.util.concurrent.atomic.AtomicInteger - -class ExecutionWitnessJsonRpcClient( - private val rpcClient: JsonRpcClient, -) : ExecutionWitnessClient { - - constructor( - vertx: Vertx, - rpcClient: JsonRpcClient, - retryConfig: RequestRetryConfig, - log: Logger = LogManager.getLogger(ExecutionWitnessJsonRpcClient::class.java), - ) : this( - JsonRpcRequestRetryer( - vertx, - rpcClient, - config = JsonRpcRequestRetryer.Config( - methodsToRetry = retryableMethods, - requestRetry = retryConfig, - ), - log = log, - ), - ) - - private val requestId = AtomicInteger(0) - - override fun getExecutionWitness( - block: BlockParameter, - ): SafeFuture>> { - val jsonRequest = JsonRpcRequestListParams( - jsonrpc = "2.0", - id = requestId.incrementAndGet(), - method = "debug_executionWitness", - params = listOf(block.toDebugExecutionWitnessRpcParam()), - ) - - return rpcClient - .makeRequest(jsonRequest) - .toSafeFuture() - .thenApply { responseResult -> - responseResult.fold( - { success -> ExecutionWitnessResponseParser.parseExecutionWitness(success) }, - { error -> Err(ExecutionWitnessResponseParser.mapRpcError(error)) }, - ) - } - } - - companion object { - internal val retryableMethods = setOf("debug_executionWitness") - } -} diff --git a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt b/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt deleted file mode 100644 index 25aa07bac01..00000000000 --- a/coordinator/clients/execution-witness-client/src/main/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessResponseParser.kt +++ /dev/null @@ -1,63 +0,0 @@ -package linea.coordinator.clients.executionwitness - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import io.vertx.core.json.JsonArray -import io.vertx.core.json.JsonObject -import linea.error.ErrorResponse -import linea.executionwitness.ExecutionWitness -import linea.executionwitness.ExecutionWitnessError -import linea.kotlin.decodeHex -import net.consensys.linea.jsonrpc.JsonRpcErrorResponse -import net.consensys.linea.jsonrpc.JsonRpcSuccessResponse - -object ExecutionWitnessResponseParser { - - fun mapRpcError(jsonRpcErrorResponse: JsonRpcErrorResponse): ErrorResponse { - return ErrorResponse( - ExecutionWitnessError.RPC_ERROR, - "${jsonRpcErrorResponse.error.code}: ${jsonRpcErrorResponse.error.message}", - ) - } - - fun parseExecutionWitness( - jsonRpcResponse: JsonRpcSuccessResponse, - ): Result> { - if (jsonRpcResponse.result == null) { - return Err( - ErrorResponse( - ExecutionWitnessError.NULL_RESULT, - "debug_executionWitness returned null (witness unavailable for block)", - ), - ) - } - - return try { - val json = jsonRpcResponse.result as JsonObject - Ok( - ExecutionWitness( - state = parseHexList(json, "state"), - keys = parseHexList(json, "keys"), - codes = parseHexList(json, "codes"), - headers = parseHexList(json, "headers"), - ), - ) - } catch (throwable: Throwable) { - Err( - ErrorResponse( - ExecutionWitnessError.PARSE_ERROR, - throwable.message ?: "failed to parse execution witness", - ), - ) - } - } - - private fun parseHexList(json: JsonObject, field: String): List { - val array = json.getValue(field) as? JsonArray - ?: throw IllegalArgumentException("missing or invalid field: $field") - return array.map { element -> - (element as String).decodeHex() - } - } -} diff --git a/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt b/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt deleted file mode 100644 index b6892747bd0..00000000000 --- a/coordinator/clients/execution-witness-client/src/test/kotlin/linea/coordinator/clients/executionwitness/ExecutionWitnessJsonRpcClientTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -package linea.coordinator.clients.executionwitness - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.tomakehurst.wiremock.WireMockServer -import com.github.tomakehurst.wiremock.client.WireMock.containing -import com.github.tomakehurst.wiremock.client.WireMock.ok -import com.github.tomakehurst.wiremock.client.WireMock.post -import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor -import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo -import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options -import io.micrometer.core.instrument.simple.SimpleMeterRegistry -import io.vertx.core.Vertx -import io.vertx.core.json.JsonObject -import io.vertx.junit5.VertxExtension -import linea.domain.BlockParameter -import linea.executionwitness.ExecutionWitnessError -import linea.kotlin.decodeHex -import linea.kotlin.encodeHex -import net.consensys.linea.async.get -import net.consensys.linea.jsonrpc.client.RequestRetryConfig -import net.consensys.linea.jsonrpc.client.VertxHttpJsonRpcClientFactory -import net.consensys.linea.metrics.micrometer.MicrometerMetricsFacade -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import java.net.URI -import java.net.URL -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -@ExtendWith(VertxExtension::class) -class ExecutionWitnessJsonRpcClientTest { - private lateinit var wiremock: WireMockServer - private lateinit var client: ExecutionWitnessJsonRpcClient - private lateinit var serverUri: URL - - private val sampleWitnessJson = """ - { - "state": ["f902"], - "keys": ["f844"], - "codes": ["608060"], - "headers": ["f902"] - } - """.trimIndent() - - @BeforeEach - fun setup(vertx: Vertx) { - wiremock = WireMockServer(options().dynamicPort()) - wiremock.start() - serverUri = URI("http://127.0.0.1:${wiremock.port()}").toURL() - - val metricsFacade = MicrometerMetricsFacade(SimpleMeterRegistry(), "linea") - val rpcClient = VertxHttpJsonRpcClientFactory(vertx, metricsFacade).createWithRetries( - serverUri, - methodsToRetry = ExecutionWitnessJsonRpcClient.retryableMethods, - retryConfig = RequestRetryConfig( - maxRetries = 2u, - timeout = 10.seconds, - backoffDelay = 10.milliseconds, - failuresWarningThreshold = 1u, - ), - ) - client = ExecutionWitnessJsonRpcClient(rpcClient) - } - - @AfterEach - fun tearDown() { - wiremock.stop() - } - - @Test - fun `getExecutionWitness returns parsed witness for block number`() { - wiremock.stubFor( - post(urlEqualTo("/")) - .withRequestBody(containing("\"method\":\"debug_executionWitness\"")) - .withRequestBody(containing("\"params\":[\"42\"]")) - .willReturn( - ok( - JsonObject.of( - "jsonrpc", - "2.0", - "id", - 1, - "result", - JsonObject(sampleWitnessJson), - ).encode(), - ), - ), - ) - - val result = client.getExecutionWitness(BlockParameter.BlockNumber(42UL)).get() - - assertThat(result).isEqualTo( - Ok( - linea.executionwitness.ExecutionWitness( - state = listOf("f902".decodeHex()), - keys = listOf("f844".decodeHex()), - codes = listOf("608060".decodeHex()), - headers = listOf("f902".decodeHex()), - ), - ), - ) - wiremock.verify(postRequestedFor(urlEqualTo("/"))) - } - - @Test - fun `getExecutionWitness returns parsed witness for block hash`() { - val hash = ByteArray(32) { 0xab.toByte() } - val hashParam = hash.encodeHex(prefix = true) - - wiremock.stubFor( - post(urlEqualTo("/")) - .withRequestBody(containing("\"params\":[\"$hashParam\"]")) - .willReturn( - ok( - JsonObject.of( - "jsonrpc", - "2.0", - "id", - 1, - "result", - JsonObject(sampleWitnessJson), - ).encode(), - ), - ), - ) - - val result = client.getExecutionWitness(BlockParameter.fromHash(hash)).get() - - assertThat(result).isInstanceOf(Ok::class.java) - wiremock.verify( - postRequestedFor(urlEqualTo("/")) - .withRequestBody(containing(hashParam)), - ) - } - - @Test - fun `getExecutionWitness returns NULL_RESULT when result is null`() { - wiremock.stubFor( - post(urlEqualTo("/")) - .willReturn( - ok( - JsonObject.of( - "jsonrpc", - "2.0", - "id", - 1, - "result", - null, - ).encode(), - ), - ), - ) - - val result = client.getExecutionWitness(BlockParameter.Tag.LATEST).get() - - assertThat(result).isEqualTo( - Err( - linea.error.ErrorResponse( - ExecutionWitnessError.NULL_RESULT, - "debug_executionWitness returned null (witness unavailable for block)", - ), - ), - ) - } - - @Test - fun `getExecutionWitness returns RPC_ERROR on json-rpc error`() { - wiremock.stubFor( - post(urlEqualTo("/")) - .willReturn( - ok( - JsonObject.of( - "jsonrpc", - "2.0", - "id", - 1, - "error", - JsonObject.of( - "code", - -32603, - "message", - "Internal error", - ), - ).encode(), - ), - ), - ) - - val result = client.getExecutionWitness(BlockParameter.Tag.LATEST).get() - - assertThat(result).isEqualTo( - Err( - linea.error.ErrorResponse( - ExecutionWitnessError.RPC_ERROR, - "-32603: Internal error", - ), - ), - ) - } -} diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/EthApiClient.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/EthApiClient.kt index 5f0392468cf..77f1ce26bcc 100644 --- a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/EthApiClient.kt +++ b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/EthApiClient.kt @@ -11,7 +11,8 @@ interface EthApiClient : EthLogsClient, EthApiAccountClient, EthApiTransactionClient, - EthApiExecutionClientInfo + EthApiExecutionClientInfo, + ExecutionWitnessClient // future methods to eventually add if necessary // fun ethGetCode(address: ByteArray, blockParameter: BlockParameter): SafeFuture // fun ethGetStorageAt(address: ByteArray, position: BigInteger, blockParameter: BlockParameter): SafeFuture diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/ExecutionWitnessClient.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/ExecutionWitnessClient.kt new file mode 100644 index 00000000000..1bce8c9dbe8 --- /dev/null +++ b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/ethapi/ExecutionWitnessClient.kt @@ -0,0 +1,48 @@ +package linea.ethapi + +import linea.domain.BlockParameter +import linea.kotlin.byteArrayListEquals +import linea.kotlin.byteArrayListHashCode +import tech.pegasys.teku.infrastructure.async.SafeFuture + +interface ExecutionWitnessClient { + fun getExecutionWitness( + block: BlockParameter, + ): SafeFuture +} + +data class ExecutionWitness( + val state: List, + val keys: List, + val codes: List, + val headers: List, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as ExecutionWitness + return state.byteArrayListEquals(other.state) && + keys.byteArrayListEquals(other.keys) && + codes.byteArrayListEquals(other.codes) && + headers.byteArrayListEquals(other.headers) + } + + override fun hashCode(): Int { + var result = state.byteArrayListHashCode() + result = 31 * result + keys.byteArrayListHashCode() + result = 31 * result + codes.byteArrayListHashCode() + result = 31 * result + headers.byteArrayListHashCode() + return result + } +} + +enum class ExecutionWitnessError { + NULL_RESULT, + PARSE_ERROR, +} + +class ExecutionWitnessClientException( + val errorType: ExecutionWitnessError, + override val message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt deleted file mode 100644 index 279474bb0ec..00000000000 --- a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitness.kt +++ /dev/null @@ -1,41 +0,0 @@ -package linea.executionwitness - -data class ExecutionWitness( - val state: List, - val keys: List, - val codes: List, - val headers: List, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as ExecutionWitness - return byteArrayListsEqual(state, other.state) && - byteArrayListsEqual(keys, other.keys) && - byteArrayListsEqual(codes, other.codes) && - byteArrayListsEqual(headers, other.headers) - } - - override fun hashCode(): Int { - var result = byteArrayListHashCode(state) - result = 31 * result + byteArrayListHashCode(keys) - result = 31 * result + byteArrayListHashCode(codes) - result = 31 * result + byteArrayListHashCode(headers) - return result - } - - private fun byteArrayListsEqual(a: List, b: List): Boolean { - if (a.size != b.size) return false - return a.indices.all { i -> a[i].contentEquals(b[i]) } - } - - private fun byteArrayListHashCode(list: List): Int { - return list.fold(0) { acc, bytes -> 31 * acc + bytes.contentHashCode() } - } -} - -enum class ExecutionWitnessError { - NULL_RESULT, - RPC_ERROR, - PARSE_ERROR, -} diff --git a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt b/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt deleted file mode 100644 index 2936a4b54ff..00000000000 --- a/jvm-libs/linea/clients/interfaces/src/main/kotlin/linea/executionwitness/ExecutionWitnessClient.kt +++ /dev/null @@ -1,12 +0,0 @@ -package linea.executionwitness - -import com.github.michaelbull.result.Result -import linea.domain.BlockParameter -import linea.error.ErrorResponse -import tech.pegasys.teku.infrastructure.async.SafeFuture - -interface ExecutionWitnessClient { - fun getExecutionWitness( - block: BlockParameter, - ): SafeFuture>> -} diff --git a/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/ethapi/FakeEthApiClientTest.kt b/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/ethapi/FakeEthApiClientTest.kt index cbcf904dcce..bdc6d9f8841 100644 --- a/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/ethapi/FakeEthApiClientTest.kt +++ b/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/ethapi/FakeEthApiClientTest.kt @@ -4,6 +4,7 @@ import linea.domain.BlockParameter import linea.domain.BlockParameter.Companion.toBlockParameter import linea.domain.EthLog import linea.kotlin.decodeHex +import net.consensys.linea.async.get import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -190,4 +191,22 @@ class FakeEthApiClientTest { assertThat(client.ethGetBlockByNumberFullTxs(BlockParameter.Tag.FINALIZED).get().number).isEqualTo(20UL) } } + + @Test + fun `getExecutionWitness should find witness by block hash key`() { + val blockHash = BlockParameter.fromHash(ByteArray(32) { 3 }) + val witness = ExecutionWitness( + state = listOf(byteArrayOf(1)), + keys = emptyList(), + codes = emptyList(), + headers = emptyList(), + ) + val client = FakeEthApiClient( + witnessesByBlock = mapOf(blockHash to witness), + ) + + val result = client.getExecutionWitness(BlockParameter.fromHash(blockHash.getHash())).get() + + assertThat(result).isEqualTo(witness) + } } diff --git a/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt b/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt deleted file mode 100644 index 3a47beba7ab..00000000000 --- a/jvm-libs/linea/clients/interfaces/src/test/kotlin/linea/executionwitness/FakeExecutionWitnessClientTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package linea.executionwitness - -import com.github.michaelbull.result.getOrElse -import linea.domain.BlockParameter -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test - -class FakeExecutionWitnessClientTest { - - @Test - fun `getExecutionWitness should find witness by block hash key`() { - val blockHash = BlockParameter.fromHash(ByteArray(32) { 3 }) - val witness = ExecutionWitness( - state = listOf(byteArrayOf(1)), - keys = emptyList(), - codes = emptyList(), - headers = emptyList(), - ) - val client = FakeExecutionWitnessClient( - witnessesByBlock = mapOf(blockHash to witness), - ) - - val result = client.getExecutionWitness(BlockParameter.fromHash(blockHash.getHash().copyOf())).get() - .getOrElse { error("unexpected error: $it") } - - assertThat(result).isEqualTo(witness) - } -} diff --git a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt index baf13059058..1a465293e1f 100644 --- a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt +++ b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt @@ -15,6 +15,7 @@ import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.Logger import tech.pegasys.teku.infrastructure.async.SafeFuture import java.math.BigInteger +import kotlin.collections.get import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -34,6 +35,7 @@ class FakeEthApiClient( ), private val topicsTranslation: Map = emptyMap(), private val log: Logger = LogManager.getLogger(FakeEthApiClient::class.java), + private val witnessesByBlock: Map = emptyMap(), ) : EthApiClient { private val blockTags: MutableMap = initialTagsBlocks.toMutableMap() private val logsDb: MutableList = mutableListOf() @@ -267,6 +269,17 @@ class FakeEthApiClient( } } + override fun getExecutionWitness(block: BlockParameter): SafeFuture { + val witness = witnessesByBlock[block] + ?: return SafeFuture.failedFuture( + ExecutionWitnessClientException( + ExecutionWitnessError.NULL_RESULT, + "no witness configured for block=$block", + ), + ) + return SafeFuture.completedFuture(witness) + } + private fun findLogsInRange(fromBlock: BlockParameter, toBlock: BlockParameter): List { return logsDb.filter { isInRange(it.blockNumber, fromBlock, toBlock) } } @@ -300,7 +313,7 @@ class FakeEthApiClient( is BlockParameter.BlockNumber -> blockParameter.getNumber() is BlockParameter.BlockHash -> - blocksDb.values.firstOrNull { it.hash.contentEquals(blockParameter.getHash()) }?.number + blocksDb.values.firstOrNull { it.hash.contentEquals(blockParameter.getHash().decodeHex()) }?.number ?: throw IllegalArgumentException("Block hash not found in fake client: $blockParameter") } } diff --git a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt deleted file mode 100644 index 1849ddc1a3b..00000000000 --- a/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/executionwitness/FakeExecutionWitnessClient.kt +++ /dev/null @@ -1,25 +0,0 @@ -package linea.executionwitness - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import linea.domain.BlockParameter -import linea.error.ErrorResponse -import tech.pegasys.teku.infrastructure.async.SafeFuture - -class FakeExecutionWitnessClient( - private val witnessesByBlock: Map = emptyMap(), -) : ExecutionWitnessClient { - - override fun getExecutionWitness( - block: BlockParameter, - ): SafeFuture>> { - val witness = witnessesByBlock[block] - ?: return SafeFuture.completedFuture( - Err( - ErrorResponse(ExecutionWitnessError.NULL_RESULT, "no witness configured for block=$block"), - ), - ) - return SafeFuture.completedFuture(Ok(witness)) - } -} diff --git a/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt b/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt index 6a543f13f95..42862f1a95d 100644 --- a/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt +++ b/jvm-libs/linea/core/domain-models/src/main/kotlin/linea/domain/BlockParameter.kt @@ -8,6 +8,8 @@ sealed interface BlockParameter { fun getNumber(): ULong + fun getHash(): String + companion object { private const val BLOCK_HASH_HEX_LENGTH = 64 @@ -18,27 +20,31 @@ sealed interface BlockParameter { fun fromNumber(blockNumber: ULong): BlockNumber = BlockNumber(blockNumber) - fun fromHash(blockHash: ByteArray): BlockHash = BlockHash(blockHash) + fun fromHash(blockHash: ByteArray): BlockHash = BlockHash(blockHash.copyOf()) fun fromHash(blockHashHex: String): BlockHash = BlockHash(blockHashHex.decodeHex()) fun parse(input: String): BlockParameter { return try { + // Try to parse the input as a tag Tag.fromString(input) } catch (e: IllegalArgumentException) { - val normalized = input.lowercase() - if (normalized.startsWith("0x")) { - val hexBody = normalized.drop(2) + // If it's not a valid tag, try to parse it as a block hash or block number + if (input.startsWith("0x")) { + val hexBody = input.drop(2) if (hexBody.length == BLOCK_HASH_HEX_LENGTH) { - return BlockHash("0x$hexBody".decodeHex()) + return fromHash(input) } - val blockNumber = hexBody.toULongOrNull(radix = 16) - ?: throw IllegalArgumentException("Invalid BlockParameter: $input") - return BlockNumber(blockNumber) + ( + hexBody.toULongOrNull(radix = 16) + ?: throw IllegalArgumentException("Invalid BlockParameter: $input") + ).toBlockParameter() + } else { + ( + input.toULongOrNull(radix = 10) + ?: throw IllegalArgumentException("Invalid BlockParameter: $input") + ).toBlockParameter() } - val blockNumber = input.toULongOrNull(radix = 10) - ?: throw IllegalArgumentException("Invalid BlockParameter: $input") - blockNumber.toBlockParameter() } } @@ -60,6 +66,9 @@ sealed interface BlockParameter { override fun getNumber(): ULong = throw UnsupportedOperationException( "getNumber isn't supposed to be called on a block tag!", ) + override fun getHash(): String = throw UnsupportedOperationException( + "getHash isn't supposed to be called on a block tag!", + ) companion object { @JvmStatic @@ -83,6 +92,10 @@ sealed interface BlockParameter { return parameter } + override fun getHash(): String { + throw UnsupportedOperationException("getHash isn't supported on BlockNumber!") + } + override fun toString(): String { return parameter.toString() } @@ -93,7 +106,7 @@ sealed interface BlockParameter { require(hash.size == 32) { "block hash must be 32 bytes, got ${hash.size}" } } - fun getHash(): ByteArray = hash + override fun getHash(): String = hash.encodeHex(prefix = true) override fun getTag(): String { throw UnsupportedOperationException("getTag isn't supported on BlockHash!") diff --git a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt index 2f1c54175b7..9731968a91d 100644 --- a/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt +++ b/jvm-libs/linea/core/domain-models/src/test/kotlin/linea/domain/BlockParameterTest.kt @@ -1,6 +1,5 @@ package linea.domain -import linea.kotlin.decodeHex import linea.kotlin.encodeHex import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy @@ -28,24 +27,23 @@ class BlockParameterTest { @Test fun `parse should parse block hash`() { val hashHex = "0x" + "ab".repeat(32) - val expectedHash = hashHex.decodeHex() val parsed = BlockParameter.parse(hashHex) as BlockParameter.BlockHash - assertThat(parsed.getHash()).isEqualTo(expectedHash) + assertThat(parsed.getHash()).isEqualTo(hashHex) } @Test fun `parse should parse block hash from encoded byte array`() { - val expectedHash = ByteArray(32) { index -> (index + 1).toByte() } - val hashHex = expectedHash.encodeHex(prefix = true) + val hashHex = ByteArray(32) { index -> (index + 1).toByte() }.encodeHex(prefix = true) val parsed = BlockParameter.parse(hashHex) as BlockParameter.BlockHash - assertThat(parsed.getHash()).isEqualTo(expectedHash) + assertThat(parsed.getHash()).isEqualTo(hashHex) } @Test fun `fromHash should accept bytes and hex string`() { val hash = ByteArray(32) { 1 } - assertThat(BlockParameter.fromHash(hash).getHash()).isEqualTo(hash) - assertThat(BlockParameter.fromHash(hash.encodeHex(prefix = true)).getHash()).isEqualTo(hash) + val hashHex = hash.encodeHex(prefix = true) + assertThat(BlockParameter.fromHash(hash).getHash()).isEqualTo(hashHex) + assertThat(BlockParameter.fromHash(hashHex).getHash()).isEqualTo(hashHex) } @Test diff --git a/jvm-libs/linea/web3j-extensions/build.gradle b/jvm-libs/linea/web3j-extensions/build.gradle index 2c0af067959..67cf932cb8b 100644 --- a/jvm-libs/linea/web3j-extensions/build.gradle +++ b/jvm-libs/linea/web3j-extensions/build.gradle @@ -16,6 +16,9 @@ dependencies { api project(':jvm-libs:generic:extensions:futures') api project(':jvm-libs:linea:besu-libs') api project(':jvm-libs:generic:serialization:jackson') + api project(':jvm-libs:generic:json-rpc') + + testImplementation(testFixtures(project(":jvm-libs:generic:json-rpc"))) testImplementation "org.apache.logging.log4j:log4j-slf4j2-impl:${libs.versions.log4j.get()}" diff --git a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/ExecutionWitnessResponseParser.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/ExecutionWitnessResponseParser.kt new file mode 100644 index 00000000000..ee50f4148eb --- /dev/null +++ b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/ExecutionWitnessResponseParser.kt @@ -0,0 +1,38 @@ +package linea.web3j.ethapi + +import com.fasterxml.jackson.databind.JsonNode +import linea.ethapi.ExecutionWitness +import linea.ethapi.ExecutionWitnessClientException +import linea.ethapi.ExecutionWitnessError +import linea.kotlin.decodeHex + +object ExecutionWitnessResponseParser { + + fun parse(json: JsonNode): ExecutionWitness { + return try { + ExecutionWitness( + state = parseHexList(json, "state"), + keys = parseHexList(json, "keys"), + codes = parseHexList(json, "codes"), + headers = parseHexList(json, "headers"), + ) + } catch (throwable: Throwable) { + throw ExecutionWitnessClientException( + ExecutionWitnessError.PARSE_ERROR, + throwable.message ?: "failed to parse execution witness", + throwable, + ) + } + } + + private fun parseHexList(json: JsonNode, field: String): List { + val array = json.get(field) + ?: throw IllegalArgumentException("missing or invalid field: $field") + if (!array.isArray) { + throw IllegalArgumentException("missing or invalid field: $field") + } + return array.map { element -> + element.asText().decodeHex() + } + } +} diff --git a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClient.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClient.kt index 1f4f3cdb327..98b343003b5 100644 --- a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClient.kt +++ b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClient.kt @@ -9,6 +9,7 @@ import linea.domain.Transaction import linea.domain.TransactionForEthCall import linea.domain.TransactionReceipt import linea.ethapi.EthApiClient +import linea.ethapi.ExecutionWitnessClient import linea.ethapi.StateOverride import linea.kotlin.decodeHex import linea.kotlin.encodeHex @@ -38,7 +39,8 @@ import kotlin.jvm.optionals.getOrNull class Web3jEthApiClient( val web3jClient: Web3j, val web3jService: Web3jService = web3jClient.getWeb3jService(), -) : EthApiClient { + val executionWitnessClient: ExecutionWitnessClient = Web3jExecutionWitnessClient(web3jService), +) : EthApiClient, ExecutionWitnessClient by executionWitnessClient { override fun getLogs( fromBlock: BlockParameter, toBlock: BlockParameter, diff --git a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClientWithRetries.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClientWithRetries.kt index b613ce5ffbf..dc3d3646013 100644 --- a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClientWithRetries.kt +++ b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jEthApiClientWithRetries.kt @@ -11,6 +11,7 @@ import linea.domain.Transaction import linea.domain.TransactionForEthCall import linea.domain.TransactionReceipt import linea.ethapi.EthApiClient +import linea.ethapi.ExecutionWitness import linea.ethapi.StateOverride import net.consensys.linea.async.AsyncRetryer import tech.pegasys.teku.infrastructure.async.SafeFuture @@ -169,4 +170,10 @@ class Web3jEthApiClientWithRetries( override fun ethEstimateGas(transaction: TransactionForEthCall): SafeFuture { return retry { ethApiClient.ethEstimateGas(transaction) } } + + override fun getExecutionWitness(block: BlockParameter): SafeFuture { + return retry(stopRetriesPredicateForTag(block)) { + ethApiClient.getExecutionWitness(block) + } + } } diff --git a/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClient.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClient.kt new file mode 100644 index 00000000000..15f92cc5172 --- /dev/null +++ b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClient.kt @@ -0,0 +1,73 @@ +package linea.web3j.ethapi + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectReader +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import linea.domain.BlockParameter +import linea.ethapi.ExecutionWitness +import linea.ethapi.ExecutionWitnessClient +import linea.ethapi.ExecutionWitnessClientException +import linea.ethapi.ExecutionWitnessError +import linea.web3j.requestAsync +import org.web3j.protocol.ObjectMapperFactory +import org.web3j.protocol.Web3jService +import org.web3j.protocol.core.Request +import org.web3j.protocol.core.Response +import tech.pegasys.teku.infrastructure.async.SafeFuture + +/** + * Web3j based implementation of [ExecutionWitnessClient] for the `debug_executionWitness` JSON-RPC method. + */ +class Web3jExecutionWitnessClient( + private val web3jService: Web3jService, +) : ExecutionWitnessClient { + + override fun getExecutionWitness(block: BlockParameter): SafeFuture { + return Request( + "debug_executionWitness", + listOf(block.toDebugExecutionWitnessRpcParam()), + web3jService, + ExecutionWitnessResponse::class.java, + ).requestAsync { response -> + response.result + ?: throw ExecutionWitnessClientException( + ExecutionWitnessError.NULL_RESULT, + "debug_executionWitness returned null (witness unavailable for block)", + ) + } + } +} + +private fun BlockParameter.toDebugExecutionWitnessRpcParam(): String = + when (this) { + is BlockParameter.Tag -> getTag() + is BlockParameter.BlockNumber -> getNumber().toString() + is BlockParameter.BlockHash -> getHash() + } + +class ExecutionWitnessResponse : Response() { + @JsonDeserialize(using = ResponseDeserializer::class) + override fun setResult(result: ExecutionWitness?) { + super.setResult(result) + } + + class ResponseDeserializer : JsonDeserializer() { + private val objectReader: ObjectReader = ObjectMapperFactory.getObjectReader() + + override fun deserialize( + jsonParser: JsonParser, + deserializationContext: DeserializationContext, + ): ExecutionWitness? { + return if (jsonParser.currentToken != JsonToken.VALUE_NULL) { + val json = objectReader.readValue(jsonParser, JsonNode::class.java) + ExecutionWitnessResponseParser.parse(json) + } else { + null + } + } + } +} diff --git a/jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClientTest.kt b/jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClientTest.kt new file mode 100644 index 00000000000..7a482e29381 --- /dev/null +++ b/jvm-libs/linea/web3j-extensions/src/test/kotlin/linea/web3j/ethapi/Web3jExecutionWitnessClientTest.kt @@ -0,0 +1,181 @@ +package linea.web3j.ethapi + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock.containing +import com.github.tomakehurst.wiremock.client.WireMock.ok +import com.github.tomakehurst.wiremock.client.WireMock.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor +import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo +import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options +import io.vertx.core.json.JsonObject +import linea.domain.BlockParameter +import linea.error.JsonRpcErrorResponseException +import linea.ethapi.ExecutionWitness +import linea.ethapi.ExecutionWitnessClientException +import linea.ethapi.ExecutionWitnessError +import linea.kotlin.decodeHex +import linea.kotlin.encodeHex +import linea.web3j.createWeb3jHttpService +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.Assertions.catchThrowable +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class Web3jExecutionWitnessClientTest { + private lateinit var wiremock: WireMockServer + private lateinit var client: Web3jExecutionWitnessClient + + private val sampleWitnessJson = """ + { + "state": ["0xf902"], + "keys": ["0xf844"], + "codes": ["0x608060"], + "headers": ["0xf902"] + } + """.trimIndent() + + @BeforeEach + fun setup() { + wiremock = WireMockServer(options().dynamicPort()) + wiremock.start() + val web3jService = createWeb3jHttpService(rpcUrl = "http://127.0.0.1:${wiremock.port()}") + client = Web3jExecutionWitnessClient(web3jService) + } + + @AfterEach + fun tearDown() { + wiremock.stop() + } + + @Test + fun `getExecutionWitness returns parsed witness for block number`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .withRequestBody(containing("\"method\":\"debug_executionWitness\"")) + .withRequestBody(containing("\"params\":[\"42\"]")) + .willReturn( + ok( + JsonObject.of("jsonrpc", "2.0", "id", 1, "result", JsonObject(sampleWitnessJson)).encode(), + ), + ), + ) + + val result = client.getExecutionWitness(BlockParameter.BlockNumber(42UL)).get() + + assertThat(result).isEqualTo( + ExecutionWitness( + state = listOf("f902".decodeHex()), + keys = listOf("f844".decodeHex()), + codes = listOf("608060".decodeHex()), + headers = listOf("f902".decodeHex()), + ), + ) + wiremock.verify(postRequestedFor(urlEqualTo("/"))) + } + + @Test + fun `getExecutionWitness returns parsed witness for block hash`() { + val hash = ByteArray(32) { 0xab.toByte() } + val hashParam = hash.encodeHex(prefix = true) + + wiremock.stubFor( + post(urlEqualTo("/")) + .withRequestBody(containing("\"params\":[\"$hashParam\"]")) + .willReturn( + ok( + JsonObject.of("jsonrpc", "2.0", "id", 1, "result", JsonObject(sampleWitnessJson)).encode(), + ), + ), + ) + + val result = client.getExecutionWitness(BlockParameter.fromHash(hash)).get() + + assertThat(result.state).isNotEmpty + wiremock.verify( + postRequestedFor(urlEqualTo("/")).withRequestBody(containing(hashParam)), + ) + } + + @Test + fun `getExecutionWitness throws NULL_RESULT when result is null`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .willReturn( + ok( + JsonObject.of("jsonrpc", "2.0", "id", 1, "result", null).encode(), + ), + ), + ) + + assertThatThrownBy { client.getExecutionWitness(BlockParameter.Tag.LATEST).get() } + .rootCause() + .isInstanceOfSatisfying(ExecutionWitnessClientException::class.java) { ex -> + assertThat(ex.errorType).isEqualTo(ExecutionWitnessError.NULL_RESULT) + assertThat(ex.message).contains("returned null") + } + } + + @Test + fun `getExecutionWitness throws PARSE_ERROR when result is malformed`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .willReturn( + ok( + JsonObject.of( + "jsonrpc", + "2.0", + "id", + 1, + "result", + // "state" is not an array -> parse failure + JsonObject.of( + "state", + "not-an-array", + "keys", + JsonObject(), + "codes", + JsonObject(), + "headers", + JsonObject(), + ), + ).encode(), + ), + ), + ) + + val thrown = catchThrowable { client.getExecutionWitness(BlockParameter.Tag.LATEST).get() } + val witnessException = generateSequence(thrown) { it.cause } + .filterIsInstance() + .firstOrNull() + assertThat(witnessException).isNotNull + assertThat(witnessException!!.errorType).isEqualTo(ExecutionWitnessError.PARSE_ERROR) + } + + @Test + fun `getExecutionWitness throws on json-rpc error`() { + wiremock.stubFor( + post(urlEqualTo("/")) + .willReturn( + ok( + JsonObject.of( + "jsonrpc", + "2.0", + "id", + 1, + "error", + JsonObject.of("code", -32603, "message", "Internal error"), + ).encode(), + ), + ), + ) + + assertThatThrownBy { client.getExecutionWitness(BlockParameter.Tag.LATEST).get() } + .rootCause() + .isInstanceOfSatisfying(JsonRpcErrorResponseException::class.java) { ex -> + assertThat(ex.rpcErrorCode).isEqualTo(-32603) + assertThat(ex.rpcErrorMessage).isEqualTo("Internal error") + } + } +} diff --git a/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt b/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt index caff4090247..08885423f16 100644 --- a/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt +++ b/linea-besu/plugins/state-recovery/besu-plugin/src/main/kotlin/linea/staterecovery/clients/ExecutionLayerInProcessClient.kt @@ -72,7 +72,7 @@ class ExecutionLayerInProcessClient( is BlockParameter.BlockHash -> blockchainService - .getBlockHeaderByHash(Hash.wrap(Bytes32.wrap(blockParameter.getHash()))) + .getBlockHeaderByHash(Hash.wrap(Bytes32.fromHexString(blockParameter.getHash()))) .getOrNull() } diff --git a/settings.gradle b/settings.gradle index ca91d8454a5..1cf3775a2ae 100644 --- a/settings.gradle +++ b/settings.gradle @@ -52,7 +52,6 @@ include 'coordinator:clients:shomei-client' include 'coordinator:clients:smart-contract-client' include 'coordinator:clients:web3signer-client' include 'coordinator:clients:forced-transactions-client' -include 'coordinator:clients:execution-witness-client' include 'coordinator:ethereum:models-helper' include 'coordinator:ethereum:finalization-monitor' include 'coordinator:ethereum:forced-transactions'