Skip to content

Complete Algorand support: real lower-bound detection, settings detector, chain validator#824

Merged
filinvadim merged 10 commits into
masterfrom
claude/algorand-completion
May 5, 2026
Merged

Complete Algorand support: real lower-bound detection, settings detector, chain validator#824
filinvadim merged 10 commits into
masterfrom
claude/algorand-completion

Conversation

@filinvadim
Copy link
Copy Markdown
Contributor

Summary

Finishes the Algorand (AVM) blockchain support that was previously stubbed out:

  • AvmLowerBoundStateDetector — replaces the hard-coded LowerBoundData(1, STATE) with a RecursiveLowerBound binary search over GET /v2/blocks/{round}?header-only=true. After auditing the algod OpenAPI spec, this is the cheapest reliable signal we can get:
    • /v2/status only carries last-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.
    • The 200/404 probe converges in O(log latest_round) calls at startup and refreshes cheaply via the cached-bound fast path. Header-only mode keeps each probe response small.
  • AvmLowerBoundService — forwards the upstream so the recursive detector can read its head and ingress reader.
  • AvmUpstreamSettingsDetector (new) — reads /v2/versions for client_version (build.major.minor.build_number) and labels client_type=algod.
  • AvmChainSpecific — wires the new settings detector and adds an optional chain-id validator that compares the configured chain-id against /v2/genesis (matches network, id, or network-id); skips cleanly when chain.chainId is blank since AVM has no entries in chains.yaml yet, so the validator framework doesn't reject every Algorand upstream.

Test plan

  • Pre-merge CI is green (ktlint already verified locally).
  • Manual smoke test against a public algod endpoint (https://mainnet-api.algonode.cloud):
    • head poll: latestBlockRequest() → status round + block fetch parses without error
    • availability validator: OK from /v2/status; SYNCING if catchup-time > 0
    • settings detector: client_type=algod, client_version populated from build
    • lower-bound detector: returns a sensible round (1 for archival, prune horizon for non-archival)

Generated by Claude Code

…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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/versions and 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")
filinvadim added 8 commits May 5, 2026 12:11
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.
@filinvadim filinvadim merged commit aec4418 into master May 5, 2026
1 check passed
@filinvadim filinvadim deleted the claude/algorand-completion branch May 5, 2026 11:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants