From 2b79458bf295965090762dbfeabc0117e8d7b08c Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Thu, 25 Jun 2026 14:07:55 +0300 Subject: [PATCH] Add node_getChainTips fallback for Aztec v5 (#873) --- .../upstream/aztec/AztecChainSpecific.kt | 69 ++++++++- .../upstream/calls/DefaultAztecMethods.kt | 17 +- .../upstream/aztec/AztecChainSpecificTest.kt | 146 ++++++++++++++++++ 3 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt index 0400c2ab..c4bf14b9 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecific.kt @@ -1,6 +1,7 @@ package io.emeraldpay.dshackle.upstream.aztec import com.fasterxml.jackson.databind.JsonNode +import com.github.benmanes.caffeine.cache.Caffeine import io.emeraldpay.dshackle.Chain import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.config.ChainsConfig.ChainConfig @@ -8,6 +9,7 @@ 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.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.GenericSingleCallValidator import io.emeraldpay.dshackle.upstream.SingleValidator @@ -21,16 +23,36 @@ import io.emeraldpay.dshackle.upstream.rpcclient.ListParams import org.slf4j.LoggerFactory import reactor.core.publisher.Mono import java.math.BigInteger +import java.time.Duration import java.time.Instant object AztecChainSpecific : AbstractPollChainSpecific() { private val log = LoggerFactory.getLogger(AztecChainSpecific::class.java) - // node_getL2Tips reshaped between Aztec versions: + private const val METHOD_NOT_FOUND = -32601 + + // Aztec v5 (v5.0.0-rc.1) renamed the tips RPC: node_getL2Tips -> node_getChainTips. + // Older nodes (incl. current mainnet) expose only the legacy name; v5+ nodes expose + // only the new one. We probe the legacy method first (most upstreams are still on it) + // and fall back to the new one on "method not found", remembering the working method + // per upstream so we stop probing the dead one on every poll. + private val LEGACY_TIPS_REQUEST = ChainRequest("node_getL2Tips", ListParams()) + private val CHAIN_TIPS_REQUEST = ChainRequest("node_getChainTips", ListParams()) + + // Bounded so per-upstream entries can't accumulate without limit (e.g. across config + // reloads): a hard size cap plus idle expiry evict stale ids, and an evicted entry just + // costs one re-probe. Only upstreams that actually fall back take a slot — legacy-only + // ones keep using the default and never populate it. + private val workingTipsRequest = Caffeine.newBuilder() + .maximumSize(1024) + .expireAfterAccess(Duration.ofHours(1)) + .build() + + // The tips response reshaped between Aztec versions: // v3 (and earlier): {proposed: {number, hash}, proven: {number, hash}, checkpointed: {number, hash}} - // v4: proven/finalized/checkpointed each became {block: {number, hash}, checkpoint: {number, hash}} - // proposed stayed flat. We always look at the v4 nested path first and fall back - // to the flat v3 path so an upstream on either version is parsed correctly. + // v4/v5: proven/finalized/checkpointed each became {block: {number, hash}, checkpoint: {number, hash}} + // proposed stayed flat across all versions. We look at the flat path first and fall back + // to the nested path so an upstream on any version is parsed correctly. private val PROPOSED_NUMBER = arrayOf("proposed.number", "proposed.block.number") private val PROPOSED_HASH = arrayOf("proposed.hash", "proposed.block.hash") @@ -122,8 +144,43 @@ object AztecChainSpecific : AbstractPollChainSpecific() { return AztecLowerBoundService(chain, upstream) } - override fun latestBlockRequest(): ChainRequest = - ChainRequest("node_getL2Tips", ListParams()) + override fun latestBlockRequest(): ChainRequest = LEGACY_TIPS_REQUEST + + // Try the per-upstream remembered method (legacy by default); on "method not found" + // fall back to the other one and remember whichever succeeds, so subsequent polls go + // straight to the working method. Any other error propagates as before. + override fun getLatestBlock(api: ChainReader, upstreamId: String): Mono { + val preferred = workingTipsRequest.getIfPresent(upstreamId) ?: LEGACY_TIPS_REQUEST + val fallback = if (preferred === LEGACY_TIPS_REQUEST) CHAIN_TIPS_REQUEST else LEGACY_TIPS_REQUEST + return fetchTips(api, upstreamId, preferred) + .onErrorResume { err -> + if (isMethodNotFound(err)) { + log.info( + "Aztec upstream {} does not support {}, falling back to {}", + upstreamId, + preferred.method, + fallback.method, + ) + fetchTips(api, upstreamId, fallback) + .doOnNext { workingTipsRequest.put(upstreamId, fallback) } + } else { + Mono.error(err) + } + } + } + + private fun fetchTips(api: ChainReader, upstreamId: String, request: ChainRequest): Mono { + return api.read(request).flatMap { + parseBlock(it.getResult(), upstreamId, api) + } + } + + private fun isMethodNotFound(err: Throwable): Boolean { + if (err is ChainException && err.error.code == METHOD_NOT_FOUND) { + return true + } + return err.message?.contains("method not found", ignoreCase = true) ?: false + } override fun upstreamSettingsDetector( chain: Chain, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt index a7be791c..d2d89a08 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/calls/DefaultAztecMethods.kt @@ -16,18 +16,24 @@ class DefaultAztecMethods : CallMethods { "node_getBlockNumber", "node_getProvenBlockNumber", "node_getL2Tips", + "node_getChainTips", "node_getBlock", "node_getBlocks", + "node_getBlockData", "node_getBlockHeader", "node_getBlockByArchive", "node_getBlockByHash", "node_getBlockHeaderByArchive", - // Checkpoints + // Checkpoints / consensus "node_getCheckpointNumber", + "node_getCheckpoint", "node_getCheckpointedBlockNumber", "node_getCheckpointedBlocks", "node_getCheckpoints", + "node_getCheckpointsData", + "node_getCheckpointAttestationsForSlot", + "node_getProposalsForSlot", // Transactions "node_sendTx", @@ -45,6 +51,11 @@ class DefaultAztecMethods : CallMethods { "node_getWorldStateSyncStatus", "node_findLeavesIndexes", + // Sync status + "node_getSyncedL1Timestamp", + "node_getSyncedL2EpochNumber", + "node_getSyncedL2SlotNumber", + // Sibling paths "node_getNullifierSiblingPath", "node_getNoteHashSiblingPath", @@ -65,12 +76,14 @@ class DefaultAztecMethods : CallMethods { "node_getL1ToL2MessageCheckpoint", "node_isL1ToL2MessageSynced", "node_getL2ToL1Messages", + "node_getL2ToL1MembershipWitness", // Logs "node_getPrivateLogs", "node_getPrivateLogsByTags", "node_getPublicLogs", "node_getPublicLogsByTagsFromContract", + "node_getPublicLogsByTags", "node_getContractClassLogs", "node_getLogsByTags", @@ -86,12 +99,14 @@ class DefaultAztecMethods : CallMethods { "node_getVersion", "node_getChainId", "node_getL1ContractAddresses", + "node_getL1Constants", "node_getProtocolContractAddresses", "node_getEncodedEnr", // Fees "node_getCurrentBaseFees", "node_getCurrentMinFees", + "node_getPredictedMinFees", "node_getMaxPriorityFees", // Validators diff --git a/src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt new file mode 100644 index 00000000..fa45433f --- /dev/null +++ b/src/test/kotlin/io/emeraldpay/dshackle/upstream/aztec/AztecChainSpecificTest.kt @@ -0,0 +1,146 @@ +package io.emeraldpay.dshackle.upstream.aztec + +import io.emeraldpay.dshackle.reader.ChainReader +import io.emeraldpay.dshackle.upstream.ChainCallError +import io.emeraldpay.dshackle.upstream.ChainCallUpstreamException +import io.emeraldpay.dshackle.upstream.ChainRequest +import io.emeraldpay.dshackle.upstream.ChainResponse +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import reactor.core.publisher.Mono +import java.util.concurrent.atomic.AtomicInteger + +// v5 (v5.0.0-rc.1) node_getChainTips response: proposed stays flat, the rest nested. +private val chainTipsResponse = """ + { + "proposed": {"number": 12345, "hash": "0xaaaa"}, + "checkpointed": {"block": {"number": 12340, "hash": "0xbbbb"}, "checkpoint": {"number": 100, "hash": "0x1111"}}, + "proven": {"block": {"number": 12330, "hash": "0xcccc"}, "checkpoint": {"number": 99, "hash": "0x2222"}}, + "finalized": {"block": {"number": 12320, "hash": "0xdddd"}, "checkpoint": {"number": 98, "hash": "0x3333"}} + } +""".trimIndent() + +// legacy node_getL2Tips response (v3-era flat proposed) +private val l2TipsResponse = """ + { + "proposed": {"number": 999, "hash": "0x0999"}, + "proven": {"number": 990, "hash": "0x0990"}, + "checkpointed": {"number": 980, "hash": "0x0980"} + } +""".trimIndent() + +// defensive: some versions nest proposed under .block +private val nestedProposedResponse = """ + { + "proposed": {"block": {"number": 777, "hash": "0x0777"}} + } +""".trimIndent() + +private fun methodNotFound(method: String) = + Mono.error( + ChainCallUpstreamException( + ChainResponse.NumberId(1), + ChainCallError(-32601, "Method not found: $method"), + ), + ) + +class AztecChainSpecificTest { + + @Test + fun latestBlockRequestUsesL2Tips() { + Assertions.assertThat(AztecChainSpecific.latestBlockRequest().method) + .isEqualTo("node_getL2Tips") + } + + @Test + fun parseBlockReadsFlatProposed() { + val result = AztecChainSpecific.parseBlock( + chainTipsResponse.toByteArray(), + "up-flat", + noopReader(), + ).block()!! + + Assertions.assertThat(result.height).isEqualTo(12345L) + Assertions.assertThat(result.hash.toHex()).contains("aaaa") + } + + @Test + fun parseBlockReadsNestedProposed() { + val result = AztecChainSpecific.parseBlock( + nestedProposedResponse.toByteArray(), + "up-nested", + noopReader(), + ).block()!! + + Assertions.assertThat(result.height).isEqualTo(777L) + Assertions.assertThat(result.hash.toHex()).contains("0777") + } + + @Test + fun getLatestBlockUsesL2TipsWhenAvailable() { + val calls = mutableListOf() + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + calls += key.method + return Mono.just(ChainResponse(l2TipsResponse.toByteArray(), null)) + } + } + + val result = AztecChainSpecific.getLatestBlock(reader, "up-legacy").block()!! + + Assertions.assertThat(result.height).isEqualTo(999L) + Assertions.assertThat(calls).containsExactly("node_getL2Tips") + } + + @Test + fun getLatestBlockFallsBackToChainTipsAndCaches() { + val calls = mutableListOf() + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + calls += key.method + return when (key.method) { + "node_getL2Tips" -> methodNotFound("node_getL2Tips") + "node_getChainTips" -> Mono.just(ChainResponse(chainTipsResponse.toByteArray(), null)) + else -> Mono.error(IllegalStateException("unexpected ${key.method}")) + } + } + } + + // first poll: probes legacy, falls back to v5 + val first = AztecChainSpecific.getLatestBlock(reader, "up-v5").block()!! + Assertions.assertThat(first.height).isEqualTo(12345L) + Assertions.assertThat(calls).containsExactly("node_getL2Tips", "node_getChainTips") + + // second poll: must hit the cached working method directly, no dead probe + calls.clear() + val second = AztecChainSpecific.getLatestBlock(reader, "up-v5").block()!! + Assertions.assertThat(second.height).isEqualTo(12345L) + Assertions.assertThat(calls).containsExactly("node_getChainTips") + } + + @Test + fun getLatestBlockDoesNotFallBackOnOtherErrors() { + val attempts = AtomicInteger(0) + val reader = object : ChainReader { + override fun read(key: ChainRequest): Mono { + attempts.incrementAndGet() + return Mono.error( + ChainCallUpstreamException( + ChainResponse.NumberId(1), + ChainCallError(-32000, "internal error"), + ), + ) + } + } + + val thrown = runCatching { AztecChainSpecific.getLatestBlock(reader, "up-err").block() } + Assertions.assertThat(thrown.isFailure).isTrue() + // only the primary method is attempted; no fallback probe on a non-method-not-found error + Assertions.assertThat(attempts.get()).isEqualTo(1) + } + + private fun noopReader() = object : ChainReader { + override fun read(key: ChainRequest): Mono = + Mono.error(IllegalStateException("not expected")) + } +}