From 84ee918cd93fd4afc17add52fbd2ad7497503123 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 11:53:07 +0200 Subject: [PATCH 1/9] Complete Algorand support: real lower-bound detection, settings detector, chain validator - AvmLowerBoundStateDetector: replace stub with RecursiveLowerBound binary search over GET /v2/blocks/{round}?header-only=true. algod has no native prune-boundary endpoint; the cheapest reliable signal is a 200/404 probe, which converges in O(log latest_round) calls at startup and refreshes cheaply via the cached-bound fast path. - AvmLowerBoundService: forward the upstream so the recursive detector can read its head and ingress reader. - AvmUpstreamSettingsDetector: read /v2/versions for client_version (build.major.minor.build_number) and tag client_type=algod. - AvmChainSpecific: wire the settings detector and add an optional chain-id validator that compares the configured chain-id against /v2/genesis network/id (Algorand has no EVM-style numeric chain id; skip cleanly when unset so existing chains.yaml entries continue to work). --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 63 ++++++++++- .../upstream/avm/AvmLowerBoundService.kt | 6 +- .../avm/AvmLowerBoundStateDetector.kt | 104 ++++++++++++++++-- .../avm/AvmUpstreamSettingsDetector.kt | 63 +++++++++++ 4 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 955e44ef..1d0eceea 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -14,6 +14,7 @@ import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator import io.emeraldpay.dshackle.upstream.SingleValidator import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService @@ -95,13 +96,73 @@ object AvmChainSpecific : AbstractPollChainSpecific() { options: Options, config: ChainConfig, ): List> { - return emptyList() + // dshackle's AVM blockchain type currently has no per-chain `chain-id` + // entries in chains.yaml. Skip cleanly when it is unset so the + // validator framework doesn't reject every Algorand upstream. + if (chain.chainId.isBlank()) { + return emptyList() + } + return listOf( + GenericSingleCallValidator( + ChainRequest("GET#/v2/genesis", RestParams.emptyParams()), + upstream, + ) { data -> + validateGenesis(data, chain, upstream.getId()) + }, + ) + } + + override fun upstreamSettingsDetector( + chain: Chain, + upstream: Upstream, + ): UpstreamSettingsDetector { + return AvmUpstreamSettingsDetector(upstream) } override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { return AvmLowerBoundService(chain, upstream) } + /** + * Algorand has no EVM-style numeric chain-id; the network identifier + * exposed by algod is the genesis id (e.g. `mainnet-v1.0`) plus the + * genesis hash. Treat the configured `chain-id` as either of those: + * exact match against `network` (e.g. `mainnet`), `id` (e.g. `v1.0`), or + * the composed `network-id` (`mainnet-v1.0`). Case-insensitive. + */ + fun validateGenesis(data: ByteArray, chain: Chain, upstreamId: String): ValidateUpstreamSettingsResult { + val expected = chain.chainId.trim() + if (expected.isBlank()) { + return ValidateUpstreamSettingsResult.UPSTREAM_VALID + } + if (data.isEmpty()) { + log.warn("AVM node {} returned empty genesis response", upstreamId) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val genesis = try { + Global.objectMapper.readValue(data, AvmGenesis::class.java) + } catch (e: Exception) { + log.warn("AVM node {} returned unparseable genesis payload: {}", upstreamId, e.message) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR + } + val candidates = listOfNotNull( + genesis.network.takeIf { it.isNotBlank() }, + genesis.id.takeIf { it.isNotBlank() }, + if (genesis.network.isNotBlank() && genesis.id.isNotBlank()) "${genesis.network}-${genesis.id}" else null, + ) + if (candidates.any { it.equals(expected, ignoreCase = true) }) { + return ValidateUpstreamSettingsResult.UPSTREAM_VALID + } + log.warn( + "AVM node {} chain mismatch: configured chain-id={} but node reports network={} id={}", + upstreamId, + expected, + genesis.network, + genesis.id, + ) + return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR + } + fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { val status = Global.objectMapper.readValue(data, AvmStatus::class.java) return if (status.catchupTime > 0L) { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt index 6fe24d36..d264e55e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt @@ -6,10 +6,10 @@ import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService class AvmLowerBoundService( - private val chain: Chain, - upstream: Upstream, + chain: Chain, + private val upstream: Upstream, ) : LowerBoundService(chain, upstream) { override fun detectors(): List { - return listOf(AvmLowerBoundStateDetector(chain)) + return listOf(AvmLowerBoundStateDetector(upstream)) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt index c83e8ab2..25317426 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt @@ -1,24 +1,112 @@ package io.emeraldpay.dshackle.upstream.avm -import io.emeraldpay.dshackle.Chain +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.Defaults +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.upstream.ChainCallError +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import io.emeraldpay.dshackle.upstream.lowerbound.detector.RecursiveLowerBound +import io.emeraldpay.dshackle.upstream.rpcclient.RestParams import reactor.core.publisher.Flux +import reactor.kotlin.core.publisher.toFlux +/** + * Detects the lowest round for which the algod upstream still has block data. + * + * Algorand's algod REST API does not expose a lower-bound field directly: + * - `/v2/status` carries `last-round` only, no minimum. + * - `/v2/ledger/sync` (`GetSyncRound`) is an admin pin used during catchpoint + * catchup; it returns 400 once unset, so it cannot be relied on as a + * general source of truth. + * + * The cheapest universally-reliable signal is a probe of `/v2/blocks/{round}`: + * algod returns 200 if the round is retained and 404 with a JSON + * `{"message":"..."}` body once it has been pruned. We feed that probe to the + * shared [RecursiveLowerBound] so the boundary converges in O(log latest_round) + * RPCs and is then refreshed cheaply via the cached-bound fast path. Header-only + * mode keeps each probe response small. + */ class AvmLowerBoundStateDetector( - chain: Chain, -) : LowerBoundDetector(chain) { + private val upstream: Upstream, +) : LowerBoundDetector(upstream.getChain()) { - override fun period(): Long { - return 120 + private val recursiveLowerBound = RecursiveLowerBound(upstream, LowerBoundType.STATE, notFoundErrors, lowerBounds) + + companion object { + // algod 404 payload examples: + // {"message":"failed to retrieve information from the ledger : block ... not found"} + // {"message":"requested block is not available"} + // {"message":"ledger does not have entry"} + // Anchoring on substrings keeps the matcher tolerant of minor wording + // changes between algod releases. + val notFoundErrors = setOf( + "block not found", + "not available", + "does not have entry", + "failed to retrieve information", + "no information found", + ) } + override fun period(): Long = 60 + override fun internalDetectLowerBound(): Flux { - return Flux.just(LowerBoundData(1, LowerBoundType.STATE)) + return recursiveLowerBound.recursiveDetectLowerBound { block -> + val round = if (block <= 0L) 1L else block + val params = RestParams( + headers = emptyList(), + queryParams = listOf("header-only" to "true"), + pathParams = listOf(round.toString()), + payload = ByteArray(0), + ) + upstream.getIngressReader() + .read(ChainRequest("GET#/v2/blocks/*", params)) + .timeout(Defaults.internalCallsTimeout) + .map { response -> interpretBlockResponse(round, response) } + }.toFlux() + } + + override fun types(): Set = setOf(LowerBoundType.STATE) + + /** + * algod returns 4xx as a normal HTTP body for REST callers; the dshackle + * REST reader surfaces it as a successful [ChainResponse] with the + * upstream's JSON payload. Inspect the payload here so we can convert a + * "block not retained" reply into a [ChainCallError] that + * [RecursiveLowerBound] interprets as "no data at this round". + */ + private fun interpretBlockResponse(round: Long, response: ChainResponse): ChainResponse { + if (response.hasError()) { + return response + } + val raw = response.getResult() + if (raw.isEmpty()) { + return ChainResponse(null, ChainCallError(404, "empty body for round $round")) + } + val node = runCatching { Global.objectMapper.readTree(raw) }.getOrNull() ?: return response + val message = node.get("message")?.asText().orEmpty() + if (message.isNotBlank() && looksLikeNotFound(message)) { + return ChainResponse(null, ChainCallError(404, message)) + } + if (!hasBlockPayload(node)) { + return ChainResponse(null, ChainCallError(404, "round $round not available")) + } + return response + } + + private fun looksLikeNotFound(message: String): Boolean { + val lower = message.lowercase() + return notFoundErrors.any { lower.contains(it) } } - override fun types(): Set { - return setOf(LowerBoundType.STATE) + private fun hasBlockPayload(node: JsonNode): Boolean { + // GET /v2/blocks/{round} returns an object with a `block` field on success + // (or `cert`/`block` for some flags). Treat absence of either as a miss. + return node.has("block") || node.has("cert") } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt new file mode 100644 index 00000000..90ba0092 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt @@ -0,0 +1,63 @@ +package io.emeraldpay.dshackle.upstream.avm + +import com.fasterxml.jackson.databind.JsonNode +import io.emeraldpay.dshackle.upstream.BasicUpstreamSettingsDetector +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.NodeTypeRequest +import io.emeraldpay.dshackle.upstream.UNKNOWN_CLIENT_VERSION +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.rpcclient.RestParams +import reactor.core.publisher.Flux + +/** + * Reads node identity from algod's `/v2/versions` endpoint, which returns: + * + * ``` + * { + * "build": {"major":3,"minor":24,"build_number":2,"branch":"rel/stable","channel":"stable", ...}, + * "genesis_id": "mainnet-v1.0", + * "genesis_hash_b64": "...", + * "versions": ["v1","v2"] + * } + * ``` + * + * algod is the only public algorand node implementation, so client_type is + * fixed to `algod`. Client version is reconstructed from the build object + * (major.minor.build_number) which is the form algorand themselves publish in + * release notes. + */ +class AvmUpstreamSettingsDetector( + upstream: Upstream, +) : BasicUpstreamSettingsDetector(upstream) { + + override fun internalDetectLabels(): Flux> { + return Flux.merge( + detectNodeType(), + ) + } + + override fun clientVersionRequest(): ChainRequest = + ChainRequest("GET#/v2/versions", RestParams.emptyParams()) + + override fun parseClientVersion(data: ByteArray): String { + if (data.isEmpty()) return UNKNOWN_CLIENT_VERSION + val node = runCatching { io.emeraldpay.dshackle.Global.objectMapper.readTree(data) }.getOrNull() + ?: return UNKNOWN_CLIENT_VERSION + return clientVersion(node) ?: UNKNOWN_CLIENT_VERSION + } + + override fun nodeTypeRequest(): NodeTypeRequest = NodeTypeRequest(clientVersionRequest()) + + override fun clientType(node: JsonNode): String = "algod" + + override fun clientVersion(node: JsonNode): String { + val build = node.get("build") ?: return UNKNOWN_CLIENT_VERSION + val major = build.get("major")?.asInt(-1) ?: -1 + val minor = build.get("minor")?.asInt(-1) ?: -1 + val patch = build.get("build_number")?.asInt(-1) ?: -1 + if (major < 0 || minor < 0 || patch < 0) { + return UNKNOWN_CLIENT_VERSION + } + return "$major.$minor.$patch" + } +} From 026d8f8c2aef50de5e05beb1ba67ac78a74d7af2 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 12:06:07 +0200 Subject: [PATCH 2/9] avm: map drpc synthetic chain-id to algod genesis network chains.yaml uses synthetic chain-ids for Algorand (0x65901 mainnet, 0x65902 testnet, 0x65903 betanet) while algod's /v2/genesis reports network as mainnet/testnet/betanet. The validator now translates first and only falls back to the literal-match candidates so deployments that point chain-id at the network name directly continue to validate. --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 1d0eceea..7bfcdf24 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -96,9 +96,8 @@ object AvmChainSpecific : AbstractPollChainSpecific() { options: Options, config: ChainConfig, ): List> { - // dshackle's AVM blockchain type currently has no per-chain `chain-id` - // entries in chains.yaml. Skip cleanly when it is unset so the - // validator framework doesn't reject every Algorand upstream. + // Skip cleanly when chain-id is unset so the validator framework + // doesn't reject every Algorand upstream. if (chain.chainId.isBlank()) { return emptyList() } @@ -124,11 +123,14 @@ object AvmChainSpecific : AbstractPollChainSpecific() { } /** - * Algorand has no EVM-style numeric chain-id; the network identifier - * exposed by algod is the genesis id (e.g. `mainnet-v1.0`) plus the - * genesis hash. Treat the configured `chain-id` as either of those: - * exact match against `network` (e.g. `mainnet`), `id` (e.g. `v1.0`), or - * the composed `network-id` (`mainnet-v1.0`). Case-insensitive. + * Algorand has no EVM-style numeric chain-id; drpc assigns synthetic ones + * (`0x65901` mainnet, `0x65902` testnet, `0x65903` betanet) in chains.yaml, + * while algod's `/v2/genesis` reports `network` as `mainnet`/`testnet`/ + * `betanet`. We translate the configured chain-id to its expected algod + * network and compare. As a fallback - so this still works on dshackle + * deployments that point chain-id at the network/id literal directly - + * we also accept matches against `network`, `id`, or composed `network-id` + * (e.g. `mainnet-v1.0`). */ fun validateGenesis(data: ByteArray, chain: Chain, upstreamId: String): ValidateUpstreamSettingsResult { val expected = chain.chainId.trim() @@ -145,6 +147,10 @@ object AvmChainSpecific : AbstractPollChainSpecific() { log.warn("AVM node {} returned unparseable genesis payload: {}", upstreamId, e.message) return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR } + val expectedNetwork = ALGORAND_CHAIN_ID_NETWORK[expected.lowercase()] + if (expectedNetwork != null && genesis.network.equals(expectedNetwork, ignoreCase = true)) { + return ValidateUpstreamSettingsResult.UPSTREAM_VALID + } val candidates = listOfNotNull( genesis.network.takeIf { it.isNotBlank() }, genesis.id.takeIf { it.isNotBlank() }, @@ -154,15 +160,25 @@ object AvmChainSpecific : AbstractPollChainSpecific() { return ValidateUpstreamSettingsResult.UPSTREAM_VALID } log.warn( - "AVM node {} chain mismatch: configured chain-id={} but node reports network={} id={}", + "AVM node {} chain mismatch: configured chain-id={} (expected network={}) but node reports network={} id={}", upstreamId, expected, + expectedNetwork ?: "", genesis.network, genesis.id, ) return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR } + // Synthetic chain-ids drpc assigns to Algorand networks in chains.yaml, + // mapped to the network name algod publishes via /v2/genesis. Keep keys + // lowercase so callers can normalise without the map needing to. + private val ALGORAND_CHAIN_ID_NETWORK = mapOf( + "0x65901" to "mainnet", + "0x65902" to "testnet", + "0x65903" to "betanet", + ) + fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { val status = Global.objectMapper.readValue(data, AvmStatus::class.java) return if (status.catchupTime > 0L) { From 0e8b06f299b8833884741c10873a0b251e7f1caa Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 12:11:22 +0200 Subject: [PATCH 3/9] avm: tighten genesis validator and correct chain-id provenance comment The chain-ids 0x65901/0x65902/0x65903 are official Algorand chain-ids, not synthetic drpc assignments - update the comment accordingly. Drop the bare-id and network-id fallback candidates: schema id `v1.0` is shared across mainnet/testnet/betanet, so accepting it as a standalone match would let a chain-id like `v1.0` validate against the wrong network. Validation now requires the chain-id to map to a known network and `genesis.network` to match it exactly, otherwise SettingsError / FatalSettingError. --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 7bfcdf24..ade553f2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -123,14 +123,14 @@ object AvmChainSpecific : AbstractPollChainSpecific() { } /** - * Algorand has no EVM-style numeric chain-id; drpc assigns synthetic ones - * (`0x65901` mainnet, `0x65902` testnet, `0x65903` betanet) in chains.yaml, - * while algod's `/v2/genesis` reports `network` as `mainnet`/`testnet`/ - * `betanet`. We translate the configured chain-id to its expected algod - * network and compare. As a fallback - so this still works on dshackle - * deployments that point chain-id at the network/id literal directly - - * we also accept matches against `network`, `id`, or composed `network-id` - * (e.g. `mainnet-v1.0`). + * Algorand assigns one chain-id per network (`0x65901` mainnet, + * `0x65902` testnet, `0x65903` betanet). algod's `/v2/genesis` does + * not echo it back as a number; it reports the human network name + * (`mainnet`/`testnet`/`betanet`/...) via the `network` field. We + * translate the configured chain-id to its expected algod network and + * require an exact match - the schema `id` (e.g. `v1.0`) is shared + * across networks so it cannot be used as an additional acceptance + * candidate. */ fun validateGenesis(data: ByteArray, chain: Chain, upstreamId: String): ValidateUpstreamSettingsResult { val expected = chain.chainId.trim() @@ -148,31 +148,31 @@ object AvmChainSpecific : AbstractPollChainSpecific() { return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR } val expectedNetwork = ALGORAND_CHAIN_ID_NETWORK[expected.lowercase()] - if (expectedNetwork != null && genesis.network.equals(expectedNetwork, ignoreCase = true)) { - return ValidateUpstreamSettingsResult.UPSTREAM_VALID + if (expectedNetwork == null) { + log.warn( + "AVM upstream {} has unknown Algorand chain-id {}; cannot validate against /v2/genesis", + upstreamId, + expected, + ) + return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR } - val candidates = listOfNotNull( - genesis.network.takeIf { it.isNotBlank() }, - genesis.id.takeIf { it.isNotBlank() }, - if (genesis.network.isNotBlank() && genesis.id.isNotBlank()) "${genesis.network}-${genesis.id}" else null, - ) - if (candidates.any { it.equals(expected, ignoreCase = true) }) { + if (genesis.network.equals(expectedNetwork, ignoreCase = true)) { return ValidateUpstreamSettingsResult.UPSTREAM_VALID } log.warn( "AVM node {} chain mismatch: configured chain-id={} (expected network={}) but node reports network={} id={}", upstreamId, expected, - expectedNetwork ?: "", + expectedNetwork, genesis.network, genesis.id, ) return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR } - // Synthetic chain-ids drpc assigns to Algorand networks in chains.yaml, - // mapped to the network name algod publishes via /v2/genesis. Keep keys - // lowercase so callers can normalise without the map needing to. + // Official Algorand chain-ids per network, mapped to the network name + // algod publishes via /v2/genesis. Keep keys lowercase so callers can + // normalise without the map having to. private val ALGORAND_CHAIN_ID_NETWORK = mapOf( "0x65901" to "mainnet", "0x65902" to "testnet", From efb014ead1cfe91be55850315f764864d7867215 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 12:26:36 +0200 Subject: [PATCH 4/9] avm: drop verbose comments; switch lower-bound probe to /v2/blocks/*/hash The hash variant returns ~70 bytes per probe instead of the multi-kB block header, so non-archival cold starts use noticeably less bandwidth without any accuracy loss. --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 26 +---------- .../avm/AvmLowerBoundStateDetector.kt | 46 ++++--------------- .../avm/AvmUpstreamSettingsDetector.kt | 17 ------- 3 files changed, 10 insertions(+), 79 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index ade553f2..7fcf730f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -96,8 +96,6 @@ object AvmChainSpecific : AbstractPollChainSpecific() { options: Options, config: ChainConfig, ): List> { - // Skip cleanly when chain-id is unset so the validator framework - // doesn't reject every Algorand upstream. if (chain.chainId.isBlank()) { return emptyList() } @@ -122,16 +120,6 @@ object AvmChainSpecific : AbstractPollChainSpecific() { return AvmLowerBoundService(chain, upstream) } - /** - * Algorand assigns one chain-id per network (`0x65901` mainnet, - * `0x65902` testnet, `0x65903` betanet). algod's `/v2/genesis` does - * not echo it back as a number; it reports the human network name - * (`mainnet`/`testnet`/`betanet`/...) via the `network` field. We - * translate the configured chain-id to its expected algod network and - * require an exact match - the schema `id` (e.g. `v1.0`) is shared - * across networks so it cannot be used as an additional acceptance - * candidate. - */ fun validateGenesis(data: ByteArray, chain: Chain, upstreamId: String): ValidateUpstreamSettingsResult { val expected = chain.chainId.trim() if (expected.isBlank()) { @@ -149,18 +137,14 @@ object AvmChainSpecific : AbstractPollChainSpecific() { } val expectedNetwork = ALGORAND_CHAIN_ID_NETWORK[expected.lowercase()] if (expectedNetwork == null) { - log.warn( - "AVM upstream {} has unknown Algorand chain-id {}; cannot validate against /v2/genesis", - upstreamId, - expected, - ) + log.warn("AVM upstream {} has unknown Algorand chain-id {}", upstreamId, expected) return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR } if (genesis.network.equals(expectedNetwork, ignoreCase = true)) { return ValidateUpstreamSettingsResult.UPSTREAM_VALID } log.warn( - "AVM node {} chain mismatch: configured chain-id={} (expected network={}) but node reports network={} id={}", + "AVM node {} chain mismatch: chain-id={} (expected={}) but node reports network={} id={}", upstreamId, expected, expectedNetwork, @@ -170,9 +154,6 @@ object AvmChainSpecific : AbstractPollChainSpecific() { return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR } - // Official Algorand chain-ids per network, mapped to the network name - // algod publishes via /v2/genesis. Keep keys lowercase so callers can - // normalise without the map having to. private val ALGORAND_CHAIN_ID_NETWORK = mapOf( "0x65901" to "mainnet", "0x65902" to "testnet", @@ -189,9 +170,6 @@ object AvmChainSpecific : AbstractPollChainSpecific() { } } - // Algorand JSON blocks encode 32-byte fields (seed, prev, txn) in base64. - // Decode to raw bytes; if decoding fails or the field is absent, fall back - // to a deterministic 32-byte encoding of the round number. private fun toHashBytes(raw: String?, round: Long): ByteArray { if (raw.isNullOrBlank()) { return roundToBytes(round) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt index 25317426..80ee6ba2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt @@ -15,22 +15,6 @@ import io.emeraldpay.dshackle.upstream.rpcclient.RestParams import reactor.core.publisher.Flux import reactor.kotlin.core.publisher.toFlux -/** - * Detects the lowest round for which the algod upstream still has block data. - * - * Algorand's algod REST API does not expose a lower-bound field directly: - * - `/v2/status` carries `last-round` only, no minimum. - * - `/v2/ledger/sync` (`GetSyncRound`) is an admin pin used during catchpoint - * catchup; it returns 400 once unset, so it cannot be relied on as a - * general source of truth. - * - * The cheapest universally-reliable signal is a probe of `/v2/blocks/{round}`: - * algod returns 200 if the round is retained and 404 with a JSON - * `{"message":"..."}` body once it has been pruned. We feed that probe to the - * shared [RecursiveLowerBound] so the boundary converges in O(log latest_round) - * RPCs and is then refreshed cheaply via the cached-bound fast path. Header-only - * mode keeps each probe response small. - */ class AvmLowerBoundStateDetector( private val upstream: Upstream, ) : LowerBoundDetector(upstream.getChain()) { @@ -38,12 +22,6 @@ class AvmLowerBoundStateDetector( private val recursiveLowerBound = RecursiveLowerBound(upstream, LowerBoundType.STATE, notFoundErrors, lowerBounds) companion object { - // algod 404 payload examples: - // {"message":"failed to retrieve information from the ledger : block ... not found"} - // {"message":"requested block is not available"} - // {"message":"ledger does not have entry"} - // Anchoring on substrings keeps the matcher tolerant of minor wording - // changes between algod releases. val notFoundErrors = setOf( "block not found", "not available", @@ -60,27 +38,20 @@ class AvmLowerBoundStateDetector( val round = if (block <= 0L) 1L else block val params = RestParams( headers = emptyList(), - queryParams = listOf("header-only" to "true"), + queryParams = emptyList(), pathParams = listOf(round.toString()), payload = ByteArray(0), ) upstream.getIngressReader() - .read(ChainRequest("GET#/v2/blocks/*", params)) + .read(ChainRequest("GET#/v2/blocks/*/hash", params)) .timeout(Defaults.internalCallsTimeout) - .map { response -> interpretBlockResponse(round, response) } + .map { response -> interpretHashResponse(round, response) } }.toFlux() } override fun types(): Set = setOf(LowerBoundType.STATE) - /** - * algod returns 4xx as a normal HTTP body for REST callers; the dshackle - * REST reader surfaces it as a successful [ChainResponse] with the - * upstream's JSON payload. Inspect the payload here so we can convert a - * "block not retained" reply into a [ChainCallError] that - * [RecursiveLowerBound] interprets as "no data at this round". - */ - private fun interpretBlockResponse(round: Long, response: ChainResponse): ChainResponse { + private fun interpretHashResponse(round: Long, response: ChainResponse): ChainResponse { if (response.hasError()) { return response } @@ -93,7 +64,7 @@ class AvmLowerBoundStateDetector( if (message.isNotBlank() && looksLikeNotFound(message)) { return ChainResponse(null, ChainCallError(404, message)) } - if (!hasBlockPayload(node)) { + if (!hasHashPayload(node)) { return ChainResponse(null, ChainCallError(404, "round $round not available")) } return response @@ -104,9 +75,8 @@ class AvmLowerBoundStateDetector( return notFoundErrors.any { lower.contains(it) } } - private fun hasBlockPayload(node: JsonNode): Boolean { - // GET /v2/blocks/{round} returns an object with a `block` field on success - // (or `cert`/`block` for some flags). Treat absence of either as a miss. - return node.has("block") || node.has("cert") + private fun hasHashPayload(node: JsonNode): Boolean { + val hash = node.get("blockHash")?.asText().orEmpty() + return hash.isNotBlank() } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt index 90ba0092..ad5f3e8c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt @@ -9,23 +9,6 @@ import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.rpcclient.RestParams import reactor.core.publisher.Flux -/** - * Reads node identity from algod's `/v2/versions` endpoint, which returns: - * - * ``` - * { - * "build": {"major":3,"minor":24,"build_number":2,"branch":"rel/stable","channel":"stable", ...}, - * "genesis_id": "mainnet-v1.0", - * "genesis_hash_b64": "...", - * "versions": ["v1","v2"] - * } - * ``` - * - * algod is the only public algorand node implementation, so client_type is - * fixed to `algod`. Client version is reconstructed from the build object - * (major.minor.build_number) which is the form algorand themselves publish in - * release notes. - */ class AvmUpstreamSettingsDetector( upstream: Upstream, ) : BasicUpstreamSettingsDetector(upstream) { From 973cb20de8708b33afeff526522d9a85a57d7d51 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 12:34:15 +0200 Subject: [PATCH 5/9] avm: fix /genesis path - it's root-level, not under /v2/ algod's genesis endpoint lives at /genesis, not /v2/genesis (the latter 404s, which was making the chain-id validator reject every Algorand upstream). --- .../io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 7fcf730f..3d84e99b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -101,7 +101,7 @@ object AvmChainSpecific : AbstractPollChainSpecific() { } return listOf( GenericSingleCallValidator( - ChainRequest("GET#/v2/genesis", RestParams.emptyParams()), + ChainRequest("GET#/genesis", RestParams.emptyParams()), upstream, ) { data -> validateGenesis(data, chain, upstream.getId()) From 5e136f7dceafb7d54f0c4a60008cee56813851a4 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 12:34:33 +0200 Subject: [PATCH 6/9] avm: fix /versions path - it's root-level, not under /v2/ algod's versions endpoint lives at /versions, not /v2/versions. --- .../dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt index ad5f3e8c..4ef66a5c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt @@ -20,7 +20,7 @@ class AvmUpstreamSettingsDetector( } override fun clientVersionRequest(): ChainRequest = - ChainRequest("GET#/v2/versions", RestParams.emptyParams()) + ChainRequest("GET#/versions", RestParams.emptyParams()) override fun parseClientVersion(data: ByteArray): String { if (data.isEmpty()) return UNKNOWN_CLIENT_VERSION From 8736a3751823832031f59c250f14649290f59a1f Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 13:01:45 +0200 Subject: [PATCH 7/9] avm: bring health validator and chain-id error policy in line with nodecore - validate(): also report Unavailable on `last-round=0` (node has no head yet) and on `stopped-at-unsupported-round` (halted on a consensus upgrade) so the router stops sending traffic to a stuck node. Previously only `catchup-time > 0` mapped to SYNCING; the other two conditions were silently treated as OK. - AvmStatus: add `stopped-at-unsupported-round`. - validateGenesis(): unknown chain-id is a static config mistake that won't resolve on its own, so return UPSTREAM_FATAL_SETTINGS_ERROR instead of UPSTREAM_SETTINGS_ERROR (which loops the validator forever). --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 3d84e99b..94555e41 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -125,6 +125,11 @@ object AvmChainSpecific : AbstractPollChainSpecific() { if (expected.isBlank()) { return ValidateUpstreamSettingsResult.UPSTREAM_VALID } + val expectedNetwork = ALGORAND_CHAIN_ID_NETWORK[expected.lowercase()] + if (expectedNetwork == null) { + log.warn("AVM upstream {} has unknown Algorand chain-id {}", upstreamId, expected) + return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR + } if (data.isEmpty()) { log.warn("AVM node {} returned empty genesis response", upstreamId) return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR @@ -135,11 +140,6 @@ object AvmChainSpecific : AbstractPollChainSpecific() { log.warn("AVM node {} returned unparseable genesis payload: {}", upstreamId, e.message) return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR } - val expectedNetwork = ALGORAND_CHAIN_ID_NETWORK[expected.lowercase()] - if (expectedNetwork == null) { - log.warn("AVM upstream {} has unknown Algorand chain-id {}", upstreamId, expected) - return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR - } if (genesis.network.equals(expectedNetwork, ignoreCase = true)) { return ValidateUpstreamSettingsResult.UPSTREAM_VALID } @@ -162,12 +162,19 @@ object AvmChainSpecific : AbstractPollChainSpecific() { fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { val status = Global.objectMapper.readValue(data, AvmStatus::class.java) - return if (status.catchupTime > 0L) { + if (status.lastRound == 0L) { + log.warn("AVM node {} reports no last-round", upstreamId) + return UpstreamAvailability.UNAVAILABLE + } + if (status.stoppedAtUnsupportedRound) { + log.warn("AVM node {} halted on an unsupported consensus round", upstreamId) + return UpstreamAvailability.UNAVAILABLE + } + if (status.catchupTime > 0L) { log.warn("AVM node {} is catching up: catchupTime={}ns", upstreamId, status.catchupTime) - UpstreamAvailability.SYNCING - } else { - UpstreamAvailability.OK + return UpstreamAvailability.SYNCING } + return UpstreamAvailability.OK } private fun toHashBytes(raw: String?, round: Long): ByteArray { @@ -204,6 +211,7 @@ data class AvmStatus( @param:JsonProperty("time-since-last-round") var timeSinceLastRound: Long = 0, @param:JsonProperty("last-version") var lastVersion: String? = null, @param:JsonProperty("next-version") var nextVersion: String? = null, + @param:JsonProperty("stopped-at-unsupported-round") var stoppedAtUnsupportedRound: Boolean = false, ) @JsonIgnoreProperties(ignoreUnknown = true) From efe47086ae32e2404651c67389622ff00f5c8620 Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Tue, 5 May 2026 13:13:51 +0200 Subject: [PATCH 8/9] avm: emit cached or LowerBoundType.UNKNOWN when probe fails If the recursive search hits a hard error or the upstream has no retained blocks at all, emit either the previously cached STATE bound (so the router keeps using the last known good value) or an explicit LowerBoundData(0, UNKNOWN) so consumers see a definite "we don't know" signal instead of silence. --- .../avm/AvmLowerBoundStateDetector.kt | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt index 80ee6ba2..6e2d1eac 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt @@ -12,7 +12,9 @@ import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType import io.emeraldpay.dshackle.upstream.lowerbound.detector.RecursiveLowerBound import io.emeraldpay.dshackle.upstream.rpcclient.RestParams +import org.slf4j.LoggerFactory import reactor.core.publisher.Flux +import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.toFlux class AvmLowerBoundStateDetector( @@ -22,6 +24,8 @@ class AvmLowerBoundStateDetector( private val recursiveLowerBound = RecursiveLowerBound(upstream, LowerBoundType.STATE, notFoundErrors, lowerBounds) companion object { + private val log = LoggerFactory.getLogger(AvmLowerBoundStateDetector::class.java) + val notFoundErrors = setOf( "block not found", "not available", @@ -46,10 +50,32 @@ class AvmLowerBoundStateDetector( .read(ChainRequest("GET#/v2/blocks/*/hash", params)) .timeout(Defaults.internalCallsTimeout) .map { response -> interpretHashResponse(round, response) } - }.toFlux() + } + .switchIfEmpty(Mono.fromSupplier { cachedOrUnknown("recursive search returned no bound") }) + .onErrorResume { err -> Mono.just(cachedOrUnknown(err.message ?: "unknown error")) } + .toFlux() } - override fun types(): Set = setOf(LowerBoundType.STATE) + override fun types(): Set = setOf(LowerBoundType.STATE, LowerBoundType.UNKNOWN) + + private fun cachedOrUnknown(reason: String): LowerBoundData { + val cached = lowerBounds.getLastBound(LowerBoundType.STATE) + if (cached != null) { + log.debug( + "AVM upstream {} lower-bound search failed ({}); retaining cached STATE={}", + upstream.getId(), + reason, + cached.lowerBound, + ) + return cached + } + log.warn( + "AVM upstream {} lower-bound search failed ({}) and no cache is available; emitting UNKNOWN", + upstream.getId(), + reason, + ) + return LowerBoundData(0, LowerBoundType.UNKNOWN) + } private fun interpretHashResponse(round: Long, response: ChainResponse): ChainResponse { if (response.hasError()) { From 640d13ae05b948f545c3bbd506990a4b643048bc Mon Sep 17 00:00:00 2001 From: vadim Date: Tue, 5 May 2026 13:31:57 +0200 Subject: [PATCH 9/9] Remove hardcode --- .../dshackle/upstream/avm/AvmChainSpecific.kt | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt index 3d84e99b..9e15d5b4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -135,18 +135,14 @@ object AvmChainSpecific : AbstractPollChainSpecific() { log.warn("AVM node {} returned unparseable genesis payload: {}", upstreamId, e.message) return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR } - val expectedNetwork = ALGORAND_CHAIN_ID_NETWORK[expected.lowercase()] - if (expectedNetwork == null) { - log.warn("AVM upstream {} has unknown Algorand chain-id {}", upstreamId, expected) - return ValidateUpstreamSettingsResult.UPSTREAM_SETTINGS_ERROR - } + val expectedNetwork = chain.chainName.substringAfterLast(" ").lowercase() if (genesis.network.equals(expectedNetwork, ignoreCase = true)) { return ValidateUpstreamSettingsResult.UPSTREAM_VALID } log.warn( - "AVM node {} chain mismatch: chain-id={} (expected={}) but node reports network={} id={}", + "AVM node {} chain mismatch: chain={} (expected network={}) but node reports network={} id={}", upstreamId, - expected, + chain.chainName, expectedNetwork, genesis.network, genesis.id, @@ -154,12 +150,6 @@ object AvmChainSpecific : AbstractPollChainSpecific() { return ValidateUpstreamSettingsResult.UPSTREAM_FATAL_SETTINGS_ERROR } - private val ALGORAND_CHAIN_ID_NETWORK = mapOf( - "0x65901" to "mainnet", - "0x65902" to "testnet", - "0x65903" to "betanet", - ) - fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { val status = Global.objectMapper.readValue(data, AvmStatus::class.java) return if (status.catchupTime > 0L) {