diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt index f2647030..1ddd05af 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/UpstreamSettingsDetector.kt @@ -10,6 +10,7 @@ import reactor.core.publisher.Flux import reactor.core.publisher.Mono const val UNKNOWN_CLIENT_VERSION = "unknown" +const val DEFAULT_CLIENT_TYPE = "default client" typealias UpstreamSettingsDetectorBuilder = (Chain, Upstream) -> UpstreamSettingsDetector? @@ -85,22 +86,72 @@ abstract class BasicEthUpstreamSettingsDetector( override fun clientVersion(node: JsonNode): String? { val client = mapping(node) + val firstSlash = client.indexOf("/") - val secondSlash = client.indexOf("/", firstSlash + 1) - if (firstSlash == -1 || secondSlash == -1 || secondSlash < firstSlash) { - return node.asText() + if (firstSlash != -1) { + // Standard format with slashes: "geth/1.9.0/linux/go1.15" or "Type/Version" + val secondSlash = client.indexOf("/", firstSlash + 1) + if (secondSlash != -1) { + // Full standard format: return version part between first and second slash + return client.substring(firstSlash + 1, secondSlash) + } else { + // "Type/Version" format: return version part after slash + val version = client.substring(firstSlash + 1) + return if (version.isEmpty()) { + log.warn("Could not determine client version for upstream ${upstream.getId()}, empty version after slash in: '{}'", client) + UNKNOWN_CLIENT_VERSION + } else { + version + } + } } - return client.substring(firstSlash + 1, secondSlash) + + // Check if it's semver format without client type + if (isSemverLike(client)) { + return client + } + + // String without slashes and dots - return unknown version + if (!client.contains(".")) { + log.warn("Could not determine client version for upstream ${upstream.getId()}, single word without version info: '{}'", client) + return UNKNOWN_CLIENT_VERSION + } + + // Fallback: return original as version + log.warn("Could not determine client version for upstream ${upstream.getId()}, raw version: '{}'", client) + return client } override fun clientType(node: JsonNode): String? { val client = mapping(node) - val firstSlash = client.indexOf("/") - if (firstSlash == -1) { - log.debug("Unknown client type: {}", client) - return null + + if (client.isEmpty()) { + return DEFAULT_CLIENT_TYPE } - return client.substring(0, firstSlash).lowercase() + + val firstSlash = client.indexOf("/") + if (firstSlash != -1) { + // Has slash - return part before first slash as client type + val clientPart = client.substring(0, firstSlash) + return if (clientPart.isEmpty()) DEFAULT_CLIENT_TYPE else clientPart.lowercase() + } + + // Check if it's semver format without client type + if (isSemverLike(client)) { + return DEFAULT_CLIENT_TYPE + } + + // String without slashes and dots - use whole string as client type + if (!client.contains(".")) { + return client.lowercase() + } + + // Fallback for unrecognized formats + return DEFAULT_CLIENT_TYPE + } + + private fun isSemverLike(version: String): Boolean { + return version.matches(Regex("^v?\\d+\\.\\d+\\.\\d+.*")) } } 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 8223d195..eb4b04c7 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumUpstreamSettingsDetectorSpec.groovy @@ -63,6 +63,74 @@ class EthereumUpstreamSettingsDetectorSpec extends Specification { "Bor/v0.4.0/linux-amd64/go1.19.10" | "0x23c2f342"| "600000000" | "600000000" | "bor" | "v0.4.0" } + def "Detect unknown client version formats"() { + setup: + def up = TestingCommons.upstream( + new ApiReaderMock().tap { + answer("web3_clientVersion", [], response) + // Mock other calls to avoid complexity - only test client version parsing + answer("eth_blockNumber", [], "0x10df3e5") + answer("eth_getBalance", ["0x0000000000000000000000000000000000000000", "0x10dccd5"], null) + answer("eth_getBalance", ["0x0000000000000000000000000000000000000000", "0x2710"], null) + answer("eth_call", [[ + "to": "0x53Daa71B04d589429f6d3DF52db123913B818F22", + "data": "0x51be4eaa", + ], + "latest", + [ + "0x53Daa71B04d589429f6d3DF52db123913B818F22": [ + "code": "0x6080604052348015600f57600080fd5b506004361060285760003560e01c806351be4eaa14602d575b600080fd5b60336047565b604051603e91906066565b60405180910390f35b60005a905090565b6000819050919050565b606081604f565b82525050565b6000602082019050607960008301846059565b9291505056fea26469706673582212201c0202887c1afe66974b06ee355dee07542bbc424cf4d1659c91f56c08c3dcc064736f6c63430008130033", + ], + ]], null) + answer("eth_getBlockByNumber", ["pending", false], null) + } + ) + def detector = new EthereumUpstreamSettingsDetector(up, Chain.ETHEREUM__MAINNET) + + when: + def act = detector.internalDetectLabels() + then: + def result = StepVerifier.create(act) + result.expectNext(new Pair("client_type", clientType)) + result.expectNext(new Pair("client_version", clientVersion)) + // Skip other labels - focus only on client type/version + result.expectNext(new Pair("archive", "false")) + result.expectNext(new Pair("flashblocks", "false")) + result.expectComplete().verify(Duration.ofSeconds(1)) + + where: + response | clientType | clientVersion + // Type/Version formats + "Monad/e1e25be" | "monad" | "e1e25be" + "Monad/v0.9.3" | "monad" | "v0.9.3" + "Monad/f9c50c1" | "monad" | "f9c50c1" + "thirdweb/1.0" | "thirdweb" | "1.0" + "envio.dev/hyperrpc" | "envio.dev" | "hyperrpc" + // Semver formats (should use "default client") + "3.0.0-rc0" | "default client" | "3.0.0-rc0" + "0.17.0" | "default client" | "0.17.0" + "0.16.5" | "default client" | "0.16.5" + "0.14.4-rc1" | "default client" | "0.14.4-rc1" + "v1.29.0" | "default client" | "v1.29.0" + // Single words (no slashes or dots) + "blockpi" | "blockpi" | "unknown" + "tomo-fullnode-testnet-03" | "tomo-fullnode-testnet-03" | "unknown" + "drpc-br-viction-testnet-02" | "drpc-br-viction-testnet-02" | "unknown" + "drpc-sgp-viction-mainnet-01" | "drpc-sgp-viction-mainnet-01" | "unknown" + "bh-rocks-rpc" | "bh-rocks-rpc" | "unknown" + "coin98-full-03" | "coin98-full-03" | "unknown" + "my-full-node" | "my-full-node" | "unknown" + "Amenemhat-viction" | "amenemhat-viction" | "unknown" + "Thoth-Viction" | "thoth-viction" | "unknown" + "unnamed_GlTVHn" | "unnamed_gltvhn" | "unknown" + // Edge cases + "" | "default client" | "unknown" + "/" | "default client" | "unknown" + "client/" | "client" | "unknown" + // Non-semver with dots (should fallback to default client) + "1.0" | "default client" | "1.0" + } + def "Not archival node if null response"() { setup: def up = TestingCommons.upstream(