From 5414e06e46f39ee9aa6dff7df12c17d3f3794f1d Mon Sep 17 00:00:00 2001 From: Vadim Filin Date: Thu, 23 Apr 2026 13:10:56 +0200 Subject: [PATCH] Add support for Algorand Virtual Machine (AVM) blockchain (#813) * add AVM blockchain type support for Algorand Introduce a new blockchain type `avm` (Algorand Virtual Machine) so Algorand-based chains (Mainnet, Testnet, Betanet) declared in chains.yaml can be parsed and driven through dshackle's generic polling pipeline. Key additions: - BlockchainType.AVM (JSON_RPC) and `avm` mapping in chain codegen - AvmChainSpecific: polls `algod_getBlock` for latest block and uses `algod_status` / `algod_genesis` for health and settings validation - DefaultAvmMethods: allowed algod_* RPC surface with send-transaction broadcast quorum and hardcoded `algod_chainId` / `algod_genesisId` - AvmLowerBoundService / AvmLowerBoundStateDetector for lower-bound tracking - Registration in ChainSpecificRegistry and CallTargetsHolder - Unit tests for block parsing, sync validation and method policies https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * inline DummyChainReader in AVM test Match the Starknet test pattern and drop the dedicated DummyChainReader file; instead pass an anonymous ChainReader directly where needed. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * fix AVM network validation logic Validation used AND across all conditions, so when netVersion was 0 (typical for non-EVM chains) the check short-circuited and allowed a mismatching chain.chainId through. Simplify to the Cosmos pattern: reject when chain.chainId is set and doesn't match genesis.network. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * switch AVM to REST transport for native algod nodes Native Algorand algod nodes expose a REST API (/v2/status, /v2/blocks, /v2/transactions, ...) rather than JSON-RPC, so drive AVM upstreams through dshackle's REST reader instead: - BlockchainType.AVM is now ApiType.REST - AvmChainSpecific polls GET#/v2/status for latest block info and uses GET#/v2/genesis for chain-id validation, both via RestParams - DefaultAvmMethods exposes the algod /v2 endpoints with VERB#/path identifiers (POST#/v2/transactions uses BroadcastQuorum for sends) - Tests updated to match the REST-based method names https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * align AVM endpoints with algod OpenAPI spec Validated the AVM method surface against the official algod spec (algorand/go-algorand algod.oas3.yml) and corrected mismatches: Wrong paths removed: - GET /v2/genesis, /v2/versions, /v2/health, /v2/ready, /v2/metrics (these exist at root level, not under /v2/) - GET /v2/blocks/{round}/header (use header-only query on /v2/blocks/{round}) - GET /v2/blocks/{round}/transactions (no such path) - GET /v2/lightheader/{round} (real path is /v2/blocks/{round}/lightheader/proof) - POST /v2/transactions/dryrun (real path is /v2/teal/dryrun, already listed) Added real endpoints: - Root: GET /genesis, /health, /ready, /metrics, /versions, /swagger.json - /v2/blocks/*/txids, /v2/blocks/*/logs, /v2/blocks/*/lightheader/proof - /v2/accounts/*/transactions/pending - /v2/deltas/*, /v2/deltas/*/txn/group, /v2/deltas/txn/group/* Also fixed AvmChainSpecific settings validator: use GET#/genesis (root) instead of the non-existent GET#/v2/genesis. Added regression test asserting the spurious paths are NOT callable. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * fetch real AVM block data via chained api.read parseBlock now uses the ChainReader passed through AbstractPollChainSpecific to fetch GET#/v2/blocks/{lastRound}?header-only=true after /v2/status, so BlockContainer.timestamp comes from block.ts and hash/parentHash decode from block.seed/block.prev (base64 -> 32 raw bytes) instead of being synthesized from the round number. Also differentiate quorum per method in DefaultAvmMethods: single- resource lookups (/v2/blocks/{round}, /v2/accounts/{addr}, etc.) now use NotNullQuorum so a single replica returning empty/404 doesn't shadow a valid response from another upstream. List and status endpoints keep AlwaysQuorum; send endpoints keep BroadcastQuorum. https://claude.ai/code/session_01TZ64DZis9YaJSU3oMwtRLe * Update submodules * Update chains * Fix settings validation --------- Co-authored-by: Claude --- Makefile | 2 +- .../kotlin/chainsconfig.codegen.gradle.kts | 1 + emerald-grpc | 2 +- .../io/emeraldpay/dshackle/BlockchainType.kt | 3 +- foundation/src/main/resources/public | 2 +- .../dshackle/upstream/CallTargetsHolder.kt | 3 + .../dshackle/upstream/avm/AvmChainSpecific.kt | 175 ++++++++++++++++++ .../upstream/avm/AvmLowerBoundService.kt | 15 ++ .../avm/AvmLowerBoundStateDetector.kt | 24 +++ .../upstream/calls/DefaultAvmMethods.kt | 150 +++++++++++++++ .../upstream/generic/ChainSpecific.kt | 3 + .../upstream/avm/AvmChainSpecificTest.kt | 93 ++++++++++ .../upstream/calls/DefaultAvmMethodsTest.kt | 119 ++++++++++++ 13 files changed, 588 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt diff --git a/Makefile b/Makefile index bc1bac9b..800186e3 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ build-foundation: cd foundation && ../gradlew build publishToMavenLocal run-main: - ./gradlew run + ./gradlew run -x test build-main: ./gradlew build diff --git a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts index 0d810905..ec0699f2 100644 --- a/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts +++ b/buildSrc/src/main/kotlin/chainsconfig.codegen.gradle.kts @@ -140,6 +140,7 @@ open class CodeGen(private val config: ChainsConfig) { "cosmos" -> "BlockchainType.COSMOS" "ripple" -> "BlockchainType.RIPPLE" "kadena" -> "BlockchainType.KADENA" + "avm" -> "BlockchainType.AVM" "app" -> "BlockchainType.ETHEREUM" else -> throw IllegalArgumentException("unknown blockchain type $type") } diff --git a/emerald-grpc b/emerald-grpc index a0df7d71..67b4cccd 160000 --- a/emerald-grpc +++ b/emerald-grpc @@ -1 +1 @@ -Subproject commit a0df7d714482fcea2445abd81abce7805ab78995 +Subproject commit 67b4cccdd71bb86db0e93e4b118c38e8f6b386e2 diff --git a/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt b/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt index 227f6dde..a81006c0 100644 --- a/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt +++ b/foundation/src/main/kotlin/io/emeraldpay/dshackle/BlockchainType.kt @@ -15,7 +15,8 @@ enum class BlockchainType( COSMOS(ApiType.JSON_RPC), TON(ApiType.REST), RIPPLE(ApiType.JSON_RPC), - KADENA(ApiType.REST),; + KADENA(ApiType.REST), + AVM(ApiType.REST),; } enum class ApiType { diff --git a/foundation/src/main/resources/public b/foundation/src/main/resources/public index 4510b37a..c0fc62c4 160000 --- a/foundation/src/main/resources/public +++ b/foundation/src/main/resources/public @@ -1 +1 @@ -Subproject commit 4510b37a56e97c188b30dff63093f10c560c68d6 +Subproject commit c0fc62c41fb4048fa91875f8f3a5ec432b628ff2 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt index fa51101b..b877d56f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/CallTargetsHolder.kt @@ -1,5 +1,6 @@ package io.emeraldpay.dshackle.upstream +import io.emeraldpay.dshackle.BlockchainType.AVM import io.emeraldpay.dshackle.BlockchainType.AZTEC import io.emeraldpay.dshackle.BlockchainType.BITCOIN import io.emeraldpay.dshackle.BlockchainType.COSMOS @@ -17,6 +18,7 @@ import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.upstream.calls.CallMethods +import io.emeraldpay.dshackle.upstream.calls.DefaultAvmMethods import io.emeraldpay.dshackle.upstream.calls.DefaultAztecMethods import io.emeraldpay.dshackle.upstream.calls.DefaultBeaconChainMethods import io.emeraldpay.dshackle.upstream.calls.DefaultBitcoinMethods @@ -49,6 +51,7 @@ class CallTargetsHolder { ): CallMethods { val created = when (chain.type) { BITCOIN -> DefaultBitcoinMethods(options.providesBalance == true) + AVM -> DefaultAvmMethods() AZTEC -> DefaultAztecMethods() ETHEREUM -> DefaultEthereumMethods(chain) STARKNET -> DefaultStarknetMethods(chain) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt new file mode 100644 index 00000000..955e44ef --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecific.kt @@ -0,0 +1,175 @@ +package io.emeraldpay.dshackle.upstream.avm + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.Global +import io.emeraldpay.dshackle.config.ChainsConfig.ChainConfig +import io.emeraldpay.dshackle.data.BlockContainer +import io.emeraldpay.dshackle.data.BlockId +import io.emeraldpay.dshackle.foundation.ChainOptions.Options +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +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.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.generic.AbstractPollChainSpecific +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService +import io.emeraldpay.dshackle.upstream.rpcclient.RestParams +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import java.math.BigInteger +import java.time.Instant + +object AvmChainSpecific : AbstractPollChainSpecific() { + + private val log = LoggerFactory.getLogger(AvmChainSpecific::class.java) + + override fun latestBlockRequest(): ChainRequest = + ChainRequest("GET#/v2/status", RestParams.emptyParams()) + + override fun parseBlock(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + val status = Global.objectMapper.readValue(data, AvmStatus::class.java) + val round = status.lastRound + val blockRequest = ChainRequest( + "GET#/v2/blocks/$round", + RestParams( + headers = emptyList(), + queryParams = listOf("format" to "json", "header-only" to "true"), + pathParams = emptyList(), + payload = ByteArray(0), + ), + ) + return api.read(blockRequest) + .map { resp -> + val blockData = resp.getResult() + val block = Global.objectMapper.readValue(blockData, AvmBlockResult::class.java).block + BlockContainer( + height = block.round, + hash = BlockId.from(toHashBytes(block.seed ?: block.txnRoot, block.round)), + difficulty = BigInteger.ZERO, + timestamp = Instant.ofEpochSecond(block.timestamp), + full = false, + json = blockData, + parsed = block, + transactions = emptyList(), + upstreamId = upstreamId, + parentHash = BlockId.from(toHashBytes(block.previousBlockHash, block.round - 1)), + ) + } + } + + override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono { + throw NotImplementedError() + } + + override fun listenNewHeadsRequest(): ChainRequest { + throw NotImplementedError() + } + + override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest { + throw NotImplementedError() + } + + override fun upstreamValidators( + chain: Chain, + upstream: Upstream, + options: Options, + config: ChainConfig, + ): List> { + return listOf( + GenericSingleCallValidator( + ChainRequest("GET#/v2/status", RestParams.emptyParams()), + upstream, + ) { data -> + validate(data, upstream.getId()) + }, + ) + } + + override fun upstreamSettingsValidators( + chain: Chain, + upstream: Upstream, + options: Options, + config: ChainConfig, + ): List> { + return emptyList() + } + + override fun lowerBoundService(chain: Chain, upstream: Upstream): LowerBoundService { + return AvmLowerBoundService(chain, upstream) + } + + fun validate(data: ByteArray, upstreamId: String): UpstreamAvailability { + val status = Global.objectMapper.readValue(data, AvmStatus::class.java) + return if (status.catchupTime > 0L) { + log.warn("AVM node {} is catching up: catchupTime={}ns", upstreamId, status.catchupTime) + UpstreamAvailability.SYNCING + } else { + UpstreamAvailability.OK + } + } + + // 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) + } + val stripped = raw.removePrefix("blk-") + return try { + java.util.Base64.getDecoder().decode(stripped) + } catch (_: IllegalArgumentException) { + try { + java.util.Base64.getUrlDecoder().decode(stripped) + } catch (_: IllegalArgumentException) { + roundToBytes(round) + } + } + } + + private fun roundToBytes(round: Long): ByteArray { + val bytes = ByteArray(32) + var value = if (round < 0) 0L else round + for (i in 0 until 8) { + bytes[31 - i] = (value and 0xff).toByte() + value = value ushr 8 + } + return bytes + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmStatus( + @param:JsonProperty("last-round") var lastRound: Long = 0, + @param:JsonProperty("catchup-time") var catchupTime: Long = 0, + @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, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmBlockResult( + @param:JsonProperty("block") var block: AvmBlock, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmBlock( + @param:JsonProperty("rnd") var round: Long, + @param:JsonProperty("ts") var timestamp: Long, + @param:JsonProperty("prev") var previousBlockHash: String? = null, + @param:JsonProperty("seed") var seed: String? = null, + @param:JsonProperty("txn") var txnRoot: String? = null, + @param:JsonProperty("gh") var genesisHash: String? = null, + @param:JsonProperty("gen") var genesisId: String? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AvmGenesis( + @param:JsonProperty("network") var network: String = "", + @param:JsonProperty("id") var id: String = "", + @param:JsonProperty("proto") var proto: String = "", +) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt new file mode 100644 index 00000000..6fe24d36 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundService.kt @@ -0,0 +1,15 @@ +package io.emeraldpay.dshackle.upstream.avm + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundService + +class AvmLowerBoundService( + private val chain: Chain, + upstream: Upstream, +) : LowerBoundService(chain, upstream) { + override fun detectors(): List { + return listOf(AvmLowerBoundStateDetector(chain)) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt new file mode 100644 index 00000000..c83e8ab2 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmLowerBoundStateDetector.kt @@ -0,0 +1,24 @@ +package io.emeraldpay.dshackle.upstream.avm + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundData +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundDetector +import io.emeraldpay.dshackle.upstream.lowerbound.LowerBoundType +import reactor.core.publisher.Flux + +class AvmLowerBoundStateDetector( + chain: Chain, +) : LowerBoundDetector(chain) { + + override fun period(): Long { + return 120 + } + + override fun internalDetectLowerBound(): Flux { + return Flux.just(LowerBoundData(1, LowerBoundType.STATE)) + } + + override fun types(): Set { + return setOf(LowerBoundType.STATE) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt new file mode 100644 index 00000000..f75455a7 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethods.kt @@ -0,0 +1,150 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * Copyright (c) 2019 ETCDEV GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.upstream.calls + +import io.emeraldpay.dshackle.quorum.AlwaysQuorum +import io.emeraldpay.dshackle.quorum.BroadcastQuorum +import io.emeraldpay.dshackle.quorum.CallQuorum +import io.emeraldpay.dshackle.quorum.NotNullQuorum +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException + +/** + * Default configuration for AVM (Algorand Virtual Machine) REST API, matching + * the algod OpenAPI spec. Method identifiers use the `VERB#/path` convention + * consumed by dshackle's REST HTTP reader. + */ +class DefaultAvmMethods : CallMethods { + + // Root-level common endpoints (not under /v2/*) + private val commonMethods = setOf( + getMethod("/genesis"), + getMethod("/health"), + getMethod("/ready"), + getMethod("/metrics"), + getMethod("/versions"), + getMethod("/swagger.json"), + ) + + // Node / ledger / blocks read endpoints under /v2/* + private val nodeMethods = setOf( + getMethod("/v2/status"), + getMethod("/v2/status/wait-for-block-after/*"), + getMethod("/v2/ledger/supply"), + getMethod("/v2/ledger/sync"), + getMethod("/v2/blocks/*"), + getMethod("/v2/blocks/*/hash"), + getMethod("/v2/blocks/*/txids"), + getMethod("/v2/blocks/*/logs"), + getMethod("/v2/blocks/*/lightheader/proof"), + getMethod("/v2/blocks/*/transactions/*/proof"), + getMethod("/v2/stateproofs/*"), + getMethod("/v2/deltas/*"), + getMethod("/v2/deltas/*/txn/group"), + getMethod("/v2/deltas/txn/group/*"), + ) + + private val accountMethods = setOf( + getMethod("/v2/accounts/*"), + getMethod("/v2/accounts/*/assets"), + getMethod("/v2/accounts/*/assets/*"), + getMethod("/v2/accounts/*/applications/*"), + getMethod("/v2/accounts/*/transactions/pending"), + getMethod("/v2/applications/*"), + getMethod("/v2/applications/*/box"), + getMethod("/v2/applications/*/boxes"), + getMethod("/v2/assets/*"), + ) + + private val transactionReadMethods = setOf( + getMethod("/v2/transactions/params"), + getMethod("/v2/transactions/pending"), + getMethod("/v2/transactions/pending/*"), + ) + + private val sendMethods = setOf( + postMethod("/v2/transactions"), + postMethod("/v2/transactions/async"), + ) + + private val computeMethods = setOf( + postMethod("/v2/transactions/simulate"), + postMethod("/v2/teal/compile"), + postMethod("/v2/teal/disassemble"), + postMethod("/v2/teal/dryrun"), + ) + + private val allowedMethods: Set = + commonMethods + nodeMethods + accountMethods + transactionReadMethods + sendMethods + computeMethods + + // Paths that look up a specific resource by id/round and should reject + // empty/404 answers via NotNullQuorum, so a single missing-replica + // response doesn't silently beat valid ones from other upstreams. + private val notNullReadMethods: Set = setOf( + getMethod("/v2/blocks/*"), + getMethod("/v2/blocks/*/hash"), + getMethod("/v2/blocks/*/txids"), + getMethod("/v2/blocks/*/logs"), + getMethod("/v2/blocks/*/lightheader/proof"), + getMethod("/v2/blocks/*/transactions/*/proof"), + getMethod("/v2/accounts/*"), + getMethod("/v2/accounts/*/assets/*"), + getMethod("/v2/accounts/*/applications/*"), + getMethod("/v2/applications/*"), + getMethod("/v2/applications/*/box"), + getMethod("/v2/applications/*/boxes"), + getMethod("/v2/assets/*"), + getMethod("/v2/transactions/pending/*"), + getMethod("/v2/stateproofs/*"), + getMethod("/v2/deltas/*"), + getMethod("/v2/deltas/*/txn/group"), + getMethod("/v2/deltas/txn/group/*"), + ) + + override fun createQuorumFor(method: String): CallQuorum { + return when { + sendMethods.contains(method) -> BroadcastQuorum() + notNullReadMethods.contains(method) -> NotNullQuorum() + else -> AlwaysQuorum() + } + } + + override fun isCallable(method: String): Boolean { + return allowedMethods.contains(method) + } + + override fun isHardcoded(method: String): Boolean { + return false + } + + override fun executeHardcoded(method: String): ByteArray { + throw RpcException(-32601, "Method not found") + } + + override fun getGroupMethods(groupName: String): Set = + when (groupName) { + "default" -> getSupportedMethods() + else -> emptySet() + } + + override fun getSupportedMethods(): Set { + return allowedMethods.toSortedSet() + } + + private fun getMethod(path: String) = "GET#$path" + + private fun postMethod(path: String) = "POST#$path" +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt index ddb6863f..1e03b59e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/generic/ChainSpecific.kt @@ -1,5 +1,6 @@ package io.emeraldpay.dshackle.upstream.generic +import io.emeraldpay.dshackle.BlockchainType.AVM import io.emeraldpay.dshackle.BlockchainType.AZTEC import io.emeraldpay.dshackle.BlockchainType.BITCOIN import io.emeraldpay.dshackle.BlockchainType.COSMOS @@ -33,6 +34,7 @@ import io.emeraldpay.dshackle.upstream.UpstreamRpcMethodsDetector import io.emeraldpay.dshackle.upstream.UpstreamSettingsDetector import io.emeraldpay.dshackle.upstream.UpstreamValidator import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult +import io.emeraldpay.dshackle.upstream.avm.AvmChainSpecific import io.emeraldpay.dshackle.upstream.aztec.AztecChainSpecific import io.emeraldpay.dshackle.upstream.beaconchain.BeaconChainSpecific import io.emeraldpay.dshackle.upstream.calls.CallMethods @@ -115,6 +117,7 @@ object ChainSpecificRegistry { @JvmStatic fun resolve(chain: Chain): ChainSpecific { return when (chain.type) { + AVM -> AvmChainSpecific AZTEC -> AztecChainSpecific ETHEREUM -> EthereumChainSpecific STARKNET -> StarknetChainSpecific diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt new file mode 100644 index 00000000..2aaa86f5 --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/avm/AvmChainSpecificTest.kt @@ -0,0 +1,93 @@ +package io.emeraldpay.dshackle.upstream.avm + +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import io.emeraldpay.dshackle.upstream.UpstreamAvailability +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import reactor.core.publisher.Mono + +val avmStatusSynced = """ + { + "last-round": 30000000, + "last-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version-round": 30000001, + "next-version-supported": true, + "time-since-last-round": 1500000000, + "catchup-time": 0, + "last-catchpoint": "" + } +""".trimIndent() + +val avmStatusCatchingUp = """ + { + "last-round": 30000000, + "last-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version": "https://github.com/algorandfoundation/specs/tree/somehash", + "next-version-round": 30000001, + "next-version-supported": true, + "time-since-last-round": 1500000000, + "catchup-time": 1500000000, + "last-catchpoint": "30000000#QWERTYU" + } +""".trimIndent() + +val avmBlockHeader = """ + { + "block": { + "rnd": 30000000, + "ts": 1696802363, + "prev": "blk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "seed": "c29tZXNlZWRieXRlc3RoYXRpczMyYnl0ZXNsb25nISEh", + "txn": "dHhucm9vdGhhc2h2YWx1ZWZvcnRlc3RpbmcxMjM0NTY=", + "gen": "mainnet-v1.0", + "proto": "https://github.com/algorandfoundation/specs/tree/somehash" + }, + "cert": {} + } +""".trimIndent() + +class AvmChainSpecificTest { + + @Test + fun parseBlockChainsThroughBlockEndpoint() { + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + Assertions.assertThat(key.method).isEqualTo("GET#/v2/blocks/30000000") + return Mono.just(ChainResponse(avmBlockHeader.toByteArray(), null)) + } + } + + val result = AvmChainSpecific.parseBlock( + avmStatusSynced.toByteArray(), + "upstream-1", + reader, + ).block()!! + + Assertions.assertThat(result.height).isEqualTo(30000000L) + Assertions.assertThat(result.upstreamId).isEqualTo("upstream-1") + Assertions.assertThat(result.timestamp.epochSecond).isEqualTo(1696802363L) + Assertions.assertThat(result.hash.toHex()).isNotEmpty() + Assertions.assertThat(result.parentHash?.toHex()).isNotEmpty() + } + + @Test + fun validateSyncedNode() { + Assertions.assertThat(AvmChainSpecific.validate(avmStatusSynced.toByteArray(), "test")) + .isEqualTo(UpstreamAvailability.OK) + } + + @Test + fun validateCatchingUpNode() { + Assertions.assertThat(AvmChainSpecific.validate(avmStatusCatchingUp.toByteArray(), "test")) + .isEqualTo(UpstreamAvailability.SYNCING) + } + + @Test + fun latestBlockRequestUsesStatusEndpoint() { + val request = AvmChainSpecific.latestBlockRequest() + Assertions.assertThat(request.method).isEqualTo("GET#/v2/status") + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt new file mode 100644 index 00000000..8f6ed15c --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAvmMethodsTest.kt @@ -0,0 +1,119 @@ +package io.emeraldpay.dshackle.upstream.calls + +import io.emeraldpay.dshackle.quorum.AlwaysQuorum +import io.emeraldpay.dshackle.quorum.BroadcastQuorum +import io.emeraldpay.dshackle.quorum.NotNullQuorum +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test + +class DefaultAvmMethodsTest { + + private val methods = DefaultAvmMethods() + + @Test + fun rootLevelCommonEndpointsAreCallable() { + Assertions.assertThat(methods.isCallable("GET#/genesis")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/health")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/ready")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/versions")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/metrics")).isTrue() + } + + @Test + fun v2ReadMethodsAreCallable() { + Assertions.assertThat(methods.isCallable("GET#/v2/status")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/hash")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/txids")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/lightheader/proof")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/accounts/*")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/accounts/*/transactions/pending")).isTrue() + Assertions.assertThat(methods.isCallable("GET#/v2/transactions/pending")).isTrue() + } + + @Test + fun sendMethodsAreCallable() { + Assertions.assertThat(methods.isCallable("POST#/v2/transactions")).isTrue() + Assertions.assertThat(methods.isCallable("POST#/v2/transactions/async")).isTrue() + } + + @Test + fun spuriousAlgodEndpointsAreNotCallable() { + // These paths don't exist in algod's OpenAPI spec — regression guards. + Assertions.assertThat(methods.isCallable("GET#/v2/genesis")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/versions")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/health")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/ready")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/metrics")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/header")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/blocks/*/transactions")).isFalse() + Assertions.assertThat(methods.isCallable("GET#/v2/lightheader/*")).isFalse() + Assertions.assertThat(methods.isCallable("POST#/v2/transactions/dryrun")).isFalse() + } + + @Test + fun unknownMethodsAreNotCallable() { + Assertions.assertThat(methods.isCallable("GET#/eth/blockNumber")).isFalse() + Assertions.assertThat(methods.isCallable("algod_status")).isFalse() + Assertions.assertThat(methods.isCallable("DELETE#/v2/status")).isFalse() + } + + @Test + fun sendMethodsUseBroadcastQuorum() { + Assertions.assertThat(methods.createQuorumFor("POST#/v2/transactions")) + .isInstanceOf(BroadcastQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("POST#/v2/transactions/async")) + .isInstanceOf(BroadcastQuorum::class.java) + } + + @Test + fun listReadMethodsUseAlwaysQuorum() { + Assertions.assertThat(methods.createQuorumFor("GET#/v2/status")) + .isInstanceOf(AlwaysQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/transactions/pending")) + .isInstanceOf(AlwaysQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/ledger/supply")) + .isInstanceOf(AlwaysQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/genesis")) + .isInstanceOf(AlwaysQuorum::class.java) + } + + @Test + fun byIdLookupsUseNotNullQuorum() { + Assertions.assertThat(methods.createQuorumFor("GET#/v2/blocks/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/blocks/*/hash")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/accounts/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/applications/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/assets/*")) + .isInstanceOf(NotNullQuorum::class.java) + Assertions.assertThat(methods.createQuorumFor("GET#/v2/transactions/pending/*")) + .isInstanceOf(NotNullQuorum::class.java) + } + + @Test + fun noHardcodedMethods() { + Assertions.assertThat(methods.isHardcoded("GET#/v2/status")).isFalse() + Assertions.assertThat(methods.isHardcoded("GET#/genesis")).isFalse() + } + + @Test + fun defaultGroupReturnsAllSupported() { + Assertions.assertThat(methods.getGroupMethods("default")).isEqualTo(methods.getSupportedMethods()) + Assertions.assertThat(methods.getGroupMethods("unknown")).isEmpty() + } + + @Test + fun supportedMethodsIncludeCoreEndpoints() { + val supported = methods.getSupportedMethods() + Assertions.assertThat(supported).contains( + "GET#/v2/status", + "GET#/genesis", + "POST#/v2/transactions", + "POST#/v2/teal/compile", + ) + } +}