Add node_getChainTips fallback for Aztec v5 (#873)

This commit is contained in:
a10zn8
2026-06-25 14:07:55 +03:00
committed by GitHub
parent 634291bc98
commit 2b79458bf2
3 changed files with 225 additions and 7 deletions

View File

@@ -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<String, ChainRequest>()
// 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<BlockContainer> {
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<BlockContainer> {
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,

View File

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

View File

@@ -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<ChainResponse>(
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<String>()
val reader = object : ChainReader {
override fun read(key: ChainRequest): Mono<ChainResponse> {
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<String>()
val reader = object : ChainReader {
override fun read(key: ChainRequest): Mono<ChainResponse> {
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<ChainResponse> {
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<ChainResponse> =
Mono.error(IllegalStateException("not expected"))
}
}