Complete Algorand support: real lower-bound detection, settings detector, chain validator#824
Merged
Merged
Conversation
…tor, 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).
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.
There was a problem hiding this comment.
Pull request overview
Completes the previously stubbed Algorand/AVM upstream support by replacing the fixed lower-bound implementation, adding upstream client metadata detection, and introducing genesis-based chain validation hooks. This extends AVM support to better match the capabilities already present for other chain types in the codebase.
Changes:
- Replaced the hard-coded AVM state lower bound with a recursive
/v2/blocks/{round}probe-based detector. - Added an AVM upstream settings detector that reads
/v2/versionsand labels algod client type/version. - Added optional AVM genesis validation and wired the new detector/lower-bound service into
AvmChainSpecific.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmUpstreamSettingsDetector.kt |
Adds algod client type/version detection from /v2/versions. |
src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt |
Replaces stubbed lower-bound logic with recursive block-retention probing. |
src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt |
Passes the upstream through so the new detector can query head/reader state. |
src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt |
Wires the AVM settings detector and adds genesis-based upstream settings validation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+115
to
119
| chain: Chain, | ||
| upstream: Upstream, | ||
| ): UpstreamSettingsDetector { | ||
| return AvmUpstreamSettingsDetector(upstream) | ||
| } |
Comment on lines
+148
to
+152
| 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 |
Comment on lines
+133
to
+163
| * (e.g. `mainnet-v1.0`). | ||
| */ | ||
| 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 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() }, | ||
| 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={} (expected network={}) but node reports network={} id={}", |
Comment on lines
+29
to
+61
| class AvmUpstreamSettingsDetector( | ||
| upstream: Upstream, | ||
| ) : BasicUpstreamSettingsDetector(upstream) { | ||
|
|
||
| override fun internalDetectLabels(): Flux<Pair<String, String>> { | ||
| 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" |
Comment on lines
+108
to
+110
| // 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") |
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.
…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.
algod's genesis endpoint lives at /genesis, not /v2/genesis (the latter 404s, which was making the chain-id validator reject every Algorand upstream).
algod's versions endpoint lives at /versions, not /v2/versions.
…decore - 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).
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Finishes the Algorand (
AVM) blockchain support that was previously stubbed out:AvmLowerBoundStateDetector— replaces the hard-codedLowerBoundData(1, STATE)with aRecursiveLowerBoundbinary search overGET /v2/blocks/{round}?header-only=true. After auditing the algod OpenAPI spec, this is the cheapest reliable signal we can get:/v2/statusonly carrieslast-round(no minimum)./v2/ledger/sync(GetSyncRound) is an admin pin used during catchpoint catchup; once the operator unsets it the endpoint returns 400, so it can't be used as a general source of truth.AvmLowerBoundService— forwards the upstream so the recursive detector can read its head and ingress reader.AvmUpstreamSettingsDetector(new) — reads/v2/versionsforclient_version(build.major.minor.build_number) and labelsclient_type=algod.AvmChainSpecific— wires the new settings detector and adds an optional chain-id validator that compares the configuredchain-idagainst/v2/genesis(matchesnetwork,id, ornetwork-id); skips cleanly whenchain.chainIdis blank since AVM has no entries inchains.yamlyet, so the validator framework doesn't reject every Algorand upstream.Test plan
https://mainnet-api.algonode.cloud):latestBlockRequest()→ status round + block fetch parses without errorOKfrom/v2/status;SYNCINGifcatchup-time > 0client_type=algod,client_versionpopulated frombuildGenerated by Claude Code