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 <noreply@anthropic.com>
This commit is contained in:
Vadim Filin
2026-04-23 13:10:56 +02:00
committed by GitHub
parent aa40578664
commit 5414e06e46
13 changed files with 588 additions and 4 deletions

View File

@@ -3,7 +3,7 @@ build-foundation:
cd foundation && ../gradlew build publishToMavenLocal
run-main:
./gradlew run
./gradlew run -x test
build-main:
./gradlew build

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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<BlockContainer> {
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<BlockContainer> {
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<SingleValidator<UpstreamAvailability>> {
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<SingleValidator<ValidateUpstreamSettingsResult>> {
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 = "",
)

View File

@@ -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<LowerBoundDetector> {
return listOf(AvmLowerBoundStateDetector(chain))
}
}

View File

@@ -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<LowerBoundData> {
return Flux.just(LowerBoundData(1, LowerBoundType.STATE))
}
override fun types(): Set<LowerBoundType> {
return setOf(LowerBoundType.STATE)
}
}

View File

@@ -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<String> =
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<String> = 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<String> =
when (groupName) {
"default" -> getSupportedMethods()
else -> emptySet()
}
override fun getSupportedMethods(): Set<String> {
return allowedMethods.toSortedSet()
}
private fun getMethod(path: String) = "GET#$path"
private fun postMethod(path: String) = "POST#$path"
}

View File

@@ -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

View File

@@ -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<ChainResponse> {
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")
}
}

View File

@@ -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",
)
}
}