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