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:
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user