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/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/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/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/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt b/jvm-libs/linea/clients/interfaces/src/testFixtures/kotlin/linea/ethapi/FakeEthApiClient.kt index fa5a0609243..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) } } @@ -298,6 +311,10 @@ class FakeEthApiClient( ?: throw IllegalArgumentException("Invalid blockParameter=$blockParameter") is BlockParameter.BlockNumber -> blockParameter.getNumber() + + is BlockParameter.BlockHash -> + 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/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..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 @@ -1,11 +1,18 @@ package linea.domain +import linea.kotlin.decodeHex +import linea.kotlin.encodeHex + sealed interface BlockParameter { fun getTag(): String fun getNumber(): ULong + fun getHash(): String + 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,19 +20,31 @@ sealed interface BlockParameter { fun fromNumber(blockNumber: ULong): BlockNumber = BlockNumber(blockNumber) + 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) { - // 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) + // 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 fromHash(input) + } + ( + hexBody.toULongOrNull(radix = 16) + ?: throw IllegalArgumentException("Invalid BlockParameter: $input") + ).toBlockParameter() } else { - input.toULongOrNull(radix = 10) - } ?: throw IllegalArgumentException("Invalid BlockParameter: $input") - - blockNumber.toBlockParameter() + ( + input.toULongOrNull(radix = 10) + ?: throw IllegalArgumentException("Invalid BlockParameter: $input") + ).toBlockParameter() + } } } @@ -47,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 @@ -70,8 +92,40 @@ sealed interface BlockParameter { return parameter } + override fun getHash(): String { + throw UnsupportedOperationException("getHash isn't supported on BlockNumber!") + } + override fun toString(): String { return parameter.toString() } } + + data class BlockHash(private val hash: ByteArray) : BlockParameter { + init { + require(hash.size == 32) { "block hash must be 32 bytes, got ${hash.size}" } + } + + override fun getHash(): String = hash.encodeHex(prefix = true) + + 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 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 2a9a3731dd9..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,5 +1,6 @@ package linea.domain +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 +24,45 @@ class BlockParameterTest { assertThat(BlockParameter.parse("0x78")).isEqualTo(BlockParameter.BlockNumber(120UL)) } + @Test + fun `parse should parse block hash`() { + val hashHex = "0x" + "ab".repeat(32) + val parsed = BlockParameter.parse(hashHex) as BlockParameter.BlockHash + assertThat(parsed.getHash()).isEqualTo(hashHex) + } + + @Test + fun `parse should parse block hash from encoded byte array`() { + val hashHex = ByteArray(32) { index -> (index + 1).toByte() }.encodeHex(prefix = true) + val parsed = BlockParameter.parse(hashHex) as BlockParameter.BlockHash + assertThat(parsed.getHash()).isEqualTo(hashHex) + } + + @Test + fun `fromHash should accept bytes and hex string`() { + val hash = ByteArray(32) { 1 } + val hashHex = hash.encodeHex(prefix = true) + assertThat(BlockParameter.fromHash(hash).getHash()).isEqualTo(hashHex) + assertThat(BlockParameter.fromHash(hashHex).getHash()).isEqualTo(hashHex) + } + + @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)) } + .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/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/domain/BlockParameterExtensions.kt b/jvm-libs/linea/web3j-extensions/src/main/kotlin/linea/web3j/domain/BlockParameterExtensions.kt index 8fcbb3c23d1..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 @@ -8,5 +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 -> + throw UnsupportedOperationException( + "Web3j DefaultBlockParameter does not support block hash; blockParameter=$this", + ) } } 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/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") + } +} 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 14a5dd8a270..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 @@ -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.fromHexString(blockParameter.getHash()))) + .getOrNull() } return blockHeader