Handle unescaped control chars in web3_clientVersion responses (#818)

* Fix node type detection for Tendermint nodes with unescaped control chars

Some Tendermint EVM nodes (e.g. Moca) return a web3_clientVersion result
like "Version dev ()\nCompiled at  using Go go1.23.11 (amd64)" where the
\n is a real LF (CTRL-CHAR, code 10), not a JSON escape. Jackson's
default parser rejects this with "Illegal unquoted character", causing
EthereumUpstreamSettingsDetector to fail node type detection.

Parse the response leniently with ALLOW_UNQUOTED_CONTROL_CHARS at this
single call site, and trim the version label to its first line so the
resulting client_type/client_version labels stay clean.

* Normalize whitespace uniformly instead of dropping post-newline content

Previous fix took only the first line of the version string, which would
drop the version token if a node placed it after the LF (only Moca
happens to put it on the first line). Replace that with a uniform
whitespace-collapsing helper applied in both code paths that extract a
client version (mapping for node-type detection and parseClientVersion
for client-version detection), so every token of the version string is
preserved as a single-line, single-spaced label regardless of where it
sits.

* Revert version-string normalization, keep only ALLOW_UNQUOTED_CONTROL_CHARS

The lenient JSON parser is sufficient on its own to fix the original
parse error. Reverting the mapping/parseClientVersion changes leaves the
existing slash/semver/dot logic untouched and lets the raw version
string flow through as before.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Vadim Filin
2026-04-30 11:46:55 +02:00
committed by GitHub
parent 06ce6927e2
commit aeebca7248
3 changed files with 145 additions and 2 deletions

View File

@@ -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<JsonNode>(it) }
.map { parseLenientJson(it) }
.flatMapMany { node ->
val labels = mutableListOf<Pair<String, String>>()
clientType(node)?.let {

View File

@@ -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<String, String>("client_type", "default client"))
.expectNext(new Pair<String, String>("client_version", rawVersion))
.expectNext(new Pair<String, String>("archive", "false"))
.expectNext(new Pair<String, String>("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))
}
}

View File

@@ -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())
}
}