Solana Head Detection: slotSubscribe (#772)
* Implement Solana head detection strategies with metrics tracking and testing * Add benchmark tests for Solana BlockSubscribe and SlotSubscribe strategies * Implement metrics logging and strategy configuration for Solana head detection * Add metrics logging and comparison for SLOT_SUBSCRIBE strategy * Refactor SolanaChainSpecificTest: Consolidate tests for slotSubscribe strategy, remove BlockSubscribe tests, and enhance caching logic. Delete SolanaStrategyBenchmark for performance comparison. * add synthetic parentHash, improve tests * Fix linter * Optimize Solana getLatestBlock to use single getEpochInfo call Replace two separate RPC calls (getSlot + getBlockHeight) with a single getEpochInfo call that returns both absoluteSlot and blockHeight * Enhance height estimation logic in SolanaChainSpecific: Implement optimistic height estimation and improve error handling for RPC failures. Update tests to validate new behavior. * Refactor height verification in SolanaChainSpecific: Replace getBlockHeight with getEpochInfo for improved efficiency and accuracy in height estimation. Update logic to handle actual slot and height retrieval.
This commit is contained in:
Submodule foundation/src/main/resources/public updated: de2e459ee1...a93f66d7b0
@@ -30,91 +30,141 @@ import org.slf4j.LoggerFactory
|
||||
import reactor.core.publisher.Mono
|
||||
import reactor.core.scheduler.Scheduler
|
||||
import java.math.BigInteger
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Solana chain-specific implementation using slotSubscribe for head detection.
|
||||
*
|
||||
* Uses lightweight slotSubscribe WebSocket subscription instead of expensive blockSubscribe:
|
||||
* - ~50 bytes per notification vs ~1KB for blockSubscribe
|
||||
* - Stable API (no special node flags required)
|
||||
* - Universal provider support
|
||||
* - Throttled getEpochInfo calls (every N slots) to get actual slot and block height
|
||||
* - Synthetic hash based on slot for ForkChoice deduplication
|
||||
*/
|
||||
object SolanaChainSpecific : AbstractChainSpecific() {
|
||||
|
||||
private val log = LoggerFactory.getLogger(SolanaChainSpecific::class.java)
|
||||
|
||||
// Throttle: check actual block height every N slots
|
||||
private const val HEIGHT_CHECK_INTERVAL = 5
|
||||
|
||||
// Cache per upstream for throttling
|
||||
private val lastKnownHeights = ConcurrentHashMap<String, Long>()
|
||||
private val lastCheckedSlots = ConcurrentHashMap<String, Long>()
|
||||
|
||||
override fun getLatestBlock(api: ChainReader, upstreamId: String): Mono<BlockContainer> {
|
||||
return api.read(ChainRequest("getSlot", ListParams())).flatMap {
|
||||
val slot = it.getResultAsProcessedString().toLong()
|
||||
api.read(
|
||||
ChainRequest(
|
||||
"getBlocks",
|
||||
ListParams(
|
||||
slot - 10,
|
||||
slot,
|
||||
),
|
||||
),
|
||||
).flatMap {
|
||||
val response = Global.objectMapper.readValue(it.getResult(), LongArray::class.java)
|
||||
if (response == null || response.isEmpty()) {
|
||||
Mono.empty()
|
||||
} else {
|
||||
api.read(
|
||||
ChainRequest(
|
||||
"getBlock",
|
||||
ListParams(
|
||||
response.max(),
|
||||
mapOf(
|
||||
"commitment" to "confirmed",
|
||||
"showRewards" to false,
|
||||
"transactionDetails" to "none",
|
||||
"maxSupportedTransactionVersion" to 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
).map {
|
||||
val raw = it.getResult()
|
||||
val block = Global.objectMapper.readValue(it.getResult(), SolanaBlock::class.java)
|
||||
makeBlock(raw, block, upstreamId, response.max())
|
||||
}.onErrorResume {
|
||||
log.debug("error during getting last solana block - ${it.message}")
|
||||
Mono.empty()
|
||||
}
|
||||
}
|
||||
return api.read(ChainRequest("getEpochInfo", ListParams()))
|
||||
.map { response ->
|
||||
val epochInfo = Global.objectMapper.readValue(
|
||||
response.getResult(),
|
||||
SolanaEpochInfo::class.java,
|
||||
)
|
||||
lastKnownHeights[upstreamId] = epochInfo.blockHeight
|
||||
lastCheckedSlots[upstreamId] = epochInfo.absoluteSlot
|
||||
makeBlockFromSlot(epochInfo.absoluteSlot, epochInfo.absoluteSlot - 1, epochInfo.blockHeight, upstreamId, ByteArray(0))
|
||||
}
|
||||
.onErrorResume { error ->
|
||||
log.debug("error during getting latest solana block - ${error.message}")
|
||||
Mono.empty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFromHeader(data: ByteArray, upstreamId: String, api: ChainReader): Mono<BlockContainer> {
|
||||
val res = Global.objectMapper.readValue(data, SolanaWrapper::class.java)
|
||||
return Mono.just(makeBlock(data, res.value.block, upstreamId, res.context.slot))
|
||||
return try {
|
||||
val notification = Global.objectMapper.readValue(data, SolanaSlotNotification::class.java)
|
||||
val slot = notification.slot
|
||||
|
||||
val lastChecked = lastCheckedSlots[upstreamId] ?: 0L
|
||||
val lastHeight = lastKnownHeights[upstreamId]
|
||||
val shouldCheckHeight = slot - lastChecked >= HEIGHT_CHECK_INTERVAL
|
||||
|
||||
// Optimistic height estimation: assume 1:1 slot-to-block ratio
|
||||
val estimatedHeight = if (lastHeight != null && lastChecked > 0) {
|
||||
lastHeight + (slot - lastChecked)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (shouldCheckHeight || estimatedHeight == null) {
|
||||
// Verify actual height using getEpochInfo (single call for both slot and height)
|
||||
api.read(ChainRequest("getEpochInfo", ListParams()))
|
||||
.map { response ->
|
||||
val epochInfo = Global.objectMapper.readValue(
|
||||
response.getResult(),
|
||||
SolanaEpochInfo::class.java,
|
||||
)
|
||||
val actualSlot = epochInfo.absoluteSlot
|
||||
val actualHeight = epochInfo.blockHeight
|
||||
|
||||
if (estimatedHeight != null && estimatedHeight != actualHeight) {
|
||||
log.debug(
|
||||
"Height drift detected for {}: estimated={}, actual={}, diff={}",
|
||||
upstreamId,
|
||||
estimatedHeight,
|
||||
actualHeight,
|
||||
estimatedHeight - actualHeight,
|
||||
)
|
||||
}
|
||||
|
||||
lastKnownHeights[upstreamId] = actualHeight
|
||||
lastCheckedSlots[upstreamId] = actualSlot
|
||||
makeBlockFromSlot(actualSlot, actualSlot - 1, actualHeight, upstreamId, data)
|
||||
}
|
||||
.onErrorResume { error ->
|
||||
log.warn("Failed to get block height, using estimated value: ${error.message}")
|
||||
val height = estimatedHeight ?: lastHeight ?: slot
|
||||
Mono.just(makeBlockFromSlot(slot, notification.parent, height, upstreamId, data))
|
||||
}
|
||||
} else {
|
||||
// Use optimistic estimated height
|
||||
Mono.just(makeBlockFromSlot(slot, notification.parent, estimatedHeight, upstreamId, data))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to parse slotSubscribe notification", e)
|
||||
Mono.empty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeBlock(raw: ByteArray, block: SolanaBlock, upstreamId: String, slot: Long): BlockContainer {
|
||||
override fun listenNewHeadsRequest(): ChainRequest {
|
||||
return ChainRequest("slotSubscribe", ListParams())
|
||||
}
|
||||
|
||||
override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest {
|
||||
return ChainRequest("slotUnsubscribe", ListParams(subId))
|
||||
}
|
||||
|
||||
private fun makeBlockFromSlot(slot: Long, parentSlot: Long, height: Long, upstreamId: String, data: ByteArray): BlockContainer {
|
||||
// Synthetic hash from slot for ForkChoice deduplication
|
||||
val syntheticHash = BlockId.from(
|
||||
ByteBuffer.allocate(32).putLong(slot).array(),
|
||||
)
|
||||
// Synthetic parent hash from parent slot for chain tracking
|
||||
val syntheticParentHash = BlockId.from(
|
||||
ByteBuffer.allocate(32).putLong(parentSlot).array(),
|
||||
)
|
||||
|
||||
return BlockContainer(
|
||||
height = block.height,
|
||||
hash = BlockId.fromBase64(block.hash),
|
||||
height = height,
|
||||
hash = syntheticHash,
|
||||
difficulty = BigInteger.ZERO,
|
||||
timestamp = Instant.ofEpochMilli(block.timestamp),
|
||||
timestamp = Instant.now(),
|
||||
full = false,
|
||||
json = raw,
|
||||
parsed = block,
|
||||
json = data,
|
||||
parsed = null,
|
||||
transactions = emptyList(),
|
||||
upstreamId = upstreamId,
|
||||
parentHash = BlockId.fromBase64(block.parent),
|
||||
parentHash = syntheticParentHash,
|
||||
slot = slot,
|
||||
)
|
||||
}
|
||||
|
||||
override fun listenNewHeadsRequest(): ChainRequest {
|
||||
return ChainRequest(
|
||||
"blockSubscribe",
|
||||
ListParams(
|
||||
"all",
|
||||
mapOf(
|
||||
"commitment" to "confirmed",
|
||||
"showRewards" to false,
|
||||
"transactionDetails" to "none",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun unsubscribeNewHeadsRequest(subId: Any): ChainRequest {
|
||||
return ChainRequest("blockUnsubscribe", ListParams(subId))
|
||||
// For testing only - clear height cache
|
||||
internal fun clearCache() {
|
||||
lastKnownHeights.clear()
|
||||
lastCheckedSlots.clear()
|
||||
}
|
||||
|
||||
override fun upstreamValidators(
|
||||
@@ -165,26 +215,28 @@ object SolanaChainSpecific : AbstractChainSpecific() {
|
||||
}
|
||||
}
|
||||
|
||||
// slotSubscribe response format
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SolanaWrapper(
|
||||
@param:JsonProperty("context") var context: SolanaContext,
|
||||
@param:JsonProperty("value") var value: SolanaResult,
|
||||
data class SolanaSlotNotification(
|
||||
@param:JsonProperty("slot") val slot: Long,
|
||||
@param:JsonProperty("parent") val parent: Long,
|
||||
@param:JsonProperty("root") val root: Long,
|
||||
)
|
||||
|
||||
// getEpochInfo response format
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SolanaContext(
|
||||
@param:JsonProperty("slot") var slot: Long,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SolanaResult(
|
||||
@param:JsonProperty("block") var block: SolanaBlock,
|
||||
data class SolanaEpochInfo(
|
||||
@param:JsonProperty("absoluteSlot") val absoluteSlot: Long,
|
||||
@param:JsonProperty("blockHeight") val blockHeight: Long,
|
||||
@param:JsonProperty("epoch") val epoch: Long,
|
||||
@param:JsonProperty("slotIndex") val slotIndex: Long,
|
||||
@param:JsonProperty("slotsInEpoch") val slotsInEpoch: Long,
|
||||
)
|
||||
|
||||
// getBlock response format (used by SolanaLowerBoundSlotDetector)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SolanaBlock(
|
||||
@param:JsonProperty("blockHeight") var height: Long,
|
||||
@param:JsonProperty("blockTime") var timestamp: Long,
|
||||
@param:JsonProperty("blockhash") var hash: String,
|
||||
@param:JsonProperty("previousBlockhash") var parent: String,
|
||||
@param:JsonProperty("blockHeight") val height: Long,
|
||||
@param:JsonProperty("blockhash") val hash: String,
|
||||
@param:JsonProperty("blockTime") val time: Long,
|
||||
)
|
||||
|
||||
@@ -1,39 +1,216 @@
|
||||
package io.emeraldpay.dshackle.upstream.solana
|
||||
|
||||
import io.emeraldpay.dshackle.Global
|
||||
import io.emeraldpay.dshackle.data.BlockId
|
||||
import io.emeraldpay.dshackle.reader.ChainReader
|
||||
import org.assertj.core.api.Assertions
|
||||
import io.emeraldpay.dshackle.upstream.ChainRequest
|
||||
import io.emeraldpay.dshackle.upstream.ChainResponse
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import reactor.core.publisher.Mono
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
val example = """{
|
||||
"context": {
|
||||
"slot": 112301554
|
||||
},
|
||||
"value": {
|
||||
"slot": 112301554,
|
||||
"block": {
|
||||
"previousBlockhash": "GJp125YAN4ufCSUvZJVdCyWQJ7RPWMmwxoyUQySydZA",
|
||||
"blockhash": "6ojMHjctdqfB55JDpEpqfHnP96fiaHEcvzEQ2NNcxzHP",
|
||||
"parentSlot": 112301553,
|
||||
"blockTime": 1639926816,
|
||||
"blockHeight": 101210751
|
||||
},
|
||||
"err": null
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
class SolanaChainSpecificTest {
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// Clear cache before each test
|
||||
SolanaChainSpecific.clearCache()
|
||||
}
|
||||
|
||||
private fun epochInfoResponse(absoluteSlot: Long, blockHeight: Long): ChainResponse {
|
||||
val json = """{"absoluteSlot": $absoluteSlot, "blockHeight": $blockHeight, "epoch": 100, "slotIndex": 1000, "slotsInEpoch": 432000}"""
|
||||
return ChainResponse(json.toByteArray(), null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseBlock() {
|
||||
val reader = mock<ChainReader> {}
|
||||
fun `parseBlock from slotSubscribe notification`() {
|
||||
val reader = mock<ChainReader> {
|
||||
on { read(any<ChainRequest>()) }.thenReturn(
|
||||
Mono.just(epochInfoResponse(112301554, 101210751)),
|
||||
)
|
||||
}
|
||||
|
||||
val result = SolanaChainSpecific.getFromHeader(example.toByteArray(), "1", reader).block()!!
|
||||
val beforeCall = Instant.now()
|
||||
val json = """{"slot": 112301554, "parent": 112301553, "root": 112301500}"""
|
||||
val result = SolanaChainSpecific.getFromHeader(json.toByteArray(), "upstream-1", reader).block()!!
|
||||
val afterCall = Instant.now()
|
||||
|
||||
Assertions.assertThat(result.height).isEqualTo(101210751)
|
||||
Assertions.assertThat(result.hash).isEqualTo(BlockId.fromBase64("6ojMHjctdqfB55JDpEpqfHnP96fiaHEcvzEQ2NNcxzHP"))
|
||||
Assertions.assertThat(result.upstreamId).isEqualTo("1")
|
||||
Assertions.assertThat(result.parentHash).isEqualTo(BlockId.fromBase64("GJp125YAN4ufCSUvZJVdCyWQJ7RPWMmwxoyUQySydZA"))
|
||||
// Uses actual data from getEpochInfo
|
||||
assertThat(result.slot).isEqualTo(112301554)
|
||||
assertThat(result.height).isEqualTo(101210751)
|
||||
assertThat(result.upstreamId).isEqualTo("upstream-1")
|
||||
|
||||
// Synthetic hash based on actualSlot from getEpochInfo
|
||||
val expectedHash = BlockId.from(ByteBuffer.allocate(32).putLong(112301554).array())
|
||||
assertThat(result.hash).isEqualTo(expectedHash)
|
||||
|
||||
// Synthetic parent hash based on actualSlot - 1
|
||||
val expectedParentHash = BlockId.from(ByteBuffer.allocate(32).putLong(112301553).array())
|
||||
assertThat(result.parentHash).isEqualTo(expectedParentHash)
|
||||
|
||||
// Timestamp is synthetic (Instant.now() at call time)
|
||||
assertThat(result.timestamp).isBetween(beforeCall, afterCall.plus(1, ChronoUnit.SECONDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listenNewHeadsRequest returns slotSubscribe`() {
|
||||
val request = SolanaChainSpecific.listenNewHeadsRequest()
|
||||
|
||||
assertThat(request.method).isEqualTo("slotSubscribe")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsubscribeNewHeadsRequest returns slotUnsubscribe`() {
|
||||
val request = SolanaChainSpecific.unsubscribeNewHeadsRequest("sub-123")
|
||||
|
||||
assertThat(request.method).isEqualTo("slotUnsubscribe")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `throttle HTTP calls every 5 slots`() {
|
||||
val reader = mock<ChainReader> {
|
||||
on { read(any<ChainRequest>()) }.thenReturn(
|
||||
Mono.just(epochInfoResponse(100, 100000000)),
|
||||
)
|
||||
}
|
||||
|
||||
// First slot - should trigger HTTP call (no cache)
|
||||
val slot1 = """{"slot": 100, "parent": 99, "root": 50}"""
|
||||
SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block()
|
||||
|
||||
// Next 4 slots - should use cached height (within interval of 5)
|
||||
for (i in 101..104) {
|
||||
val slotN = """{"slot": $i, "parent": ${i - 1}, "root": 50}"""
|
||||
SolanaChainSpecific.getFromHeader(slotN.toByteArray(), "upstream-1", reader).block()
|
||||
}
|
||||
|
||||
// Only 1 HTTP call so far
|
||||
verify(reader, times(1)).read(any<ChainRequest>())
|
||||
|
||||
// Slot 105 - should trigger new HTTP call (interval reached)
|
||||
val slot105 = """{"slot": 105, "parent": 104, "root": 50}"""
|
||||
SolanaChainSpecific.getFromHeader(slot105.toByteArray(), "upstream-1", reader).block()
|
||||
|
||||
// Now 2 HTTP calls
|
||||
verify(reader, times(2)).read(any<ChainRequest>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthetic hash is deterministic based on slot`() {
|
||||
val slot = 12345L
|
||||
val hash1 = BlockId.from(ByteBuffer.allocate(32).putLong(slot).array())
|
||||
val hash2 = BlockId.from(ByteBuffer.allocate(32).putLong(slot).array())
|
||||
|
||||
assertThat(hash1).isEqualTo(hash2)
|
||||
|
||||
val differentSlot = 12346L
|
||||
val hash3 = BlockId.from(ByteBuffer.allocate(32).putLong(differentSlot).array())
|
||||
|
||||
assertThat(hash1).isNotEqualTo(hash3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SolanaSlotNotification parses correctly`() {
|
||||
val json = """{"slot": 123456, "parent": 123455, "root": 123400}"""
|
||||
val notification = Global.objectMapper.readValue(json, SolanaSlotNotification::class.java)
|
||||
|
||||
assertThat(notification.slot).isEqualTo(123456)
|
||||
assertThat(notification.parent).isEqualTo(123455)
|
||||
assertThat(notification.root).isEqualTo(123400)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses slot as height when cache is empty and no HTTP call`() {
|
||||
val reader = mock<ChainReader> {
|
||||
on { read(any<ChainRequest>()) }.thenReturn(Mono.error(RuntimeException("Network error")))
|
||||
}
|
||||
|
||||
// First slot with HTTP error - should fallback to slot as height
|
||||
val json = """{"slot": 112301554, "parent": 112301553, "root": 112301500}"""
|
||||
val result = SolanaChainSpecific.getFromHeader(json.toByteArray(), "upstream-1", reader).block()!!
|
||||
|
||||
assertThat(result.slot).isEqualTo(112301554)
|
||||
// When cache empty and HTTP fails, uses slot as height
|
||||
assertThat(result.height).isEqualTo(112301554)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses optimistic estimated height between throttle intervals`() {
|
||||
val reader = mock<ChainReader> {
|
||||
on { read(any<ChainRequest>()) }.thenReturn(
|
||||
Mono.just(epochInfoResponse(100, 100000000)),
|
||||
)
|
||||
}
|
||||
|
||||
// First call sets cache at slot 100 with height 100000000
|
||||
val slot1 = """{"slot": 100, "parent": 99, "root": 50}"""
|
||||
val result1 = SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block()!!
|
||||
assertThat(result1.height).isEqualTo(100000000)
|
||||
|
||||
// Second call uses optimistic estimated height: 100000000 + (101 - 100) = 100000001
|
||||
val slot2 = """{"slot": 101, "parent": 100, "root": 50}"""
|
||||
val result2 = SolanaChainSpecific.getFromHeader(slot2.toByteArray(), "upstream-1", reader).block()!!
|
||||
|
||||
assertThat(result2.slot).isEqualTo(101)
|
||||
assertThat(result2.height).isEqualTo(100000001) // optimistic estimated height
|
||||
|
||||
// Third call: 100000000 + (103 - 100) = 100000003
|
||||
val slot3 = """{"slot": 103, "parent": 102, "root": 50}"""
|
||||
val result3 = SolanaChainSpecific.getFromHeader(slot3.toByteArray(), "upstream-1", reader).block()!!
|
||||
|
||||
assertThat(result3.slot).isEqualTo(103)
|
||||
assertThat(result3.height).isEqualTo(100000003) // optimistic estimated height
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `height estimation resets after RPC check`() {
|
||||
val reader = mock<ChainReader> {
|
||||
on { read(any<ChainRequest>()) }
|
||||
.thenReturn(Mono.just(epochInfoResponse(100, 100000000)))
|
||||
.thenReturn(Mono.just(epochInfoResponse(105, 100000004)))
|
||||
}
|
||||
|
||||
// First call at slot 100 - sets cache with height 100000000
|
||||
val slot1 = """{"slot": 100, "parent": 99, "root": 50}"""
|
||||
SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block()
|
||||
|
||||
// Slot 105 triggers RPC check - gets actual slot 105 and height 100000004 (1 slot was skipped)
|
||||
val slot105 = """{"slot": 105, "parent": 104, "root": 50}"""
|
||||
val result105 = SolanaChainSpecific.getFromHeader(slot105.toByteArray(), "upstream-1", reader).block()!!
|
||||
assertThat(result105.slot).isEqualTo(105)
|
||||
assertThat(result105.height).isEqualTo(100000004)
|
||||
|
||||
// Slot 107 uses new baseline: 100000004 + (107 - 105) = 100000006
|
||||
val slot107 = """{"slot": 107, "parent": 106, "root": 50}"""
|
||||
val result107 = SolanaChainSpecific.getFromHeader(slot107.toByteArray(), "upstream-1", reader).block()!!
|
||||
assertThat(result107.height).isEqualTo(100000006)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses estimated height on RPC error when estimation available`() {
|
||||
val reader = mock<ChainReader> {
|
||||
on { read(any<ChainRequest>()) }
|
||||
.thenReturn(Mono.just(epochInfoResponse(100, 100000000)))
|
||||
.thenReturn(Mono.error(RuntimeException("Network error")))
|
||||
}
|
||||
|
||||
// First call succeeds at slot 100
|
||||
val slot1 = """{"slot": 100, "parent": 99, "root": 50}"""
|
||||
SolanaChainSpecific.getFromHeader(slot1.toByteArray(), "upstream-1", reader).block()
|
||||
|
||||
// Slot 105 triggers RPC check but fails - should use estimated height: 100000000 + (105 - 100) = 100000005
|
||||
val slot105 = """{"slot": 105, "parent": 104, "root": 50}"""
|
||||
val result = SolanaChainSpecific.getFromHeader(slot105.toByteArray(), "upstream-1", reader).block()!!
|
||||
|
||||
assertThat(result.slot).isEqualTo(105)
|
||||
assertThat(result.height).isEqualTo(100000005) // estimated height used as fallback
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user