diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt index 2f021a73..01ff6bd6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt @@ -1,7 +1,7 @@ package io.emeraldpay.dshackle.upstream +import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.module.kotlin.readValue import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Defaults.Companion.internalCallsTimeout import io.emeraldpay.dshackle.Global @@ -47,6 +47,21 @@ abstract class UpstreamSettingsDetector( protected abstract fun parseClientVersion(data: ByteArray): String } +/** + * Parse the response of `web3_clientVersion`-like calls leniently. Some nodes + * (e.g. Moca's Tendermint EVM) return a JSON string that contains raw, unescaped + * control characters such as line feeds. Jackson rejects those by default with + * "Illegal unquoted character", so we enable ALLOW_UNQUOTED_CONTROL_CHARS for + * this single call site. + */ +internal fun parseLenientJson(data: ByteArray): JsonNode { + val factory = Global.objectMapper.factory + factory.createParser(data).use { parser -> + parser.enable(JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS) + return Global.objectMapper.readTree(parser) + } +} + abstract class BasicUpstreamSettingsDetector( private val upstream: Upstream, ) : UpstreamSettingsDetector(upstream) { @@ -60,7 +75,7 @@ abstract class BasicUpstreamSettingsDetector( .getIngressReader() .read(nodeTypeRequest.request) .flatMap(ChainResponse::requireResult) - .map { Global.objectMapper.readValue(it) } + .map { parseLenientJson(it) } .flatMapMany { node -> val labels = mutableListOf>() clientType(node)?.let { diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy index eb4b04c7..13e868ad 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy @@ -239,4 +239,81 @@ class EthereumUpstreamSettingsDetectorSpec extends Specification { .expectComplete() .verify(Duration.ofSeconds(1)) } + + // Regression: Moca Tendermint EVM returns a JSON string with raw, unescaped LFs: + // "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + // Jackson's default parser rejects this with "Illegal unquoted character (code 10)", + // which previously caused EthereumUpstreamSettingsDetector to fail node type detection. + def "Detect node type when client version contains unescaped control chars (Moca Tendermint)"() { + setup: + def rawVersion = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + def jsonResultBytes = ('"' + rawVersion + '"').getBytes("UTF-8") + def up = Mock(DefaultUpstream) { + getId() >> "tiernet-us-east-bcn-05-moca-mainnet" + 6 * getIngressReader() >> Mock(Reader) { + 1 * read(new ChainRequest("web3_clientVersion", new ListParams())) >> + Mono.just(new ChainResponse(jsonResultBytes, null)) + 1 * read(new ChainRequest("eth_blockNumber", new ListParams())) >> + Mono.just(new ChainResponse("\"0x10df3e5\"".getBytes(), null)) + 1 * read(new ChainRequest("eth_getBalance", new ListParams(["0x0000000000000000000000000000000000000000", "0x10dccd5"]))) >> + Mono.error(new RuntimeException()) + 1 * read(new ChainRequest("eth_getBalance", new ListParams(["0x0000000000000000000000000000000000000000", "0x2710"]))) >> + Mono.just(new ChainResponse("".getBytes(), null)) + 1 * read(new ChainRequest("eth_call", new ListParams([ + "to": "0x53Daa71B04d589429f6d3DF52db123913B818F22", + "data": "0x51be4eaa", + ], + "latest", + [ + "0x53Daa71B04d589429f6d3DF52db123913B818F22": [ + "code": "0x6080604052348015600f57600080fd5b506004361060285760003560e01c806351be4eaa14602d575b600080fd5b60336047565b604051603e91906066565b60405180910390f35b60005a905090565b6000819050919050565b606081604f565b82525050565b6000602082019050607960008301846059565b9291505056fea26469706673582212201c0202887c1afe66974b06ee355dee07542bbc424cf4d1659c91f56c08c3dcc064736f6c63430008130033", + ], + ], + ))) >> + Mono.just(new ChainResponse("".getBytes(), null)) + 1 * read(new ChainRequest("eth_getBlockByNumber", new ListParams(["pending", false]))) >> + Mono.just(new ChainResponse("{}".getBytes(), null)) + } + getLabels() >> [] + } + def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET) + when: + def act = detector.internalDetectLabels() + then: + // The detector must not crash on the unescaped LF. With ALLOW_UNQUOTED_CONTROL_CHARS + // the JSON parses, and the resulting string runs through the existing + // slash/semver/dot logic: no slash, not semver-like, contains a dot -> + // client_type falls back to "default client" and client_version is the raw + // version string (preserved as-is, including the embedded LF). + StepVerifier.create(act) + .expectNext(new Pair("client_type", "default client")) + .expectNext(new Pair("client_version", rawVersion)) + .expectNext(new Pair("archive", "false")) + .expectNext(new Pair("flashblocks", "false")) + .expectComplete() + .verify(Duration.ofSeconds(1)) + } + + def "detectClientVersion handles unescaped control chars in version string"() { + setup: + def rawVersion = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + def jsonResultBytes = ('"' + rawVersion + '"').getBytes("UTF-8") + def up = Mock(DefaultUpstream) { + 2 * getIngressReader() >> Mock(Reader) { + 1 * read(new ChainRequest("web3_clientVersion", new ListParams())) >> + Mono.just(new ChainResponse(jsonResultBytes, null)) + } + 1 * getLabels() >> List.of() + } + def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET) + when: + def act = detector.detectClientVersion() + then: + // parseClientVersion only strips the outer JSON quotes; the embedded LF is + // passed through unchanged. The important behavior is that it does not throw. + StepVerifier.create(act) + .expectNext(rawVersion) + .expectComplete() + .verify(Duration.ofSeconds(1)) + } } diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt new file mode 100644 index 00000000..ecdf0ce7 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetectorTest.kt @@ -0,0 +1,51 @@ +package io.emeraldpay.dshackle.upstream + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class UpstreamSettingsDetectorTest { + + @Test + fun `parseLenientJson accepts plain JSON string`() { + val data = "\"Geth/v1.12.0/linux-amd64/go1.20.3\"".toByteArray() + val node = parseLenientJson(data) + assertTrue(node.isTextual) + assertEquals("Geth/v1.12.0/linux-amd64/go1.20.3", node.asText()) + } + + @Test + fun `parseLenientJson accepts JSON string with unescaped LF (Moca Tendermint)`() { + // Real-world Moca Tendermint web3_clientVersion result: + // "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + // where \n is a real line feed (CTRL-CHAR, code 10), not an escape sequence. + // Default Jackson rejects this with "Illegal unquoted character"; the lenient + // parser must accept it. + val raw = "Version dev ()\nCompiled at using Go go1.23.11 (amd64)" + val data = ("\"" + raw + "\"").toByteArray() + + val node = parseLenientJson(data) + + assertTrue(node.isTextual) + assertEquals(raw, node.asText()) + } + + @Test + fun `parseLenientJson accepts JSON string with unescaped CR and tab`() { + val raw = "Version dev ()\r\n\tCompiled with Go go1.23.11" + val data = ("\"" + raw + "\"").toByteArray() + + val node = parseLenientJson(data) + + assertTrue(node.isTextual) + assertEquals(raw, node.asText()) + } + + @Test + fun `parseLenientJson still parses normal JSON objects`() { + val data = """{"foo":"bar","n":42}""".toByteArray() + val node = parseLenientJson(data) + assertEquals("bar", node.get("foo").asText()) + assertEquals(42, node.get("n").asInt()) + } +}