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
|
cd foundation && ../gradlew build publishToMavenLocal
|
||||||
|
|
||||||
run-main:
|
run-main:
|
||||||
./gradlew run
|
./gradlew run -x test
|
||||||
|
|
||||||
build-main:
|
build-main:
|
||||||
./gradlew build
|
./gradlew build
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ open class CodeGen(private val config: ChainsConfig) {
|
|||||||
"cosmos" -> "BlockchainType.COSMOS"
|
"cosmos" -> "BlockchainType.COSMOS"
|
||||||
"ripple" -> "BlockchainType.RIPPLE"
|
"ripple" -> "BlockchainType.RIPPLE"
|
||||||
"kadena" -> "BlockchainType.KADENA"
|
"kadena" -> "BlockchainType.KADENA"
|
||||||
|
"avm" -> "BlockchainType.AVM"
|
||||||
"app" -> "BlockchainType.ETHEREUM"
|
"app" -> "BlockchainType.ETHEREUM"
|
||||||
else -> throw IllegalArgumentException("unknown blockchain type $type")
|
else -> throw IllegalArgumentException("unknown blockchain type $type")
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule emerald-grpc updated: a0df7d7144...67b4cccdd7
@@ -15,7 +15,8 @@ enum class BlockchainType(
|
|||||||
COSMOS(ApiType.JSON_RPC),
|
COSMOS(ApiType.JSON_RPC),
|
||||||
TON(ApiType.REST),
|
TON(ApiType.REST),
|
||||||
RIPPLE(ApiType.JSON_RPC),
|
RIPPLE(ApiType.JSON_RPC),
|
||||||
KADENA(ApiType.REST),;
|
KADENA(ApiType.REST),
|
||||||
|
AVM(ApiType.REST),;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ApiType {
|
enum class ApiType {
|
||||||
|
|||||||
Submodule foundation/src/main/resources/public updated: 4510b37a56...c0fc62c41f
@@ -1,5 +1,6 @@
|
|||||||
package io.emeraldpay.dshackle.upstream
|
package io.emeraldpay.dshackle.upstream
|
||||||
|
|
||||||
|
import io.emeraldpay.dshackle.BlockchainType.AVM
|
||||||
import io.emeraldpay.dshackle.BlockchainType.AZTEC
|
import io.emeraldpay.dshackle.BlockchainType.AZTEC
|
||||||
import io.emeraldpay.dshackle.BlockchainType.BITCOIN
|
import io.emeraldpay.dshackle.BlockchainType.BITCOIN
|
||||||
import io.emeraldpay.dshackle.BlockchainType.COSMOS
|
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.config.UpstreamsConfig
|
||||||
import io.emeraldpay.dshackle.foundation.ChainOptions
|
import io.emeraldpay.dshackle.foundation.ChainOptions
|
||||||
import io.emeraldpay.dshackle.upstream.calls.CallMethods
|
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.DefaultAztecMethods
|
||||||
import io.emeraldpay.dshackle.upstream.calls.DefaultBeaconChainMethods
|
import io.emeraldpay.dshackle.upstream.calls.DefaultBeaconChainMethods
|
||||||
import io.emeraldpay.dshackle.upstream.calls.DefaultBitcoinMethods
|
import io.emeraldpay.dshackle.upstream.calls.DefaultBitcoinMethods
|
||||||
@@ -49,6 +51,7 @@ class CallTargetsHolder {
|
|||||||
): CallMethods {
|
): CallMethods {
|
||||||
val created = when (chain.type) {
|
val created = when (chain.type) {
|
||||||
BITCOIN -> DefaultBitcoinMethods(options.providesBalance == true)
|
BITCOIN -> DefaultBitcoinMethods(options.providesBalance == true)
|
||||||
|
AVM -> DefaultAvmMethods()
|
||||||
AZTEC -> DefaultAztecMethods()
|
AZTEC -> DefaultAztecMethods()
|
||||||
ETHEREUM -> DefaultEthereumMethods(chain)
|
ETHEREUM -> DefaultEthereumMethods(chain)
|
||||||
STARKNET -> DefaultStarknetMethods(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
|
package io.emeraldpay.dshackle.upstream.generic
|
||||||
|
|
||||||
|
import io.emeraldpay.dshackle.BlockchainType.AVM
|
||||||
import io.emeraldpay.dshackle.BlockchainType.AZTEC
|
import io.emeraldpay.dshackle.BlockchainType.AZTEC
|
||||||
import io.emeraldpay.dshackle.BlockchainType.BITCOIN
|
import io.emeraldpay.dshackle.BlockchainType.BITCOIN
|
||||||
import io.emeraldpay.dshackle.BlockchainType.COSMOS
|
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.UpstreamSettingsDetector
|
||||||
import io.emeraldpay.dshackle.upstream.UpstreamValidator
|
import io.emeraldpay.dshackle.upstream.UpstreamValidator
|
||||||
import io.emeraldpay.dshackle.upstream.ValidateUpstreamSettingsResult
|
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.aztec.AztecChainSpecific
|
||||||
import io.emeraldpay.dshackle.upstream.beaconchain.BeaconChainSpecific
|
import io.emeraldpay.dshackle.upstream.beaconchain.BeaconChainSpecific
|
||||||
import io.emeraldpay.dshackle.upstream.calls.CallMethods
|
import io.emeraldpay.dshackle.upstream.calls.CallMethods
|
||||||
@@ -115,6 +117,7 @@ object ChainSpecificRegistry {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun resolve(chain: Chain): ChainSpecific {
|
fun resolve(chain: Chain): ChainSpecific {
|
||||||
return when (chain.type) {
|
return when (chain.type) {
|
||||||
|
AVM -> AvmChainSpecific
|
||||||
AZTEC -> AztecChainSpecific
|
AZTEC -> AztecChainSpecific
|
||||||
ETHEREUM -> EthereumChainSpecific
|
ETHEREUM -> EthereumChainSpecific
|
||||||
STARKNET -> StarknetChainSpecific
|
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