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:
2
Makefile
2
Makefile
@@ -3,7 +3,7 @@ build-foundation:
|
||||
cd foundation && ../gradlew build publishToMavenLocal
|
||||
|
||||
run-main:
|
||||
./gradlew run
|
||||
./gradlew run -x test
|
||||
|
||||
build-main:
|
||||
./gradlew build
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Submodule emerald-grpc updated: a0df7d7144...67b4cccdd7
@@ -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 {
|
||||
|
||||
Submodule foundation/src/main/resources/public updated: 4510b37a56...c0fc62c41f
@@ -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)
|
||||
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user