Add node_getChainTips fallback for Aztec v5 (#873)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user