From 7b394cc51e8af82b1ec6083bbd902f7ad465975a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:56:12 +0200 Subject: [PATCH 01/46] Validate NiPoPoW proof parameters --- .../modifiers/history/popow/NipopowAlgos.scala | 4 ++-- .../modifiers/history/popow/PoPowParams.scala | 8 +++++++- .../modifiers/history/popow/NipopowProverWithDbAlgs.scala | 4 ++-- .../storage/modifierprocessors/PopowProcessor.scala | 5 +++-- .../ergoplatform/http/routes/NipopowApiRoutesSpec.scala | 6 ++++++ .../ergoplatform/modifiers/history/PoPowAlgosSpec.scala | 8 ++++++++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala index a441cfe9ff..f793081495 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala @@ -129,9 +129,9 @@ class NipopowAlgos(val chainSettings: ChainSettings) { def prove(chain: Seq[PoPowHeader])(params: PoPowParams): Try[NipopowProof] = Try { val k = params.k val m = params.m + val minChainLength = params.minChainLength - require(params.k >= 1, s"$k < 1") - require(chain.lengthCompare(k + m) >= 0, s"Can not prove chain of size < ${k + m}") + require(chain.lengthCompare(minChainLength) >= 0, s"Can not prove chain of size < $minChainLength") require(chain.head.header.isGenesis, "Can not prove non-anchored chain") @scala.annotation.tailrec diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala index 93aacc7339..771354bdf5 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala @@ -12,5 +12,11 @@ package org.ergoplatform.modifiers.history.popow * to the block header) * */ -case class PoPowParams(m: Int, k: Int, continuous: Boolean) +case class PoPowParams(m: Int, k: Int, continuous: Boolean) { + require(m >= 1, s"$m < 1") + require(k >= 1, s"$k < 1") + require(m <= Int.MaxValue - k, s"$m + $k exceeds Int.MaxValue") + + val minChainLength: Int = m + k +} diff --git a/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala b/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala index d4bffa263d..ae5c2dcd7e 100644 --- a/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala +++ b/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala @@ -28,9 +28,9 @@ object NipopowProverWithDbAlgs { val k = params.k val m = params.m + val minChainLength = params.minChainLength - require(params.k >= 1, s"$k < 1") - require(histReader.headersHeight >= k + m, s"Can not prove chain of size < ${k + m}") + require(histReader.headersHeight >= minChainLength, s"Can not prove chain of size < $minChainLength") def linksWithIndexes(header: PoPowHeader): Seq[(ModifierId, Int)] = header.interlinks.tail.reverse.zipWithIndex diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala index 59922347a3..4f94c76790 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala @@ -107,8 +107,9 @@ trait PopowProcessor extends BasicReaders with ScorexLogging { * @return PoPow proof if success, Failure instance otherwise */ def popowProof(m: Int, k: Int, headerIdOpt: Option[ModifierId]): Try[NipopowProof] = { - val proofParams = PoPowParams(m, k, continuous = true) - NipopowProverWithDbAlgs.prove(historyReader, headerIdOpt = headerIdOpt, chainSettings)(proofParams) + Try(PoPowParams(m, k, continuous = true)).flatMap { proofParams => + NipopowProverWithDbAlgs.prove(historyReader, headerIdOpt = headerIdOpt, chainSettings)(proofParams) + } } /** diff --git a/src/test/scala/org/ergoplatform/http/routes/NipopowApiRoutesSpec.scala b/src/test/scala/org/ergoplatform/http/routes/NipopowApiRoutesSpec.scala index cfc95a8337..205aedc0bf 100644 --- a/src/test/scala/org/ergoplatform/http/routes/NipopowApiRoutesSpec.scala +++ b/src/test/scala/org/ergoplatform/http/routes/NipopowApiRoutesSpec.scala @@ -35,6 +35,12 @@ class NipopowApiRoutesSpec extends AnyFlatSpec } } + it should "reject proof request when minimum and suffix length overflow" in { + Get(s"/nipopow/proof/${Int.MaxValue}/1") ~> route ~> check { + status shouldBe StatusCodes.BadRequest + } + } + it should "proof request with missing headerId" in { Get("/nipopow/proof/1/1/05bf63aa1ecfc9f4e3fadc993f87b33edb4d58e151c1891816d734dd5a0e2e09") ~> route ~> check { status shouldBe StatusCodes.BadRequest diff --git a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala index 66d903e79b..0bc9651148 100644 --- a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala @@ -17,6 +17,14 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { private def toPoPoWChain = (c: Seq[ErgoFullBlock]) => c.map(b => PoPowHeader.fromBlock(b).get) + property("PoPowParams rejects invalid minimum chain lengths") { + an[IllegalArgumentException] should be thrownBy PoPowParams(0, 1, continuous = false) + an[IllegalArgumentException] should be thrownBy PoPowParams(1, 0, continuous = false) + an[IllegalArgumentException] should be thrownBy PoPowParams(Int.MaxValue, 1, continuous = false) + + PoPowParams(1, 1, continuous = false).minChainLength shouldBe 2 + } + property("updateInterlinks") { val chain = genChain(ChainLength) val genesis = chain.head From 21c327f2ddef3de55bbfcf7de6b88e2dac0bbbf9 Mon Sep 17 00:00:00 2001 From: a-shannon Date: Tue, 16 Jun 2026 15:42:13 +0200 Subject: [PATCH 02/46] Address NiPoPoW params review feedback --- .../modifiers/history/popow/NipopowAlgos.scala | 3 +-- .../modifiers/history/popow/PoPowParams.scala | 14 +++++++++----- .../history/popow/NipopowProverWithDbAlgs.scala | 3 +-- .../modifierprocessors/PopowProcessor.scala | 2 +- .../ergoplatform/local/NipopowVerifierSpec.scala | 2 +- .../modifiers/history/PoPowAlgosSpec.scala | 16 ++++++++-------- .../modifiers/history/PoPowAlgosWithDBSpec.scala | 4 ++-- .../utils/generators/ErgoNodeGenerators.scala | 2 +- 8 files changed, 24 insertions(+), 22 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala index f793081495..e66dfc36f6 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala @@ -129,9 +129,8 @@ class NipopowAlgos(val chainSettings: ChainSettings) { def prove(chain: Seq[PoPowHeader])(params: PoPowParams): Try[NipopowProof] = Try { val k = params.k val m = params.m - val minChainLength = params.minChainLength - require(chain.lengthCompare(minChainLength) >= 0, s"Can not prove chain of size < $minChainLength") + require(chain.lengthCompare(k + m) >= 0, s"Can not prove chain of size < ${k + m}") require(chain.head.header.isGenesis, "Can not prove non-anchored chain") @scala.annotation.tailrec diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala index 771354bdf5..486435a42c 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala @@ -1,5 +1,7 @@ package org.ergoplatform.modifiers.history.popow +import scala.util.Try + /** * NiPoPoW proof params from the KMZ17 paper * @@ -12,11 +14,13 @@ package org.ergoplatform.modifiers.history.popow * to the block header) * */ -case class PoPowParams(m: Int, k: Int, continuous: Boolean) { - require(m >= 1, s"$m < 1") - require(k >= 1, s"$k < 1") - require(m <= Int.MaxValue - k, s"$m + $k exceeds Int.MaxValue") +final class PoPowParams private (val m: Int, val k: Int, val continuous: Boolean, val minChainLength: Int) - val minChainLength: Int = m + k +object PoPowParams { + def apply(m: Int, k: Int, continuous: Boolean): Try[PoPowParams] = Try { + require(m >= 1, s"$m < 1") + require(k >= 1, s"$k < 1") + new PoPowParams(m, k, continuous, Math.addExact(m, k)) + } } diff --git a/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala b/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala index ae5c2dcd7e..94dc4924fa 100644 --- a/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala +++ b/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProverWithDbAlgs.scala @@ -28,9 +28,8 @@ object NipopowProverWithDbAlgs { val k = params.k val m = params.m - val minChainLength = params.minChainLength - require(histReader.headersHeight >= minChainLength, s"Can not prove chain of size < $minChainLength") + require(histReader.headersHeight >= k + m, s"Can not prove chain of size < ${k + m}") def linksWithIndexes(header: PoPowHeader): Seq[(ModifierId, Int)] = header.interlinks.tail.reverse.zipWithIndex diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala index 4f94c76790..5e2ebd2183 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala @@ -107,7 +107,7 @@ trait PopowProcessor extends BasicReaders with ScorexLogging { * @return PoPow proof if success, Failure instance otherwise */ def popowProof(m: Int, k: Int, headerIdOpt: Option[ModifierId]): Try[NipopowProof] = { - Try(PoPowParams(m, k, continuous = true)).flatMap { proofParams => + PoPowParams(m, k, continuous = true).flatMap { proofParams => NipopowProverWithDbAlgs.prove(historyReader, headerIdOpt = headerIdOpt, chainSettings)(proofParams) } } diff --git a/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala b/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala index 1216d77244..6131d75494 100644 --- a/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala +++ b/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala @@ -11,7 +11,7 @@ class NipopowVerifierSpec extends AnyPropSpec with Matchers { import org.ergoplatform.utils.generators.ChainGenerator._ - private val poPowParams = PoPowParams(30, 30, continuous = false) + private val poPowParams = PoPowParams(30, 30, continuous = false).get val toPoPoWChain = (c: Seq[ErgoFullBlock]) => c.map(b => PoPowHeader.fromBlock(b).get) property("processes new proofs") { diff --git a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala index 0bc9651148..3ce110d8ec 100644 --- a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala @@ -12,17 +12,17 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { import org.ergoplatform.utils.generators.CoreObjectGenerators._ import org.ergoplatform.utils.ErgoCoreTestConstants._ - private val poPowParams = PoPowParams(30, 30, continuous = false) + private val poPowParams = PoPowParams(30, 30, continuous = false).get private val ChainLength = 10 private def toPoPoWChain = (c: Seq[ErgoFullBlock]) => c.map(b => PoPowHeader.fromBlock(b).get) property("PoPowParams rejects invalid minimum chain lengths") { - an[IllegalArgumentException] should be thrownBy PoPowParams(0, 1, continuous = false) - an[IllegalArgumentException] should be thrownBy PoPowParams(1, 0, continuous = false) - an[IllegalArgumentException] should be thrownBy PoPowParams(Int.MaxValue, 1, continuous = false) + PoPowParams(0, 1, continuous = false) shouldBe 'failure + PoPowParams(1, 0, continuous = false) shouldBe 'failure + PoPowParams(Int.MaxValue, 1, continuous = false) shouldBe 'failure - PoPowParams(1, 1, continuous = false).minChainLength shouldBe 2 + PoPowParams(1, 1, continuous = false).get.minChainLength shouldBe 2 } property("updateInterlinks") { @@ -152,7 +152,7 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { } property("isBetterThan - a disconnected prefix chain should not win") { - val smallPoPowParams = PoPowParams(50, 1, continuous = false) + val smallPoPowParams = PoPowParams(50, 1, continuous = false).get val size = 100 val chain = toPoPoWChain(genChain(size)) val proof = nipopowAlgos.prove(chain)(smallPoPowParams).get @@ -166,7 +166,7 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { } property("hasValidConnections - ensures a connected prefix chain") { - val smallPoPowParams = PoPowParams(5, 5, continuous = false) + val smallPoPowParams = PoPowParams(5, 5, continuous = false).get val sizes = Seq(100, 200) sizes.foreach { size => val chain = toPoPoWChain(genChain(size)) @@ -180,7 +180,7 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { } property("hasValidConnections - ensures a connected suffix chain") { - val smallPoPowParams = PoPowParams(5, 5, continuous = false) + val smallPoPowParams = PoPowParams(5, 5, continuous = false).get val sizes = Seq(100, 200) sizes.foreach { size => diff --git a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosWithDBSpec.scala b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosWithDBSpec.scala index c81df767c2..f708e6e966 100644 --- a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosWithDBSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosWithDBSpec.scala @@ -12,7 +12,7 @@ class PoPowAlgosWithDBSpec extends AnyPropSpec with Matchers { import org.ergoplatform.utils.generators.ChainGenerator._ property("proof(chain) is equivalent to proof(histReader)") { - val poPowParams = PoPowParams(m = 5, k = 6, continuous = false) + val poPowParams = PoPowParams(m = 5, k = 6, continuous = false).get val blocksChain = genChain(3000) val pchain = blocksChain.map(b => PoPowHeader.fromBlock(b).get) val proof0 = nipopowAlgos.prove(pchain)(poPowParams).get @@ -30,7 +30,7 @@ class PoPowAlgosWithDBSpec extends AnyPropSpec with Matchers { } property("proof(histReader) for a header in the past") { - val poPowParams = PoPowParams(5, 6, continuous = false) + val poPowParams = PoPowParams(5, 6, continuous = false).get val blocksChain = genChain(300) val at = 200 diff --git a/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeGenerators.scala b/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeGenerators.scala index b4043fe3e7..c4b03e7072 100644 --- a/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeGenerators.scala +++ b/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeGenerators.scala @@ -24,7 +24,7 @@ object ErgoNodeGenerators { } yield { val chain = genHeaderChain(m * mulM + k, diffBitsOpt = None, useRealTs = false) val popowChain = popowHeaderChain(chain) - val params = PoPowParams(m, k, continuous = false) + val params = PoPowParams(m, k, continuous = false).get nipopowAlgos.prove(popowChain)(params).get } } From a2ffb6e906326ba17a3b7b8e3d848a9571431c80 Mon Sep 17 00:00:00 2001 From: a-shannon Date: Fri, 19 Jun 2026 13:40:23 +0200 Subject: [PATCH 03/46] Stabilize fee estimation heuristics --- .../nodeView/mempool/ErgoMemPool.scala | 8 +++-- .../nodeView/mempool/ErgoMemPoolSpec.scala | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/mempool/ErgoMemPool.scala b/src/main/scala/org/ergoplatform/nodeView/mempool/ErgoMemPool.scala index a385c09317..11a83bca4c 100644 --- a/src/main/scala/org/ergoplatform/nodeView/mempool/ErgoMemPool.scala +++ b/src/main/scala/org/ergoplatform/nodeView/mempool/ErgoMemPool.scala @@ -323,7 +323,8 @@ class ErgoMemPool private[mempool](private[mempool] val pool: OrderedTxPool, case _ => None } - loop(waitMinutes = 0).getOrElse(settings.nodeSettings.minimalFeeAmount) + val recommendedFee = loop(waitMinutes = 0).getOrElse(settings.nodeSettings.minimalFeeAmount) + math.max(recommendedFee, settings.nodeSettings.minimalFeeAmount) } /** @@ -346,8 +347,9 @@ class ErgoMemPool private[mempool](private[mempool] val pool: OrderedTxPool, // Time since statistics measurement interval (needed to calculate average tx rate) val elapsed = System.currentTimeMillis() - stats.startMeasurement - if (stats.takenTxns != 0) { - elapsed * posInPool / stats.takenTxns + val cappedElapsed = math.max(0L, math.min(elapsed, MemPoolStatistics.measurementIntervalMsec.toLong)) + if (stats.takenTxns > 0) { + cappedElapsed * posInPool / stats.takenTxns } else { 0 } diff --git a/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala b/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala index 42fd3ca55d..9485e85870 100644 --- a/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala @@ -10,6 +10,7 @@ import org.ergoplatform.settings.{ErgoSettings, ErgoValidationSettingsUpdate, Pa import org.ergoplatform.utils.{ErgoTestHelpers, RandomWrapper} import org.scalatest.flatspec.AnyFlatSpec import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks +import scorex.crypto.authds.ADKey import scorex.util.encode.Base16 import sigma.ast.ByteArrayConstant import sigma.interpreter.{ContextExtension, ProverResult} @@ -24,6 +25,13 @@ class ErgoMemPoolSpec extends AnyFlatSpec import org.ergoplatform.utils.generators.ErgoCoreTransactionGenerators._ import org.ergoplatform.utils.generators.ValidBlocksGenerators._ + private def feeTx(inputSeed: Byte, fee: Long): ErgoTransaction = { + ErgoTransaction( + IndexedSeq(new Input(ADKey @@ Array.fill(32)(inputSeed), emptyProverResult)), + IndexedSeq(new ErgoBoxCandidate(fee, feeProp, creationHeight = 0)) + ) + } + it should "accept valid transaction" in { val (us, bh) = createUtxoState(settings) val genesis = validFullBlock(None, us, bh) @@ -417,6 +425,31 @@ class ErgoMemPoolSpec extends AnyFlatSpec pool.stats.takenTxns shouldBe (family_depth + 1) * txs.size } + it should "not recommend fee below node minimal fee" in { + val feeSettings = settings.copy(nodeSettings = settings.nodeSettings.copy(minimalFeeAmount = 1000000L)) + val minimalFee = feeSettings.nodeSettings.minimalFeeAmount + val now = System.currentTimeMillis() + val lowFeeHistogram = FeeHistogramBin(nTxns = 1, totalFee = minimalFee / 2) :: + List.fill(MemPoolStatistics.nHistogramBins - 1)(FeeHistogramBin(0, 0)) + val stats = MemPoolStatistics(now, takenTxns = 1, snapTime = now, histogram = lowFeeHistogram) + val pool = new ErgoMemPool(OrderedTxPool.empty(feeSettings), stats, SortingOption.FeePerByte)(feeSettings) + + pool.getRecommendedFee(expectedWaitTimeMinutes = 0, txSize = 1024) shouldBe minimalFee + } + + it should "not let idle uptime dominate expected wait time" in { + val feeSettings = settings.copy(nodeSettings = settings.nodeSettings.copy(minimalFeeAmount = 1000000L)) + val minimalFee = feeSettings.nodeSettings.minimalFeeAmount + val poolWithHigherFeeTx = ErgoMemPool.empty(feeSettings) + .put(UnconfirmedTransaction(feeTx(inputSeed = 1, fee = minimalFee * 100), None)) + val now = System.currentTimeMillis() + val staleMeasurementStart = now - 365L * 24 * 60 * 60 * 1000 + val staleStats = MemPoolStatistics(staleMeasurementStart, takenTxns = 1, snapTime = now) + val pool = new ErgoMemPool(poolWithHigherFeeTx.pool, staleStats, SortingOption.FeePerByte)(feeSettings) + + pool.getExpectedWaitTime(txFee = minimalFee, txSize = 1024) should be <= MemPoolStatistics.measurementIntervalMsec.toLong + } + it should "put not adding transaction twice" in { val pool = ErgoMemPool.empty(settings).pool val tx = invalidErgoTransactionGen.sample.get From 477781818677fed089ef69e161a1c6a97b0fd37b Mon Sep 17 00:00:00 2001 From: Alexander Chepurnoy Date: Thu, 9 Jul 2026 00:14:58 +0300 Subject: [PATCH 04/46] 6.0.5 version set --- src/main/resources/api/openapi.yaml | 2 +- src/main/resources/application.conf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/api/openapi.yaml b/src/main/resources/api/openapi.yaml index f421d82176..b64eb403ff 100644 --- a/src/main/resources/api/openapi.yaml +++ b/src/main/resources/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.2" info: - version: "6.0.4" + version: "6.0.5" title: Ergo Node API description: API docs for Ergo Node. Models are shared between all Ergo products contact: diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 4bbea9b00a..bc8db13f0e 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -446,7 +446,7 @@ scorex { nodeName = "ergo-node" # Network protocol version to be sent in handshakes - appVersion = 6.0.4 + appVersion = 6.0.5 # Network agent name. May contain information about client code # stack, starting from core code-base up to the end graphical interface. From 366a046a182b2cfed988a6e253f7c74d748d3beb Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:53:15 +0200 Subject: [PATCH 05/46] Validate NiPoPoW proof header PoW Reject proofs whose header chain contains invalid Autolykos PoW before bootstrap headers are applied. Add regression coverage demonstrating that rejected proof headers are not inserted into history. --- .../history/popow/NipopowAlgos.scala | 2 + .../history/popow/NipopowProof.scala | 8 +++- .../history/PopowProcessorSpecification.scala | 39 ++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala index e66dfc36f6..58a0ff0ec1 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala @@ -75,6 +75,8 @@ class NipopowAlgos(val chainSettings: ChainSettings) { Int.MaxValue } + def hasValidPow(header: Header): Boolean = powScheme.validate(header).isSuccess + /** * Computes best score of a given chain. * The score value depends on number of ยต-superblocks in the given chain. diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index c963877ada..65252a8121 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -72,7 +72,11 @@ case class NipopowProof(popowAlgos: NipopowAlgos, * @return true if the proof is valid */ lazy val isValid: Boolean = { - this.hasValidConnections && this.hasValidHeights && this.hasValidProofs && this.hasValidDifficultyHeaders + this.hasValidConnections && + this.hasValidHeights && + this.hasValidProofs && + this.hasValidDifficultyHeaders && + this.hasValidPow } /** @@ -155,6 +159,8 @@ case class NipopowProof(popowAlgos: NipopowAlgos, suffixHead.checkInterlinksProof() } + lazy val hasValidPow: Boolean = headersChain.forall(popowAlgos.hasValidPow) + } object NipopowProof { diff --git a/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala index d9ace006c3..93e113a14b 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala @@ -1,13 +1,17 @@ package org.ergoplatform.nodeView.history +import org.ergoplatform.mining.AutolykosPowScheme import org.ergoplatform.modifiers.ErgoFullBlock import org.ergoplatform.modifiers.history.popow.PoPowHeader import org.ergoplatform.nodeView.state.StateType +import org.ergoplatform.settings.NipopowSettings import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.wallet.utils.FileUtils import scorex.util.ModifierId -class PopowProcessorSpecification extends ErgoCorePropertyTest { +class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { import org.ergoplatform.utils.HistoryTestHelpers._ + import org.ergoplatform.utils.ErgoNodeTestConstants.{settings => baseSettings} import org.ergoplatform.utils.generators.ChainGenerator._ private def genHistory(genesisIdOpt: Option[ModifierId], popowBootstrap: Boolean) = @@ -15,6 +19,21 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest { epochLength = 10000, useLastEpochs = 3, initialDiffOpt = None, genesisIdOpt) .ensuring(_.bestFullBlockOpt.isEmpty) + private def genRealPowHistory(genesisIdOpt: Option[ModifierId], + realPowScheme: AutolykosPowScheme): ErgoHistory = { + val realPowSettings = baseSettings.copy( + directory = createTempDir.getAbsolutePath, + chainSettings = baseSettings.chainSettings.copy(powScheme = realPowScheme, genesisId = genesisIdOpt), + nodeSettings = baseSettings.nodeSettings.copy( + stateType = StateType.Utxo, + verifyTransactions = true, + blocksToKeep = -1, + nipopowSettings = NipopowSettings(nipopowBootstrap = true, p2pNipopows = 1) + ) + ) + ErgoHistory.readOrGenerate(realPowSettings)(null).ensuring(_.bestFullBlockOpt.isEmpty) + } + val toPoPoWChain = (c: Seq[ErgoFullBlock]) => c.map(b => PoPowHeader.fromBlock(b).get) property("popow proof application") { @@ -32,4 +51,22 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest { receiverHistory.bestHeaderOpt.get shouldBe senderHistory.bestHeaderOpt.get } + property("popow proof application rejects headers failing real Autolykos validation") { + val senderHistory = genHistory(None, popowBootstrap = false) + val senderChain = genChain(80, senderHistory) + applyChain(senderHistory, senderChain) + + val popowProofBytes = senderHistory.popowProofBytes().get + val realPowScheme = new AutolykosPowScheme(baseSettings.chainSettings.powScheme.k, baseSettings.chainSettings.powScheme.n) + val receiverHistory = genRealPowHistory(senderHistory.bestHeaderAtHeight(1).map(_.id), realPowScheme) + val popowProof = receiverHistory.nipopowSerializer.parseBytes(popowProofBytes) + + popowProof.headersChain.exists(h => realPowScheme.validate(h).isFailure) shouldBe true + + receiverHistory.headersHeight shouldBe 0 + receiverHistory.applyPopowProof(popowProof) + receiverHistory.headersHeight shouldBe 0 + receiverHistory.bestHeaderOpt shouldBe None + } + } From 594eeb94e852a017ee26a853b9ee08cd38886f34 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:07:06 +0200 Subject: [PATCH 06/46] Remove unused InfoApiRoute import Restore the v6.0.5 node and integration-test builds under fatal unused-import warnings. --- src/main/scala/org/ergoplatform/http/api/InfoApiRoute.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/scala/org/ergoplatform/http/api/InfoApiRoute.scala b/src/main/scala/org/ergoplatform/http/api/InfoApiRoute.scala index af0a44d7ef..43b54378d1 100644 --- a/src/main/scala/org/ergoplatform/http/api/InfoApiRoute.scala +++ b/src/main/scala/org/ergoplatform/http/api/InfoApiRoute.scala @@ -1,7 +1,6 @@ package org.ergoplatform.http.api import akka.actor.{ActorRef, ActorRefFactory} -import akka.http.scaladsl.model.ContentTypes import akka.http.scaladsl.server.Route import akka.pattern.ask import io.circe.syntax._ From 706a9163695632e62bc9ef1f9ec68a8aafca7292 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:46:50 +0200 Subject: [PATCH 07/46] Reject invalid inbound NiPoPoW parameters --- .../history/popow/NipopowAlgos.scala | 2 + .../history/popow/NipopowProof.scala | 3 +- .../local/NipopowVerifierSpec.scala | 48 +++++++++++++++++++ .../modifiers/history/PoPowAlgosSpec.scala | 23 +++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala index 58a0ff0ec1..0d76a9815e 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala @@ -98,6 +98,8 @@ class NipopowAlgos(val chainSettings: ChainSettings) { * end function */ def bestArg(chain: Seq[Header])(m: Int): Int = { + require(m >= 1, s"$m < 1") + @scala.annotation.tailrec def loop(level: Int, acc: Seq[(Int, Int)] = Seq.empty): Seq[(Int, Int)] = if (level == 0) { diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index 65252a8121..f4610f08ee 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -72,7 +72,8 @@ case class NipopowProof(popowAlgos: NipopowAlgos, * @return true if the proof is valid */ lazy val isValid: Boolean = { - this.hasValidConnections && + PoPowParams(m, k, continuous).isSuccess && + this.hasValidConnections && this.hasValidHeights && this.hasValidProofs && this.hasValidDifficultyHeaders && diff --git a/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala b/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala index 6131d75494..e4b8543b8d 100644 --- a/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala +++ b/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala @@ -1,5 +1,8 @@ package org.ergoplatform.local +import java.util.concurrent.{CountDownLatch, TimeUnit} +import java.util.concurrent.atomic.AtomicReference + import org.ergoplatform.modifiers.history.popow.{PoPowHeader, PoPowParams} import org.ergoplatform.modifiers.ErgoFullBlock import org.scalatest.matchers.should.Matchers @@ -43,4 +46,49 @@ class NipopowVerifierSpec extends AnyPropSpec with Matchers { verifier.bestChain.last.id shouldBe longestProof.headersChain.last.id } } + + property("rejects proofs with invalid security parameters") { + val baseChain = genChain(100) + val params = PoPowParams(5, 5, continuous = false).get + val proof = nipopowAlgos.prove(toPoPoWChain(baseChain))(params).get + + Seq( + proof.copy(m = 0), + proof.copy(k = 0), + proof.copy(m = Int.MaxValue, k = 1) + ).foreach { invalidProof => + val proofBytes = invalidProof.serializer.toBytes(invalidProof) + val receivedProof = invalidProof.serializer.parseBytes(proofBytes) + receivedProof.isValid shouldBe false + + val verifier = new NipopowVerifier(Some(baseChain.head.id)) + verifier.process(receivedProof) shouldBe ValidationError + verifier.bestChain shouldBe empty + } + } + + property("returns when a duplicate invalid proof is processed") { + val baseChain = genChain(100) + val params = PoPowParams(5, 5, continuous = false).get + val invalidProof = nipopowAlgos.prove(toPoPoWChain(baseChain))(params).get.copy(m = 0) + val proofBytes = invalidProof.serializer.toBytes(invalidProof) + val receivedProof = invalidProof.serializer.parseBytes(proofBytes) + val verifier = new NipopowVerifier(Some(baseChain.head.id)) + + val firstResult = verifier.process(receivedProof) + val secondResult = new AtomicReference[NipopowProofVerificationResult]() + val completed = new CountDownLatch(1) + val worker = new Thread(new Runnable { + override def run(): Unit = + try secondResult.set(verifier.process(receivedProof)) + finally completed.countDown() + }) + worker.setDaemon(true) + worker.start() + + completed.await(2, TimeUnit.SECONDS) shouldBe true + firstResult shouldBe ValidationError + secondResult.get() shouldBe ValidationError + verifier.bestChain shouldBe empty + } } diff --git a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala index 3ce110d8ec..597e1de070 100644 --- a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala @@ -1,5 +1,8 @@ package org.ergoplatform.modifiers.history +import java.util.concurrent.{CountDownLatch, TimeUnit} +import java.util.concurrent.atomic.AtomicReference + import org.ergoplatform.modifiers.history.popow.{NipopowAlgos, NipopowProof, PoPowHeader, PoPowParams} import org.ergoplatform.modifiers.ErgoFullBlock import org.scalacheck.Gen @@ -25,6 +28,26 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { PoPowParams(1, 1, continuous = false).get.minChainLength shouldBe 2 } + property("bestArg rejects a non-positive security parameter without looping") { + val completed = new CountDownLatch(1) + val error = new AtomicReference[Throwable]() + val worker = new Thread(new Runnable { + override def run(): Unit = + try { + nipopowAlgos.bestArg(Seq.empty)(0) + } catch { + case t: Throwable => error.set(t) + } finally { + completed.countDown() + } + }) + worker.setDaemon(true) + worker.start() + + completed.await(2, TimeUnit.SECONDS) shouldBe true + error.get() shouldBe a[IllegalArgumentException] + } + property("updateInterlinks") { val chain = genChain(ChainLength) val genesis = chain.head From 3cc7f3c4c47c9ab88ff004c459963e7a9bb44791 Mon Sep 17 00:00:00 2001 From: a-shannon Date: Mon, 13 Jul 2026 23:00:55 +0200 Subject: [PATCH 08/46] refactor: reuse NiPoPoW parameter validation --- .../modifiers/history/popow/NipopowProof.scala | 2 +- .../modifiers/history/popow/PoPowParams.scala | 8 +++++--- .../ergoplatform/modifiers/history/PoPowAlgosSpec.scala | 8 +++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index f4610f08ee..b922aca7c0 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -72,7 +72,7 @@ case class NipopowProof(popowAlgos: NipopowAlgos, * @return true if the proof is valid */ lazy val isValid: Boolean = { - PoPowParams(m, k, continuous).isSuccess && + PoPowParams.isValid(m, k) && this.hasValidConnections && this.hasValidHeights && this.hasValidProofs && diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala index 486435a42c..659fe3c8e8 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala @@ -17,10 +17,12 @@ import scala.util.Try final class PoPowParams private (val m: Int, val k: Int, val continuous: Boolean, val minChainLength: Int) object PoPowParams { + def isValid(m: Int, k: Int): Boolean = + m >= 1 && k >= 1 && m.toLong + k.toLong <= Int.MaxValue + def apply(m: Int, k: Int, continuous: Boolean): Try[PoPowParams] = Try { - require(m >= 1, s"$m < 1") - require(k >= 1, s"$k < 1") - new PoPowParams(m, k, continuous, Math.addExact(m, k)) + require(isValid(m, k), s"Invalid NiPoPoW parameters: m=$m, k=$k") + new PoPowParams(m, k, continuous, m + k) } } diff --git a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala index 597e1de070..54c01f2ecb 100644 --- a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala @@ -21,20 +21,26 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { private def toPoPoWChain = (c: Seq[ErgoFullBlock]) => c.map(b => PoPowHeader.fromBlock(b).get) property("PoPowParams rejects invalid minimum chain lengths") { + PoPowParams.isValid(0, 1) shouldBe false + PoPowParams.isValid(1, 0) shouldBe false + PoPowParams.isValid(Int.MaxValue, 1) shouldBe false + PoPowParams(0, 1, continuous = false) shouldBe 'failure PoPowParams(1, 0, continuous = false) shouldBe 'failure PoPowParams(Int.MaxValue, 1, continuous = false) shouldBe 'failure + PoPowParams.isValid(Int.MaxValue - 1, 1) shouldBe true PoPowParams(1, 1, continuous = false).get.minChainLength shouldBe 2 } property("bestArg rejects a non-positive security parameter without looping") { + val algos = nipopowAlgos val completed = new CountDownLatch(1) val error = new AtomicReference[Throwable]() val worker = new Thread(new Runnable { override def run(): Unit = try { - nipopowAlgos.bestArg(Seq.empty)(0) + algos.bestArg(Seq.empty)(0) } catch { case t: Throwable => error.set(t) } finally { From 14e7e71a34e162532db4f99d017b992e73a84cad Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:17:53 +0200 Subject: [PATCH 09/46] Bound per-peer outbound buffering --- .../core/network/PeerConnectionHandler.scala | 46 ++++- .../PeerConnectionHandlerSpecification.scala | 164 ++++++++++++++++++ 2 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala diff --git a/src/main/scala/scorex/core/network/PeerConnectionHandler.scala b/src/main/scala/scorex/core/network/PeerConnectionHandler.scala index 8c32487397..bd6a573bed 100644 --- a/src/main/scala/scorex/core/network/PeerConnectionHandler.scala +++ b/src/main/scala/scorex/core/network/PeerConnectionHandler.scala @@ -4,14 +4,19 @@ import akka.actor.{Actor, ActorRef, Cancellable, Props, SupervisorStrategy} import akka.io.Tcp import akka.io.Tcp._ import akka.util.{ByteString, CompactByteString} -import org.ergoplatform.network.{Handshake, HandshakeSerializer, PeerSpec, Version} import org.ergoplatform.network.Version.Eip37ForkVersion -import scorex.core.app.ScorexContext -import scorex.core.network.NetworkController.ReceivableMessages.{Handshaked, PenalizePeer} -import scorex.core.network.PeerConnectionHandler.ReceivableMessages +import org.ergoplatform.network.{Handshake, HandshakeSerializer, PeerSpec, Version} +import org.ergoplatform.network.message.MessageConstants.{ + ChecksumLength, + HeaderLength, + MaxMessageSize +} import org.ergoplatform.network.message.MessageSerializer import org.ergoplatform.network.peer.{PeerInfo, PenaltyType} import org.ergoplatform.settings.ScorexSettings +import scorex.core.app.ScorexContext +import scorex.core.network.NetworkController.ReceivableMessages.{Handshaked, PenalizePeer} +import scorex.core.network.PeerConnectionHandler.ReceivableMessages import scorex.util.ScorexLogging import scala.annotation.tailrec @@ -27,6 +32,7 @@ class PeerConnectionHandler(scorexSettings: ScorexSettings, extends Actor with ScorexLogging { import PeerConnectionHandler.ReceivableMessages._ + import PeerConnectionHandler.{MaxBufferedOutboundBytes, MaxBufferedOutboundMessages} private val networkSettings = scorexSettings.network private val connection = connectionDescription.connection @@ -48,6 +54,8 @@ class PeerConnectionHandler(scorexSettings: ScorexSettings, private var outMessagesBuffer: TreeMap[Long, ByteString] = TreeMap.empty + private var outMessagesBufferBytes: Long = 0L + private var outMessagesCounter: Long = 0 override def preStart: Unit = { @@ -179,7 +187,10 @@ class PeerConnectionHandler(scorexSettings: ScorexSettings, writeFirst() case ReceivableMessages.Ack(id) => - outMessagesBuffer -= id + outMessagesBuffer.get(id).foreach { msg => + outMessagesBuffer -= id + outMessagesBufferBytes -= msg.length + } if (outMessagesBuffer.nonEmpty){ writeFirst() } else { @@ -226,7 +237,22 @@ class PeerConnectionHandler(scorexSettings: ScorexSettings, } private def buffer(id: Long, msg: ByteString): Unit = { - outMessagesBuffer += id -> msg + val previousMessage = outMessagesBuffer.get(id) + val previousLength = previousMessage.fold(0)(_.length) + val candidateBytes = outMessagesBufferBytes - previousLength + msg.length + val candidateMessages = outMessagesBuffer.size + previousMessage.fold(1)(_ => 0) + if (candidateBytes > MaxBufferedOutboundBytes || + candidateMessages > MaxBufferedOutboundMessages) { + log.warn(s"Buffered outbound data for $connectionId would exceed its limit " + + s"($candidateMessages messages, $candidateBytes bytes), aborting the connection") + outMessagesBuffer = TreeMap.empty + outMessagesBufferBytes = 0L + connection ! Abort + context.stop(self) + } else { + outMessagesBuffer += id -> msg + outMessagesBufferBytes = candidateBytes + } } private def writeFirst(): Unit = { @@ -259,6 +285,14 @@ class PeerConnectionHandler(scorexSettings: ScorexSettings, object PeerConnectionHandler { + // Keep one maximum serialized frame per peer. Backpressured snapshot transfers + // retry instead of retaining their entire application-level in-flight window. + private[network] val MaxBufferedOutboundBytes: Long = + MaxMessageSize.toLong + HeaderLength + ChecksumLength + + // Independently bound collection overhead from small messages. + private[network] val MaxBufferedOutboundMessages: Int = 64 + object ReceivableMessages { case object HandshakeTimeout diff --git a/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala b/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala new file mode 100644 index 0000000000..70d4b8b3c6 --- /dev/null +++ b/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala @@ -0,0 +1,164 @@ +package scorex.core.network + +import akka.io.Tcp +import akka.testkit.{TestActorRef, TestProbe} +import akka.util.ByteString +import org.ergoplatform.network.message.{ + GetPeersSpec, + Message, + MessageSpec, + UtxoSnapshotChunkSpec +} +import org.ergoplatform.network.{Handshake, HandshakeSerializer} +import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.utils.ErgoNodeTestConstants.{defaultPeerSpec, settings} +import scorex.core.app.ScorexContext +import scorex.testkit.utils.AkkaFixture + +import java.net.InetSocketAddress +import scala.concurrent.Await +import scala.concurrent.duration.{Duration, DurationInt} + +class PeerConnectionHandlerSpecification extends ErgoCorePropertyTest { + private final class ConnectedHandler(val connection: TestProbe, + val watcher: TestProbe, + val handler: TestActorRef[PeerConnectionHandler]) + + private def withConnectedHandler( + messageSpecs: Seq[MessageSpec[_]], + localPort: Int + )(test: ConnectedHandler => Unit): Unit = { + val fixture = new AkkaFixture + try { + implicit val system = fixture.system + implicit val ec = system.dispatcher + val connection = TestProbe("connection") + val controller = TestProbe("controller") + val localAddress = new InetSocketAddress("127.0.0.1", localPort) + val remoteAddress = new InetSocketAddress("127.0.0.1", localPort + 1) + val description = ConnectionDescription( + connection.ref, + ConnectionId(remoteAddress, localAddress, Incoming), + Some(localAddress), + Seq.empty + ) + val handler = TestActorRef(new PeerConnectionHandler( + settings.scorexSettings, + controller.ref, + ScorexContext(messageSpecs, None, None), + description + )) + + connection.expectMsgType[Tcp.Register] + connection.expectMsg(Tcp.ResumeReading) + connection.expectMsgType[Tcp.Write] + + val handshake = HandshakeSerializer.toBytes( + Handshake(defaultPeerSpec, System.currentTimeMillis()) + ) + connection.send(handler, Tcp.Received(ByteString(handshake))) + controller.expectMsgType[NetworkController.ReceivableMessages.Handshaked] + connection.expectMsg(Tcp.ResumeReading) + controller.watch(handler) + + test(new ConnectedHandler(connection, controller, handler)) + } finally { + Await.result(fixture.system.terminate(), Duration.Inf) + } + } + + property("abort before a fifth maximum snapshot frame is retained") { + withConnectedHandler(Seq(UtxoSnapshotChunkSpec), localPort = 9083) { fixture => + val chunkMessage = Message( + UtxoSnapshotChunkSpec, + Right(Array.fill[Byte](3999996)(1)), + None + ) + fixture.handler ! chunkMessage + val failedWrite = fixture.connection.expectMsgType[Tcp.Write] + failedWrite.data.length shouldEqual 4000013 + fixture.connection.send(fixture.handler, Tcp.CommandFailed(failedWrite)) + fixture.connection.expectMsg(Tcp.ResumeWriting) + + (2 to 4).foreach { id => + val write = Tcp.Write( + failedWrite.data, + PeerConnectionHandler.ReceivableMessages.Ack(id) + ) + fixture.connection.send(fixture.handler, Tcp.CommandFailed(write)) + fixture.connection.expectMsg(Tcp.ResumeWriting) + } + fixture.connection.expectNoMessage(200.millis) + + val overLimitWrite = Tcp.Write( + failedWrite.data, + PeerConnectionHandler.ReceivableMessages.Ack(5) + ) + fixture.connection.send( + fixture.handler, + Tcp.CommandFailed(overLimitWrite) + ) + fixture.connection.expectMsg(Tcp.ResumeWriting) + fixture.connection.expectMsg(1.second, Tcp.Abort) + fixture.watcher.expectTerminated(fixture.handler) + } + } + + property("abort before more than 64 outbound messages are buffered") { + withConnectedHandler(Seq(GetPeersSpec), localPort = 9093) { fixture => + val getPeersMessage = Message(GetPeersSpec, Right(()), None) + fixture.handler ! getPeersMessage + val failedWrite = fixture.connection.expectMsgType[Tcp.Write] + fixture.connection.send(fixture.handler, Tcp.CommandFailed(failedWrite)) + fixture.connection.expectMsg(Tcp.ResumeWriting) + + (1 until PeerConnectionHandler.MaxBufferedOutboundMessages) + .foreach(_ => fixture.handler ! getPeersMessage) + fixture.connection.expectNoMessage(200.millis) + + fixture.handler ! getPeersMessage + fixture.connection.expectMsg(1.second, Tcp.Abort) + fixture.watcher.expectTerminated(fixture.handler) + } + } + + property("account retried and acknowledged writes exactly") { + withConnectedHandler(Seq(GetPeersSpec), localPort = 9103) { fixture => + val maxSizedWrite = Tcp.Write( + ByteString(new Array[Byte]( + PeerConnectionHandler.MaxBufferedOutboundBytes.toInt + )), + PeerConnectionHandler.ReceivableMessages.Ack(1) + ) + fixture.connection.send(fixture.handler, Tcp.CommandFailed(maxSizedWrite)) + fixture.connection.expectMsg(Tcp.ResumeWriting) + fixture.connection.expectNoMessage(200.millis) + + val replacementWrite = Tcp.Write( + ByteString(new Array[Byte](9)), + PeerConnectionHandler.ReceivableMessages.Ack(1) + ) + fixture.connection.send( + fixture.handler, + Tcp.CommandFailed(replacementWrite) + ) + fixture.connection.expectMsg(Tcp.ResumeWriting) + fixture.connection.expectNoMessage(200.millis) + + fixture.connection.send(fixture.handler, Tcp.WritingResumed) + val retriedWrite = fixture.connection.expectMsgType[Tcp.Write] + retriedWrite.data.length shouldEqual 9 + retriedWrite.ack shouldEqual PeerConnectionHandler.ReceivableMessages.Ack(1) + fixture.connection.send( + fixture.handler, + PeerConnectionHandler.ReceivableMessages.Ack(1) + ) + + fixture.handler ! Message(GetPeersSpec, Right(()), None) + val nextWrite = fixture.connection.expectMsgType[Tcp.Write] + fixture.connection.send(fixture.handler, Tcp.CommandFailed(nextWrite)) + fixture.connection.expectMsg(Tcp.ResumeWriting) + fixture.connection.expectNoMessage(200.millis) + } + } +} From eaa7f1bd79c8250d357c4fc9149b9c6591ad69a3 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:28:30 +0200 Subject: [PATCH 10/46] Close all live connections for blacklisted IPs --- .../core/network/NetworkController.scala | 10 +- .../core/network/NetworkControllerSpec.scala | 123 +++++++++++++++++- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/main/scala/scorex/core/network/NetworkController.scala b/src/main/scala/scorex/core/network/NetworkController.scala index 8db3500db9..0cec487f35 100644 --- a/src/main/scala/scorex/core/network/NetworkController.scala +++ b/src/main/scala/scorex/core/network/NetworkController.scala @@ -163,11 +163,11 @@ class NetworkController(ergoSettings: ErgoSettings, peerManagerRef ! PeerManager.ReceivableMessages.Penalize(peerAddress, penaltyType) case Blacklisted(peerAddress) => - connections.get(peerAddress).foreach { peer => - connections = connections.filterNot { case (address, _) => // clear all connections related to banned peer ip - Option(peer.connectionId.remoteAddress.getAddress).exists(Option(address.getAddress).contains(_)) - } - peer.handlerRef ! CloseConnection + Option(peerAddress.getAddress).foreach { blacklistedIp => + val peersToClose = connections.valuesIterator.filter { peer => + Option(peer.connectionId.remoteAddress.getAddress).contains(blacklistedIp) + }.toSeq + peersToClose.foreach(_.handlerRef ! CloseConnection) } } diff --git a/src/test/scala/scorex/core/network/NetworkControllerSpec.scala b/src/test/scala/scorex/core/network/NetworkControllerSpec.scala index 8bc287d6d9..1265b535ab 100644 --- a/src/test/scala/scorex/core/network/NetworkControllerSpec.scala +++ b/src/test/scala/scorex/core/network/NetworkControllerSpec.scala @@ -3,6 +3,7 @@ package scorex.core.network import akka.actor.ActorRef import akka.io.Tcp import akka.testkit.{TestActorRef, TestProbe} +import org.ergoplatform.network.ErgoNodeViewSynchronizerMessages.DisconnectedPeer import org.ergoplatform.network.message.MessageConstants.MessageCode import org.ergoplatform.network.peer.PeerInfo import org.ergoplatform.utils.ErgoCorePropertyTest @@ -25,6 +26,8 @@ class NetworkControllerSpec extends ErgoCorePropertyTest { val scorexContext: ScorexContext = ScorexContext(Seq.empty, None, None) + case class EstablishedConnection(connectionProbe: TestProbe, handlerRef: ActorRef) + def createController(maxConnections: Int): (TestActorRef[NetworkController], TestProbe, TestProbe) = { val peerManagerProbe = TestProbe("PeerManager") val tcpManagerProbe = TestProbe("TcpManager") @@ -56,6 +59,33 @@ class NetworkControllerSpec extends ErgoCorePropertyTest { peerManagerProbe: TestProbe, remoteAddress: InetSocketAddress ): InetSocketAddress = { + beginIncomingConnection(controller, peerManagerProbe, remoteAddress) + remoteAddress + } + + def establishIncomingConnectionWithHandler( + controller: TestActorRef[NetworkController], + peerManagerProbe: TestProbe, + remoteAddress: InetSocketAddress + ): EstablishedConnection = { + val connectionProbe = beginIncomingConnection( + controller, + peerManagerProbe, + remoteAddress + ) + + val handlerRef = connectionProbe.expectMsgType[Tcp.Register].handler + connectionProbe.expectMsg(Tcp.ResumeReading) + connectionProbe.expectMsgType[Tcp.Write] + + EstablishedConnection(connectionProbe, handlerRef) + } + + private def beginIncomingConnection( + controller: TestActorRef[NetworkController], + peerManagerProbe: TestProbe, + remoteAddress: InetSocketAddress + ): TestProbe = { val localAddress = settings.scorexSettings.network.bindAddress val connectionProbe = TestProbe("Connection") @@ -66,7 +96,7 @@ class NetworkControllerSpec extends ErgoCorePropertyTest { controller ! ConnectionConfirmed(ConnectionId(remoteAddress, localAddress, Incoming), handlerRef) } - remoteAddress + connectionProbe } def establishOutgoingConnection( @@ -191,6 +221,97 @@ class NetworkControllerSpec extends ErgoCorePropertyTest { } } + property("blacklisting should close exactly the live connections for the banned IP") { + withFixture { f => + implicit val system = f.system + val (controller, peerManagerProbe, _) = f.createController(maxConnections = 30) + val disconnectProbe = TestProbe("DisconnectedPeers") + f.system.eventStream.subscribe(disconnectProbe.ref, classOf[DisconnectedPeer]) + + val firstAddress = new InetSocketAddress("192.0.2.10", 9101) + val secondAddress = new InetSocketAddress("192.0.2.10", 9102) + val unrelatedAddress = new InetSocketAddress("198.51.100.20", 9201) + val first = f.establishIncomingConnectionWithHandler( + controller, + peerManagerProbe, + firstAddress + ) + val second = f.establishIncomingConnectionWithHandler( + controller, + peerManagerProbe, + secondAddress + ) + val unrelated = f.establishIncomingConnectionWithHandler( + controller, + peerManagerProbe, + unrelatedAddress + ) + + peerManagerProbe.send(controller, Blacklisted(firstAddress)) + + first.connectionProbe.expectMsg(Tcp.Abort) + second.connectionProbe.expectMsg(Tcp.Abort) + unrelated.connectionProbe.expectNoMessage(200.millis) + + val duplicateBeforeTermination = TestProbe("DuplicateBeforeTermination") + duplicateBeforeTermination.send( + controller, + Tcp.Connected(secondAddress, settings.scorexSettings.network.bindAddress) + ) + duplicateBeforeTermination.expectMsg(Tcp.Close) + + first.connectionProbe.watch(first.handlerRef) + second.connectionProbe.watch(second.handlerRef) + first.connectionProbe.send(first.handlerRef, Tcp.Aborted) + second.connectionProbe.send(second.handlerRef, Tcp.Aborted) + first.connectionProbe.expectTerminated(first.handlerRef) + second.connectionProbe.expectTerminated(second.handlerRef) + + val disconnectedAddresses = disconnectProbe.receiveN(2, 2.seconds).collect { + case DisconnectedPeer(peer) => peer.connectionId.remoteAddress + }.toSet + disconnectedAddresses shouldBe Set(firstAddress, secondAddress) + + val replacement = TestProbe("ReplacementConnection") + replacement.send( + controller, + Tcp.Connected(secondAddress, settings.scorexSettings.network.bindAddress) + ) + peerManagerProbe.expectMsgPF(1.second) { + case ConfirmConnection(connectionId, connectionRef) => + connectionId.remoteAddress shouldBe secondAddress + connectionRef shouldBe replacement.ref + } + + val unrelatedDuplicate = TestProbe("UnrelatedDuplicate") + unrelatedDuplicate.send( + controller, + Tcp.Connected(unrelatedAddress, settings.scorexSettings.network.bindAddress) + ) + unrelatedDuplicate.expectMsg(Tcp.Close) + } + } + + property("blacklisting should match by IP when the exact socket is absent") { + withFixture { f => + val (controller, peerManagerProbe, _) = f.createController(maxConnections = 30) + val siblingAddress = new InetSocketAddress("192.0.2.30", 9301) + val missingSocketAddress = new InetSocketAddress("192.0.2.30", 9399) + val sibling = f.establishIncomingConnectionWithHandler( + controller, + peerManagerProbe, + siblingAddress + ) + + peerManagerProbe.send(controller, Blacklisted(missingSocketAddress)) + + sibling.connectionProbe.expectMsg(Tcp.Abort) + sibling.connectionProbe.watch(sibling.handlerRef) + sibling.connectionProbe.send(sibling.handlerRef, Tcp.Aborted) + sibling.connectionProbe.expectTerminated(sibling.handlerRef) + } + } + property("outgoing connection should be accepted when total below maxConnections") { withFixture { f => val (controller, peerManagerProbe, tcpManagerProbe) = f.createController(maxConnections = 10) From b7baa6c992edaf7d8651e5c855179d289cdf3afe Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:29:46 +0200 Subject: [PATCH 11/46] Preserve asset issuance with token burn requests --- .../nodeView/wallet/ErgoWalletSupport.scala | 2 +- .../wallet/ErgoWalletServiceSpec.scala | 98 ++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletSupport.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletSupport.scala index 03b91e19ca..17e2a7e36b 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletSupport.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/ErgoWalletSupport.scala @@ -324,7 +324,7 @@ trait ErgoWalletSupport extends ScorexLogging { require(outputs.forall(_.additionalTokens.forall(_._2 > 0)), "Non-positive asset value") val assetIssueBox = outputs - .zip(requests) + .zip(requestsWithoutBurnTokens) .filter(_._2.isInstanceOf[AssetIssueRequest]) .map(_._1) .headOption diff --git a/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala b/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala index cc261eebc5..284952b6f4 100644 --- a/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/wallet/ErgoWalletServiceSpec.scala @@ -7,7 +7,7 @@ import org.ergoplatform.modifiers.mempool.{ErgoTransaction, UnconfirmedTransacti import org.ergoplatform.nodeView.mempool.ErgoMemPoolReader import org.ergoplatform.nodeView.wallet.WalletScanLogic.ScanResults import org.ergoplatform.nodeView.wallet.persistence.{OffChainRegistry, WalletRegistry, WalletStorage} -import org.ergoplatform.nodeView.wallet.requests.{AssetIssueRequest, PaymentRequest} +import org.ergoplatform.nodeView.wallet.requests.{AssetIssueRequest, BurnTokensRequest, PaymentRequest} import org.ergoplatform.nodeView.wallet.scanning.{EqualsScanningPredicate, ScanRequest, ScanWalletInteraction} import org.ergoplatform.sdk.SecretString import org.ergoplatform.sdk.wallet.secrets.{DerivationPath, ExtendedSecretKey} @@ -28,6 +28,7 @@ import scorex.db.{LDBKVStore, LDBVersionedStore} import scorex.util.encode.Base16 import sigma.Extensions.ArrayOps import sigma.ast.{ByteArrayConstant, EvaluatedValue, FalseLeaf, SType} +import sigmastate.eval.Extensions._ import sigmastate.helpers.TestingHelpers.testBox import scala.collection.compat.immutable.ArraySeq @@ -274,6 +275,101 @@ class ErgoWalletServiceSpec } } + property("asset issuance should be independent of burn request order") { + withVersionedStore(2) { versionedStore => + withStore { store => + val wState = initialState(store, versionedStore) + val existingAssetAmount = 10L + val burnAmount = 3L + val issueAmount = 7L + val inputBoxes = boxesAvailable( + makeGenesisBlock(pks.head.pubkey, Seq(newAssetIdStub -> existingAssetAmount)), + pks.head.pubkey + ) + val existingTokenId = inputBoxes.flatMap(_.additionalTokens.toArray).head._1 + val encodedBoxes = inputBoxes.map(box => Base16.encode(ErgoBoxSerializer.toBytes(box))) + val burnRequest = BurnTokensRequest(Array(existingTokenId -> burnAmount)) + val paymentRequest = PaymentRequest(pks.head, 1000000L, Array.empty, Map.empty) + val issueRequest = AssetIssueRequest( + address = pks.head, + valueOpt = Some(10000000L), + amount = issueAmount, + name = "test-name", + description = "test-description", + decimals = 4, + registers = Option.empty + ) + val boxSelector = new ReplaceCompactCollectBoxSelector( + settings.walletSettings.maxInputs, + settings.walletSettings.optimalInputs, + None + ) + + val requestOrders = Seq( + Seq(burnRequest, issueRequest), + Seq(issueRequest, burnRequest) + ) ++ Seq(burnRequest, issueRequest, paymentRequest).permutations.toSeq + + requestOrders.foreach { requests => + val result = generateUnsignedTransaction( + wState, + boxSelector, + requests, + inputsRaw = encodedBoxes, + dataInputsRaw = Seq.empty + ) + val requestOrder = requests.map(_.getClass.getSimpleName).mkString(", ") + withClue(s"request order: $requestOrder; failure: ${result.failed.map(_.getMessage).toOption}") { + result.isSuccess shouldBe true + } + + val (tx, selectedInputs, _) = result.get + val issuedTokenId = selectedInputs.head.id.toTokenId + val issueOutputs = tx.outputCandidates.filter( + _.additionalTokens.toArray.exists { case (tokenId, _) => tokenId == issuedTokenId } + ) + issueOutputs should have size 1 + issueOutputs.head.value shouldBe issueRequest.valueOpt.get + issueOutputs.head.ergoTree shouldBe pks.head.script + issueOutputs.head.additionalTokens.toArray should contain(issuedTokenId -> issueAmount) + issueOutputs.head.additionalRegisters shouldBe Map( + ErgoBox.R4 -> ByteArrayConstant("test-name".getBytes("UTF-8")), + ErgoBox.R5 -> ByteArrayConstant("test-description".getBytes("UTF-8")), + ErgoBox.R6 -> ByteArrayConstant("4".getBytes("UTF-8")) + ) + + if (requests.contains(paymentRequest)) { + val paymentOutputs = tx.outputCandidates.filter(_.value == paymentRequest.value) + paymentOutputs should have size 1 + paymentOutputs.head.ergoTree shouldBe pks.head.script + paymentOutputs.head.additionalTokens.toArray shouldBe empty + paymentOutputs.head.additionalRegisters shouldBe empty + } + + selectedInputs + .flatMap(_.additionalTokens.toArray) + .collect { case (tokenId, amount) if tokenId == issuedTokenId => amount } + .sum shouldBe 0L + tx.outputCandidates + .flatMap(_.additionalTokens.toArray) + .collect { case (tokenId, amount) if tokenId == issuedTokenId => amount } + .sum shouldBe issueAmount + + val selectedExistingAmount = selectedInputs + .flatMap(_.additionalTokens.toArray) + .collect { case (tokenId, amount) if tokenId == existingTokenId => amount } + .sum + val outputExistingAmount = tx.outputCandidates + .flatMap(_.additionalTokens.toArray) + .collect { case (tokenId, amount) if tokenId == existingTokenId => amount } + .sum + selectedExistingAmount - outputExistingAmount shouldBe burnAmount + selectedInputs.map(_.value).sum shouldBe tx.outputCandidates.map(_.value).sum + } + } + } + } + property("it should process unlock using preEip3Derivation") { withVersionedStore(2) { versionedStore => withStore { store => From 514800bb42643cfe8060789968f50294f076810a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:13:45 +0200 Subject: [PATCH 12/46] Test outbound retry accounting through ACK flow --- .../PeerConnectionHandlerSpecification.scala | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala b/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala index 70d4b8b3c6..f8319787f4 100644 --- a/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala +++ b/src/test/scala/scorex/core/network/PeerConnectionHandlerSpecification.scala @@ -123,42 +123,45 @@ class PeerConnectionHandlerSpecification extends ErgoCorePropertyTest { } property("account retried and acknowledged writes exactly") { - withConnectedHandler(Seq(GetPeersSpec), localPort = 9103) { fixture => - val maxSizedWrite = Tcp.Write( - ByteString(new Array[Byte]( - PeerConnectionHandler.MaxBufferedOutboundBytes.toInt - )), - PeerConnectionHandler.ReceivableMessages.Ack(1) + withConnectedHandler( + Seq(UtxoSnapshotChunkSpec), + localPort = 9103 + ) { fixture => + val chunkMessage = Message( + UtxoSnapshotChunkSpec, + Right(Array.fill[Byte](3999996)(1)), + None ) - fixture.connection.send(fixture.handler, Tcp.CommandFailed(maxSizedWrite)) - fixture.connection.expectMsg(Tcp.ResumeWriting) - fixture.connection.expectNoMessage(200.millis) + fixture.handler ! chunkMessage + val failedWrite = fixture.connection.expectMsgType[Tcp.Write] + failedWrite.data.length shouldEqual 4000013 + failedWrite.ack shouldEqual PeerConnectionHandler.ReceivableMessages.Ack(1) - val replacementWrite = Tcp.Write( - ByteString(new Array[Byte](9)), - PeerConnectionHandler.ReceivableMessages.Ack(1) - ) - fixture.connection.send( - fixture.handler, - Tcp.CommandFailed(replacementWrite) - ) + fixture.connection.send(fixture.handler, Tcp.CommandFailed(failedWrite)) fixture.connection.expectMsg(Tcp.ResumeWriting) + + (2 to 4).foreach(_ => fixture.handler ! chunkMessage) fixture.connection.expectNoMessage(200.millis) fixture.connection.send(fixture.handler, Tcp.WritingResumed) val retriedWrite = fixture.connection.expectMsgType[Tcp.Write] - retriedWrite.data.length shouldEqual 9 + retriedWrite.data shouldEqual failedWrite.data retriedWrite.ack shouldEqual PeerConnectionHandler.ReceivableMessages.Ack(1) + fixture.connection.send(fixture.handler, Tcp.CommandFailed(retriedWrite)) + fixture.connection.expectMsg(Tcp.ResumeWriting) + fixture.connection.expectNoMessage(200.millis) + + fixture.connection.send(fixture.handler, Tcp.WritingResumed) + val finalRetry = fixture.connection.expectMsgType[Tcp.Write] + finalRetry.data shouldEqual failedWrite.data + finalRetry.ack shouldEqual PeerConnectionHandler.ReceivableMessages.Ack(1) fixture.connection.send( fixture.handler, PeerConnectionHandler.ReceivableMessages.Ack(1) ) - fixture.handler ! Message(GetPeersSpec, Right(()), None) val nextWrite = fixture.connection.expectMsgType[Tcp.Write] - fixture.connection.send(fixture.handler, Tcp.CommandFailed(nextWrite)) - fixture.connection.expectMsg(Tcp.ResumeWriting) - fixture.connection.expectNoMessage(200.millis) + nextWrite.ack shouldEqual PeerConnectionHandler.ReceivableMessages.Ack(2) } } } From 0dc1a141e72703fb96043bbf73fba3a0ce6a2941 Mon Sep 17 00:00:00 2001 From: Alexander Chepurnoy Date: Sat, 25 Jul 2026 01:05:00 +0300 Subject: [PATCH 13/46] initial implementation of cap on peers db size --- .../network/ErgoNodeViewSynchronizer.scala | 2 +- .../network/peer/PeerDatabase.scala | 98 ++++++++++- .../network/peer/PeerManager.scala | 43 ++++- .../network/peer/PeerDatabaseSpec.scala | 156 ++++++++++++++++++ .../network/peer/PeerManagerSpec.scala | 93 +++++++++++ 5 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala create mode 100644 src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala diff --git a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala index 5d7ff03a56..65354c4991 100644 --- a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala +++ b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala @@ -1473,7 +1473,7 @@ class ErgoNodeViewSynchronizer(networkControllerRef: ActorRef, log.info(s"Penalize spamming peer $peer for too costly transaction $id") penalizeSpammingPeer(peer) case _ => - log.info(s"Penalize peer $peer for too costly transaction $id (reason: $error)") + log.info(s"Penalize peer $peer for transaction $id (reason: $error)") penalizeMisbehavingPeer(peer) } } diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala index 8a3c624791..06bacb2b2b 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala @@ -17,7 +17,10 @@ import scala.util.{Failure, Success, Try} /** * In-memory peer database implementation supporting temporal blacklisting. */ -final class PeerDatabase(settings: ErgoSettings) extends ScorexLogging { +final class PeerDatabase( + settings: ErgoSettings, + private[peer] val maxKnownPeers: Int = PeerDatabase.MaxKnownPeers +) extends ScorexLogging { private val persistentStore = LDBFactory.createKvDb(s"${settings.directory}/peers") @@ -76,16 +79,77 @@ final class PeerDatabase(settings: ErgoSettings) extends ScorexLogging { def get(peer: InetSocketAddress): Option[PeerInfo] = peers.get(peer) - def addOrUpdateKnownPeer(peerInfo: PeerInfo): Unit = { + def addOrUpdateKnownPeer( + peerInfo: PeerInfo, + connectedPeers: Set[InetSocketAddress] = Set.empty + ): Unit = { if (!peerInfo.peerSpec.declaredAddress.exists(x => isBlacklisted(x.getAddress))) { peerInfo.peerSpec.address.foreach { address => - log.debug(s"Updating peer info for $address") - peers += address -> peerInfo - persistentStore.insert(serialize(address), PeerInfoSerializer.toBytes(peerInfo)) + if (peers.contains(address)) { + log.debug(s"Updating peer info for $address") + updatePeer(address, peerInfo) + } else if ( + peers.size < maxKnownPeers || + makeRoomForPeer(peerInfo.lastHandshake, connectedPeers) + ) { + log.debug(s"Adding peer info for $address") + updatePeer(address, peerInfo) + } else { + log.debug(s"Peer database is full, ignoring $address") + } } } } + private def updatePeer(address: InetSocketAddress, peerInfo: PeerInfo): Unit = { + peers += address -> peerInfo + persistentStore.insert(serialize(address), PeerInfoSerializer.toBytes(peerInfo)) + } + + /** + * Evict the oldest known peer (by lastHandshake) to make room for a new peer, + * but never evict a currently connected peer. + * + * @param candidateHandshake - lastHandshake of the peer we want to insert + * @return true if room was made, false otherwise + */ + private def makeRoomForPeer( + candidateHandshake: Long, + connectedPeers: Set[InetSocketAddress] + ): Boolean = { + val evictionCandidates = peers.filterNot { case (address, _) => + connectedPeers.contains(address) + } + if (evictionCandidates.nonEmpty) { + val (oldestAddress, oldestInfo) = evictionCandidates.minBy(_._2.lastHandshake) + if (candidateHandshake > oldestInfo.lastHandshake) { + log.info( + s"Evicting peer $oldestAddress with lastHandshake " + + s"${oldestInfo.lastHandshake} to make room for a newer peer" + ) + remove(oldestAddress) + true + } else { + false + } + } else { + false + } + } + + /** + * Remove peers whose lastHandshake is older than 60 days, excluding connected peers. + */ + def removeOldPeers(connectedPeers: Set[InetSocketAddress] = Set.empty): Unit = { + val cutoff = System.currentTimeMillis() - PeerDatabase.KnownPeerMaxAgeMs + val toRemove = peers.filterNot { case (address, _) => + connectedPeers.contains(address) + }.filter { case (_, info) => + info.lastHandshake < cutoff + }.keys + toRemove.foreach(remove) + } + def addToBlacklist(socketAddress: InetSocketAddress, penaltyType: PenaltyType): Unit = { remove(socketAddress) Option(socketAddress.getAddress).foreach { address => @@ -112,6 +176,11 @@ final class PeerDatabase(settings: ErgoSettings) extends ScorexLogging { def knownPeers: Map[InetSocketAddress, PeerInfo] = peers + /** + * Close the underlying persistent store. + */ + def close(): Unit = persistentStore.close() + def blacklistedPeers: Seq[InetAddress] = blacklist.map { case (address, bannedTill) => @@ -188,3 +257,22 @@ final class PeerDatabase(settings: ErgoSettings) extends ScorexLogging { (360 * 10).days.toMillis } } + +object PeerDatabase { + + /** + * Hardcoded cap on the total number of known peers. + */ + val MaxKnownPeers: Int = 131072 + + /** + * Hardcoded maximum age (60 days) for a known peer's lastHandshake. + */ + val KnownPeerMaxAgeMs: Long = 60L * 24 * 60 * 60 * 1000 + + /** + * Hardcoded interval (24 hours) between cleanup runs. + */ + val KnownPeerCleanupIntervalMs: Long = 24L * 60 * 60 * 1000 + +} diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala b/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala index 96b7b392d7..0a960f4dc3 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala @@ -2,6 +2,10 @@ package org.ergoplatform.network.peer import java.net.{InetAddress, InetSocketAddress} import akka.actor.{Actor, ActorRef, ActorSystem, Props} +import org.ergoplatform.network.ErgoNodeViewSynchronizerMessages.{ + DisconnectedPeer, + HandshakedPeer +} import org.ergoplatform.network.PeerSpec import org.ergoplatform.settings.ErgoSettings import scorex.core.app.ScorexContext @@ -9,6 +13,7 @@ import scorex.core.network._ import scorex.core.utils.NetworkUtils import scorex.util.ScorexLogging +import scala.concurrent.duration._ import scala.util.Random /** @@ -20,13 +25,34 @@ class PeerManager(settings: ErgoSettings, scorexContext: ScorexContext) extends import PeerManager.ReceivableMessages._ private val peerDatabase = new PeerDatabase(settings) + private var connectedPeerAddresses = Set.empty[InetSocketAddress] + + override def preStart(): Unit = { + context.system.eventStream.subscribe(self, classOf[HandshakedPeer]) + context.system.eventStream.subscribe(self, classOf[DisconnectedPeer]) + scheduleOldPeersCleanup() + } + + override def postStop(): Unit = { + peerDatabase.close() + super.postStop() + } + + private def scheduleOldPeersCleanup(): Unit = { + context.system.scheduler.scheduleWithFixedDelay( + FiniteDuration(PeerDatabase.KnownPeerCleanupIntervalMs, MILLISECONDS), + FiniteDuration(PeerDatabase.KnownPeerCleanupIntervalMs, MILLISECONDS), + self, + CleanupOldPeers + )(context.system.dispatcher) + } if (peerDatabase.isEmpty) { // fill database with peers from config file if empty log.info("No peers in database, seeding peers database with nodes from config") settings.scorexSettings.network.knownPeers.foreach { address => if (!isSelf(address)) { - peerDatabase.addOrUpdateKnownPeer(PeerInfo.fromAddress(address)) + peerDatabase.addOrUpdateKnownPeer(PeerInfo.fromAddress(address), connectedPeerAddresses) } } } else { @@ -51,9 +77,18 @@ class PeerManager(settings: ErgoSettings, scorexContext: ScorexContext) extends case AddOrUpdatePeer(peerInfo) => // We have connected to a peer and got his peerInfo from him if (!isSelf(peerInfo.peerSpec) && !peerInfo.peerSpec.address.exists(isLocal(_))) { - peerDatabase.addOrUpdateKnownPeer(peerInfo) + peerDatabase.addOrUpdateKnownPeer(peerInfo, connectedPeerAddresses) } + case CleanupOldPeers => + peerDatabase.removeOldPeers(connectedPeerAddresses) + + case HandshakedPeer(remote) => + connectedPeerAddresses += remote.connectionId.remoteAddress + + case DisconnectedPeer(connectedPeer) => + connectedPeerAddresses -= connectedPeer.connectionId.remoteAddress + case Penalize(peer, penaltyType) => log.info(s"$peer penalized, penalty: $penaltyType") if (peerDatabase.penalize(peer, penaltyType)) { @@ -67,7 +102,7 @@ class PeerManager(settings: ErgoSettings, scorexContext: ScorexContext) extends if (peerSpec.address.forall(a => peerDatabase.get(a).isEmpty) && !isSelf(peerSpec) && !peerSpec.address.exists(isLocal(_))) { val peerInfo: PeerInfo = PeerInfo(peerSpec, 0, None) log.info(s"New discovered peer: $peerInfo") - peerDatabase.addOrUpdateKnownPeer(peerInfo) + peerDatabase.addOrUpdateKnownPeer(peerInfo, connectedPeerAddresses) } case RemovePeer(address) => @@ -125,6 +160,8 @@ object PeerManager { case class RemovePeer(address: InetSocketAddress) + case object CleanupOldPeers + /** * Message to get peers from known peers map filtered by `choose` function */ diff --git a/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala b/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala new file mode 100644 index 0000000000..fa8c8737de --- /dev/null +++ b/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala @@ -0,0 +1,156 @@ +package org.ergoplatform.network.peer + +import org.ergoplatform.db.DBSpec +import org.ergoplatform.network.PeerSpec +import org.ergoplatform.settings.ErgoSettings +import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.utils.ErgoNodeTestConstants._ + +import java.io.File +import java.net.InetSocketAddress + +class PeerDatabaseSpec extends ErgoCorePropertyTest with DBSpec { + + private def testSettings(dir: File): ErgoSettings = + settings.copy(directory = dir.getAbsolutePath) + + private def peerInfo(address: InetSocketAddress, lastHandshake: Long): PeerInfo = { + PeerInfo( + defaultPeerSpec.copy(declaredAddress = Some(address)), + lastHandshake, + None, + 0L + ) + } + + private def peerInfo(spec: PeerSpec, lastHandshake: Long): PeerInfo = { + PeerInfo(spec, lastHandshake, None, 0L) + } + + private def withDb[T](maxKnownPeers: Int = PeerDatabase.MaxKnownPeers) + (body: PeerDatabase => T): T = { + val dir = createTempDir + val db = new PeerDatabase(testSettings(dir), maxKnownPeers) + try { + body(db) + } finally { + db.close() + deleteRecursive(dir) + } + } + + property("PeerDatabase should store and retrieve a known peer") { + val address = new InetSocketAddress("8.8.8.8", 9001) + val info = peerInfo(address, System.currentTimeMillis()) + withDb() { db => + db.addOrUpdateKnownPeer(info) + db.get(address) shouldBe Some(info) + db.knownPeers should contain(address -> info) + } + } + + property("PeerDatabase should ignore a peer without a usable address") { + val info = peerInfo(defaultPeerSpec, System.currentTimeMillis()) + withDb() { db => + db.addOrUpdateKnownPeer(info) + db.knownPeers shouldBe empty + } + } + + property("PeerDatabase should cap and evict oldest non-connected peer") { + val addresses = (1 to 4).map(i => new InetSocketAddress(s"8.8.8.$i", 9000 + i)) + withDb(maxKnownPeers = 3) { db => + addresses.zip(Seq(1L, 2L, 3L, 4L)).foreach { case (addr, ts) => + db.addOrUpdateKnownPeer(peerInfo(addr, ts)) + } + db.knownPeers.keys should contain(addresses(1)) + db.knownPeers.keys should contain(addresses(2)) + db.knownPeers.keys should contain(addresses(3)) + db.knownPeers.keys should not contain addresses(0) + } + } + + property("PeerDatabase should not evict a connected peer when making room") { + val addresses = (1 to 4).map(i => new InetSocketAddress(s"8.8.8.$i", 9000 + i)) + val connected = Set(addresses.head) + withDb(maxKnownPeers = 3) { db => + addresses.zip(Seq(1L, 2L, 3L, 4L)).foreach { case (addr, ts) => + db.addOrUpdateKnownPeer(peerInfo(addr, ts), connected) + } + db.knownPeers.keys should contain(addresses(0)) + db.knownPeers.keys should contain(addresses(2)) + db.knownPeers.keys should contain(addresses(3)) + db.knownPeers.keys should not contain addresses(1) + } + } + + property("PeerDatabase should ignore peer older than oldest when full") { + val addresses = (1 to 3).map(i => new InetSocketAddress(s"8.8.8.$i", 9000 + i)) + val older = new InetSocketAddress("8.8.8.100", 9999) + withDb(maxKnownPeers = 3) { db => + addresses.zip(Seq(10L, 20L, 30L)).foreach { case (addr, ts) => + db.addOrUpdateKnownPeer(peerInfo(addr, ts)) + } + db.addOrUpdateKnownPeer(peerInfo(older, 5L)) + db.knownPeers.keys should not contain older + } + } + + property("PeerDatabase should remove only old disconnected peers during cleanup") { + var connected = Set.empty[InetSocketAddress] + val oldConnected = new InetSocketAddress("8.8.8.1", 9001) + val oldDisconnected = new InetSocketAddress("8.8.8.2", 9002) + val recent = new InetSocketAddress("8.8.8.3", 9003) + val now = System.currentTimeMillis() + withDb(maxKnownPeers = 100) { db => + connected += oldConnected + val oldTs = now - PeerDatabase.KnownPeerMaxAgeMs - 1000 + db.addOrUpdateKnownPeer(peerInfo(oldConnected, oldTs), connected) + db.addOrUpdateKnownPeer(peerInfo(oldDisconnected, oldTs), connected) + db.addOrUpdateKnownPeer(peerInfo(recent, now - 1000), connected) + db.removeOldPeers(connected) + db.knownPeers.keys should contain(oldConnected) + db.knownPeers.keys should contain(recent) + db.knownPeers.keys should not contain oldDisconnected + } + } + + property("PeerDatabase should persist peers across close and reopen") { + val dir = createTempDir + val dbSettings = testSettings(dir) + val address = new InetSocketAddress("8.8.8.8", 9001) + val info = peerInfo(address, 123456789L) + try { + val db1 = new PeerDatabase(dbSettings) + db1.addOrUpdateKnownPeer(info) + db1.close() + val db2 = new PeerDatabase(dbSettings) + db2.get(address) shouldBe Some(info) + db2.knownPeers should contain(address -> info) + db2.close() + } finally { + deleteRecursive(dir) + } + } + + property("PeerDatabase should not reload removed peers") { + val dir = createTempDir + val dbSettings = testSettings(dir) + val address1 = new InetSocketAddress("8.8.8.1", 9001) + val address2 = new InetSocketAddress("8.8.8.2", 9002) + try { + val db1 = new PeerDatabase(dbSettings) + db1.addOrUpdateKnownPeer(peerInfo(address1, 100L)) + db1.addOrUpdateKnownPeer(peerInfo(address2, 200L)) + db1.remove(address1) + db1.close() + val db2 = new PeerDatabase(dbSettings) + db2.knownPeers.keys should not contain address1 + db2.knownPeers should contain(address2 -> peerInfo(address2, 200L)) + db2.close() + } finally { + deleteRecursive(dir) + } + } + +} diff --git a/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala b/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala new file mode 100644 index 0000000000..d63b6a9619 --- /dev/null +++ b/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala @@ -0,0 +1,93 @@ +package org.ergoplatform.network.peer + +import akka.actor.ActorRef +import akka.testkit.{TestActorRef, TestProbe} +import org.ergoplatform.db.DBSpec +import org.ergoplatform.network.ErgoNodeViewSynchronizerMessages.{ + DisconnectedPeer, + HandshakedPeer +} +import org.ergoplatform.network.PeerSpec +import org.ergoplatform.settings.ErgoSettings +import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.utils.ErgoNodeTestConstants._ +import scorex.core.app.ScorexContext +import scorex.core.network.{ConnectionId, ConnectedPeer, Outgoing} +import scorex.testkit.utils.AkkaFixture + +import java.io.File +import java.net.InetSocketAddress +import scala.concurrent.Await +import scala.concurrent.duration.Duration + +class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { + + import PeerManager.ReceivableMessages._ + + private class PeerManagerFixture extends AkkaFixture { + val dir: File = createTempDir + + val settings: ErgoSettings = { + val base = initSettings.copy(directory = dir.getAbsolutePath) + base.copy( + scorexSettings = base.scorexSettings.copy( + network = base.scorexSettings.network.copy( + knownPeers = Seq.empty + ) + ) + ) + } + + val scorexContext: ScorexContext = ScorexContext(Seq.empty, None, None) + val peerManager: TestActorRef[PeerManager] = + TestActorRef(new PeerManager(settings, scorexContext)) + } + + private def withFixture(testCode: PeerManagerFixture => Any): Unit = { + val f = new PeerManagerFixture + try { + testCode(f) + } finally { + Await.result(f.system.terminate(), Duration.Inf) + } + } + + private def peerSpec(address: InetSocketAddress): PeerSpec = + defaultPeerSpec.copy(declaredAddress = Some(address)) + + private def connectedPeer(address: InetSocketAddress): ConnectedPeer = { + val localAddress = new InetSocketAddress("127.0.0.1", 9002) + ConnectedPeer( + ConnectionId(address, localAddress, Outgoing), + ActorRef.noSender, + None + ) + } + + property("PeerManager should keep a connected peer during old-peer cleanup") { + withFixture { f => + import f._ + val address = new InetSocketAddress("8.8.8.8", 9001) + val spec = peerSpec(address) + val probe = TestProbe() + + probe.send(peerManager, AddPeerIfEmpty(spec)) + probe.send(peerManager, GetAllPeers) + val peers1 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers1.keys should contain(address) + + probe.send(peerManager, HandshakedPeer(connectedPeer(address))) + probe.send(peerManager, CleanupOldPeers) + probe.send(peerManager, GetAllPeers) + val peers2 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers2.keys should contain(address) + + probe.send(peerManager, DisconnectedPeer(connectedPeer(address))) + probe.send(peerManager, CleanupOldPeers) + probe.send(peerManager, GetAllPeers) + val peers3 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers3.keys should not contain address + } + } + +} From 3ee5bbf421955bcc287a802d4e02c8a5eb0eed20 Mon Sep 17 00:00:00 2001 From: Alexander Chepurnoy Date: Tue, 28 Jul 2026 17:19:08 +0300 Subject: [PATCH 14/46] more optimal algs --- .../network/peer/PeerDatabase.scala | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala index 06bacb2b2b..85902b3327 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala @@ -11,11 +11,12 @@ import org.ergoplatform.settings.ErgoSettings import scorex.db.LDBFactory import scorex.util.ScorexLogging +import scala.util.Random import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} /** - * In-memory peer database implementation supporting temporal blacklisting. + * In-memory peer database with . */ final class PeerDatabase( settings: ErgoSettings, @@ -40,6 +41,8 @@ final class PeerDatabase( Map.empty[InetSocketAddress, PeerInfo] } + private val EvictionSampleSize = 32 + /** * penalized peer ip -> (accumulated penalty score, last penalty timestamp) */ @@ -88,10 +91,8 @@ final class PeerDatabase( if (peers.contains(address)) { log.debug(s"Updating peer info for $address") updatePeer(address, peerInfo) - } else if ( - peers.size < maxKnownPeers || - makeRoomForPeer(peerInfo.lastHandshake, connectedPeers) - ) { + } else if (peers.size < maxKnownPeers || + makeRoomForPeer(peerInfo.lastHandshake, connectedPeers)) { log.debug(s"Adding peer info for $address") updatePeer(address, peerInfo) } else { @@ -107,8 +108,8 @@ final class PeerDatabase( } /** - * Evict the oldest known peer (by lastHandshake) to make room for a new peer, - * but never evict a currently connected peer. + * Evict the oldest known peer (by lastHandshake) from a random sample to make room + * for a new peer, but never evict a currently connected peer. * * @param candidateHandshake - lastHandshake of the peer we want to insert * @return true if room was made, false otherwise @@ -117,23 +118,44 @@ final class PeerDatabase( candidateHandshake: Long, connectedPeers: Set[InetSocketAddress] ): Boolean = { - val evictionCandidates = peers.filterNot { case (address, _) => - connectedPeers.contains(address) + val oldest = randomPeerSample(EvictionSampleSize).foldLeft( + Option.empty[(InetSocketAddress, PeerInfo)] + ) { (acc, entry) => + val (address, info) = entry + if (connectedPeers.contains(address)) { + acc + } else { + acc match { + case Some((_, oldestInfo)) if oldestInfo.lastHandshake <= info.lastHandshake => + acc + case _ => Some(entry) + } + } } - if (evictionCandidates.nonEmpty) { - val (oldestAddress, oldestInfo) = evictionCandidates.minBy(_._2.lastHandshake) - if (candidateHandshake > oldestInfo.lastHandshake) { + oldest match { + case Some((oldestAddress, oldestInfo)) + if candidateHandshake > oldestInfo.lastHandshake => log.info( s"Evicting peer $oldestAddress with lastHandshake " + - s"${oldestInfo.lastHandshake} to make room for a newer peer" + s"${oldestInfo.lastHandshake} to make room for a newer peer" ) remove(oldestAddress) true - } else { + case _ => false - } + } + } + + /** + * Select a uniform random sample of up to `sampleSize` peers using reservoir sampling. + */ + private def randomPeerSample(sampleSize: Int): Seq[(InetSocketAddress, PeerInfo)] = { + if (peers.isEmpty) { + Seq.empty } else { - false + val start = Random.nextInt(peers.size) + val finish = math.min(start + sampleSize, peers.size) + peers.slice(start, finish).toSeq } } @@ -142,11 +164,11 @@ final class PeerDatabase( */ def removeOldPeers(connectedPeers: Set[InetSocketAddress] = Set.empty): Unit = { val cutoff = System.currentTimeMillis() - PeerDatabase.KnownPeerMaxAgeMs - val toRemove = peers.filterNot { case (address, _) => - connectedPeers.contains(address) - }.filter { case (_, info) => - info.lastHandshake < cutoff - }.keys + val toRemove = peers.collect { + case (address, info) + if !connectedPeers.contains(address) && info.lastHandshake < cutoff => + address + } toRemove.foreach(remove) } @@ -164,7 +186,7 @@ final class PeerDatabase( } } - def removeFromBlacklist(address: InetAddress): Unit = { + private def removeFromBlacklist(address: InetAddress): Unit = { log.info(s"$address removed from blacklist") blacklist -= address } From c69c75ead300185bb7085d890aa3791dfeb817cc Mon Sep 17 00:00:00 2001 From: Alexander Chepurnoy Date: Thu, 30 Jul 2026 20:30:49 +0300 Subject: [PATCH 15/46] optimization, SeenPeers limiting --- .../http/api/ErgoPeersApiRoute.scala | 23 +++- .../network/peer/PeerDatabase.scala | 117 +++++++++++++----- .../network/peer/PeerManager.scala | 52 +++++--- .../http/routes/ErgoPeersApiRouteSpec.scala | 71 +++++++++++ .../network/peer/PeerDatabaseSpec.scala | 25 ++++ .../network/peer/PeerManagerSpec.scala | 80 +++++++++++- 6 files changed, 311 insertions(+), 57 deletions(-) diff --git a/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala b/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala index 88bf56eeea..b6fbf24f9c 100644 --- a/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala +++ b/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala @@ -29,6 +29,8 @@ class ErgoPeersApiRoute(peerManager: ActorRef, override implicit lazy val timeout: Timeout = Timeout(1.minute) + private val DefaultPeersPageSize = 50 + override lazy val route: Route = pathPrefix("peers") { allPeers ~ connectedPeers ~ blacklistedPeers ~ connect ~ peersStatus ~ syncInfo ~ trackInfo } @@ -41,13 +43,22 @@ class ErgoPeersApiRoute(peerManager: ActorRef, ApiResponse(deliveryTracker.fullInfo) } - def allPeers: Route = (path("all") & get) { - val result = askActor[Map[InetSocketAddress, PeerInfo]](peerManager, GetAllPeers).map { - _.map { case (address, peerInfo) => - PeerInfoResponse.fromAddressAndInfo(address, peerInfo) + def allPeers: Route = (path("all") & get & parameters("limit".as[Int].optional, "offset".as[Int].optional)) { + (limitOpt, offsetOpt) => + val limit = limitOpt.getOrElse(DefaultPeersPageSize) + val offset = offsetOpt.getOrElse(0) + validate(offset >= 0 && limit > 0, + "limit and offset must be non-negative and limit must be positive") { + val result = askActor[Map[InetSocketAddress, PeerInfo]](peerManager, GetAllPeers).map { peers => + peers.toSeq + .sortBy(_._1.toString) + .slice(offset, offset + limit) + .map { case (address, peerInfo) => + PeerInfoResponse.fromAddressAndInfo(address, peerInfo) + } + } + ApiResponse(result) } - } - ApiResponse(result) } def connectedPeers: Route = (path("connected") & get) { diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala index 85902b3327..fa2f95e349 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala @@ -7,16 +7,16 @@ import java.io.{ ObjectOutputStream } import java.net.{InetAddress, InetSocketAddress} +import java.util.concurrent.ThreadLocalRandom import org.ergoplatform.settings.ErgoSettings import scorex.db.LDBFactory import scorex.util.ScorexLogging -import scala.util.Random import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} /** - * In-memory peer database with . + * In-memory peer database with temporal blacklisting and peer count cap. */ final class PeerDatabase( settings: ErgoSettings, @@ -25,6 +25,26 @@ final class PeerDatabase( private val persistentStore = LDBFactory.createKvDb(s"${settings.directory}/peers") + /** + * Serialized peer info size must stay below this bound. The value is twice + * the maximum handshake size (8KB) to leave a comfortable margin while still + * preventing a single malformed/crafted entry from consuming a lot of memory. + */ + private val MaxSerializedPeerInfoSize = 16384 + + /** + * Serialized peer address (InetSocketAddress Java serialization) size bound. + * Legitimate hostnames can be up to 253 characters, so leave plenty of headroom. + */ + private val MaxSerializedPeerAddressSize = 1024 + + private case class LoadedPeer( + lastHandshake: Long, + address: InetSocketAddress, + peerInfo: PeerInfo, + keyBytes: Array[Byte] + ) + /** * banned peer ip -> ban expiration timestamp */ @@ -41,8 +61,6 @@ final class PeerDatabase( Map.empty[InetSocketAddress, PeerInfo] } - private val EvictionSampleSize = 32 - /** * penalized peer ip -> (accumulated penalty score, last penalty timestamp) */ @@ -68,16 +86,57 @@ final class PeerDatabase( } /* - * Load peers from persistent storage + * Load peers from persistent storage. + * + * Enforces the in-memory cap and per-entry size limits at load time so a + * pre-existing or malformed DB cannot OOM the node on startup. Oversized or + * excess entries are dropped from the loaded set (and excess keys are removed + * from the store to keep the DB trimmed). */ private def loadPeers: Try[Map[InetSocketAddress, PeerInfo]] = Try { - var peers = Map.empty[InetSocketAddress, PeerInfo] - for ((addr, peer) <- persistentStore.getAll) { - val address = deserialize(addr).asInstanceOf[InetSocketAddress] - val peerInfo = PeerInfoSerializer.parseBytes(peer) - peers += address -> peerInfo + val (oversizedKeysRev, validPeersRev) = + persistentStore.getAll.toVector.foldLeft( + (List.empty[Array[Byte]], List.empty[LoadedPeer]) + ) { case ((badKeys, goodPeers), (addr, peer)) => + if (addr.length > MaxSerializedPeerAddressSize || peer.length > MaxSerializedPeerInfoSize) { + log.warn( + s"Dropping oversized peer entry from database: key=${addr.length} bytes, " + + s"value=${peer.length} bytes" + ) + (addr :: badKeys, goodPeers) + } else { + val addressTry = Try(deserialize(addr).asInstanceOf[InetSocketAddress]) + val peerInfoTry = PeerInfoSerializer.parseBytesTry(peer) + (addressTry, peerInfoTry) match { + case (Success(address), Success(peerInfo)) => + val loaded = LoadedPeer(peerInfo.lastHandshake, address, peerInfo, addr) + (badKeys, loaded :: goodPeers) + case _ => + log.warn(s"Unable to deserialize peer entry from database, skipping it") + (badKeys, goodPeers) + } + } + } + + val oversizedKeys = oversizedKeysRev.reverse + val validPeers = validPeersRev.reverse + + val sorted = validPeers.sortBy(_.lastHandshake)(Ordering[Long].reverse) + val (kept, drop) = sorted.splitAt(maxKnownPeers) + val keysToRemove = oversizedKeys ++ drop.map(_.keyBytes) + + flushKeysToRemove(keysToRemove.toArray) + kept.map(p => p.address -> p.peerInfo).toMap + } + + private def flushKeysToRemove(keys: Array[Array[Byte]]): Unit = { + if (keys.nonEmpty) { + persistentStore.remove(keys) match { + case Success(_) => // ok + case Failure(ex) => + log.warn("Unable to remove dropped peer entries from persistent store", ex) + } } - peers } def get(peer: InetSocketAddress): Option[PeerInfo] = peers.get(peer) @@ -118,20 +177,12 @@ final class PeerDatabase( candidateHandshake: Long, connectedPeers: Set[InetSocketAddress] ): Boolean = { - val oldest = randomPeerSample(EvictionSampleSize).foldLeft( - Option.empty[(InetSocketAddress, PeerInfo)] - ) { (acc, entry) => - val (address, info) = entry - if (connectedPeers.contains(address)) { - acc - } else { - acc match { - case Some((_, oldestInfo)) if oldestInfo.lastHandshake <= info.lastHandshake => - acc - case _ => Some(entry) - } - } - } + val EvictionSampleSize = 16 + val oldest = randomPeerSample(EvictionSampleSize) + .filterNot { case (address, _) => connectedPeers.contains(address) } + .sortBy(_._2.lastHandshake) + .headOption + oldest match { case Some((oldestAddress, oldestInfo)) if candidateHandshake > oldestInfo.lastHandshake => @@ -147,15 +198,17 @@ final class PeerDatabase( } /** - * Select a uniform random sample of up to `sampleSize` peers using reservoir sampling. + * Select a small random slice of known peers to consider for eviction. + * The slice is contiguous in the map's iteration order and bounded by + * `sampleSize`, so the cost stays low even when the peer set is large. */ private def randomPeerSample(sampleSize: Int): Seq[(InetSocketAddress, PeerInfo)] = { if (peers.isEmpty) { Seq.empty } else { - val start = Random.nextInt(peers.size) - val finish = math.min(start + sampleSize, peers.size) - peers.slice(start, finish).toSeq + val sample = math.min(sampleSize, peers.size) + val start = ThreadLocalRandom.current().nextInt(peers.size - sample + 1) + peers.slice(start, start + sample).toSeq } } @@ -285,16 +338,16 @@ object PeerDatabase { /** * Hardcoded cap on the total number of known peers. */ - val MaxKnownPeers: Int = 131072 + val MaxKnownPeers: Int = 32768 /** * Hardcoded maximum age (60 days) for a known peer's lastHandshake. */ - val KnownPeerMaxAgeMs: Long = 60L * 24 * 60 * 60 * 1000 + val KnownPeerMaxAgeMs: Long = 60.days.toMillis /** * Hardcoded interval (24 hours) between cleanup runs. */ - val KnownPeerCleanupIntervalMs: Long = 24L * 60 * 60 * 1000 + val KnownPeerCleanupIntervalMs: Long = 24.hours.toMillis } diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala b/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala index 0a960f4dc3..7c2e3492c3 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala @@ -1,6 +1,7 @@ package org.ergoplatform.network.peer import java.net.{InetAddress, InetSocketAddress} +import java.util.concurrent.ThreadLocalRandom import akka.actor.{Actor, ActorRef, ActorSystem, Props} import org.ergoplatform.network.ErgoNodeViewSynchronizerMessages.{ DisconnectedPeer, @@ -178,28 +179,45 @@ object PeerManager { */ case class SeenPeers(howMany: Int) extends GetPeers[Seq[PeerInfo]] with ScorexLogging { - val limit: Long = 3 * 60 * 60 * 1000 // 3h + val limit: Long = 3.hours.toMillis // 3h + + private val ScanBudgetMultiplier = 8 + private val MinScanBudget = 256 override def choose(knownPeers: Map[InetSocketAddress, PeerInfo], blacklistedPeers: Seq[InetAddress], sc: ScorexContext): Seq[PeerInfo] = { - val nonBlacklisted = knownPeers.values.toSeq - .filter { p => - (p.connectionType.isDefined || p.lastHandshake > 0) && - !blacklistedPeers.exists(ip => p.peerSpec.declaredAddress.exists(_.getAddress == ip)) - } - - val recentlySeenNonBlacklisted = nonBlacklisted.filter { p => - (System.currentTimeMillis() - p.lastStoredActivityTime < limit) - } - - if (recentlySeenNonBlacklisted.nonEmpty) { - val res = Random.shuffle(recentlySeenNonBlacklisted).take(howMany) - log.debug(s"Sending ${res.length} active peers: " + res) - res + if (howMany <= 0 || knownPeers.isEmpty) { + Seq.empty } else { - val res = Random.shuffle(nonBlacklisted).take(howMany) - log.debug(s"Sending ${res.length} known peers: " + res) + val scanBudget = math.max(howMany * ScanBudgetMultiplier, MinScanBudget) + val size = knownPeers.size + val window = math.min(scanBudget, size) + val start = + if (window == size) { + 0 + } else { + ThreadLocalRandom.current().nextInt(size - window + 1) + } + + val now = System.currentTimeMillis() + val cutoff = now - limit + + def isBlacklisted(p: PeerInfo): Boolean = + blacklistedPeers.exists(ip => p.peerSpec.declaredAddress.exists(_.getAddress == ip)) + + val candidates = knownPeers.valuesIterator + .drop(start) + .take(window) + .toSeq + .filter { p => + (p.connectionType.isDefined || p.lastHandshake > 0) && !isBlacklisted(p) + } + + val recentCandidates = candidates.filter(_.lastStoredActivityTime > cutoff) + val chosen = if (recentCandidates.nonEmpty) recentCandidates else candidates + val res = Random.shuffle(chosen).take(howMany) + log.debug(s"Sending ${res.length} peers (scanned $window of $size, window $start-${start + window})") res } } diff --git a/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala b/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala index 5353ec7499..3e16e26b34 100644 --- a/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala +++ b/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala @@ -15,6 +15,7 @@ import org.scalatest.matchers.should.Matchers import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks import scorex.core.network.NetworkController.ReceivableMessages.GetConnectedPeers import org.ergoplatform.network.peer.PeerManager.ReceivableMessages.GetAllPeers +import org.ergoplatform.network.peer.PeerInfo import org.ergoplatform.settings.RESTApiSettings import java.net.InetSocketAddress @@ -89,5 +90,75 @@ class ErgoPeersApiRouteSpec extends AnyFlatSpec } } } + + it should "return at most 50 peers by default" in { + val networkControllerProbe = TestProbe() + val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route + val peers = (1 to 55).map { i => + val addr = new InetSocketAddress(s"8.8.0.$i", 9000 + i) + addr -> PeerInfo.fromAddress(addr) + }.toMap + Future { + peerManagerProbe.expectMsg(GetAllPeers) + peerManagerProbe.reply(peers) + } + + Get("/peers/all") ~> route ~> check { + status shouldBe StatusCodes.OK + responseAs[Json].asArray.get.size shouldBe 50 + } + } + + it should "respect limit and offset query parameters" in { + val networkControllerProbe = TestProbe() + val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route + val peers = (1 to 20).map { i => + val addr = new InetSocketAddress(s"8.8.0.$i", 9000 + i) + addr -> PeerInfo.fromAddress(addr) + }.toMap + val sortedAddresses = peers.keys.toSeq.sortBy(_.toString) + Future { + peerManagerProbe.expectMsg(GetAllPeers) + peerManagerProbe.reply(peers) + } + + Get("/peers/all?limit=5&offset=10") ~> route ~> check { + status shouldBe StatusCodes.OK + val arr = responseAs[Json].asArray.get + arr.size shouldBe 5 + arr.head.hcursor.downField("address").as[String] shouldEqual Right(sortedAddresses(10).toString) + } + } + + it should "return empty array when offset is beyond peer count" in { + val networkControllerProbe = TestProbe() + val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route + val peers = (1 to 5).map { i => + val addr = new InetSocketAddress(s"8.8.0.$i", 9000 + i) + addr -> PeerInfo.fromAddress(addr) + }.toMap + Future { + peerManagerProbe.expectMsg(GetAllPeers) + peerManagerProbe.reply(peers) + } + + Get("/peers/all?offset=100") ~> route ~> check { + status shouldBe StatusCodes.OK + responseAs[Json].asArray.get shouldBe empty + } + } + + it should "reject invalid pagination parameters" in { + val networkControllerProbe = TestProbe() + val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route + Future { + peerManagerProbe.expectMsg(GetAllPeers) + peerManagerProbe.reply(Map.empty[InetSocketAddress, PeerInfo]) + } + + Get("/peers/all?limit=-1") ~> Route.seal(route) ~> check { + status shouldBe StatusCodes.BadRequest + } + } } diff --git a/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala b/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala index fa8c8737de..12191a91dc 100644 --- a/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala +++ b/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala @@ -153,4 +153,29 @@ class PeerDatabaseSpec extends ErgoCorePropertyTest with DBSpec { } } + property("PeerDatabase should load only newest peers when persisted set exceeds cap") { + val dir = createTempDir + val dbSettings = testSettings(dir) + val addresses = (1 to 5).map(i => new InetSocketAddress(s"8.8.8.$i", 9000 + i)) + try { + val db1 = new PeerDatabase(dbSettings, maxKnownPeers = 5) + addresses.zip(Seq(10L, 20L, 30L, 40L, 50L)).foreach { case (addr, ts) => + db1.addOrUpdateKnownPeer(peerInfo(addr, ts)) + } + db1.knownPeers should have size 5 + db1.close() + + val db2 = new PeerDatabase(dbSettings, maxKnownPeers = 3) + db2.knownPeers should have size 3 + db2.knownPeers.keys should contain(addresses(2)) + db2.knownPeers.keys should contain(addresses(3)) + db2.knownPeers.keys should contain(addresses(4)) + db2.knownPeers.keys should not contain addresses(0) + db2.knownPeers.keys should not contain addresses(1) + db2.close() + } finally { + deleteRecursive(dir) + } + } + } diff --git a/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala b/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala index d63b6a9619..43f406551e 100644 --- a/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala +++ b/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala @@ -12,11 +12,11 @@ import org.ergoplatform.settings.ErgoSettings import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.ErgoNodeTestConstants._ import scorex.core.app.ScorexContext -import scorex.core.network.{ConnectionId, ConnectedPeer, Outgoing} +import scorex.core.network.{ConnectionDirection, ConnectionId, ConnectedPeer, Outgoing} import scorex.testkit.utils.AkkaFixture import java.io.File -import java.net.InetSocketAddress +import java.net.{InetAddress, InetSocketAddress} import scala.concurrent.Await import scala.concurrent.duration.Duration @@ -55,6 +55,24 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { private def peerSpec(address: InetSocketAddress): PeerSpec = defaultPeerSpec.copy(declaredAddress = Some(address)) + private def peerInfo(address: InetSocketAddress, + lastHandshake: Long = 0L, + connectionType: Option[ConnectionDirection] = None, + lastActivity: Long = 0L): PeerInfo = + PeerInfo( + defaultPeerSpec.copy(declaredAddress = Some(address)), + lastHandshake, + connectionType, + lastActivity + ) + + private def address(i: Int): InetSocketAddress = new InetSocketAddress(s"8.8.${i / 256}.${i % 256}", 9000 + i) + + private def seenPeers(howMany: Int, + peers: Map[InetSocketAddress, PeerInfo], + blacklisted: Seq[InetAddress] = Seq.empty): Seq[PeerInfo] = + SeenPeers(howMany).choose(peers, blacklisted, ScorexContext(Seq.empty, None, None)) + private def connectedPeer(address: InetSocketAddress): ConnectedPeer = { val localAddress = new InetSocketAddress("127.0.0.1", 9002) ConnectedPeer( @@ -90,4 +108,62 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { } } + property("SeenPeers should return empty for non-positive or empty input") { + seenPeers(0, Map.empty) shouldBe empty + seenPeers(-1, Map.empty) shouldBe empty + seenPeers(5, Map.empty) shouldBe empty + } + + property("SeenPeers should return at most howMany peers") { + val peers = (1 to 10).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L)).toMap + val chosen = seenPeers(3, peers) + chosen.size should be <= 3 + chosen.size should be > 0 + } + + property("SeenPeers should not return peers with neither handshake nor connection type") { + val good = (1 to 5).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L)).toMap + val bad = (6 to 10).map(i => address(i) -> peerInfo(address(i))).toMap + val chosen = seenPeers(10, good ++ bad) + chosen.map(_.peerSpec.declaredAddress.get).toSet.intersect(bad.keys.toSet) shouldBe empty + chosen.size shouldBe 5 + } + + property("SeenPeers should exclude blacklisted peers") { + val peers = (1 to 10).map { i => + val addr = address(i) + addr -> peerInfo(addr, lastHandshake = 1L) + }.toMap + val blacklistedIp = InetAddress.getByName("8.8.8.1") + val chosen = (1 to 100).flatMap(_ => seenPeers(10, peers, Seq(blacklistedIp))).toSet + chosen.map(_.peerSpec.declaredAddress.get.getAddress).toSet should not contain blacklistedIp + } + + property("SeenPeers should prefer recently active peers") { + val now = System.currentTimeMillis() + val recent = (1 to 5).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L, lastActivity = now)).toMap + val old = (6 to 10).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L, lastActivity = 0L)).toMap + val chosen = seenPeers(10, recent ++ old) + chosen.size shouldBe 5 + chosen.map(_.lastStoredActivityTime).toSet should contain only now + } + + property("SeenPeers should be able to reach any peer in a small DB over multiple calls") { + val peers = (1 to 50).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L)).toMap + val returned = (1 to 200).flatMap(_ => seenPeers(5, peers)).map(_.peerSpec.declaredAddress.get).toSet + returned.size should be >= 45 + } + + property("SeenPeers should handle a large DB without materializing the full map") { + val peers = (1 to 5000).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L)).toMap + val chosen = seenPeers(8, peers) + chosen.size shouldBe 8 + chosen.toSet.size shouldBe 8 + } + + property("SeenPeers should return all peers when howMany exceeds eligible count") { + val peers = (1 to 5).map(i => address(i) -> peerInfo(address(i), lastHandshake = 1L)).toMap + seenPeers(10, peers).size shouldBe 5 + } + } From d2f2ee453d7867cab51e918bd9b021d064ea9cf2 Mon Sep 17 00:00:00 2001 From: Alexander Chepurnoy Date: Thu, 30 Jul 2026 20:48:31 +0300 Subject: [PATCH 16/46] optimizations in loadPeers --- .../org/ergoplatform/network/peer/PeerDatabase.scala | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala index fa2f95e349..958deaf254 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala @@ -94,7 +94,7 @@ final class PeerDatabase( * from the store to keep the DB trimmed). */ private def loadPeers: Try[Map[InetSocketAddress, PeerInfo]] = Try { - val (oversizedKeysRev, validPeersRev) = + val (oversizedKeys, validPeers) = persistentStore.getAll.toVector.foldLeft( (List.empty[Array[Byte]], List.empty[LoadedPeer]) ) { case ((badKeys, goodPeers), (addr, peer)) => @@ -118,11 +118,7 @@ final class PeerDatabase( } } - val oversizedKeys = oversizedKeysRev.reverse - val validPeers = validPeersRev.reverse - - val sorted = validPeers.sortBy(_.lastHandshake)(Ordering[Long].reverse) - val (kept, drop) = sorted.splitAt(maxKnownPeers) + val (kept, drop) = validPeers.splitAt(maxKnownPeers) val keysToRemove = oversizedKeys ++ drop.map(_.keyBytes) flushKeysToRemove(keysToRemove.toArray) From e027c7364a09e202c12617fb515d07f9fa2d592f Mon Sep 17 00:00:00 2001 From: Ergologica <153913412+Ergologica@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:37:01 +0200 Subject: [PATCH 17/46] Log API queries at DEBUG level Log method, relative URI, response status and elapsed time for every query served by the node's HTTP interface. Bodies are not logged: requests to this API carry secrets (mnemonic on /wallet/restore, password on /wallet/unlock). Logging goes through ScorexLogging rather than akka's LoggingAdapter, so no dependency is added and the node's HTTP verbosity is not tied to akka's global log level. It is off by default, as the root logger is at INFO, and costs nothing when off since log.debug is a macro guarded by isDebugEnabled. The directive wraps the route outside handleRejections, so rejected requests are logged too, with the status they were answered with. Closes #1909 --- .../ergoplatform/http/ErgoHttpService.scala | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/main/scala/org/ergoplatform/http/ErgoHttpService.scala b/src/main/scala/org/ergoplatform/http/ErgoHttpService.scala index c331e005e2..eb99d040a2 100644 --- a/src/main/scala/org/ergoplatform/http/ErgoHttpService.scala +++ b/src/main/scala/org/ergoplatform/http/ErgoHttpService.scala @@ -8,6 +8,7 @@ import akka.http.scaladsl.server.Directive0 import akka.http.scaladsl.server.directives.RouteDirectives import scorex.core.api.http.{ApiErrorHandler, ApiRejectionHandler, ApiRoute, CorsHandler} import akka.http.scaladsl.model.headers._ +import scorex.util.ScorexLogging import scala.collection.immutable @@ -15,7 +16,7 @@ final case class ErgoHttpService( apiRoutes: Seq[ApiRoute], swaggerRoute: SwaggerRoute, panelRoute: NodePanelRoute -)(implicit val system: ActorSystem) extends CorsHandler { +)(implicit val system: ActorSystem) extends CorsHandler with ScorexLogging { def rejectionHandler: RejectionHandler = ApiRejectionHandler.rejectionHandler @@ -36,15 +37,41 @@ final case class ErgoHttpService( super.respondWithHeaders(corsResponseHeaders) } + /** + * Logs every query served by the node's HTTP interface: method, relative URI (path and query + * string), response status and how long it took. + * + * Bodies are deliberately not logged, as requests carry secrets (a mnemonic on + * `/wallet/restore`, a password on `/wallet/unlock`, and so on) and responses can be large. + * + * Off by default, since the root logger is at INFO. To switch it on, add to `logback.xml`: + * {{{ + * + * }}} + * When it is off, the message is never built: `log.debug` is a macro guarded by `isDebugEnabled`. + */ + private val logQueries: Directive0 = + extractRequest.flatMap { request => + val startTime = System.currentTimeMillis() + mapResponse { response => + val elapsedMs = System.currentTimeMillis() - startTime + log.debug(s"${request.method.value} ${request.uri.toRelative} - " + + s"${response.status.intValue()} in $elapsedMs ms") + response + } + } + val compositeRoute: Route = - handleRejections(rejectionHandler) { - handleExceptions(exceptionHandler) { - corsHandler { - apiR ~ - apiSpecR ~ - swaggerRoute.route ~ - panelRoute.route ~ - redirectToSwaggerR + logQueries { + handleRejections(rejectionHandler) { + handleExceptions(exceptionHandler) { + corsHandler { + apiR ~ + apiSpecR ~ + swaggerRoute.route ~ + panelRoute.route ~ + redirectToSwaggerR + } } } } From e5844b158b1d215e8a84946c6681f31ec48c4bf7 Mon Sep 17 00:00:00 2001 From: Ergologica <153913412+Ergologica@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:41:05 +0200 Subject: [PATCH 18/46] Document the API query logger in logback.xml Commented-out logger element so the switch is discoverable. --- src/main/resources/logback.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 2929ac6ade..d2f0932a66 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -30,6 +30,10 @@ + + + From bd6991d6637e35a0cf0ccbd598c798b40c4ebaa8 Mon Sep 17 00:00:00 2001 From: Ergologica <153913412+Ergologica@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:42:04 +0200 Subject: [PATCH 19/46] Add spec covering the API query logger Attaches a logback ListAppender to the service logger and asserts on what is emitted: one line per served query with method, URI, status and duration; the query string included and unmatched paths logged with the status they were answered with; nothing logged below DEBUG; and the response body unchanged with logging on and off. --- .../http/routes/ErgoHttpServiceSpec.scala | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/test/scala/org/ergoplatform/http/routes/ErgoHttpServiceSpec.scala diff --git a/src/test/scala/org/ergoplatform/http/routes/ErgoHttpServiceSpec.scala b/src/test/scala/org/ergoplatform/http/routes/ErgoHttpServiceSpec.scala new file mode 100644 index 0000000000..13b730ecc8 --- /dev/null +++ b/src/test/scala/org/ergoplatform/http/routes/ErgoHttpServiceSpec.scala @@ -0,0 +1,112 @@ +package org.ergoplatform.http.routes + +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Route +import akka.http.scaladsl.testkit.ScalatestRouteTest +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.{Level, Logger => LogbackLogger} +import ch.qos.logback.core.read.ListAppender +import org.ergoplatform.http.api.EmissionApiRoute +import org.ergoplatform.http.{ErgoHttpService, NodePanelRoute, SwaggerRoute} +import org.ergoplatform.utils.Stubs +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.slf4j.LoggerFactory + +import scala.collection.JavaConverters._ + +class ErgoHttpServiceSpec extends AnyFlatSpec + with Matchers + with ScalatestRouteTest + with Stubs { + + import org.ergoplatform.utils.ErgoNodeTestConstants._ + + private val restApiSettings = settings.scorexSettings.restApi + + private val service = ErgoHttpService( + apiRoutes = Seq(EmissionApiRoute(settings)), + swaggerRoute = SwaggerRoute(restApiSettings, swaggerConfig = ""), + panelRoute = NodePanelRoute() + ) + + private val route: Route = service.compositeRoute + + private val serviceLogger: LogbackLogger = + LoggerFactory.getLogger(classOf[ErgoHttpService]).asInstanceOf[LogbackLogger] + + /** Runs `body` while capturing what the service logs at `level` */ + private def capturingLogs[T](level: Level)(body: => T): (T, Seq[String]) = { + val appender = new ListAppender[ILoggingEvent] + appender.start() + val previousLevel = serviceLogger.getLevel + serviceLogger.setLevel(level) + serviceLogger.addAppender(appender) + try { + val result = body + (result, appender.list.asScala.map(_.getFormattedMessage).toList) + } finally { + serviceLogger.detachAppender(appender) + serviceLogger.setLevel(previousLevel) + appender.stop() + } + } + + it should "log served queries at DEBUG level" in { + val (_, messages) = capturingLogs(Level.DEBUG) { + Get("/emission/at/100") ~> route ~> check { + status shouldBe StatusCodes.OK + } + } + + val logged = messages.filter(_.startsWith("GET /emission/at/100")) + logged.size shouldBe 1 + // method, uri, response status and elapsed time, and nothing else + logged.head should fullyMatch regex """GET /emission/at/100 - 200 in \d+ ms""" + } + + it should "log the query string, and log unmatched paths with the status they were rejected with" in { + val (rejectedStatus, messages) = capturingLogs(Level.DEBUG) { + Get("/emission/at/100?foo=bar") ~> route ~> check { + status shouldBe StatusCodes.OK + } + Get("/no/such/route") ~> route ~> check { + status.isSuccess() shouldBe false + status.intValue() + } + } + + messages.exists(_.startsWith("GET /emission/at/100?foo=bar - 200 in ")) shouldBe true + // rejections are turned into responses by the rejection handler, so they are logged too + messages.exists(_.startsWith(s"GET /no/such/route - $rejectedStatus in ")) shouldBe true + } + + it should "log nothing when the logger is not at DEBUG level" in { + val (_, messages) = capturingLogs(Level.INFO) { + Get("/emission/at/100") ~> route ~> check { + status shouldBe StatusCodes.OK + } + } + + messages shouldBe empty + } + + it should "not change the response when logging is enabled" in { + val body = capturingLogs(Level.DEBUG) { + Get("/emission/at/100") ~> route ~> check { + status shouldBe StatusCodes.OK + responseAs[String] + } + }._1 + + val bodyWithoutLogging = capturingLogs(Level.OFF) { + Get("/emission/at/100") ~> route ~> check { + status shouldBe StatusCodes.OK + responseAs[String] + } + }._1 + + body shouldBe bodyWithoutLogging + } + +} From 804762eeb8ed3c5ca2fc3020aa90ea0491dcdc12 Mon Sep 17 00:00:00 2001 From: kushti Date: Tue, 18 Aug 2026 22:42:15 +0300 Subject: [PATCH 20/46] Add review-requested test coverage for tx script failures and full-block solvedBlock failure --- .../mining/CandidateGeneratorSpec.scala | 64 +++++++++++++++++++ .../viewholder/ErgoNodeViewHolderSpec.scala | 42 +++++++++++- 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala index 26787c525c..c049a80872 100644 --- a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala +++ b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala @@ -151,6 +151,70 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp system.terminate() } + it should "recover when locally mined block is invalidated by full block id" in new TestKit( + ActorSystem() + ) { + val replyProbe = new TestProbe(system) + // fake node view holder: solved block is never applied, so solvedBlock stays set + val viewHolderProbe = new TestProbe(system) + + // real readers holder over real node view holder, needed for candidate generation + val realViewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(realViewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderProbe.ref, + defaultSettings + ) + + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), replyProbe.ref) + val block = replyProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(candidate: Candidate) => + defaultSettings.chainSettings.powScheme + .proveCandidate(candidate.candidateBlock, defaultMinerSecret.w, 0, 1000) + .get + } + + candidateGenerator.tell(block.header.powSolution, replyProbe.ref) + replyProbe.expectMsg(blockValidationDelay, StatusReply.Success(())) + + // block sections were sent to the (fake) node view holder + viewHolderProbe.expectMsg(LocallyGeneratedModifier(block.header)) + block.mandatoryBlockSections.foreach { section => + viewHolderProbe.expectMsg(LocallyGeneratedModifier(section)) + } + + // mining is stalled: new solutions are rejected while solvedBlock is set + candidateGenerator.tell(block.header.powSolution, replyProbe.ref) + replyProbe.expectMsgPF(blockValidationDelay) { + case r: StatusReply[_] if r.isError => + } + + // node view holder invalidates the block using full-block typeId and block id + val failedTxId = block.blockTransactions.txs.head.id + val error = + new MalformedModifierError("tx failed", failedTxId, ErgoTransaction.modifierTypeId) + system.eventStream.publish( + SemanticallyFailedModification(ErgoFullBlock.modifierTypeId, block.id, error) + ) + + // mining resumes: a new candidate is generated and new solutions are accepted again + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), replyProbe.ref) + val newBlock = replyProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(candidate: Candidate) => + defaultSettings.chainSettings.powScheme + .proveCandidate(candidate.candidateBlock, defaultMinerSecret.w, 0, 1000) + .get + } + candidateGenerator.tell(newBlock.header.powSolution, replyProbe.ref) + replyProbe.expectMsg(blockValidationDelay, StatusReply.Success(())) + + system.terminate() + } + it should "let multiple miners compete" in new TestKit(ActorSystem()) { val testProbe = new TestProbe(system) system.eventStream.subscribe(testProbe.ref, newBlockSignal) diff --git a/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala b/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala index dfd49bc78f..2d8e8f4359 100644 --- a/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala @@ -23,7 +23,7 @@ import org.ergoplatform.nodeView.mempool.ErgoMemPoolUtils.ProcessingOutcome.Acce import org.ergoplatform.wallet.utils.FileUtils import scorex.crypto.authds.{ADKey, SerializedAdProof} import scorex.util.{ModifierId, bytesToId} -import org.ergoplatform.settings.Constants.TrueTree +import org.ergoplatform.settings.Constants.{FalseTree, TrueTree} class ErgoNodeViewHolderSpec extends ErgoCorePropertyTest with NodeViewTestOps with FileUtils { import org.ergoplatform.utils.ErgoNodeTestConstants._ @@ -580,6 +580,44 @@ class ErgoNodeViewHolderSpec extends ErgoCorePropertyTest with NodeViewTestOps w } } + private val t21 = TestCase("txScriptFailure carries failing transaction id") { fixture => + import fixture._ + + val (us, bh) = createUtxoState(fixture.settings) + val wus = WrappedUtxoState(us, bh, fixture.settings) + + val genesis = validFullBlock(None, wus) + + // Apply genesis through the standard NVH route first. + applyBlock(genesis) shouldBe 'success + val wusAfterGenesis = wus.applyModifier(genesis)(_ => ()).get + + // Create a valid tx that pays to a FalseTree output. + val box = wusAfterGenesis.takeBoxes(1).head + val validTx = validTransactionFromBoxes(IndexedSeq(box), outputsProposition = FalseTree) + + val validBlock = validFullBlock(Some(genesis), wusAfterGenesis, Seq(validTx)) + + // Apply valid block and advance wrapped state. + applyBlock(validBlock) shouldBe 'success + val wusAfterValidBlock = wusAfterGenesis.applyModifier(validBlock)(_ => ()).get + + // Create a tx spending the FalseTree output; prover cannot sign it, so it has empty proofs. + val falseTreeBox = validTx.outputs.head + val invalidTx = validTransactionFromBoxes(IndexedSeq(falseTreeBox)) + + val invalidBlock = validFullBlock(Some(validBlock), wusAfterValidBlock, Seq(invalidTx)) + + subscribeEvents(classOf[SemanticallyFailedModification]) + + if (verifyTransactions) { + applyBlock(invalidBlock) shouldBe 'success + + val semFailed = expectMsgType[SemanticallyFailedModification] + ErgoNodeViewHolder.extractFailedTxId(semFailed.error) shouldBe Some(invalidTx.id) + } + } + val cases: List[TestCase] = List(t0, t1, t2, t3, t3a, t4, t5, t6, t7, t8, t9) NodeViewTestConfig.allConfigs.foreach { c => @@ -590,7 +628,7 @@ class ErgoNodeViewHolderSpec extends ErgoCorePropertyTest with NodeViewTestOps w } } - val verifyingTxCases: List[TestCase] = List(t10, t11, t12, t13, t20) + val verifyingTxCases: List[TestCase] = List(t10, t11, t12, t13, t20, t21) NodeViewTestConfig.verifyTxConfigs.foreach { c => verifyingTxCases.foreach { t => From 4d93c8a2ddfdfa996fcc1dc00bd4c93074567ea2 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:49:25 +0200 Subject: [PATCH 21/46] Fix ExtraIndexer checkpoint recovery --- .../nodeView/history/ErgoHistory.scala | 126 +++- .../nodeView/history/extra/ExtraIndexer.scala | 340 ++++++--- .../extra/IndexedContractTemplate.scala | 2 +- .../history/extra/IndexedErgoAddress.scala | 2 +- .../nodeView/history/extra/IndexedToken.scala | 2 +- .../nodeView/history/extra/IndexerState.scala | 14 +- .../history/storage/HistoryStorage.scala | 96 ++- .../history/extra/ChainGenerator.scala | 4 +- .../extra/ExtraIndexerSpecification.scala | 655 +++++++++++++++++- .../history/extra/ExtraIndexerTestActor.scala | 129 +++- .../history/storage/HistoryStorageSpec.scala | 107 ++- 11 files changed, 1307 insertions(+), 170 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala index c001dd8e64..534b031ea5 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala @@ -4,18 +4,23 @@ import akka.actor.ActorContext import org.ergoplatform.consensus.ProgressInfo import java.io.File +import java.nio.{ByteBuffer, ByteOrder} +import java.nio.charset.StandardCharsets import org.ergoplatform.mining.AutolykosPowScheme import org.ergoplatform.modifiers.history._ import org.ergoplatform.modifiers.history.header.{Header, PreGenesisHeader} -import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock, NonHeaderBlockSection} +import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock, ErgoNodeViewModifier, NonHeaderBlockSection} import org.ergoplatform.nodeView.history.extra.ExtraIndexer.ReceivableMessages.StartExtraIndexer -import org.ergoplatform.nodeView.history.extra.ExtraIndexer.{IndexedHeightKey, NewestVersion, NewestVersionBytes, SchemaVersionKey, getIndex} +import org.ergoplatform.nodeView.history.extra.ExtraIndexer.{GlobalBoxIndexKey, GlobalTxIndexKey, IndexedHeaderIdKey, + IndexedHeightKey, NewestVersion, NewestVersionBytes, RollbackToKey, SchemaVersionKey} +import org.ergoplatform.nodeView.history.extra.{IndexedErgoBox, IndexedErgoTransaction, NumericBoxIndex, NumericTxIndex} import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.nodeView.history.storage.modifierprocessors._ -import org.ergoplatform.settings.ErgoSettings +import org.ergoplatform.settings.{Algos, ErgoSettings} import org.ergoplatform.utils.LoggingUtil import org.ergoplatform.validation.RecoverableModifierError -import scorex.util.{ModifierId, ScorexLogging, idToBytes} +import scorex.db.ByteArrayWrapper +import scorex.util.{ModifierId, ScorexLogging, bytesToId, idToBytes} import scala.util.{Failure, Success, Try} @@ -265,12 +270,113 @@ object ErgoHistory extends ScorexLogging { var db = HistoryStorage(ergoSettings) // ExtraIndexer db check - if(ergoSettings.nodeSettings.extraIndex) { // check db schema - val schemaVersion: Int = getIndex(SchemaVersionKey, db).getInt - if (schemaVersion != NewestVersion) { - if(getIndex(IndexedHeightKey, db).getInt > 0) - db = db.deleteExtraDB(ergoSettings) // older schema -> delete and reopen db - db.insertExtra(Array((SchemaVersionKey, NewestVersionBytes)), Array.empty) // update version key + if(ergoSettings.nodeSettings.extraIndex) { // check db schema and checkpoint provenance + def storedBytes(key: Array[Byte]): Option[Array[Byte]] = db.modifierBytesById(bytesToId(key)) + def intValue(bytesOpt: Option[Array[Byte]]): Option[Int] = bytesOpt + .filter(_.length == Integer.BYTES) + .map(bytes => ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getInt) + def longValue(bytesOpt: Option[Array[Byte]]): Option[Long] = bytesOpt + .filter(_.length == java.lang.Long.BYTES) + .map(bytes => ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getLong) + + val schemaVersionBytesOpt = storedBytes(SchemaVersionKey) + val indexedHeightBytesOpt = storedBytes(IndexedHeightKey) + val globalTxIndexBytesOpt = storedBytes(GlobalTxIndexKey) + val globalBoxIndexBytesOpt = storedBytes(GlobalBoxIndexKey) + val rollbackToBytesOpt = storedBytes(RollbackToKey) + val schemaVersionOpt = intValue(schemaVersionBytesOpt) + val indexedHeightOpt = intValue(indexedHeightBytesOpt) + val globalTxIndexOpt = longValue(globalTxIndexBytesOpt) + val globalBoxIndexOpt = longValue(globalBoxIndexBytesOpt) + val rollbackToOpt = intValue(rollbackToBytesOpt) + val indexedHeight = indexedHeightOpt.getOrElse(0) + val globalTxIndex = globalTxIndexOpt.getOrElse(0L) + val globalBoxIndex = globalBoxIndexOpt.getOrElse(0L) + val rollbackTo = rollbackToOpt.getOrElse(0) + val indexedHeaderIdBytesOpt = db.modifierBytesById(bytesToId(IndexedHeaderIdKey)) + val indexedHeaderOpt = indexedHeaderIdBytesOpt + .filter(_.length == ErgoNodeViewModifier.ModifierIdSize) + .flatMap { idBytes => + val id = bytesToId(idBytes) + val validityKey = ByteArrayWrapper(Algos.hash("validity".getBytes(StandardCharsets.UTF_8) ++ idToBytes(id))) + val isValid = db.getIndex(validityKey).exists(_.sameElements(Array(1.toByte))) + if (isValid) db.modifierById(id).collect { + case header: Header if header.height == indexedHeight && header.id == id => header + } else None + } + val terminalRowsMatchCheckpoint = indexedHeaderOpt.exists { header => + if (globalTxIndex <= 0 || globalBoxIndex <= 0) { + false + } else { + db.modifierById(header.transactionsId).collect { + case transactions: BlockTransactions if transactions.headerId == header.id => + val lastTx = transactions.txs.last + val lastTxIndex = globalTxIndex - 1 + val firstTxBoxIndex = globalBoxIndex - lastTx.outputs.size + val expectedOutputNums = Array.tabulate(lastTx.outputs.size)(i => firstTxBoxIndex + i) + val expectedInputNumsOpt = if (header.height <= 1) { + Some(Array.fill[Long](lastTx.inputs.size)(0L)) + } else { + val inputNums = lastTx.inputs.map { input => + val inputId = bytesToId(input.boxId) + db.getExtraIndex(inputId).collect { + case box: IndexedErgoBox + if box.id == inputId && box.spendingTxIdOpt.contains(lastTx.id) && + box.spendingHeightOpt.contains(header.height) => box.globalIndex + } + } + if (inputNums.forall(_.isDefined)) Some(inputNums.flatten.toArray) else None + } + val numericTxMatches = db.getExtraIndex(bytesToId(NumericTxIndex.indexToBytes(lastTxIndex))).exists { + case NumericTxIndex(index, id) => index == lastTxIndex && id == lastTx.id + case _ => false + } + val indexedTxMatches = db.getExtraIndex(lastTx.id).exists { + case tx: IndexedErgoTransaction => + tx.txid == lastTx.id && tx.globalIndex == lastTxIndex && tx.height == header.height && + tx.index == transactions.txs.size - 1 && tx.size == lastTx.size && + expectedInputNumsOpt.exists(expected => tx.inputNums.sameElements(expected)) && + tx.outputNums.sameElements(expectedOutputNums) && tx.dataInputs.sameElements(lastTx.dataInputs) + case _ => false + } + val outputRowsMatch = expectedOutputNums.zip(lastTx.outputs).forall { case (boxIndex, output) => + val boxId = bytesToId(output.id) + val numericBoxMatches = db.getExtraIndex(bytesToId(NumericBoxIndex.indexToBytes(boxIndex))).exists { + case NumericBoxIndex(index, id) => index == boxIndex && id == boxId + case _ => false + } + val indexedBoxMatches = db.getExtraIndex(boxId).exists { + case box: IndexedErgoBox => + box.globalIndex == boxIndex && box.inclusionHeight == header.height && box.id == boxId && + box.spendingTxIdOpt.isEmpty && box.spendingHeightOpt.isEmpty && box.spendingProofOpt.isEmpty + case _ => false + } + numericBoxMatches && indexedBoxMatches + } + numericTxMatches && indexedTxMatches && outputRowsMatch + }.contains(true) + } + } + val numericValuesAreWellFormed = Seq( + indexedHeightBytesOpt.forall(_.length == Integer.BYTES), + globalTxIndexBytesOpt.forall(_.length == java.lang.Long.BYTES), + globalBoxIndexBytesOpt.forall(_.length == java.lang.Long.BYTES), + rollbackToBytesOpt.forall(_.length == Integer.BYTES) + ).forall(identity) + val valuesAreNonNegative = indexedHeight >= 0 && globalTxIndex >= 0 && globalBoxIndex >= 0 && rollbackTo >= 0 + val emptyCheckpoint = indexedHeight == 0 && globalTxIndex == 0 && globalBoxIndex == 0 && + rollbackTo == 0 && indexedHeaderIdBytesOpt.isEmpty + val nonEmptyCheckpoint = indexedHeight > 0 && Seq(indexedHeightOpt, globalTxIndexOpt, globalBoxIndexOpt, rollbackToOpt) + .forall(_.isDefined) && rollbackTo == 0 && terminalRowsMatchCheckpoint + val checkpointIsValid = schemaVersionOpt.contains(NewestVersion) && numericValuesAreWellFormed && + valuesAreNonNegative && (emptyCheckpoint || nonEmptyCheckpoint) + if (!checkpointIsValid) { + val freshDb = db.deleteExtraDBTry(ergoSettings).get + freshDb.insertExtraTry(Array((SchemaVersionKey, NewestVersionBytes)), Array.empty).recoverWith { case error => + Try(freshDb.close()).failed.foreach(error.addSuppressed) + Failure(error) + }.get + db = freshDb } } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala index 89cfddefb4..dcf8140f62 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala @@ -1,7 +1,8 @@ package org.ergoplatform.nodeView.history.extra -import akka.actor.{Actor, ActorRef, ActorSystem, Props, Stash} -import org.ergoplatform.{ErgoAddress, ErgoAddressEncoder, GlobalConstants, Pay2SAddress} +import akka.actor.{Actor, ActorRef, ActorSystem, Props, Stash, Timers} +import org.ergoplatform.{ErgoAddress, ErgoAddressEncoder, ErgoApp, GlobalConstants, Pay2SAddress} +import org.ergoplatform.consensus.ModifierSemanticValidity import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.modifiers.mempool.ErgoTransaction @@ -13,8 +14,10 @@ import org.ergoplatform.nodeView.history.extra.IndexedContractTemplateSerializer import org.ergoplatform.nodeView.history.extra.IndexedErgoAddressSerializer.hashErgoTree import org.ergoplatform.nodeView.history.extra.IndexedTokenSerializer.uniqueId import org.ergoplatform.nodeView.history.storage.HistoryStorage +import org.ergoplatform.nodeView.history.storage.modifierprocessors.FullBlockProcessor import org.ergoplatform.settings.{Algos, CacheSettings, ChainSettings} import scorex.util.{ModifierId, ScorexLogging, bytesToId} +import scorex.db.ByteArrayWrapper import sigma.ast.ErgoTree import sigma.Extensions._ import sigma.interpreter.ProverResult @@ -27,19 +30,26 @@ import java.util.concurrent.ConcurrentHashMap import scala.collection.mutable import scala.collection.concurrent import scala.concurrent.{ExecutionContextExecutor, Future} +import scala.concurrent.duration.{DurationInt, FiniteDuration} import scala.jdk.CollectionConverters._ +import scala.util.{Failure, Success, Try} /** * Base trait for extra indexer actor and its test. */ -trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { +trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { + + private case class RetryIndex(generation: Long) + private case object RetryIndexTimerKey + private case class RollbackToHeader(header: Header, resume: Boolean) + private var retryGeneration: Long = 0L private implicit val ec: ExecutionContextExecutor = context.dispatcher /** * Max buffer size (determined by config) */ - protected val saveLimit: Int + protected def saveLimit: Int /** * Number of transaction/box numeric indexes object segments contain @@ -62,16 +72,60 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { protected def historyStorage: HistoryStorage = _history.historyStorage + protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { + _history.headerIdsAtHeight(height) + .find { id => + historyStorage.getIndex(FullBlockProcessor.chainStatusKey(id)) + .map(ByteArrayWrapper.apply) + .contains(ByteArrayWrapper(FullBlockProcessor.BestChainMarker)) && + _history.isSemanticallyValid(id) == ModifierSemanticValidity.Valid + } + .flatMap(id => _history.typedModifierById[Header](id)) + } + + protected def blockTransactionsForHeader(header: Header): Option[BlockTransactions] = { + history.typedModifierById[BlockTransactions](header.transactionsId).filter(_.headerId == header.id) + } + + protected def retryDelay: FiniteDuration = 1.second + + private def scheduleRetry(): Unit = { + if (!timers.isTimerActive(RetryIndexTimerKey)) { + retryGeneration += 1 + timers.startSingleTimer(RetryIndexTimerKey, RetryIndex(retryGeneration), retryDelay) + } + } + + private def cancelRetry(): Unit = { + retryGeneration += 1 + timers.cancel(RetryIndexTimerKey) + } + + protected def resetTransientState(): Unit = { + cancelRetry() + blockCache.clear() + readingUpTo = 0 + } + /** * Used in tests to indicate the indexer has caught up to the chain */ protected def caughtUpHook(height: Int = 0): Unit = {} + protected def continueCatchUpAfterIndex(state: IndexerState): Boolean = true + + protected def requestShutdown(): Unit = { + ErgoApp.shutdownSystem()(context.system) + } + + protected def removeRollbackIndexes(ids: Array[ModifierId]): Try[Unit] = + historyStorage.removeExtraTry(ids) + /** * Used in tests to get block for rollback, maybe orphan */ protected def getLastTxForHeight(height: Int): ErgoTransaction = { - history.bestBlockTransactionsAt(height).get.txs.last + fullChainHeaderAtHeight(height).flatMap(blockTransactionsForHeader).get.txs.last } // fast access buffers @@ -100,8 +154,11 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { * @param height - blockheight to get transations from * @return transactions at height */ - private def getBlockTransactionsAt(height: Int): Option[BlockTransactions] = { - blockCache.remove(height).orElse(history.bestBlockTransactionsAt(height)).map { txs => + private def getBlockTransactionsAt(height: Int, header: Header): Option[BlockTransactions] = { + val cached = blockCache.remove(height) + val txsOpt = cached.filter(_.headerId == header.id).orElse(blockTransactionsForHeader(header)) + + txsOpt.map { txs => if (height % 1000 == 0) blockCache.keySet.filter(_ < height).map(blockCache.remove) if (readingUpTo - height < 300 && chainHeight - height > 1000) { readingUpTo = math.min(height + 1001, chainHeight) @@ -111,7 +168,7 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { blockNums.zip(blockNums.tail).map { range => // ranges of 250 blocks for each thread to read Future { (range._1 until range._2).foreach { blockNum => - history.bestBlockTransactionsAt(blockNum).map(blockCache.put(blockNum, _)) + fullChainHeaderAtHeight(blockNum).flatMap(blockTransactionsForHeader).map(blockCache.put(blockNum, _)) } } } @@ -119,7 +176,7 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { val blockNums = height + 1 to readingUpTo Future { blockNums.foreach { blockNum => - history.bestBlockTransactionsAt(blockNum).map(blockCache.put(blockNum, _)) + fullChainHeaderAtHeight(blockNum).flatMap(blockTransactionsForHeader).map(blockCache.put(blockNum, _)) } } } @@ -240,41 +297,41 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { /** * Write buffered indexes to database and clear buffers. */ - private def saveProgress(state: IndexerState): Unit = { - + private def saveProgress(state: IndexerState): Try[Unit] = Try { val start: Long = System.currentTimeMillis - // perform segmentation on big addresses and save their internal segment buffer trees.values.foreach { tree => tree.buffer.values.foreach(seg => segments.put(seg.id, seg)) tree.splitToSegments.foreach(seg => segments.put(seg.id, seg)) } - templates.values.foreach { template => template.buffer.values.foreach(seg => segments.put(seg.id, seg)) template.splitToSegments.foreach(seg => segments.put(seg.id, seg)) } - - // perform segmentation on big tokens and save their internal segment buffer tokens.values.foreach { token => token.buffer.values.foreach(seg => segments.put(seg.id, seg)) token.splitToSegments.foreach(seg => segments.put(seg.id, seg)) } - // insert modifiers and progress info to db - historyStorage.insertExtra( + val indexedHeaderEntry = state.indexedHeaderId.map { id => + IndexedHeaderIdKey -> fastIdToBytes(id) + }.toArray + val objects = (general.iterator ++ boxes.valuesIterator ++ trees.valuesIterator ++ + templates.valuesIterator ++ tokens.valuesIterator ++ segments.valuesIterator).toArray + historyStorage.insertExtraTry( Array( (IndexedHeightKey, ByteBuffer.allocate(4).putInt(state.indexedHeight).array), (GlobalTxIndexKey, ByteBuffer.allocate(8).putLong(state.globalTxIndex).array), (GlobalBoxIndexKey, ByteBuffer.allocate(8).putLong(state.globalBoxIndex).array), (RollbackToKey, ByteBuffer.allocate(4).putInt(state.rollbackTo).array) - ), - (((((general ++= boxes.values) ++= trees.values) ++= templates.values) ++= tokens.values) ++= segments.values).toArray - ) + ) ++ indexedHeaderEntry, + objects + ).recoverWith { case error => + historyStorage.invalidateExtraCache(objects.iterator.map(_.id).toSeq) + Failure(error) + }.get log.debug(s"Processed ${trees.size} ErgoTrees with ${boxes.size} boxes and inserted them to database in ${System.currentTimeMillis - start}ms") - - // clear buffers for next batch general.clear() boxes.clear() trees.clear() @@ -287,17 +344,18 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { * Process a batch of BlockTransactions into memory and occasionally write them to database. * * @param state - current indexer state - * @param headerOpt - header to index block transactions of (used after caught up with chain) + * @param header - exact full-chain header to index + * @param targetHeight - full-chain height captured for this catch-up pass */ - protected def index(state: IndexerState, headerOpt: Option[Header] = None): IndexerState = { - val btOpt = headerOpt.flatMap { header => - history.typedModifierById[BlockTransactions](header.transactionsId) - }.orElse(getBlockTransactionsAt(state.indexedHeight)) - val height = headerOpt.map(_.height).getOrElse(state.indexedHeight) + protected def index(state: IndexerState, + header: Header, + targetHeight: Int): Option[IndexerState] = { + val height = header.height + val btOpt = getBlockTransactionsAt(height, header) if (btOpt.isEmpty) { log.error(s"Could not read block $height / $chainHeight from database, waiting for new block until retrying") - return state.decrementIndexedHeight.copy(caughtUp = true) + return None } val txs: Seq[ErgoTransaction] = btOpt.get.txs @@ -375,11 +433,10 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { log.info(s"Buffered block $height / $chainHeight [txs: ${txs.length}, boxes: $boxCount] (buffer: $modCount / $saveLimit)") - val maxHeight = headerOpt.map(_.height).getOrElse(chainHeight) - newState.copy( - caughtUp = newState.indexedHeight == maxHeight, - indexedHeaderId = headerOpt.map(_.id).orElse(history.bestHeaderIdAtHeight(height)) - ) + Some(newState.copy( + caughtUp = newState.indexedHeight == targetHeight, + indexedHeaderId = Some(header.id) + )) } /** @@ -388,15 +445,15 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { * @param state - current state of indexer * @param height - forking height (height of last common block) */ - private def removeAfter(state: IndexerState, height: Int): IndexerState = { + private def removeAfter(state: IndexerState, targetHeader: Header): Try[IndexerState] = Try { var newState: IndexerState = state + val height = targetHeader.height - saveProgress(newState) + saveProgress(newState).get log.info(s"Rolling back indexes from ${state.indexedHeight} to $height") - try { - val lastTxToKeep: ErgoTransaction = getLastTxForHeight(height) + val lastTxToKeep: ErgoTransaction = blockTransactionsForHeader(targetHeader).get.txs.last val txTarget: Long = history.typedExtraIndexById[IndexedErgoTransaction](lastTxToKeep.id).get.globalIndex val boxTarget: Long = history.typedExtraIndexById[IndexedErgoBox](bytesToId(lastTxToKeep.outputs.last.id)).get.globalIndex val toRemove: ArrayBuffer[ModifierId] = ArrayBuffer.empty[ModifierId] @@ -417,12 +474,12 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { val template = history.typedExtraIndexById[IndexedContractTemplate](hashTreeTemplate(iEb.box.ergoTree)).get template.findAndModBox(iEb.globalIndex, history) - historyStorage.insertExtra(Array.empty, Array[ExtraIndex](iEb, address, template) ++ address.buffer.values ++ template.buffer.values) + historyStorage.insertExtraTry(Array.empty, Array[ExtraIndex](iEb, address, template) ++ address.buffer.values ++ template.buffer.values).get cfor(0)(_ < iEb.box.additionalTokens.length, _ + 1) { i => history.typedExtraIndexById[IndexedToken](IndexedToken.fromBox(iEb, i).id).map { token => token.findAndModBox(iEb.globalIndex, history) - historyStorage.insertExtra(Array.empty, Array[ExtraIndex](token) ++ token.buffer.values) + historyStorage.insertExtraTry(Array.empty, Array[ExtraIndex](token) ++ token.buffer.values).get } } } @@ -460,61 +517,137 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { newState = newState.incrementBoxIndex // Save changes - newState = newState.copy( + val completedState = newState.copy( indexedHeight = height, rollbackTo = 0, - caughtUp = state.caughtUp, - indexedHeaderId = history.bestHeaderIdAtHeight(height) + caughtUp = height == chainHeight && fullChainHeaderAtHeight(height).exists(_.id == targetHeader.id), + indexedHeaderId = Some(targetHeader.id) ) - historyStorage.removeExtra(toRemove.toArray) - saveProgress(newState) - } catch { - case t: Throwable => log.error(s"removeAfter during rollback failed due to: ${t.getMessage}", t) + removeRollbackIndexes(toRemove.toArray).get + saveProgress(completedState).get + completedState + } + + private def indexedTipIsOnBestFullChain(state: IndexerState): Boolean = { + state.indexedHeight == 0 || + (chainHeight >= state.indexedHeight && + state.indexedHeaderId == fullChainHeaderAtHeight(state.indexedHeight).map(_.id)) + } + + private def reconcileIndexedTip(state: IndexerState): Boolean = { + val rollbackHeaderOpt = for { + indexedHeaderId <- state.indexedHeaderId + indexedHeader <- history.typedModifierById[Header](indexedHeaderId) + bestFullBlock <- history.bestFullBlockOpt + branchPointId <- history.chainToHeader(Some(indexedHeader), bestFullBlock.header)._1 + branchHeader <- history.typedModifierById[Header](branchPointId) + if branchHeader.height < state.indexedHeight + } yield branchHeader + + rollbackHeaderOpt.exists { branchHeader => + beginRollback(state, branchHeader) + true + } + } + + private def validatedRollbackHeader(state: IndexerState, branchPoint: ModifierId): Option[Header] = { + history.typedModifierById[Header](branchPoint).filter { header => + header.height < state.indexedHeight && + fullChainHeaderAtHeight(header.height).exists(_.id == header.id) } + } - newState + protected def beginRollback(state: IndexerState, targetHeader: Header, resume: Boolean = true): Unit = { + resetTransientState() + context.become(receive.orElse(loaded(state.copy(caughtUp = false, rollbackTo = targetHeader.height)))) + self ! RollbackToHeader(targetHeader, resume) + } + + private def persistBuffered(state: IndexerState): Boolean = { + saveProgress(state) match { + case Success(_) => true + case Failure(error) => + log.error(s"Failed to persist extra indexes at height ${state.indexedHeight}; retrying", error) + scheduleRetry() + false + } } protected def loaded(state: IndexerState): Receive = { case Index() if !state.caughtUp && !state.rollbackInProgress => - val nextHeaderOpt = history.bestHeaderAtHeight(state.indexedHeight + 1) - val extendsIndexedTip = nextHeaderOpt.forall { header => - state.indexedHeight == 0 || state.indexedHeaderId.contains(header.parentId) + cancelRetry() + if (modCount < saveLimit || persistBuffered(state)) { + if (state.indexedHeight == chainHeight && indexedTipIsOnBestFullChain(state)) { + val newState = state.copy(caughtUp = true) + context.become(receive.orElse(loaded(newState))) + self ! Index() + } else { + val nextHeaderOpt = fullChainHeaderAtHeight(state.indexedHeight + 1) + val extendsIndexedTip = nextHeaderOpt.forall { header => + state.indexedHeight == 0 || state.indexedHeaderId.contains(header.parentId) + } + if (extendsIndexedTip && nextHeaderOpt.isDefined) { + index(state.incrementIndexedHeight, nextHeaderOpt.get, chainHeight) match { + case Some(newState) => + context.become(receive.orElse(loaded(newState))) + if (continueCatchUpAfterIndex(newState)) self ! Index() + case None => + scheduleRetry() + } + } else if (!reconcileIndexedTip(state)) { + log.info("Deferring catch-up because the next full-chain header does not extend the indexed tip") + scheduleRetry() + } + } } - if (extendsIndexedTip) { - val newState = index(state.incrementIndexedHeight) - if (modCount >= saveLimit) saveProgress(newState) + + case Index() if state.caughtUp && !state.rollbackInProgress && !indexedTipIsOnBestFullChain(state) => + if (!reconcileIndexedTip(state)) { + val newState = state.copy(caughtUp = false) context.become(receive.orElse(loaded(newState))) - self ! Index() - } else { - log.info("Deferring catch-up because the next header does not extend the indexed tip") + scheduleRetry() } - case Index() if state.caughtUp => - if (modCount > 0) saveProgress(state) - blockCache.clear() - caughtUpHook() - log.info("Indexer caught up with chain") + case Index() if state.caughtUp && !state.rollbackInProgress => + cancelRetry() + if (modCount == 0 || persistBuffered(state)) { + blockCache.clear() + caughtUpHook() + log.info("Indexer caught up with chain") + } + + case Index() if state.rollbackInProgress => // after the indexer caught up with the chain, stay up to date case FullBlockApplied(header: Header) if state.caughtUp && !state.rollbackInProgress => - val indexedTipStillBest = state.indexedHeight == 0 || - (chainHeight >= state.indexedHeight && state.indexedHeaderId.exists { indexedHeaderId => - history.bestHeaderIdAtHeight(state.indexedHeight).contains(indexedHeaderId) - }) + val indexedTipStillBest = indexedTipIsOnBestFullChain(state) val isDirectSuccessor = header.height == state.indexedHeight + 1 && (state.indexedHeight == 0 || state.indexedHeaderId.contains(header.parentId)) && - history.bestHeaderIdAtHeight(header.height).contains(header.id) + fullChainHeaderAtHeight(header.height).exists(_.id == header.id) if (isDirectSuccessor) { - val newState: IndexerState = index(state.incrementIndexedHeight, Some(header)) - saveProgress(newState) - context.become(receive.orElse(loaded(newState))) - caughtUpHook(header.height) + cancelRetry() + val targetHeight = chainHeight + index(state.incrementIndexedHeight, header, targetHeight) match { + case Some(newState) => + context.become(receive.orElse(loaded(newState))) + if (newState.caughtUp) { + if (persistBuffered(newState)) caughtUpHook(header.height) + } else { + self ! Index() + } + case None => + val newState = state.copy(caughtUp = false) + context.become(receive.orElse(loaded(newState))) + scheduleRetry() + } } else if (!indexedTipStillBest) { - log.info(s"Deferring block ${header.id} at height ${header.height} until rollback") - context.become(receive.orElse(loaded(state.copy(caughtUp = false)))) + log.info(s"Reconciling indexed tip before applying block ${header.id} at height ${header.height}") + if (!reconcileIndexedTip(state)) { + context.become(receive.orElse(loaded(state.copy(caughtUp = false)))) + scheduleRetry() + } } else if (header.height > state.indexedHeight + 1) { context.become(receive.orElse(loaded(state.copy(caughtUp = false)))) self ! Index() @@ -522,40 +655,56 @@ trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { log.warn(s"Skipping block ${header.id} applied at height ${header.height}, indexed height is ${state.indexedHeight}") } + case _: FullBlockApplied if !state.rollbackInProgress => + scheduleRetry() + case _: FullBlockApplied if state.rollbackInProgress => stash() case Rollback(branchPoint: ModifierId) => + cancelRetry() if (state.rollbackInProgress) { log.warn(s"Rollback already in progress") stash() + } else if (indexedTipIsOnBestFullChain(state)) { + log.info(s"Ignoring rollback to $branchPoint because the indexed tip is already on the best full chain") + if (!state.caughtUp) self ! Index() } else { - history.heightOf(branchPoint) match { - case Some(branchHeight) => - if (branchHeight < state.indexedHeight) { - context.become(receive.orElse(loaded(state.copy(rollbackTo = branchHeight)))) - self ! RemoveAfter(branchHeight) - } else if (!state.caughtUp) { - blockCache.clear() - readingUpTo = 0 - self ! Index() - } - case None => - log.error(s"No rollback height found for $branchPoint") - val newState = state.copy(rollbackTo = 0) + validatedRollbackHeader(state, branchPoint) match { + case Some(header) => beginRollback(state, header) + case None if !reconcileIndexedTip(state) => + log.info(s"Deferring rollback to $branchPoint until the indexed tip can be reconciled with the best full chain") + val newState = state.copy(caughtUp = false, rollbackTo = 0) context.become(receive.orElse(loaded(newState))) - unstashAll() + scheduleRetry() + case None => } } - case RemoveAfter(branchHeight: Int) if state.rollbackInProgress => + case RollbackToHeader(targetHeader, resume) + if state.rollbackInProgress && state.rollbackTo == targetHeader.height => blockCache.clear() readingUpTo = 0 - val newState = removeAfter(state, branchHeight) - context.become(receive.orElse(loaded(newState))) - if (!newState.caughtUp && !newState.rollbackInProgress) self ! Index() - caughtUpHook() - log.info(s"Successfully rolled back indexes to $branchHeight") - unstashAll() + removeAfter(state, targetHeader) match { + case Success(newState) => + context.become(receive.orElse(loaded(newState))) + if (resume && !newState.caughtUp) self ! Index() + caughtUpHook() + log.info(s"Successfully rolled back indexes to ${targetHeader.height}") + unstashAll() + case Failure(error) => + log.error(s"Failed to roll back extra indexes to ${targetHeader.height}; shutting down so startup can rebuild", error) + requestShutdown() + } + + case RollbackToHeader(_, _) => + + case RetryIndex(generation) if generation == retryGeneration && !state.rollbackInProgress => + self ! Index() + + case RetryIndex(_) => + + case RemoveAfter(branchHeight) => + log.warn(s"Ignoring unsupported direct extra-index rollback request to height $branchHeight") case GetSegmentThreshold => sender ! segmentThreshold @@ -678,13 +827,14 @@ object ExtraIndexer { /** * Current newest database schema version. Used to force extra database resync. */ - val NewestVersion: Int = 6 + val NewestVersion: Int = 7 val NewestVersionBytes: Array[Byte] = ByteBuffer.allocate(4).putInt(NewestVersion).array val IndexedHeightKey: Array[Byte] = Algos.hash("indexed height") val GlobalTxIndexKey: Array[Byte] = Algos.hash("txns height") val GlobalBoxIndexKey: Array[Byte] = Algos.hash("boxes height") val RollbackToKey: Array[Byte] = Algos.hash("rollback to") + val IndexedHeaderIdKey: Array[Byte] = Algos.hash("indexed header id") val SchemaVersionKey: Array[Byte] = Algos.hash("schema version") def getIndex(key: Array[Byte], history: HistoryStorage): ByteBuffer = diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala index 7121ee8b9c..24fb8edc4d 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala @@ -27,7 +27,7 @@ case class IndexedContractTemplate(templateHash: ModifierId, if (boxCount == 0) toRemove += templateHash else - history.historyStorage.insertExtra(Array.empty, Array(this)) + history.historyStorage.insertExtraTry(Array.empty, Array(this)).get toRemove.toArray } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala index c582cbbe80..d506dca795 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala @@ -88,7 +88,7 @@ case class IndexedErgoAddress(treeHash: ModifierId, if (txCount == 0 && boxCount == 0) toRemove += treeHash // all segments empty after rollback, delete parent else - history.historyStorage.insertExtra(Array.empty, Array(this)) // save the changes made to this address + history.historyStorage.insertExtraTry(Array.empty, Array(this)).get // save the changes made to this address toRemove.toArray } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala index 0015f433ab..fe9f5c647c 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala @@ -53,7 +53,7 @@ case class IndexedToken(tokenId: ModifierId, toRemove += id // all segments empty after rollback, delete parent log.info(s"Removing token $tokenId because no more boxes are associated with it") } else - history.historyStorage.insertExtra(Array.empty, Array(this)) // save the changes made to this address + history.historyStorage.insertExtraTry(Array.empty, Array(this)).get // save the changes made to this address toRemove.toArray } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala index 1ebcd653f7..f2db45be38 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala @@ -2,7 +2,9 @@ package org.ergoplatform.nodeView.history.extra import org.ergoplatform.nodeView.history.ErgoHistory import org.ergoplatform.nodeView.history.extra.ExtraIndexer._ -import scorex.util.ModifierId +import org.ergoplatform.modifiers.ErgoNodeViewModifier +import org.ergoplatform.modifiers.history.header.Header +import scorex.util.{ModifierId, bytesToId} /** * An immutable state for extra indexer @@ -40,13 +42,19 @@ object IndexerState { val globalTxIndex = getIndex(GlobalTxIndexKey, history).getLong val globalBoxIndex = getIndex(GlobalBoxIndexKey, history).getLong val rollbackTo = getIndex(RollbackToKey, history).getInt + val indexedHeaderId = history.historyStorage + .modifierBytesById(bytesToId(IndexedHeaderIdKey)) + .filter(_.length == ErgoNodeViewModifier.ModifierIdSize) + .map(bytesToId) + .filter(id => history.typedModifierById[Header](id).exists(_.height == indexedHeight)) IndexerState( indexedHeight, globalTxIndex, globalBoxIndex, rollbackTo, - caughtUp = indexedHeight == history.fullBlockHeight, - indexedHeaderId = history.bestHeaderIdAtHeight(indexedHeight) + caughtUp = indexedHeight == history.fullBlockHeight && + (indexedHeight == 0 || indexedHeaderId.isDefined), + indexedHeaderId = indexedHeaderId ) } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala index edcf2432d2..795bad23b3 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala @@ -14,7 +14,8 @@ import scala.util.{Failure, Success, Try} import spire.syntax.all.cfor import java.io.File -import java.nio.file.Files +import java.nio.file.{Files, Path} +import java.util.concurrent.locks.ReentrantReadWriteLock import scala.jdk.CollectionConverters.asScalaIteratorConverter /** @@ -51,6 +52,20 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor .maximumSize(config.history.indexesCacheSize) .build[ByteArrayWrapper, Array[Byte]] + private val extraCacheLock = new ReentrantReadWriteLock() + + private def withExtraCacheReadLock[A](body: => A): A = { + extraCacheLock.readLock().lock() + try body + finally extraCacheLock.readLock().unlock() + } + + private def withExtraCacheWriteLock[A](body: => A): A = { + extraCacheLock.writeLock().lock() + try body + finally extraCacheLock.writeLock().unlock() + } + private def cacheModifier(mod: BlockSection): Unit = mod.modifierTypeId match { case Header.modifierTypeId => headersCache.put(mod.id, mod) case _ => blockSectionsCache.put(mod.id, mod) @@ -89,7 +104,7 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor } } - def getExtraIndex(id: ModifierId): Option[ExtraIndex] = { + def getExtraIndex(id: ModifierId): Option[ExtraIndex] = withExtraCacheReadLock { Option(extraCache.getIfPresent(id)) orElse extraStore.get(idToBytes(id)).flatMap { bytes => ExtraIndexSerializer.parseBytesTry(bytes) match { case Success(pm) => @@ -150,16 +165,46 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor def insertExtra(indexesToInsert: Array[(Array[Byte], Array[Byte])], objectsToInsert: Array[ExtraIndex]): Unit = { - extraStore.insert( - objectsToInsert.map(mod => mod.serializedId), - objectsToInsert.map(mod => ExtraIndexSerializer.toBytes(mod)) - ) - cfor(0)(_ < indexesToInsert.length, _ + 1) { i => extraStore.insert(indexesToInsert(i)._1, indexesToInsert(i)._2)} + insertExtraTry(indexesToInsert, objectsToInsert).failed.foreach { error => + log.error("Failed to insert extra indexes", error) + } + } + + private[history] def invalidateExtraCache(ids: Iterable[ModifierId]): Unit = withExtraCacheWriteLock { + ids.foreach(extraCache.invalidate) + } + + def insertExtraTry(indexesToInsert: Array[(Array[Byte], Array[Byte])], + objectsToInsert: Array[ExtraIndex]): Try[Unit] = { + val objectIds = objectsToInsert.iterator.flatMap(obj => Try(obj.id).toOption).toArray + Try { + val keys = objectsToInsert.map(_.serializedId) ++ indexesToInsert.map(_._1) + val values = objectsToInsert.map(ExtraIndexSerializer.toBytes) ++ indexesToInsert.map(_._2) + keys -> values + }.flatMap { case (keys, values) => + withExtraCacheWriteLock { + extraStore.insert(keys, values).map { _ => + objectIds.foreach(extraCache.invalidate) + } + } + }.recoverWith { case error => + invalidateExtraCache(objectIds) + Failure(error) + } } def removeExtra(indexesToRemove: Array[ModifierId]) : Unit = { - extraStore.remove(indexesToRemove.map(idToBytes)) - cfor(0)(_ < indexesToRemove.length, _ + 1) { i => removeModifier(indexesToRemove(i)) } + removeExtraTry(indexesToRemove).failed.foreach { error => + log.error("Failed to remove extra indexes", error) + } + } + + def removeExtraTry(indexesToRemove: Array[ModifierId]): Try[Unit] = { + withExtraCacheWriteLock { + extraStore.remove(indexesToRemove.map(idToBytes)).map { _ => + cfor(0)(_ < indexesToRemove.length, _ + 1) { i => removeModifier(indexesToRemove(i)) } + } + } } /** @@ -205,26 +250,37 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor * Delete the extra index database and reopen it. * * @param ergoSettings - settings to use - * @return new HistoryStorage instance with empty extra database, or this instance in case of failure + * @return new HistoryStorage instance with an empty extra database */ - def deleteExtraDB(ergoSettings: ErgoSettings): HistoryStorage = { + def deleteExtraDB(ergoSettings: ErgoSettings): HistoryStorage = + deleteExtraDBTry(ergoSettings).get + + /** + * Delete the extra index database and reopen it, preserving deletion failures. + */ + def deleteExtraDBTry(ergoSettings: ErgoSettings): Try[HistoryStorage] = { log.warn(s"Removing extra index database due to old schema.") - close() - // org.ergoplatform.wallet.utils.FileUtils val root = new File(s"${ergoSettings.directory}/history/extra") - if (root.exists()) { - Files.walk(root.toPath).iterator().asScala.toSeq.reverse.foreach(path => Try(Files.delete(path))) - }else { - log.error(s"Could not delete ${root.toString}") - return this + Try(close()).flatMap { _ => + HistoryStorage.deleteRecursively(root.toPath, Files.delete) + }.map { _ => + log.info(s"Deleted ${root.toString}") + HistoryStorage.apply(ergoSettings) } - log.info(s"Deleted ${root.toString}") - HistoryStorage.apply(ergoSettings) } } object HistoryStorage { + private[storage] def deleteRecursively(root: Path, deletePath: Path => Unit): Try[Unit] = Try { + if (Files.exists(root)) { + val paths = Files.walk(root) + try paths.iterator().asScala.toSeq.reverse.foreach(deletePath) + finally paths.close() + } + require(!Files.exists(root), s"Could not delete $root") + } + def apply(ergoSettings: ErgoSettings): HistoryStorage = { val indexStore = LDBFactory.createKvDb(s"${ergoSettings.directory}/history/index") val objectsStore = LDBFactory.createKvDb(s"${ergoSettings.directory}/history/objects") diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala index de4f58bf21..ec7e8f8e21 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala @@ -108,7 +108,9 @@ object ChainGenerator extends ErgoTestHelpers with Matchers { log.info( s"Block ${block.id} with ${block.transactions.size} transactions at height ${block.header.height} generated") - loop(state.applyModifier(block, None)(_ => ()).get, outToPassNext, Some(block.header), acc :+ block.id)(history) + val newState = state.applyModifier(block, None)(_ => ()).get + history.reportModifierIsValid(block).get + loop(newState, outToPassNext, Some(block.header), acc :+ block.id)(history) } else { acc } diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala index eb6f6e0c5a..a639537ba2 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala @@ -1,8 +1,10 @@ package org.ergoplatform.nodeView.history.extra -import akka.actor.{ActorRef, ActorSystem, Props} +import akka.actor.{Actor, ActorIdentity, ActorRef, ActorSystem, Identify, Props} +import akka.testkit.TestProbe import org.ergoplatform.ErgoAddressEncoder import org.ergoplatform.http.api.SortDirection +import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.network.ErgoNodeViewSynchronizerMessages.{RemoteBlockApplied, Rollback} import org.ergoplatform.nodeView.history.extra.ExtraIndexer.ReceivableMessages.Index @@ -10,13 +12,16 @@ import org.ergoplatform.nodeView.history.extra.IndexedContractTemplateSerializer import org.ergoplatform.nodeView.history.extra.IndexedErgoAddressSerializer.hashErgoTree import org.ergoplatform.nodeView.history.extra.SegmentSerializer.{boxSegmentId, txSegmentId} import org.ergoplatform.nodeView.history.{ErgoHistory, ErgoHistoryReader} +import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.nodeView.mempool.ErgoMemPool -import org.ergoplatform.settings.ErgoSettings +import org.ergoplatform.settings.{ErgoSettings, NetworkType} import org.ergoplatform.utils.ErgoCorePropertyTest import scorex.util.{ModifierId, bytesToId} import spire.implicits.cfor import java.util.concurrent.locks.{Condition, ReentrantLock} +import java.nio.ByteBuffer +import java.nio.file.Files import scala.collection.mutable import scala.concurrent.duration.DurationInt import scala.reflect.ClassTag @@ -30,6 +35,14 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { case class ExtendDB(blockCount: Int) case class Reset() case class GenerateBetterChainTip() + case class CacheBlockTransactions(height: Int, transactions: BlockTransactions) + case class DeferNextHeaderOnce(height: Int) + case class DeferBlockTransactionsOnce(height: Int) + case class Reload() + case class ForceRollback(height: Int) + case class GetLoadedState() + case class FailNextRollbackRemoval(probe: ActorRef) + case class PauseBufferedCatchUpAt(height: Int, saveLimit: Int, probe: ActorRef) type ID_LL = mutable.HashMap[ModifierId,(Long,Long)] @@ -43,6 +56,19 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { var _history: ErgoHistory = _ def history: ErgoHistoryReader = _history.getReader + def fullChainHeaderAt(height: Int): Header = { + val bestFullBlock = history.bestFullBlockOpt.get + history.headerChainBack(bestFullBlock.height - height + 1, bestFullBlock.header, _.height == height) + .headers + .find(_.height == height) + .get + } + + def fullChainTransactionsAt(height: Int): BlockTransactions = { + val header = fullChainHeaderAt(height) + history.typedModifierById[BlockTransactions](header.transactionsId).get + } + val lock: ReentrantLock = new ReentrantLock() val done: Condition = lock.newCondition() val created: Condition = lock.newCondition() @@ -64,8 +90,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { val templates: ID_LL = mutable.HashMap[ModifierId, (Long, Long)]() val indexedTokens: ID_LL = mutable.HashMap[ModifierId, (Long, Long)]() cfor(1)(_ <= limit, _ + 1) { i => - val header = history.headerIdsAtHeight(i).last - val block = history.getFullBlock(history.typedModifierById[Header](header).get) + val header = fullChainHeaderAt(i) + val block = history.getFullBlock(header) block.get.transactions.foreach { tx => txsIndexed += 1 if (i != 1) { @@ -174,9 +200,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { val (addresses, templates, indexedTokens, txsIndexed, boxesIndexed) = manualIndex(n) // perform rollback - indexer ! Rollback(history.bestHeaderIdAtHeight(n).get) - lock.lock() - done.await() + indexer ! ForceRollback(n) + awaitCondition(done) state = IndexerState.fromHistory(_history) // address balances @@ -220,8 +245,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { println(s"Generate to $n") indexer ! CreateDB(n) indexer ! Index() - lock.lock() - done.await() + awaitCondition(done) val (addresses, _, _, _, _) = manualIndex(n) @@ -274,11 +298,438 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { indexer ! Reset() } + property("catches up past a direct block event when history is already ahead") { + indexer ! CreateDB(HEIGHT) + indexer ! Index() + awaitCondition(done) + + indexer ! ExtendDB(HEIGHT + 2) + awaitCondition(created) + val firstHeader = fullChainHeaderAt(HEIGHT + 1) + indexer ! RemoteBlockApplied(firstHeader, history.getFullBlock(firstHeader).get.transactions.map(_.id)) + + org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { + val state = IndexerState.fromHistory(_history) + state.indexedHeight shouldBe HEIGHT + 2 + state.indexedHeaderId shouldBe Some(fullChainHeaderAt(HEIGHT + 2).id) + } + indexer ! Reset() + } + + property("restores the exact persisted indexed header after the best header changes") { + indexer ! CreateDB(HEIGHT) + indexer ! Index() + awaitCondition(done) + + val persistedState = IndexerState.fromHistory(_history) + persistedState.indexedHeaderId shouldBe history.bestHeaderIdAtHeight(HEIGHT) + + indexer ! GenerateBetterChainTip() + awaitCondition(created) + indexer ! CreateDB(HEIGHT + 1) + awaitCondition(created) + history.bestHeaderIdAtHeight(HEIGHT) should not be persistedState.indexedHeaderId + + IndexerState.fromHistory(_history).indexedHeaderId shouldBe persistedState.indexedHeaderId + indexer ! Reset() + } + + property("rebuilds legacy, malformed, and interrupted checkpoints") { + def intBytes(value: Int): Array[Byte] = ByteBuffer.allocate(4).putInt(value).array + def longBytes(value: Long): Array[Byte] = ByteBuffer.allocate(8).putLong(value).array + val schema = ExtraIndexer.SchemaVersionKey -> intBytes(ExtraIndexer.NewestVersion) + val emptyMetadata = Array( + ExtraIndexer.IndexedHeightKey -> intBytes(0), + ExtraIndexer.GlobalTxIndexKey -> longBytes(0), + ExtraIndexer.GlobalBoxIndexKey -> longBytes(0), + ExtraIndexer.RollbackToKey -> intBytes(0) + ) + val invalidCheckpoints = Seq[(String, Array[(Array[Byte], Array[Byte])])]( + "legacy non-empty" -> Array( + ExtraIndexer.SchemaVersionKey -> intBytes(6), + ExtraIndexer.IndexedHeightKey -> intBytes(1), + ExtraIndexer.GlobalTxIndexKey -> longBytes(0), + ExtraIndexer.GlobalBoxIndexKey -> longBytes(0), + ExtraIndexer.RollbackToKey -> intBytes(0) + ), + "legacy height zero with stale counters" -> Array( + ExtraIndexer.SchemaVersionKey -> intBytes(6), + ExtraIndexer.IndexedHeightKey -> intBytes(0), + ExtraIndexer.GlobalTxIndexKey -> longBytes(7), + ExtraIndexer.GlobalBoxIndexKey -> longBytes(9), + ExtraIndexer.RollbackToKey -> intBytes(0) + ), + "missing header id" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> intBytes(1))), + "short header id" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> intBytes(1)) ++ + Array(ExtraIndexer.IndexedHeaderIdKey -> Array[Byte](1, 2, 3))), + "unknown header id" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> intBytes(1)) ++ + Array(ExtraIndexer.IndexedHeaderIdKey -> Array.fill[Byte](32)(1))), + "interrupted rollback" -> (Array(schema) ++ emptyMetadata.updated(3, ExtraIndexer.RollbackToKey -> intBytes(1))), + "short indexed height" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> Array[Byte](1))), + "long transaction index" -> (Array(schema) ++ emptyMetadata.updated(1, ExtraIndexer.GlobalTxIndexKey -> Array.fill[Byte](9)(1))), + "short box index" -> (Array(schema) ++ emptyMetadata.updated(2, ExtraIndexer.GlobalBoxIndexKey -> Array[Byte](1))), + "short rollback target" -> (Array(schema) ++ emptyMetadata.updated(3, ExtraIndexer.RollbackToKey -> Array[Byte](1))), + "current schema height zero with stale transaction counter" -> + (Array(schema) ++ emptyMetadata.updated(1, ExtraIndexer.GlobalTxIndexKey -> longBytes(1))), + "current schema height zero with stale box counter" -> + (Array(schema) ++ emptyMetadata.updated(2, ExtraIndexer.GlobalBoxIndexKey -> longBytes(1))), + "negative box index" -> (Array(schema) ++ emptyMetadata.updated(2, ExtraIndexer.GlobalBoxIndexKey -> longBytes(-1))) + ) + + invalidCheckpoints.foreach { case (name, entries) => + val dbDir = Files.createTempDirectory("extra-indexer-checkpoint").toFile + val dbSettings = initSettings.copy( + directory = dbDir.getAbsolutePath, + nodeSettings = initSettings.nodeSettings.copy(extraIndex = true) + ) + val db = HistoryStorage(dbSettings) + db.insertExtraTry(entries, Array.empty).get + db.close() + + val probe = TestProbe()(system) + system.actorOf(Props(new Actor { + override def preStart(): Unit = { + val reloaded = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, reloaded).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, reloaded).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, reloaded).getLong, + ExtraIndexer.getIndex(ExtraIndexer.RollbackToKey, reloaded).getInt, + reloaded.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + reloaded.closeStorage() + context.stop(self) + } + + override def receive: Receive = Actor.emptyBehavior + })) + withClue(name) { + probe.expectMsg((0, 0L, 0L, 0, None)) + } + } + } + + property("preserves a valid non-empty checkpoint across storage reopen") { + val dbDir = Files.createTempDirectory("extra-indexer-valid-checkpoint").toFile + val dbSettings = initSettings.copy( + directory = dbDir.getAbsolutePath, + networkType = NetworkType.TestNet, + nodeSettings = initSettings.nodeSettings.copy(extraIndex = true, headerChainDiff = 5000) + ) + val generationSettings = dbSettings.copy( + nodeSettings = dbSettings.nodeSettings.copy(extraIndex = false) + ) + val probe = TestProbe()(system) + system.actorOf(Props(new Actor { + override def preStart(): Unit = { + val generatedHistory = ErgoHistory.readOrGenerate(generationSettings)(context) + ChainGenerator.generate(1, dbDir, generatedHistory, None) + generatedHistory.closeStorage() + + val indexedHistory = ErgoHistory.readOrGenerate(dbSettings)(context) + val header = indexedHistory.bestFullBlockOpt.get.header + val blockTransactions = indexedHistory.typedModifierById[BlockTransactions](header.transactionsId).get + val txCount = blockTransactions.txs.size.toLong + val boxCount = blockTransactions.txs.map(_.outputs.size.toLong).sum + val lastTx = blockTransactions.txs.last + val lastTxIndex = txCount - 1 + val lastBoxIndex = boxCount - 1 + val outputIndexes = Array.tabulate(lastTx.outputs.size) { i => + boxCount - lastTx.outputs.size.toLong + i.toLong + } + val indexedTx = IndexedErgoTransaction.fromTx( + lastTx, + blockTransactions.txs.size - 1, + header.height, + lastTxIndex, + Array.fill(lastTx.inputs.size)(0L), + outputIndexes + ) + val indexedBoxes = lastTx.outputs.zip(outputIndexes).map { case (output, outputIndex) => + new IndexedErgoBox( + header.height, + None, + None, + None, + output, + outputIndex + ) + }.toArray + val numericBoxes = indexedBoxes.map(box => NumericBoxIndex(box.globalIndex, box.id)) + val lastBox = indexedBoxes.last + val numericTx = NumericTxIndex(lastTxIndex, lastTx.id) + val numericBox = numericBoxes.last + val checkpointMetadata = Array( + ExtraIndexer.SchemaVersionKey -> ByteBuffer.allocate(4).putInt(ExtraIndexer.NewestVersion).array, + ExtraIndexer.IndexedHeightKey -> ByteBuffer.allocate(4).putInt(header.height).array, + ExtraIndexer.GlobalTxIndexKey -> ByteBuffer.allocate(8).putLong(txCount).array, + ExtraIndexer.GlobalBoxIndexKey -> ByteBuffer.allocate(8).putLong(boxCount).array, + ExtraIndexer.RollbackToKey -> ByteBuffer.allocate(4).putInt(0).array, + ExtraIndexer.IndexedHeaderIdKey -> ExtraIndexer.fastIdToBytes(header.id) + ) + val checkpointObjects = Array[ExtraIndex](numericTx, indexedTx) ++ numericBoxes ++ indexedBoxes + indexedHistory.historyStorage.insertExtraTry( + checkpointMetadata, + checkpointObjects + ).get + indexedHistory.closeStorage() + + val reloaded = ErgoHistory.readOrGenerate(dbSettings)(context) + val state = IndexerState.fromHistory(reloaded) + val terminalRowsPreserved = + reloaded.typedExtraIndexById[NumericTxIndex](numericTx.id).contains(numericTx) && + reloaded.typedExtraIndexById[IndexedErgoTransaction](indexedTx.id).exists { tx => + tx.txid == indexedTx.txid && tx.globalIndex == indexedTx.globalIndex && + tx.height == indexedTx.height && tx.outputNums.sameElements(indexedTx.outputNums) + } && + reloaded.typedExtraIndexById[NumericBoxIndex](numericBox.id).contains(numericBox) && + reloaded.typedExtraIndexById[IndexedErgoBox](lastBox.id).exists(_.globalIndex == lastBoxIndex) + probe.ref ! state + probe.ref ! terminalRowsPreserved + reloaded.closeStorage() + + val interrupted = HistoryStorage(dbSettings) + interrupted.insertExtraTry( + Array(ExtraIndexer.RollbackToKey -> ByteBuffer.allocate(4).putInt(header.height).array), + Array.empty + ).get + interrupted.close() + + val rebuiltAfterInterruptedRollback = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterInterruptedRollback).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterInterruptedRollback).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterInterruptedRollback).getLong, + rebuiltAfterInterruptedRollback.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuiltAfterInterruptedRollback.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get + rebuiltAfterInterruptedRollback.closeStorage() + + val wrongSchema = HistoryStorage(dbSettings) + wrongSchema.insertExtraTry( + Array(ExtraIndexer.SchemaVersionKey -> ByteBuffer.allocate(4).putInt(ExtraIndexer.NewestVersion - 1).array), + Array.empty + ).get + wrongSchema.close() + + val rebuiltAfterSchemaMismatch = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterSchemaMismatch).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterSchemaMismatch).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterSchemaMismatch).getLong, + rebuiltAfterSchemaMismatch.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuiltAfterSchemaMismatch.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get + rebuiltAfterSchemaMismatch.closeStorage() + + val malformed = HistoryStorage(dbSettings) + val malformedTx = indexedTx.copy(outputNums = indexedTx.outputNums ++ Array(lastBoxIndex)) + malformed.insertExtraTry(Array.empty, Array(malformedTx)).get + malformed.close() + + val rebuiltAfterMalformedTx = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterMalformedTx).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterMalformedTx).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterMalformedTx).getLong, + rebuiltAfterMalformedTx.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuiltAfterMalformedTx.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get + rebuiltAfterMalformedTx.closeStorage() + + val malformedInputs = HistoryStorage(dbSettings) + val malformedInputTx = indexedTx.copy(inputNums = indexedTx.inputNums.map(_ + 1L)) + malformedInputs.insertExtraTry(Array.empty, Array(malformedInputTx)).get + malformedInputs.close() + + val rebuiltAfterMalformedInputs = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterMalformedInputs).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterMalformedInputs).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterMalformedInputs).getLong, + rebuiltAfterMalformedInputs.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuiltAfterMalformedInputs.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get + rebuiltAfterMalformedInputs.closeStorage() + + val malformedSpentOutput = HistoryStorage(dbSettings) + val spentTerminalBox = new IndexedErgoBox( + header.height, + Some(lastTx.id), + Some(header.height + 1), + None, + lastTx.outputs.last, + lastBoxIndex + ) + malformedSpentOutput.insertExtraTry(Array.empty, Array(spentTerminalBox)).get + malformedSpentOutput.close() + + val rebuiltAfterSpentOutput = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterSpentOutput).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterSpentOutput).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterSpentOutput).getLong, + rebuiltAfterSpentOutput.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuiltAfterSpentOutput.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get + rebuiltAfterSpentOutput.closeStorage() + + val corrupted = HistoryStorage(dbSettings) + corrupted.removeExtraTry(Array(numericBox.id)).get + corrupted.close() + + val rebuilt = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuilt).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuilt).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuilt).getLong, + rebuilt.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuilt.closeStorage() + + val invalidatedHeaderHistory = ErgoHistory.readOrGenerate(dbSettings)(context) + invalidatedHeaderHistory.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get + invalidatedHeaderHistory.historyStorage.insert( + Array(invalidatedHeaderHistory.validityKey(header.id) -> Array(0.toByte)), + org.ergoplatform.modifiers.BlockSection.emptyArray + ).get + invalidatedHeaderHistory.closeStorage() + + val rebuiltAfterInvalidatedHeader = ErgoHistory.readOrGenerate(dbSettings)(context) + probe.ref ! (( + ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterInvalidatedHeader).getInt, + ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterInvalidatedHeader).getLong, + ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterInvalidatedHeader).getLong, + rebuiltAfterInvalidatedHeader.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) + )) + rebuiltAfterInvalidatedHeader.closeStorage() + context.stop(self) + } + + override def receive: Receive = Actor.emptyBehavior + })) + val preserved = probe.expectMsgType[IndexerState] + preserved.indexedHeight shouldBe 1 + preserved.indexedHeaderId should not be empty + preserved.globalTxIndex should be > 0L + preserved.globalBoxIndex should be > 0L + probe.expectMsg(true) + probe.expectMsg((0, 0L, 0L, None)) + probe.expectMsg((0, 0L, 0L, None)) + probe.expectMsg((0, 0L, 0L, None)) + probe.expectMsg((0, 0L, 0L, None)) + probe.expectMsg((0, 0L, 0L, None)) + probe.expectMsg((0, 0L, 0L, None)) + probe.expectMsg((0, 0L, 0L, None)) + } + + property("binds cached transactions to the selected header") { + indexer ! CreateDB(HEIGHT) + indexer ! Index() + awaitCondition(done) + val originalTransactions = history.bestBlockTransactionsAt(HEIGHT).get + val staleTransactions = originalTransactions.copy( + txs = history.bestBlockTransactionsAt(HEIGHT - 1).get.txs + ) + + indexer ! ForceRollback(HEIGHT - 1) + awaitCondition(done) + val branchState = IndexerState.fromHistory(_history) + + indexer ! GenerateBetterChainTip() + awaitCondition(created) + indexer ! CreateDB(HEIGHT + 1) + awaitCondition(created) + val selectedTransactions = history.bestBlockTransactionsAt(HEIGHT).get + selectedTransactions.headerId should not be originalTransactions.headerId + selectedTransactions.txs.head.id should not be staleTransactions.txs.head.id + + indexer ! CacheBlockTransactions(HEIGHT, staleTransactions) + awaitCondition(created) + indexer ! Index() + awaitCondition(done) + + NumericTxIndex.getTxByNumber(history, branchState.globalTxIndex).map(_.id) shouldBe + Some(selectedTransactions.txs.head.id) + indexer ! Reset() + } + + property("retries catch-up after a transient non-extending header") { + indexer ! CreateDB(HEIGHT) + awaitCondition(created) + indexer ! DeferNextHeaderOnce(2) + awaitCondition(created) + indexer ! Index() + + org.ergoplatform.utils.untilTimeout(3.seconds, 50.millis) { + val state = IndexerState.fromHistory(_history) + state.indexedHeight shouldBe HEIGHT + state.indexedHeaderId shouldBe history.bestHeaderIdAtHeight(HEIGHT) + } + indexer ! Reset() + } + + property("retries catch-up when the selected block transactions are temporarily unavailable") { + indexer ! CreateDB(HEIGHT) + awaitCondition(created) + indexer ! DeferBlockTransactionsOnce(2) + awaitCondition(created) + indexer ! Index() + + org.ergoplatform.utils.untilTimeout(3.seconds, 50.millis) { + val state = IndexerState.fromHistory(_history) + state.indexedHeight shouldBe HEIGHT + state.indexedHeaderId shouldBe Some(fullChainHeaderAt(HEIGHT).id) + } + indexer ! Reset() + } + + property("the production actor starts catch-up from the event stream") { + val dbDir = Files.createTempDirectory("extra-indexer-production-start").toFile + val dbSettings = initSettings.copy( + directory = dbDir.getAbsolutePath, + networkType = NetworkType.TestNet, + nodeSettings = initSettings.nodeSettings.copy(extraIndex = true, headerChainDiff = 5000) + ) + val setupProbe = TestProbe()(system) + system.actorOf(Props(new Actor { + override def preStart(): Unit = { + val generatedHistory = ErgoHistory.readOrGenerate(dbSettings)(context) + ChainGenerator.generate(5, dbDir, generatedHistory, None) + setupProbe.ref ! generatedHistory + context.stop(self) + } + + override def receive: Receive = Actor.emptyBehavior + })) + val history = setupProbe.expectMsgType[ErgoHistory](30.seconds) + IndexerState.fromHistory(history).indexedHeight shouldBe 0 + + val productionIndexer = system.actorOf(Props(new ExtraIndexer( + dbSettings.cacheSettings, + dbSettings.chainSettings.addressEncoder + ))) + val probe = TestProbe()(system) + probe.send(productionIndexer, Identify("started")) + probe.expectMsg(ActorIdentity("started", Some(productionIndexer))) + system.eventStream.publish(ExtraIndexer.ReceivableMessages.StartExtraIndexer(history)) + + org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { + val state = IndexerState.fromHistory(history) + state.indexedHeight shouldBe history.fullBlockHeight + state.indexedHeaderId shouldBe Some(history.bestFullBlockOpt.get.header.id) + } + + probe.watch(productionIndexer) + system.stop(productionIndexer) + probe.expectTerminated(productionIndexer) + history.closeStorage() + } + property("transactions") { indexer ! CreateDB(HEIGHT) indexer ! Index() - lock.lock() - done.await() + awaitCondition(done) val state = IndexerState.fromHistory(_history) cfor(0)(_ < state.globalTxIndex, _ + 1) { n => val id = history.typedExtraIndexById[NumericTxIndex](bytesToId(NumericTxIndex.indexToBytes(n))) @@ -291,8 +742,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("boxes") { indexer ! CreateDB(HEIGHT) indexer ! Index() - lock.lock() - done.await() + awaitCondition(done) val state = IndexerState.fromHistory(_history) cfor(0)(_ < state.globalBoxIndex, _ + 1) { n => val id = history.typedExtraIndexById[NumericBoxIndex](bytesToId(NumericBoxIndex.indexToBytes(n))) @@ -305,8 +755,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("addresses") { indexer ! CreateDB(HEIGHT) indexer ! Index() - lock.lock() - done.await() + awaitCondition(done) val (addresses, _, _, _, _) = manualIndex(HEIGHT) checkAddresses(addresses) shouldBe 0 indexer ! Reset() @@ -315,8 +764,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("templates") { indexer ! CreateDB(HEIGHT) indexer ! Index() - lock.lock() - done.await() + awaitCondition(done) val (_, templates, _, _, _) = manualIndex(HEIGHT) checkTemplates(templates) shouldBe 0 indexer ! Reset() @@ -325,8 +773,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("tokens") { indexer ! CreateDB(HEIGHT) indexer ! Index() - lock.lock() - done.await() + awaitCondition(done) val (_, _, indexedTokens, _, _) = manualIndex(HEIGHT) checkTokens(indexedTokens) shouldBe 0 indexer ! Reset() @@ -352,26 +799,168 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { rollbackWithPattern("G-5;G-15;R-5;G-20;G-25;R-15;G-30;R-10;G-50;R-25") } - property("indexes replacement blocks after rolling back an orphan block") { + property("uses the production rollback point when the indexed tip becomes invalid") { indexer ! CreateDB(HEIGHT) indexer ! Index() awaitCondition(done) + val originalTipId = IndexerState.fromHistory(_history).indexedHeaderId.get + indexer ! GenerateBetterChainTip() - lock.lock() - created.await() - val newBestHeaderOpt = history.typedModifierById[Header](history.headerIdsAtHeight(history.fullBlockHeight).last) - indexer ! RemoteBlockApplied(newBestHeaderOpt.get, Seq.empty) // will be ignored - indexer ! CreateDB(HEIGHT + 1) - lock.lock() - created.await() + awaitCondition(created) + indexer ! ExtendDB(HEIGHT + 1) + awaitCondition(created) + val branchPoint = fullChainHeaderAt(HEIGHT - 1) + val replacementHeader = fullChainHeaderAt(HEIGHT) + val replacementTip = fullChainHeaderAt(HEIGHT + 1) + + _history.historyStorage.insert( + Array(_history.validityKey(originalTipId) -> Array(0.toByte)), + org.ergoplatform.modifiers.BlockSection.emptyArray + ).get + val eventProbe = TestProbe()(system) + eventProbe.send(indexer, RemoteBlockApplied( + replacementHeader, + history.getFullBlock(replacementHeader).get.transactions.map(_.id) + )) + eventProbe.send(indexer, RemoteBlockApplied( + replacementTip, + history.getFullBlock(replacementTip).get.transactions.map(_.id) + )) + eventProbe.send(indexer, Rollback(branchPoint.id)) + + org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { + val state = IndexerState.fromHistory(_history) + state.indexedHeight shouldBe HEIGHT + 1 + state.indexedHeaderId shouldBe Some(replacementTip.id) + } + indexer ! Reset() + } + + property("persists buffered catch-up rows before processing a reorg") { + indexer ! CreateDB(HEIGHT) + awaitCondition(created) + val pauseProbe = TestProbe()(system) + pauseProbe.send(indexer, PauseBufferedCatchUpAt(HEIGHT, Int.MaxValue, pauseProbe.ref)) + pauseProbe.expectMsg("configured") indexer ! Index() - lock.lock() - done.await() - indexer ! Rollback(history.bestHeaderIdAtHeight(HEIGHT).get) - lock.lock() - done.await() - val (_, _, indexedTokens, _, _) = manualIndex(HEIGHT) + + val bufferedState = pauseProbe.expectMsgType[IndexerState](10.seconds) + bufferedState.indexedHeight shouldBe HEIGHT + IndexerState.fromHistory(_history).indexedHeight shouldBe 0 + val originalTipId = bufferedState.indexedHeaderId.get + + indexer ! GenerateBetterChainTip() + awaitCondition(created) + indexer ! ExtendDB(HEIGHT + 1) + awaitCondition(created) + val branchPoint = fullChainHeaderAt(HEIGHT - 1) + val replacementHeader = fullChainHeaderAt(HEIGHT) + val replacementTip = fullChainHeaderAt(HEIGHT + 1) + replacementHeader.id should not be originalTipId + + val eventProbe = TestProbe()(system) + eventProbe.send(indexer, RemoteBlockApplied( + replacementHeader, + history.getFullBlock(replacementHeader).get.transactions.map(_.id) + )) + eventProbe.send(indexer, RemoteBlockApplied( + replacementTip, + history.getFullBlock(replacementTip).get.transactions.map(_.id) + )) + eventProbe.send(indexer, Rollback(branchPoint.id)) + + org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { + val state = IndexerState.fromHistory(_history) + state.indexedHeight shouldBe HEIGHT + 1 + state.indexedHeaderId shouldBe Some(replacementTip.id) + } + + val expectedTransactions = (1 to HEIGHT + 1).flatMap(fullChainTransactionsAt(_).txs) + val expectedBoxes = expectedTransactions.flatMap(_.outputs) + val state = IndexerState.fromHistory(_history) + state.globalTxIndex shouldBe expectedTransactions.size + state.globalBoxIndex shouldBe expectedBoxes.size + expectedTransactions.zipWithIndex.foreach { case (tx, index) => + NumericTxIndex.getTxByNumber(history, index).map(_.id) shouldBe Some(tx.id) + } + expectedBoxes.zipWithIndex.foreach { case (box, index) => + NumericBoxIndex.getBoxByNumber(history, index).map(_.id) shouldBe Some(bytesToId(box.id)) + } + val (addresses, templates, indexedTokens, _, _) = manualIndex(HEIGHT + 1) + checkAddresses(addresses) shouldBe 0 + checkTemplates(templates) shouldBe 0 + checkTokens(indexedTokens) shouldBe 0 + indexer ! Reset() + } + + property("requests shutdown when final removal fails after partial rollback writes") { + indexer ! CreateDB(HEIGHT) + indexer ! Index() + awaitCondition(done) + val spentInputId = bytesToId(fullChainTransactionsAt(HEIGHT).txs.flatMap(_.inputs).head.boxId) + history.typedExtraIndexById[IndexedErgoBox](spentInputId).exists(_.isSpent) shouldBe true + + val probe = TestProbe()(system) + indexer ! FailNextRollbackRemoval(probe.ref) + awaitCondition(created) + indexer ! ForceRollback(HEIGHT - 1) + + probe.expectMsg("shutdown-requested") + ExtraIndexer.getIndex(ExtraIndexer.RollbackToKey, _history).getInt shouldBe HEIGHT - 1 + _history.historyStorage.invalidateExtraCache(Seq(spentInputId)) + history.typedExtraIndexById[IndexedErgoBox](spentInputId).exists(_.isSpent) shouldBe false + indexer ! Reset() + } + + property("recovers a reloaded checkpoint without waiting for block or rollback events") { + indexer ! CreateDB(HEIGHT) + indexer ! Index() + awaitCondition(done) + val originalTipId = IndexerState.fromHistory(_history).indexedHeaderId + val branchPointId = fullChainHeaderAt(HEIGHT - 1).id + + indexer ! GenerateBetterChainTip() + awaitCondition(created) + indexer ! ExtendDB(HEIGHT + 1) + awaitCondition(created) + + val replacementHeader = fullChainHeaderAt(HEIGHT) + val replacementChild = fullChainHeaderAt(HEIGHT + 1) + replacementHeader.id should not be originalTipId.get + + indexer ! Reload() + awaitCondition(created) + + org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { + val state = IndexerState.fromHistory(_history) + state.indexedHeight shouldBe HEIGHT + 1 + state.indexedHeaderId shouldBe Some(replacementChild.id) + } + + val expectedTransactions = (1 to HEIGHT + 1).flatMap(fullChainTransactionsAt(_).txs) + val expectedBoxes = expectedTransactions.flatMap(_.outputs) + val state = IndexerState.fromHistory(_history) + state.globalTxIndex shouldBe expectedTransactions.size + state.globalBoxIndex shouldBe expectedBoxes.size + expectedTransactions.zipWithIndex.foreach { case (tx, index) => + NumericTxIndex.getTxByNumber(history, index).map(_.id) shouldBe Some(tx.id) + } + expectedBoxes.zipWithIndex.foreach { case (box, index) => + NumericBoxIndex.getBoxByNumber(history, index).map(_.id) shouldBe Some(bytesToId(box.id)) + } + + val (addresses, templates, indexedTokens, _, _) = manualIndex(HEIGHT + 1) + checkAddresses(addresses) shouldBe 0 + checkTemplates(templates) shouldBe 0 checkTokens(indexedTokens) shouldBe 0 + + val probe = TestProbe()(system) + probe.send(indexer, Rollback(branchPointId)) + probe.send(indexer, GetLoadedState()) + val stateAfterLateRollback = probe.expectMsgType[IndexerState] + stateAfterLateRollback.indexedHeight shouldBe HEIGHT + 1 + stateAfterLateRollback.indexedHeaderId shouldBe Some(replacementChild.id) + IndexerState.fromHistory(_history) shouldBe stateAfterLateRollback indexer ! Reset() } } diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala index c67e0c0c73..980ba8555f 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala @@ -1,18 +1,21 @@ package org.ergoplatform.nodeView.history.extra +import akka.actor.ActorRef import org.ergoplatform._ import org.ergoplatform.modifiers.history.header.Header +import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.mempool.ErgoTransaction import org.ergoplatform.nodeView.history.ErgoHistory import org.ergoplatform.nodeView.mempool.ErgoMemPoolUtils.SortingOption import org.ergoplatform.nodeView.state._ import org.ergoplatform.settings._ import org.ergoplatform.wallet.utils.FileUtils -import scorex.util.ModifierId +import scorex.util.{ModifierId, bytesToId} import java.io.File import scala.collection.mutable import scala.concurrent.duration.DurationInt +import scala.util.{Failure, Try} class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexerBase with FileUtils { @@ -21,8 +24,20 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe case test.ExtendDB(blockCount: Int) => extendDB(blockCount) case test.Reset() => reset() case test.GenerateBetterChainTip() => GenerateBetterChainTip() + case test.CacheBlockTransactions(height, transactions) => cacheBlockTransactions(height, transactions) + case test.DeferNextHeaderOnce(height) => deferNextHeaderOnce(height) + case test.DeferBlockTransactionsOnce(height) => deferBlockTransactionsOnce(height) + case test.Reload() => reload() + case test.FailNextRollbackRemoval(probe) => failNextRollbackRemoval(probe) + case test.PauseBufferedCatchUpAt(height, limit, probe) => pauseBufferedCatchUpAt(height, limit, probe) } + override protected def loaded(state: IndexerState): Receive = ({ + case test.ForceRollback(height) => + beginRollback(state, fullChainHeaderAtHeight(height).get, resume = false) + case test.GetLoadedState() => sender ! state + }: Receive).orElse(super.loaded(state)) + override def caughtUpHook(height: Int = 0): Unit = { if(height > 0 && height < chainHeight) return test.lock.lock() @@ -31,16 +46,18 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe } override def getLastTxForHeight(height: Int): ErgoTransaction = { - val header = history.headerIdsAtHeight(height).last - val block = history.getFullBlock(history.typedModifierById[Header](header).get) + val header = fullChainHeaderAtHeight(height).get + val block = history.getFullBlock(header) block.get.transactions.last } type ID_LL = mutable.HashMap[ModifierId,(Long,Long)] - override protected val saveLimit: Int = 1 // save every block + private var configuredSaveLimit: Int = 1 + override protected def saveLimit: Int = configuredSaveLimit override protected implicit val segmentThreshold: Int = 8 // split to smaller segments override protected implicit val addressEncoder: ErgoAddressEncoder = test.initSettings.chainSettings.addressEncoder + override protected val retryDelay = 50.millis val nodeSettings: NodeConfigurationSettings = NodeConfigurationSettings(StateType.Utxo, verifyTransactions = true, -1, UtxoSettings(utxoBootstrap = false, 0, 2), NipopowSettings(nipopowBootstrap = false, 1), mining = false, @@ -51,6 +68,51 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe private var dir: File = _ private var stateOpt: Option[UtxoState] = None + private var deferredHeaderHeightOpt: Option[Int] = None + private var deferredTransactionsHeightOpt: Option[Int] = None + private var rollbackFailureProbeOpt: Option[ActorRef] = None + private var failRollbackRemoval: Boolean = false + private var pauseCatchUpAtHeightOpt: Option[Int] = None + private var catchUpPauseProbeOpt: Option[ActorRef] = None + + override protected def continueCatchUpAfterIndex(state: IndexerState): Boolean = { + if (pauseCatchUpAtHeightOpt.contains(state.indexedHeight)) { + pauseCatchUpAtHeightOpt = None + catchUpPauseProbeOpt.foreach(_ ! state) + catchUpPauseProbeOpt = None + false + } else true + } + + override protected def removeRollbackIndexes(ids: Array[ModifierId]): Try[Unit] = + if (failRollbackRemoval) { + failRollbackRemoval = false + Failure(new IllegalStateException("injected final rollback removal failure")) + } else super.removeRollbackIndexes(ids) + + override protected def requestShutdown(): Unit = { + rollbackFailureProbeOpt.foreach(_ ! "shutdown-requested") + rollbackFailureProbeOpt = None + } + + override protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { + val headerOpt = super.fullChainHeaderAtHeight(height) + if (deferredHeaderHeightOpt.contains(height)) { + deferredHeaderHeightOpt = None + headerOpt.map(_.copy(parentId = bytesToId(Array.fill(32)(0x7f.toByte)))) + } else { + headerOpt + } + } + + override protected def blockTransactionsForHeader(header: Header): Option[BlockTransactions] = { + if (deferredTransactionsHeightOpt.contains(header.height)) { + deferredTransactionsHeightOpt = None + None + } else { + super.blockTransactionsForHeader(header) + } + } def createDB(blockCount: Int): Unit = { if(stateOpt.isEmpty) { @@ -80,13 +142,22 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe } def reset(): Unit = { + resetTransientState() stateOpt = None test._history = null general.clear() boxes.clear() trees.clear() + templates.clear() tokens.clear() segments.clear() + deferredHeaderHeightOpt = None + deferredTransactionsHeightOpt = None + rollbackFailureProbeOpt = None + failRollbackRemoval = false + configuredSaveLimit = 1 + pauseCatchUpAtHeightOpt = None + catchUpPauseProbeOpt = None context.become(receive.orElse(loaded(IndexerState(0, 0, 0, 0, caughtUp = false)))) } @@ -98,4 +169,54 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe test.lock.unlock() } + private def cacheBlockTransactions(height: Int, transactions: BlockTransactions): Unit = { + val cacheAccessor = getClass.getMethods + .find(_.getName.endsWith("$$blockCache")) + .getOrElse(throw new IllegalStateException("ExtraIndexer block cache accessor not found")) + val cache = cacheAccessor.invoke(this) + .asInstanceOf[scala.collection.concurrent.Map[Int, BlockTransactions]] + cache.put(height, transactions) + test.lock.lock() + test.created.signal() + test.lock.unlock() + } + + private def deferNextHeaderOnce(height: Int): Unit = { + deferredHeaderHeightOpt = Some(height) + test.lock.lock() + test.created.signal() + test.lock.unlock() + } + + private def deferBlockTransactionsOnce(height: Int): Unit = { + deferredTransactionsHeightOpt = Some(height) + test.lock.lock() + test.created.signal() + test.lock.unlock() + } + + private def reload(): Unit = { + resetTransientState() + context.become(receive.orElse(loaded(IndexerState.fromHistory(_history)))) + self ! ExtraIndexer.ReceivableMessages.Index() + test.lock.lock() + test.created.signal() + test.lock.unlock() + } + + private def failNextRollbackRemoval(probe: ActorRef): Unit = { + rollbackFailureProbeOpt = Some(probe) + failRollbackRemoval = true + test.lock.lock() + test.created.signal() + test.lock.unlock() + } + + private def pauseBufferedCatchUpAt(height: Int, limit: Int, probe: ActorRef): Unit = { + configuredSaveLimit = limit + pauseCatchUpAtHeightOpt = Some(height) + catchUpPauseProbeOpt = Some(probe) + probe ! "configured" + } + } diff --git a/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala b/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala index eb94a87a75..81703ff099 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala @@ -4,12 +4,21 @@ import org.ergoplatform.modifiers.BlockSection import org.ergoplatform.modifiers.history.ADProofs import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.nodeView.history.ErgoHistoryUtils._ +import org.ergoplatform.nodeView.history.extra.{ExtraIndex, IndexedErgoBox} import org.ergoplatform.settings.Algos import org.ergoplatform.utils.ErgoCorePropertyTest import org.scalacheck.Gen -import scorex.db.ByteArrayWrapper +import scorex.db.{ByteArrayWrapper, LDBFactory, LDBKVStore} import scorex.util.{ModifierId, idToBytes} +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.{CountDownLatch, TimeUnit} +import org.iq80.leveldb.Options +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, ExecutionContext, Future} +import scala.util.Try + class HistoryStorageSpec extends ErgoCorePropertyTest { import org.ergoplatform.utils.ErgoNodeTestConstants._ import org.ergoplatform.utils.generators.ErgoCoreGenerators._ @@ -40,4 +49,100 @@ class HistoryStorageSpec extends ErgoCorePropertyTest { indexes.forall(i => !db.getIndex(i._1).exists(_.nonEmpty)) shouldBe true } + property("recursive extra index deletion propagates a file failure") { + val root = Files.createTempDirectory("extra-index-delete-failure") + val sentinel = Files.createFile(root.resolve("sentinel")) + + val result = HistoryStorage.deleteRecursively(root, path => { + if (path == sentinel) throw new IOException("injected deletion failure") + Files.delete(path) + }) + + result shouldBe 'failure + Files.exists(sentinel) shouldBe true + Files.delete(sentinel) + Files.delete(root) + } + + property("extra serialization failure invalidates mutated cached objects") { + import org.ergoplatform.utils.generators.ErgoNodeTransactionGenerators.ergoBoxGenNoProp + + val indexedBox = new IndexedErgoBox(1, None, None, None, ergoBoxGenNoProp.sample.get, 0L) + db.insertExtraTry(Array.empty, Array(indexedBox)).get + val cachedBox = db.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox] + cachedBox.spendingHeightOpt = Some(2) + val unsupported = new ExtraIndex { + override def serializedId: Array[Byte] = Array.fill[Byte](32)(0x55.toByte) + } + + db.insertExtraTry(Array.empty, Array[ExtraIndex](cachedBox, unsupported)) shouldBe 'failure + db.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox].spendingHeightOpt shouldBe None + db.insertExtraTry(Array.empty, Array(cachedBox)).get + db.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox].spendingHeightOpt shouldBe Some(2) + } + + property("an in-flight cache miss cannot restore stale data after a successful write") { + import org.ergoplatform.utils.generators.ErgoNodeTransactionGenerators.ergoBoxGenNoProp + + implicit val executionContext: ExecutionContext = ExecutionContext.global + val root = Files.createTempDirectory("extra-index-cache-race") + val oldValueRead = new CountDownLatch(1) + val resumeOldRead = new CountDownLatch(1) + val writerStarted = new CountDownLatch(1) + val writeCommitted = new CountDownLatch(1) + @volatile var pauseNextRead = false + @volatile var observeWrite = false + + val options = new Options().createIfMissing(true) + val indexStore = LDBFactory.createKvDb(root.resolve("index").toString) + val objectsStore = LDBFactory.createKvDb(root.resolve("objects").toString) + val rawExtraDb = LDBFactory.factory.open(root.resolve("extra").toFile, options) + val extraStore = new LDBKVStore(rawExtraDb) { + override def get(key: Array[Byte]): Option[Array[Byte]] = { + val value = super.get(key) + if (pauseNextRead) { + pauseNextRead = false + oldValueRead.countDown() + require(resumeOldRead.await(5, TimeUnit.SECONDS), "timed out waiting to resume cache-miss read") + } + value + } + + override def update(toInsertKeys: Array[Array[Byte]], + toInsertValues: Array[Array[Byte]], + toRemove: Array[Array[Byte]]): Try[Unit] = { + val result = super.update(toInsertKeys, toInsertValues, toRemove) + if (observeWrite && result.isSuccess) writeCommitted.countDown() + result + } + } + val concurrentStorage = new HistoryStorage(indexStore, objectsStore, extraStore, settings.cacheSettings) + + try { + val indexedBox = new IndexedErgoBox(1, None, None, None, ergoBoxGenNoProp.sample.get, 0L) + concurrentStorage.insertExtraTry(Array.empty, Array(indexedBox)).get + pauseNextRead = true + val staleRead = Future(concurrentStorage.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox]) + oldValueRead.await(5, TimeUnit.SECONDS) shouldBe true + + indexedBox.spendingHeightOpt = Some(2) + observeWrite = true + val write = Future { + writerStarted.countDown() + concurrentStorage.insertExtraTry(Array.empty, Array(indexedBox)).get + } + writerStarted.await(5, TimeUnit.SECONDS) shouldBe true + val committedWhileReadWasPaused = writeCommitted.await(200, TimeUnit.MILLISECONDS) + resumeOldRead.countDown() + + Await.result(staleRead, 5.seconds).spendingHeightOpt shouldBe None + Await.result(write, 5.seconds) + committedWhileReadWasPaused shouldBe false + concurrentStorage.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox].spendingHeightOpt shouldBe Some(2) + } finally { + resumeOldRead.countDown() + concurrentStorage.close() + } + } + } From ed971faf90541839da02a5224bb6a7e8fd309f64 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:17:50 +0200 Subject: [PATCH 22/46] refactor: tighten extra indexer test seams --- .../nodeView/history/extra/ExtraIndexer.scala | 20 +++++++++++++------ .../FullBlockProcessor.scala | 14 ++++++++++--- .../VerifyNonADHistorySpecification.scala | 2 +- .../history/extra/ExtraIndexerTestActor.scala | 7 +------ 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala index dcf8140f62..3df6bf7ea0 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala @@ -17,7 +17,6 @@ import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.nodeView.history.storage.modifierprocessors.FullBlockProcessor import org.ergoplatform.settings.{Algos, CacheSettings, ChainSettings} import scorex.util.{ModifierId, ScorexLogging, bytesToId} -import scorex.db.ByteArrayWrapper import sigma.ast.ErgoTree import sigma.Extensions._ import sigma.interpreter.ProverResult @@ -75,9 +74,7 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { _history.headerIdsAtHeight(height) .find { id => - historyStorage.getIndex(FullBlockProcessor.chainStatusKey(id)) - .map(ByteArrayWrapper.apply) - .contains(ByteArrayWrapper(FullBlockProcessor.BestChainMarker)) && + FullBlockProcessor.isInBestFullChain(historyStorage, id) && _history.isSemanticallyValid(id) == ModifierSemanticValidity.Valid } .flatMap(id => _history.typedModifierById[Header](id)) @@ -145,6 +142,13 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { * Holds upcoming blocks to be indexed, and when empty, it is filled back from multiple threads */ private val blockCache: concurrent.Map[Int, BlockTransactions] = new ConcurrentHashMap[Int, BlockTransactions]().asScala + + private[extra] final def putBlockTransactionsInCache( + height: Int, + transactions: BlockTransactions + ): Unit = + blockCache.put(height, transactions) + private var readingUpTo: Int = 0 /** @@ -168,7 +172,9 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { blockNums.zip(blockNums.tail).map { range => // ranges of 250 blocks for each thread to read Future { (range._1 until range._2).foreach { blockNum => - fullChainHeaderAtHeight(blockNum).flatMap(blockTransactionsForHeader).map(blockCache.put(blockNum, _)) + fullChainHeaderAtHeight(blockNum) + .flatMap(blockTransactionsForHeader) + .map(putBlockTransactionsInCache(blockNum, _)) } } } @@ -176,7 +182,9 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { val blockNums = height + 1 to readingUpTo Future { blockNums.foreach { blockNum => - fullChainHeaderAtHeight(blockNum).flatMap(blockTransactionsForHeader).map(blockCache.put(blockNum, _)) + fullChainHeaderAtHeight(blockNum) + .flatMap(blockTransactionsForHeader) + .map(putBlockTransactionsInCache(blockNum, _)) } } } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala index 8c84852f09..f9e89f91ae 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala @@ -6,6 +6,7 @@ import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock} import org.ergoplatform.nodeView.history.ErgoHistoryUtils import org.ergoplatform.nodeView.history.ErgoHistoryUtils._ +import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.settings.Algos import scorex.db.ByteArrayWrapper import scorex.util.{ModifierId, bytesToId, idToBytes} @@ -24,9 +25,8 @@ trait FullBlockProcessor extends HeadersProcessor { private var nonBestChainsCache = FullBlockProcessor.emptyCache - def isInBestFullChain(id: ModifierId): Boolean = historyStorage.getIndex(chainStatusKey(id)) - .map(ByteArrayWrapper.apply) - .contains(ByteArrayWrapper(FullBlockProcessor.BestChainMarker)) + def isInBestFullChain(id: ModifierId): Boolean = + FullBlockProcessor.isInBestFullChain(historyStorage, id) /** * Id of header that contains transactions and proofs @@ -288,4 +288,12 @@ object FullBlockProcessor { def chainStatusKey(id: ModifierId): ByteArrayWrapper = ByteArrayWrapper(Algos.hash("main_chain".getBytes(CharsetName) ++ idToBytes(id))) + private[history] def isInBestFullChain( + historyStorage: HistoryStorage, + id: ModifierId + ): Boolean = + historyStorage.getIndex(chainStatusKey(id)) + .map(ByteArrayWrapper.apply) + .contains(ByteArrayWrapper(BestChainMarker)) + } diff --git a/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala index 9cff6acd5e..9caca833c1 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala @@ -49,7 +49,7 @@ class VerifyNonADHistorySpecification extends ErgoCorePropertyTest { property("full chain status updating") { def isInBestChain(b: ErgoFullBlock, h: ErgoHistory): Boolean = { - h.asInstanceOf[FullBlockProcessor].isInBestFullChain(b.id) + FullBlockProcessor.isInBestFullChain(h.historyStorage, b.id) } var history = genHistory() diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala index 980ba8555f..8dc8e84648 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala @@ -170,12 +170,7 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe } private def cacheBlockTransactions(height: Int, transactions: BlockTransactions): Unit = { - val cacheAccessor = getClass.getMethods - .find(_.getName.endsWith("$$blockCache")) - .getOrElse(throw new IllegalStateException("ExtraIndexer block cache accessor not found")) - val cache = cacheAccessor.invoke(this) - .asInstanceOf[scala.collection.concurrent.Map[Int, BlockTransactions]] - cache.put(height, transactions) + putBlockTransactionsInCache(height, transactions) test.lock.lock() test.created.signal() test.lock.unlock() From ac26b4483a6fc23cefeb54df3c6973a82157518b Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:46:09 +0200 Subject: [PATCH 23/46] ci: rerun integration tests From bcfe56b67e0c8988f0907ddb49f31d4e0efc361d Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:56:21 +0200 Subject: [PATCH 24/46] fix: stop only ExtraIndexer on rollback failure --- .../nodeView/history/extra/ExtraIndexer.scala | 10 ++++------ .../extra/ExtraIndexerSpecification.scala | 19 ++++++++++++------- .../history/extra/ExtraIndexerTestActor.scala | 5 +++-- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala index 3df6bf7ea0..bd7db60901 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala @@ -1,7 +1,7 @@ package org.ergoplatform.nodeView.history.extra import akka.actor.{Actor, ActorRef, ActorSystem, Props, Stash, Timers} -import org.ergoplatform.{ErgoAddress, ErgoAddressEncoder, ErgoApp, GlobalConstants, Pay2SAddress} +import org.ergoplatform.{ErgoAddress, ErgoAddressEncoder, GlobalConstants, Pay2SAddress} import org.ergoplatform.consensus.ModifierSemanticValidity import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.history.header.Header @@ -111,9 +111,7 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { protected def continueCatchUpAfterIndex(state: IndexerState): Boolean = true - protected def requestShutdown(): Unit = { - ErgoApp.shutdownSystem()(context.system) - } + protected def stopIndexer(): Unit = context.stop(self) protected def removeRollbackIndexes(ids: Array[ModifierId]): Try[Unit] = historyStorage.removeExtraTry(ids) @@ -700,8 +698,8 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { log.info(s"Successfully rolled back indexes to ${targetHeader.height}") unstashAll() case Failure(error) => - log.error(s"Failed to roll back extra indexes to ${targetHeader.height}; shutting down so startup can rebuild", error) - requestShutdown() + log.error(s"Failed to roll back extra indexes to ${targetHeader.height}; stopping extra indexer until node restart", error) + stopIndexer() } case RollbackToHeader(_, _) => diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala index a639537ba2..3d29dc8437 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala @@ -893,23 +893,28 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { indexer ! Reset() } - property("requests shutdown when final removal fails after partial rollback writes") { - indexer ! CreateDB(HEIGHT) - indexer ! Index() + property("requests only extra indexer stop when final removal fails after partial rollback writes") { + val failingIndexer = system.actorOf(Props.create(classOf[ExtraIndexerTestActor], this)) + val lifecycleProbe = TestProbe()(system) + lifecycleProbe.watch(failingIndexer) + + failingIndexer ! CreateDB(HEIGHT) + failingIndexer ! Index() awaitCondition(done) val spentInputId = bytesToId(fullChainTransactionsAt(HEIGHT).txs.flatMap(_.inputs).head.boxId) history.typedExtraIndexById[IndexedErgoBox](spentInputId).exists(_.isSpent) shouldBe true val probe = TestProbe()(system) - indexer ! FailNextRollbackRemoval(probe.ref) + failingIndexer ! FailNextRollbackRemoval(probe.ref) awaitCondition(created) - indexer ! ForceRollback(HEIGHT - 1) + failingIndexer ! ForceRollback(HEIGHT - 1) - probe.expectMsg("shutdown-requested") + probe.expectMsg("indexer-stop-requested") + lifecycleProbe.expectTerminated(failingIndexer) + system.whenTerminated.isCompleted shouldBe false ExtraIndexer.getIndex(ExtraIndexer.RollbackToKey, _history).getInt shouldBe HEIGHT - 1 _history.historyStorage.invalidateExtraCache(Seq(spentInputId)) history.typedExtraIndexById[IndexedErgoBox](spentInputId).exists(_.isSpent) shouldBe false - indexer ! Reset() } property("recovers a reloaded checkpoint without waiting for block or rollback events") { diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala index 8dc8e84648..d525ffbd6c 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala @@ -90,9 +90,10 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe Failure(new IllegalStateException("injected final rollback removal failure")) } else super.removeRollbackIndexes(ids) - override protected def requestShutdown(): Unit = { - rollbackFailureProbeOpt.foreach(_ ! "shutdown-requested") + override protected def stopIndexer(): Unit = { + rollbackFailureProbeOpt.foreach(_ ! "indexer-stop-requested") rollbackFailureProbeOpt = None + super.stopIndexer() } override protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { From 02cfd04522051df7036a36adfa1e2f0afdf4da26 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Sun, 16 Aug 2026 08:43:14 +0200 Subject: [PATCH 25/46] Remove redundant block transaction filter --- .../org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala index bd7db60901..b34cf7a7f5 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala @@ -81,7 +81,7 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { } protected def blockTransactionsForHeader(header: Header): Option[BlockTransactions] = { - history.typedModifierById[BlockTransactions](header.transactionsId).filter(_.headerId == header.id) + history.typedModifierById[BlockTransactions](header.transactionsId) } protected def retryDelay: FiniteDuration = 1.second From 572ca8e125b606f05f48ee72e86a80504cb69b35 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Sun, 16 Aug 2026 09:38:30 +0200 Subject: [PATCH 26/46] Retrigger CI after DeepRollBackSpec timeout From 994e582d6e3e979ae964a39612566f882021ddb1 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:48:49 +0200 Subject: [PATCH 27/46] refactor: restore v6.0.5 ExtraIndexer history wiring --- .../nodeView/history/ErgoHistory.scala | 126 ++---------------- 1 file changed, 10 insertions(+), 116 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala index 534b031ea5..c001dd8e64 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala @@ -4,23 +4,18 @@ import akka.actor.ActorContext import org.ergoplatform.consensus.ProgressInfo import java.io.File -import java.nio.{ByteBuffer, ByteOrder} -import java.nio.charset.StandardCharsets import org.ergoplatform.mining.AutolykosPowScheme import org.ergoplatform.modifiers.history._ import org.ergoplatform.modifiers.history.header.{Header, PreGenesisHeader} -import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock, ErgoNodeViewModifier, NonHeaderBlockSection} +import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock, NonHeaderBlockSection} import org.ergoplatform.nodeView.history.extra.ExtraIndexer.ReceivableMessages.StartExtraIndexer -import org.ergoplatform.nodeView.history.extra.ExtraIndexer.{GlobalBoxIndexKey, GlobalTxIndexKey, IndexedHeaderIdKey, - IndexedHeightKey, NewestVersion, NewestVersionBytes, RollbackToKey, SchemaVersionKey} -import org.ergoplatform.nodeView.history.extra.{IndexedErgoBox, IndexedErgoTransaction, NumericBoxIndex, NumericTxIndex} +import org.ergoplatform.nodeView.history.extra.ExtraIndexer.{IndexedHeightKey, NewestVersion, NewestVersionBytes, SchemaVersionKey, getIndex} import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.nodeView.history.storage.modifierprocessors._ -import org.ergoplatform.settings.{Algos, ErgoSettings} +import org.ergoplatform.settings.ErgoSettings import org.ergoplatform.utils.LoggingUtil import org.ergoplatform.validation.RecoverableModifierError -import scorex.db.ByteArrayWrapper -import scorex.util.{ModifierId, ScorexLogging, bytesToId, idToBytes} +import scorex.util.{ModifierId, ScorexLogging, idToBytes} import scala.util.{Failure, Success, Try} @@ -270,113 +265,12 @@ object ErgoHistory extends ScorexLogging { var db = HistoryStorage(ergoSettings) // ExtraIndexer db check - if(ergoSettings.nodeSettings.extraIndex) { // check db schema and checkpoint provenance - def storedBytes(key: Array[Byte]): Option[Array[Byte]] = db.modifierBytesById(bytesToId(key)) - def intValue(bytesOpt: Option[Array[Byte]]): Option[Int] = bytesOpt - .filter(_.length == Integer.BYTES) - .map(bytes => ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getInt) - def longValue(bytesOpt: Option[Array[Byte]]): Option[Long] = bytesOpt - .filter(_.length == java.lang.Long.BYTES) - .map(bytes => ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getLong) - - val schemaVersionBytesOpt = storedBytes(SchemaVersionKey) - val indexedHeightBytesOpt = storedBytes(IndexedHeightKey) - val globalTxIndexBytesOpt = storedBytes(GlobalTxIndexKey) - val globalBoxIndexBytesOpt = storedBytes(GlobalBoxIndexKey) - val rollbackToBytesOpt = storedBytes(RollbackToKey) - val schemaVersionOpt = intValue(schemaVersionBytesOpt) - val indexedHeightOpt = intValue(indexedHeightBytesOpt) - val globalTxIndexOpt = longValue(globalTxIndexBytesOpt) - val globalBoxIndexOpt = longValue(globalBoxIndexBytesOpt) - val rollbackToOpt = intValue(rollbackToBytesOpt) - val indexedHeight = indexedHeightOpt.getOrElse(0) - val globalTxIndex = globalTxIndexOpt.getOrElse(0L) - val globalBoxIndex = globalBoxIndexOpt.getOrElse(0L) - val rollbackTo = rollbackToOpt.getOrElse(0) - val indexedHeaderIdBytesOpt = db.modifierBytesById(bytesToId(IndexedHeaderIdKey)) - val indexedHeaderOpt = indexedHeaderIdBytesOpt - .filter(_.length == ErgoNodeViewModifier.ModifierIdSize) - .flatMap { idBytes => - val id = bytesToId(idBytes) - val validityKey = ByteArrayWrapper(Algos.hash("validity".getBytes(StandardCharsets.UTF_8) ++ idToBytes(id))) - val isValid = db.getIndex(validityKey).exists(_.sameElements(Array(1.toByte))) - if (isValid) db.modifierById(id).collect { - case header: Header if header.height == indexedHeight && header.id == id => header - } else None - } - val terminalRowsMatchCheckpoint = indexedHeaderOpt.exists { header => - if (globalTxIndex <= 0 || globalBoxIndex <= 0) { - false - } else { - db.modifierById(header.transactionsId).collect { - case transactions: BlockTransactions if transactions.headerId == header.id => - val lastTx = transactions.txs.last - val lastTxIndex = globalTxIndex - 1 - val firstTxBoxIndex = globalBoxIndex - lastTx.outputs.size - val expectedOutputNums = Array.tabulate(lastTx.outputs.size)(i => firstTxBoxIndex + i) - val expectedInputNumsOpt = if (header.height <= 1) { - Some(Array.fill[Long](lastTx.inputs.size)(0L)) - } else { - val inputNums = lastTx.inputs.map { input => - val inputId = bytesToId(input.boxId) - db.getExtraIndex(inputId).collect { - case box: IndexedErgoBox - if box.id == inputId && box.spendingTxIdOpt.contains(lastTx.id) && - box.spendingHeightOpt.contains(header.height) => box.globalIndex - } - } - if (inputNums.forall(_.isDefined)) Some(inputNums.flatten.toArray) else None - } - val numericTxMatches = db.getExtraIndex(bytesToId(NumericTxIndex.indexToBytes(lastTxIndex))).exists { - case NumericTxIndex(index, id) => index == lastTxIndex && id == lastTx.id - case _ => false - } - val indexedTxMatches = db.getExtraIndex(lastTx.id).exists { - case tx: IndexedErgoTransaction => - tx.txid == lastTx.id && tx.globalIndex == lastTxIndex && tx.height == header.height && - tx.index == transactions.txs.size - 1 && tx.size == lastTx.size && - expectedInputNumsOpt.exists(expected => tx.inputNums.sameElements(expected)) && - tx.outputNums.sameElements(expectedOutputNums) && tx.dataInputs.sameElements(lastTx.dataInputs) - case _ => false - } - val outputRowsMatch = expectedOutputNums.zip(lastTx.outputs).forall { case (boxIndex, output) => - val boxId = bytesToId(output.id) - val numericBoxMatches = db.getExtraIndex(bytesToId(NumericBoxIndex.indexToBytes(boxIndex))).exists { - case NumericBoxIndex(index, id) => index == boxIndex && id == boxId - case _ => false - } - val indexedBoxMatches = db.getExtraIndex(boxId).exists { - case box: IndexedErgoBox => - box.globalIndex == boxIndex && box.inclusionHeight == header.height && box.id == boxId && - box.spendingTxIdOpt.isEmpty && box.spendingHeightOpt.isEmpty && box.spendingProofOpt.isEmpty - case _ => false - } - numericBoxMatches && indexedBoxMatches - } - numericTxMatches && indexedTxMatches && outputRowsMatch - }.contains(true) - } - } - val numericValuesAreWellFormed = Seq( - indexedHeightBytesOpt.forall(_.length == Integer.BYTES), - globalTxIndexBytesOpt.forall(_.length == java.lang.Long.BYTES), - globalBoxIndexBytesOpt.forall(_.length == java.lang.Long.BYTES), - rollbackToBytesOpt.forall(_.length == Integer.BYTES) - ).forall(identity) - val valuesAreNonNegative = indexedHeight >= 0 && globalTxIndex >= 0 && globalBoxIndex >= 0 && rollbackTo >= 0 - val emptyCheckpoint = indexedHeight == 0 && globalTxIndex == 0 && globalBoxIndex == 0 && - rollbackTo == 0 && indexedHeaderIdBytesOpt.isEmpty - val nonEmptyCheckpoint = indexedHeight > 0 && Seq(indexedHeightOpt, globalTxIndexOpt, globalBoxIndexOpt, rollbackToOpt) - .forall(_.isDefined) && rollbackTo == 0 && terminalRowsMatchCheckpoint - val checkpointIsValid = schemaVersionOpt.contains(NewestVersion) && numericValuesAreWellFormed && - valuesAreNonNegative && (emptyCheckpoint || nonEmptyCheckpoint) - if (!checkpointIsValid) { - val freshDb = db.deleteExtraDBTry(ergoSettings).get - freshDb.insertExtraTry(Array((SchemaVersionKey, NewestVersionBytes)), Array.empty).recoverWith { case error => - Try(freshDb.close()).failed.foreach(error.addSuppressed) - Failure(error) - }.get - db = freshDb + if(ergoSettings.nodeSettings.extraIndex) { // check db schema + val schemaVersion: Int = getIndex(SchemaVersionKey, db).getInt + if (schemaVersion != NewestVersion) { + if(getIndex(IndexedHeightKey, db).getInt > 0) + db = db.deleteExtraDB(ergoSettings) // older schema -> delete and reopen db + db.insertExtra(Array((SchemaVersionKey, NewestVersionBytes)), Array.empty) // update version key } } From 93c9302f5ff1322fe792c852ac05468d0ccc7d47 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:51:10 +0200 Subject: [PATCH 28/46] fix: keep deferred ExtraIndexer catch-up retry --- .../nodeView/history/extra/ExtraIndexer.scala | 351 +++++------------- 1 file changed, 101 insertions(+), 250 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala index b34cf7a7f5..68a47c44fa 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexer.scala @@ -1,8 +1,7 @@ package org.ergoplatform.nodeView.history.extra -import akka.actor.{Actor, ActorRef, ActorSystem, Props, Stash, Timers} +import akka.actor.{Actor, ActorRef, ActorSystem, Props, Stash} import org.ergoplatform.{ErgoAddress, ErgoAddressEncoder, GlobalConstants, Pay2SAddress} -import org.ergoplatform.consensus.ModifierSemanticValidity import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.modifiers.mempool.ErgoTransaction @@ -14,7 +13,6 @@ import org.ergoplatform.nodeView.history.extra.IndexedContractTemplateSerializer import org.ergoplatform.nodeView.history.extra.IndexedErgoAddressSerializer.hashErgoTree import org.ergoplatform.nodeView.history.extra.IndexedTokenSerializer.uniqueId import org.ergoplatform.nodeView.history.storage.HistoryStorage -import org.ergoplatform.nodeView.history.storage.modifierprocessors.FullBlockProcessor import org.ergoplatform.settings.{Algos, CacheSettings, ChainSettings} import scorex.util.{ModifierId, ScorexLogging, bytesToId} import sigma.ast.ErgoTree @@ -29,26 +27,19 @@ import java.util.concurrent.ConcurrentHashMap import scala.collection.mutable import scala.collection.concurrent import scala.concurrent.{ExecutionContextExecutor, Future} -import scala.concurrent.duration.{DurationInt, FiniteDuration} import scala.jdk.CollectionConverters._ -import scala.util.{Failure, Success, Try} /** * Base trait for extra indexer actor and its test. */ -trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { - - private case class RetryIndex(generation: Long) - private case object RetryIndexTimerKey - private case class RollbackToHeader(header: Header, resume: Boolean) - private var retryGeneration: Long = 0L +trait ExtraIndexerBase extends Actor with Stash with ScorexLogging { private implicit val ec: ExecutionContextExecutor = context.dispatcher /** * Max buffer size (determined by config) */ - protected def saveLimit: Int + protected val saveLimit: Int /** * Number of transaction/box numeric indexes object segments contain @@ -71,56 +62,16 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { protected def historyStorage: HistoryStorage = _history.historyStorage - protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { - _history.headerIdsAtHeight(height) - .find { id => - FullBlockProcessor.isInBestFullChain(historyStorage, id) && - _history.isSemanticallyValid(id) == ModifierSemanticValidity.Valid - } - .flatMap(id => _history.typedModifierById[Header](id)) - } - - protected def blockTransactionsForHeader(header: Header): Option[BlockTransactions] = { - history.typedModifierById[BlockTransactions](header.transactionsId) - } - - protected def retryDelay: FiniteDuration = 1.second - - private def scheduleRetry(): Unit = { - if (!timers.isTimerActive(RetryIndexTimerKey)) { - retryGeneration += 1 - timers.startSingleTimer(RetryIndexTimerKey, RetryIndex(retryGeneration), retryDelay) - } - } - - private def cancelRetry(): Unit = { - retryGeneration += 1 - timers.cancel(RetryIndexTimerKey) - } - - protected def resetTransientState(): Unit = { - cancelRetry() - blockCache.clear() - readingUpTo = 0 - } - /** * Used in tests to indicate the indexer has caught up to the chain */ protected def caughtUpHook(height: Int = 0): Unit = {} - protected def continueCatchUpAfterIndex(state: IndexerState): Boolean = true - - protected def stopIndexer(): Unit = context.stop(self) - - protected def removeRollbackIndexes(ids: Array[ModifierId]): Try[Unit] = - historyStorage.removeExtraTry(ids) - /** * Used in tests to get block for rollback, maybe orphan */ protected def getLastTxForHeight(height: Int): ErgoTransaction = { - fullChainHeaderAtHeight(height).flatMap(blockTransactionsForHeader).get.txs.last + history.bestBlockTransactionsAt(height).get.txs.last } // fast access buffers @@ -140,13 +91,6 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { * Holds upcoming blocks to be indexed, and when empty, it is filled back from multiple threads */ private val blockCache: concurrent.Map[Int, BlockTransactions] = new ConcurrentHashMap[Int, BlockTransactions]().asScala - - private[extra] final def putBlockTransactionsInCache( - height: Int, - transactions: BlockTransactions - ): Unit = - blockCache.put(height, transactions) - private var readingUpTo: Int = 0 /** @@ -156,11 +100,8 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { * @param height - blockheight to get transations from * @return transactions at height */ - private def getBlockTransactionsAt(height: Int, header: Header): Option[BlockTransactions] = { - val cached = blockCache.remove(height) - val txsOpt = cached.filter(_.headerId == header.id).orElse(blockTransactionsForHeader(header)) - - txsOpt.map { txs => + private def getBlockTransactionsAt(height: Int): Option[BlockTransactions] = { + blockCache.remove(height).orElse(history.bestBlockTransactionsAt(height)).map { txs => if (height % 1000 == 0) blockCache.keySet.filter(_ < height).map(blockCache.remove) if (readingUpTo - height < 300 && chainHeight - height > 1000) { readingUpTo = math.min(height + 1001, chainHeight) @@ -170,9 +111,7 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { blockNums.zip(blockNums.tail).map { range => // ranges of 250 blocks for each thread to read Future { (range._1 until range._2).foreach { blockNum => - fullChainHeaderAtHeight(blockNum) - .flatMap(blockTransactionsForHeader) - .map(putBlockTransactionsInCache(blockNum, _)) + history.bestBlockTransactionsAt(blockNum).map(blockCache.put(blockNum, _)) } } } @@ -180,9 +119,7 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { val blockNums = height + 1 to readingUpTo Future { blockNums.foreach { blockNum => - fullChainHeaderAtHeight(blockNum) - .flatMap(blockTransactionsForHeader) - .map(putBlockTransactionsInCache(blockNum, _)) + history.bestBlockTransactionsAt(blockNum).map(blockCache.put(blockNum, _)) } } } @@ -303,41 +240,41 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { /** * Write buffered indexes to database and clear buffers. */ - private def saveProgress(state: IndexerState): Try[Unit] = Try { + private def saveProgress(state: IndexerState): Unit = { + val start: Long = System.currentTimeMillis + // perform segmentation on big addresses and save their internal segment buffer trees.values.foreach { tree => tree.buffer.values.foreach(seg => segments.put(seg.id, seg)) tree.splitToSegments.foreach(seg => segments.put(seg.id, seg)) } + templates.values.foreach { template => template.buffer.values.foreach(seg => segments.put(seg.id, seg)) template.splitToSegments.foreach(seg => segments.put(seg.id, seg)) } + + // perform segmentation on big tokens and save their internal segment buffer tokens.values.foreach { token => token.buffer.values.foreach(seg => segments.put(seg.id, seg)) token.splitToSegments.foreach(seg => segments.put(seg.id, seg)) } - val indexedHeaderEntry = state.indexedHeaderId.map { id => - IndexedHeaderIdKey -> fastIdToBytes(id) - }.toArray - val objects = (general.iterator ++ boxes.valuesIterator ++ trees.valuesIterator ++ - templates.valuesIterator ++ tokens.valuesIterator ++ segments.valuesIterator).toArray - historyStorage.insertExtraTry( + // insert modifiers and progress info to db + historyStorage.insertExtra( Array( (IndexedHeightKey, ByteBuffer.allocate(4).putInt(state.indexedHeight).array), (GlobalTxIndexKey, ByteBuffer.allocate(8).putLong(state.globalTxIndex).array), (GlobalBoxIndexKey, ByteBuffer.allocate(8).putLong(state.globalBoxIndex).array), (RollbackToKey, ByteBuffer.allocate(4).putInt(state.rollbackTo).array) - ) ++ indexedHeaderEntry, - objects - ).recoverWith { case error => - historyStorage.invalidateExtraCache(objects.iterator.map(_.id).toSeq) - Failure(error) - }.get + ), + (((((general ++= boxes.values) ++= trees.values) ++= templates.values) ++= tokens.values) ++= segments.values).toArray + ) log.debug(s"Processed ${trees.size} ErgoTrees with ${boxes.size} boxes and inserted them to database in ${System.currentTimeMillis - start}ms") + + // clear buffers for next batch general.clear() boxes.clear() trees.clear() @@ -350,18 +287,17 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { * Process a batch of BlockTransactions into memory and occasionally write them to database. * * @param state - current indexer state - * @param header - exact full-chain header to index - * @param targetHeight - full-chain height captured for this catch-up pass + * @param headerOpt - header to index block transactions of (used after caught up with chain) */ - protected def index(state: IndexerState, - header: Header, - targetHeight: Int): Option[IndexerState] = { - val height = header.height - val btOpt = getBlockTransactionsAt(height, header) + protected def index(state: IndexerState, headerOpt: Option[Header] = None): IndexerState = { + val btOpt = headerOpt.flatMap { header => + history.typedModifierById[BlockTransactions](header.transactionsId) + }.orElse(getBlockTransactionsAt(state.indexedHeight)) + val height = headerOpt.map(_.height).getOrElse(state.indexedHeight) if (btOpt.isEmpty) { log.error(s"Could not read block $height / $chainHeight from database, waiting for new block until retrying") - return None + return state.decrementIndexedHeight.copy(caughtUp = true) } val txs: Seq[ErgoTransaction] = btOpt.get.txs @@ -439,10 +375,11 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { log.info(s"Buffered block $height / $chainHeight [txs: ${txs.length}, boxes: $boxCount] (buffer: $modCount / $saveLimit)") - Some(newState.copy( - caughtUp = newState.indexedHeight == targetHeight, - indexedHeaderId = Some(header.id) - )) + val maxHeight = headerOpt.map(_.height).getOrElse(chainHeight) + newState.copy( + caughtUp = newState.indexedHeight == maxHeight, + indexedHeaderId = headerOpt.map(_.id).orElse(history.bestHeaderIdAtHeight(height)) + ) } /** @@ -451,15 +388,15 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { * @param state - current state of indexer * @param height - forking height (height of last common block) */ - private def removeAfter(state: IndexerState, targetHeader: Header): Try[IndexerState] = Try { + private def removeAfter(state: IndexerState, height: Int): IndexerState = { var newState: IndexerState = state - val height = targetHeader.height - saveProgress(newState).get + saveProgress(newState) log.info(s"Rolling back indexes from ${state.indexedHeight} to $height") - val lastTxToKeep: ErgoTransaction = blockTransactionsForHeader(targetHeader).get.txs.last + try { + val lastTxToKeep: ErgoTransaction = getLastTxForHeight(height) val txTarget: Long = history.typedExtraIndexById[IndexedErgoTransaction](lastTxToKeep.id).get.globalIndex val boxTarget: Long = history.typedExtraIndexById[IndexedErgoBox](bytesToId(lastTxToKeep.outputs.last.id)).get.globalIndex val toRemove: ArrayBuffer[ModifierId] = ArrayBuffer.empty[ModifierId] @@ -480,12 +417,12 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { val template = history.typedExtraIndexById[IndexedContractTemplate](hashTreeTemplate(iEb.box.ergoTree)).get template.findAndModBox(iEb.globalIndex, history) - historyStorage.insertExtraTry(Array.empty, Array[ExtraIndex](iEb, address, template) ++ address.buffer.values ++ template.buffer.values).get + historyStorage.insertExtra(Array.empty, Array[ExtraIndex](iEb, address, template) ++ address.buffer.values ++ template.buffer.values) cfor(0)(_ < iEb.box.additionalTokens.length, _ + 1) { i => history.typedExtraIndexById[IndexedToken](IndexedToken.fromBox(iEb, i).id).map { token => token.findAndModBox(iEb.globalIndex, history) - historyStorage.insertExtraTry(Array.empty, Array[ExtraIndex](token) ++ token.buffer.values).get + historyStorage.insertExtra(Array.empty, Array[ExtraIndex](token) ++ token.buffer.values) } } } @@ -523,137 +460,61 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { newState = newState.incrementBoxIndex // Save changes - val completedState = newState.copy( + newState = newState.copy( indexedHeight = height, rollbackTo = 0, - caughtUp = height == chainHeight && fullChainHeaderAtHeight(height).exists(_.id == targetHeader.id), - indexedHeaderId = Some(targetHeader.id) + caughtUp = state.caughtUp, + indexedHeaderId = history.bestHeaderIdAtHeight(height) ) - removeRollbackIndexes(toRemove.toArray).get - saveProgress(completedState).get - completedState - } - - private def indexedTipIsOnBestFullChain(state: IndexerState): Boolean = { - state.indexedHeight == 0 || - (chainHeight >= state.indexedHeight && - state.indexedHeaderId == fullChainHeaderAtHeight(state.indexedHeight).map(_.id)) - } - - private def reconcileIndexedTip(state: IndexerState): Boolean = { - val rollbackHeaderOpt = for { - indexedHeaderId <- state.indexedHeaderId - indexedHeader <- history.typedModifierById[Header](indexedHeaderId) - bestFullBlock <- history.bestFullBlockOpt - branchPointId <- history.chainToHeader(Some(indexedHeader), bestFullBlock.header)._1 - branchHeader <- history.typedModifierById[Header](branchPointId) - if branchHeader.height < state.indexedHeight - } yield branchHeader - - rollbackHeaderOpt.exists { branchHeader => - beginRollback(state, branchHeader) - true - } - } - - private def validatedRollbackHeader(state: IndexerState, branchPoint: ModifierId): Option[Header] = { - history.typedModifierById[Header](branchPoint).filter { header => - header.height < state.indexedHeight && - fullChainHeaderAtHeight(header.height).exists(_.id == header.id) + historyStorage.removeExtra(toRemove.toArray) + saveProgress(newState) + } catch { + case t: Throwable => log.error(s"removeAfter during rollback failed due to: ${t.getMessage}", t) } - } - - protected def beginRollback(state: IndexerState, targetHeader: Header, resume: Boolean = true): Unit = { - resetTransientState() - context.become(receive.orElse(loaded(state.copy(caughtUp = false, rollbackTo = targetHeader.height)))) - self ! RollbackToHeader(targetHeader, resume) - } - private def persistBuffered(state: IndexerState): Boolean = { - saveProgress(state) match { - case Success(_) => true - case Failure(error) => - log.error(s"Failed to persist extra indexes at height ${state.indexedHeight}; retrying", error) - scheduleRetry() - false - } + newState } protected def loaded(state: IndexerState): Receive = { case Index() if !state.caughtUp && !state.rollbackInProgress => - cancelRetry() - if (modCount < saveLimit || persistBuffered(state)) { - if (state.indexedHeight == chainHeight && indexedTipIsOnBestFullChain(state)) { - val newState = state.copy(caughtUp = true) - context.become(receive.orElse(loaded(newState))) - self ! Index() - } else { - val nextHeaderOpt = fullChainHeaderAtHeight(state.indexedHeight + 1) - val extendsIndexedTip = nextHeaderOpt.forall { header => - state.indexedHeight == 0 || state.indexedHeaderId.contains(header.parentId) - } - if (extendsIndexedTip && nextHeaderOpt.isDefined) { - index(state.incrementIndexedHeight, nextHeaderOpt.get, chainHeight) match { - case Some(newState) => - context.become(receive.orElse(loaded(newState))) - if (continueCatchUpAfterIndex(newState)) self ! Index() - case None => - scheduleRetry() - } - } else if (!reconcileIndexedTip(state)) { - log.info("Deferring catch-up because the next full-chain header does not extend the indexed tip") - scheduleRetry() - } - } + val nextHeaderOpt = history.bestHeaderAtHeight(state.indexedHeight + 1) + val extendsIndexedTip = nextHeaderOpt.forall { header => + state.indexedHeight == 0 || state.indexedHeaderId.contains(header.parentId) } - - case Index() if state.caughtUp && !state.rollbackInProgress && !indexedTipIsOnBestFullChain(state) => - if (!reconcileIndexedTip(state)) { - val newState = state.copy(caughtUp = false) + if (extendsIndexedTip) { + val newState = index(state.incrementIndexedHeight) + if (modCount >= saveLimit) saveProgress(newState) context.become(receive.orElse(loaded(newState))) - scheduleRetry() - } - - case Index() if state.caughtUp && !state.rollbackInProgress => - cancelRetry() - if (modCount == 0 || persistBuffered(state)) { - blockCache.clear() - caughtUpHook() - log.info("Indexer caught up with chain") + self ! Index() + } else { + log.info("Deferring catch-up because the next header does not extend the indexed tip") } - case Index() if state.rollbackInProgress => + case Index() if state.caughtUp => + if (modCount > 0) saveProgress(state) + blockCache.clear() + caughtUpHook() + log.info("Indexer caught up with chain") // after the indexer caught up with the chain, stay up to date case FullBlockApplied(header: Header) if state.caughtUp && !state.rollbackInProgress => - val indexedTipStillBest = indexedTipIsOnBestFullChain(state) + val indexedTipStillBest = state.indexedHeight == 0 || + (chainHeight >= state.indexedHeight && state.indexedHeaderId.exists { indexedHeaderId => + history.bestHeaderIdAtHeight(state.indexedHeight).contains(indexedHeaderId) + }) val isDirectSuccessor = header.height == state.indexedHeight + 1 && (state.indexedHeight == 0 || state.indexedHeaderId.contains(header.parentId)) && - fullChainHeaderAtHeight(header.height).exists(_.id == header.id) + history.bestHeaderIdAtHeight(header.height).contains(header.id) if (isDirectSuccessor) { - cancelRetry() - val targetHeight = chainHeight - index(state.incrementIndexedHeight, header, targetHeight) match { - case Some(newState) => - context.become(receive.orElse(loaded(newState))) - if (newState.caughtUp) { - if (persistBuffered(newState)) caughtUpHook(header.height) - } else { - self ! Index() - } - case None => - val newState = state.copy(caughtUp = false) - context.become(receive.orElse(loaded(newState))) - scheduleRetry() - } + val newState: IndexerState = index(state.incrementIndexedHeight, Some(header)) + saveProgress(newState) + context.become(receive.orElse(loaded(newState))) + caughtUpHook(header.height) } else if (!indexedTipStillBest) { - log.info(s"Reconciling indexed tip before applying block ${header.id} at height ${header.height}") - if (!reconcileIndexedTip(state)) { - context.become(receive.orElse(loaded(state.copy(caughtUp = false)))) - scheduleRetry() - } + log.info(s"Deferring block ${header.id} at height ${header.height} until rollback") + context.become(receive.orElse(loaded(state.copy(caughtUp = false)))) } else if (header.height > state.indexedHeight + 1) { context.become(receive.orElse(loaded(state.copy(caughtUp = false)))) self ! Index() @@ -661,56 +522,47 @@ trait ExtraIndexerBase extends Actor with Stash with Timers with ScorexLogging { log.warn(s"Skipping block ${header.id} applied at height ${header.height}, indexed height is ${state.indexedHeight}") } - case _: FullBlockApplied if !state.rollbackInProgress => - scheduleRetry() - case _: FullBlockApplied if state.rollbackInProgress => stash() + // Resume catch-up when a block is applied while the indexer is behind. + // Without this the indexer can stall after logging "Deferring catch-up", + // because no Index() message is scheduled and further FullBlockApplied events + // are dropped while caughtUp = false. + case _: FullBlockApplied if !state.caughtUp && !state.rollbackInProgress => + self ! Index() + case Rollback(branchPoint: ModifierId) => - cancelRetry() if (state.rollbackInProgress) { log.warn(s"Rollback already in progress") stash() - } else if (indexedTipIsOnBestFullChain(state)) { - log.info(s"Ignoring rollback to $branchPoint because the indexed tip is already on the best full chain") - if (!state.caughtUp) self ! Index() } else { - validatedRollbackHeader(state, branchPoint) match { - case Some(header) => beginRollback(state, header) - case None if !reconcileIndexedTip(state) => - log.info(s"Deferring rollback to $branchPoint until the indexed tip can be reconciled with the best full chain") - val newState = state.copy(caughtUp = false, rollbackTo = 0) - context.become(receive.orElse(loaded(newState))) - scheduleRetry() + history.heightOf(branchPoint) match { + case Some(branchHeight) => + if (branchHeight < state.indexedHeight) { + context.become(receive.orElse(loaded(state.copy(rollbackTo = branchHeight)))) + self ! RemoveAfter(branchHeight) + } else if (!state.caughtUp) { + blockCache.clear() + readingUpTo = 0 + self ! Index() + } case None => + log.error(s"No rollback height found for $branchPoint") + val newState = state.copy(rollbackTo = 0) + context.become(receive.orElse(loaded(newState))) + unstashAll() } } - case RollbackToHeader(targetHeader, resume) - if state.rollbackInProgress && state.rollbackTo == targetHeader.height => + case RemoveAfter(branchHeight: Int) if state.rollbackInProgress => blockCache.clear() readingUpTo = 0 - removeAfter(state, targetHeader) match { - case Success(newState) => - context.become(receive.orElse(loaded(newState))) - if (resume && !newState.caughtUp) self ! Index() - caughtUpHook() - log.info(s"Successfully rolled back indexes to ${targetHeader.height}") - unstashAll() - case Failure(error) => - log.error(s"Failed to roll back extra indexes to ${targetHeader.height}; stopping extra indexer until node restart", error) - stopIndexer() - } - - case RollbackToHeader(_, _) => - - case RetryIndex(generation) if generation == retryGeneration && !state.rollbackInProgress => - self ! Index() - - case RetryIndex(_) => - - case RemoveAfter(branchHeight) => - log.warn(s"Ignoring unsupported direct extra-index rollback request to height $branchHeight") + val newState = removeAfter(state, branchHeight) + context.become(receive.orElse(loaded(newState))) + if (!newState.caughtUp && !newState.rollbackInProgress) self ! Index() + caughtUpHook() + log.info(s"Successfully rolled back indexes to $branchHeight") + unstashAll() case GetSegmentThreshold => sender ! segmentThreshold @@ -833,14 +685,13 @@ object ExtraIndexer { /** * Current newest database schema version. Used to force extra database resync. */ - val NewestVersion: Int = 7 + val NewestVersion: Int = 6 val NewestVersionBytes: Array[Byte] = ByteBuffer.allocate(4).putInt(NewestVersion).array val IndexedHeightKey: Array[Byte] = Algos.hash("indexed height") val GlobalTxIndexKey: Array[Byte] = Algos.hash("txns height") val GlobalBoxIndexKey: Array[Byte] = Algos.hash("boxes height") val RollbackToKey: Array[Byte] = Algos.hash("rollback to") - val IndexedHeaderIdKey: Array[Byte] = Algos.hash("indexed header id") val SchemaVersionKey: Array[Byte] = Algos.hash("schema version") def getIndex(key: Array[Byte], history: HistoryStorage): ByteBuffer = From e3178cba4f0b0b86259cb2e20aefafd4957308d3 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:52:17 +0200 Subject: [PATCH 29/46] refactor: restore indexed contract template storage --- .../nodeView/history/extra/IndexedContractTemplate.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala index 24fb8edc4d..7121ee8b9c 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedContractTemplate.scala @@ -27,7 +27,7 @@ case class IndexedContractTemplate(templateHash: ModifierId, if (boxCount == 0) toRemove += templateHash else - history.historyStorage.insertExtraTry(Array.empty, Array(this)).get + history.historyStorage.insertExtra(Array.empty, Array(this)) toRemove.toArray } From 0f9862fe1fb88fd25c0ba0c776a0d632c6bb1d57 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:52:22 +0200 Subject: [PATCH 30/46] refactor: restore indexed address storage --- .../nodeView/history/extra/IndexedErgoAddress.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala index d506dca795..c582cbbe80 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedErgoAddress.scala @@ -88,7 +88,7 @@ case class IndexedErgoAddress(treeHash: ModifierId, if (txCount == 0 && boxCount == 0) toRemove += treeHash // all segments empty after rollback, delete parent else - history.historyStorage.insertExtraTry(Array.empty, Array(this)).get // save the changes made to this address + history.historyStorage.insertExtra(Array.empty, Array(this)) // save the changes made to this address toRemove.toArray } From 5d434e4097e47b8d5879ca7c86c870fe5f416cad Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:52:30 +0200 Subject: [PATCH 31/46] refactor: restore indexed token storage --- .../org/ergoplatform/nodeView/history/extra/IndexedToken.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala index fe9f5c647c..0015f433ab 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexedToken.scala @@ -53,7 +53,7 @@ case class IndexedToken(tokenId: ModifierId, toRemove += id // all segments empty after rollback, delete parent log.info(s"Removing token $tokenId because no more boxes are associated with it") } else - history.historyStorage.insertExtraTry(Array.empty, Array(this)).get // save the changes made to this address + history.historyStorage.insertExtra(Array.empty, Array(this)) // save the changes made to this address toRemove.toArray } From 12b247e7d67dabea3d5bb5b88cf3ee7c45f47898 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:54:16 +0200 Subject: [PATCH 32/46] refactor: restore v6.0.5 indexer state --- .../nodeView/history/extra/IndexerState.scala | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala index f2db45be38..1ebcd653f7 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/extra/IndexerState.scala @@ -2,9 +2,7 @@ package org.ergoplatform.nodeView.history.extra import org.ergoplatform.nodeView.history.ErgoHistory import org.ergoplatform.nodeView.history.extra.ExtraIndexer._ -import org.ergoplatform.modifiers.ErgoNodeViewModifier -import org.ergoplatform.modifiers.history.header.Header -import scorex.util.{ModifierId, bytesToId} +import scorex.util.ModifierId /** * An immutable state for extra indexer @@ -42,19 +40,13 @@ object IndexerState { val globalTxIndex = getIndex(GlobalTxIndexKey, history).getLong val globalBoxIndex = getIndex(GlobalBoxIndexKey, history).getLong val rollbackTo = getIndex(RollbackToKey, history).getInt - val indexedHeaderId = history.historyStorage - .modifierBytesById(bytesToId(IndexedHeaderIdKey)) - .filter(_.length == ErgoNodeViewModifier.ModifierIdSize) - .map(bytesToId) - .filter(id => history.typedModifierById[Header](id).exists(_.height == indexedHeight)) IndexerState( indexedHeight, globalTxIndex, globalBoxIndex, rollbackTo, - caughtUp = indexedHeight == history.fullBlockHeight && - (indexedHeight == 0 || indexedHeaderId.isDefined), - indexedHeaderId = indexedHeaderId + caughtUp = indexedHeight == history.fullBlockHeight, + indexedHeaderId = history.bestHeaderIdAtHeight(indexedHeight) ) } From eb4118ab62553988aa315fc42257154f70ff1b3c Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:54:22 +0200 Subject: [PATCH 33/46] refactor: restore v6.0.5 history storage --- .../history/storage/HistoryStorage.scala | 96 ++++--------------- 1 file changed, 20 insertions(+), 76 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala index 795bad23b3..edcf2432d2 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala @@ -14,8 +14,7 @@ import scala.util.{Failure, Success, Try} import spire.syntax.all.cfor import java.io.File -import java.nio.file.{Files, Path} -import java.util.concurrent.locks.ReentrantReadWriteLock +import java.nio.file.Files import scala.jdk.CollectionConverters.asScalaIteratorConverter /** @@ -52,20 +51,6 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor .maximumSize(config.history.indexesCacheSize) .build[ByteArrayWrapper, Array[Byte]] - private val extraCacheLock = new ReentrantReadWriteLock() - - private def withExtraCacheReadLock[A](body: => A): A = { - extraCacheLock.readLock().lock() - try body - finally extraCacheLock.readLock().unlock() - } - - private def withExtraCacheWriteLock[A](body: => A): A = { - extraCacheLock.writeLock().lock() - try body - finally extraCacheLock.writeLock().unlock() - } - private def cacheModifier(mod: BlockSection): Unit = mod.modifierTypeId match { case Header.modifierTypeId => headersCache.put(mod.id, mod) case _ => blockSectionsCache.put(mod.id, mod) @@ -104,7 +89,7 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor } } - def getExtraIndex(id: ModifierId): Option[ExtraIndex] = withExtraCacheReadLock { + def getExtraIndex(id: ModifierId): Option[ExtraIndex] = { Option(extraCache.getIfPresent(id)) orElse extraStore.get(idToBytes(id)).flatMap { bytes => ExtraIndexSerializer.parseBytesTry(bytes) match { case Success(pm) => @@ -165,46 +150,16 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor def insertExtra(indexesToInsert: Array[(Array[Byte], Array[Byte])], objectsToInsert: Array[ExtraIndex]): Unit = { - insertExtraTry(indexesToInsert, objectsToInsert).failed.foreach { error => - log.error("Failed to insert extra indexes", error) - } - } - - private[history] def invalidateExtraCache(ids: Iterable[ModifierId]): Unit = withExtraCacheWriteLock { - ids.foreach(extraCache.invalidate) - } - - def insertExtraTry(indexesToInsert: Array[(Array[Byte], Array[Byte])], - objectsToInsert: Array[ExtraIndex]): Try[Unit] = { - val objectIds = objectsToInsert.iterator.flatMap(obj => Try(obj.id).toOption).toArray - Try { - val keys = objectsToInsert.map(_.serializedId) ++ indexesToInsert.map(_._1) - val values = objectsToInsert.map(ExtraIndexSerializer.toBytes) ++ indexesToInsert.map(_._2) - keys -> values - }.flatMap { case (keys, values) => - withExtraCacheWriteLock { - extraStore.insert(keys, values).map { _ => - objectIds.foreach(extraCache.invalidate) - } - } - }.recoverWith { case error => - invalidateExtraCache(objectIds) - Failure(error) - } + extraStore.insert( + objectsToInsert.map(mod => mod.serializedId), + objectsToInsert.map(mod => ExtraIndexSerializer.toBytes(mod)) + ) + cfor(0)(_ < indexesToInsert.length, _ + 1) { i => extraStore.insert(indexesToInsert(i)._1, indexesToInsert(i)._2)} } def removeExtra(indexesToRemove: Array[ModifierId]) : Unit = { - removeExtraTry(indexesToRemove).failed.foreach { error => - log.error("Failed to remove extra indexes", error) - } - } - - def removeExtraTry(indexesToRemove: Array[ModifierId]): Try[Unit] = { - withExtraCacheWriteLock { - extraStore.remove(indexesToRemove.map(idToBytes)).map { _ => - cfor(0)(_ < indexesToRemove.length, _ + 1) { i => removeModifier(indexesToRemove(i)) } - } - } + extraStore.remove(indexesToRemove.map(idToBytes)) + cfor(0)(_ < indexesToRemove.length, _ + 1) { i => removeModifier(indexesToRemove(i)) } } /** @@ -250,37 +205,26 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor * Delete the extra index database and reopen it. * * @param ergoSettings - settings to use - * @return new HistoryStorage instance with an empty extra database + * @return new HistoryStorage instance with empty extra database, or this instance in case of failure */ - def deleteExtraDB(ergoSettings: ErgoSettings): HistoryStorage = - deleteExtraDBTry(ergoSettings).get - - /** - * Delete the extra index database and reopen it, preserving deletion failures. - */ - def deleteExtraDBTry(ergoSettings: ErgoSettings): Try[HistoryStorage] = { + def deleteExtraDB(ergoSettings: ErgoSettings): HistoryStorage = { log.warn(s"Removing extra index database due to old schema.") + close() + // org.ergoplatform.wallet.utils.FileUtils val root = new File(s"${ergoSettings.directory}/history/extra") - Try(close()).flatMap { _ => - HistoryStorage.deleteRecursively(root.toPath, Files.delete) - }.map { _ => - log.info(s"Deleted ${root.toString}") - HistoryStorage.apply(ergoSettings) + if (root.exists()) { + Files.walk(root.toPath).iterator().asScala.toSeq.reverse.foreach(path => Try(Files.delete(path))) + }else { + log.error(s"Could not delete ${root.toString}") + return this } + log.info(s"Deleted ${root.toString}") + HistoryStorage.apply(ergoSettings) } } object HistoryStorage { - private[storage] def deleteRecursively(root: Path, deletePath: Path => Unit): Try[Unit] = Try { - if (Files.exists(root)) { - val paths = Files.walk(root) - try paths.iterator().asScala.toSeq.reverse.foreach(deletePath) - finally paths.close() - } - require(!Files.exists(root), s"Could not delete $root") - } - def apply(ergoSettings: ErgoSettings): HistoryStorage = { val indexStore = LDBFactory.createKvDb(s"${ergoSettings.directory}/history/index") val objectsStore = LDBFactory.createKvDb(s"${ergoSettings.directory}/history/objects") From 077985c173612fae3f7ca03bc3dfb0ad717cdaf2 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:54:28 +0200 Subject: [PATCH 34/46] refactor: restore v6.0.5 full block processor --- .../modifierprocessors/FullBlockProcessor.scala | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala index f9e89f91ae..8c84852f09 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/FullBlockProcessor.scala @@ -6,7 +6,6 @@ import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock} import org.ergoplatform.nodeView.history.ErgoHistoryUtils import org.ergoplatform.nodeView.history.ErgoHistoryUtils._ -import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.settings.Algos import scorex.db.ByteArrayWrapper import scorex.util.{ModifierId, bytesToId, idToBytes} @@ -25,8 +24,9 @@ trait FullBlockProcessor extends HeadersProcessor { private var nonBestChainsCache = FullBlockProcessor.emptyCache - def isInBestFullChain(id: ModifierId): Boolean = - FullBlockProcessor.isInBestFullChain(historyStorage, id) + def isInBestFullChain(id: ModifierId): Boolean = historyStorage.getIndex(chainStatusKey(id)) + .map(ByteArrayWrapper.apply) + .contains(ByteArrayWrapper(FullBlockProcessor.BestChainMarker)) /** * Id of header that contains transactions and proofs @@ -288,12 +288,4 @@ object FullBlockProcessor { def chainStatusKey(id: ModifierId): ByteArrayWrapper = ByteArrayWrapper(Algos.hash("main_chain".getBytes(CharsetName) ++ idToBytes(id))) - private[history] def isInBestFullChain( - historyStorage: HistoryStorage, - id: ModifierId - ): Boolean = - historyStorage.getIndex(chainStatusKey(id)) - .map(ByteArrayWrapper.apply) - .contains(ByteArrayWrapper(BestChainMarker)) - } From 02923ecbe8283d9d0a773863108351b281c9b02b Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:54:35 +0200 Subject: [PATCH 35/46] test: restore non-AD history specification --- .../nodeView/history/VerifyNonADHistorySpecification.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala index 9caca833c1..9cff6acd5e 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/VerifyNonADHistorySpecification.scala @@ -49,7 +49,7 @@ class VerifyNonADHistorySpecification extends ErgoCorePropertyTest { property("full chain status updating") { def isInBestChain(b: ErgoFullBlock, h: ErgoHistory): Boolean = { - FullBlockProcessor.isInBestFullChain(h.historyStorage, b.id) + h.asInstanceOf[FullBlockProcessor].isInBestFullChain(b.id) } var history = genHistory() From 8edecb18e654286f035c44330120362b0bbf79b9 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:55:08 +0200 Subject: [PATCH 36/46] test: restore ExtraIndexer chain generator --- .../ergoplatform/nodeView/history/extra/ChainGenerator.scala | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala index ec7e8f8e21..de4f58bf21 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ChainGenerator.scala @@ -108,9 +108,7 @@ object ChainGenerator extends ErgoTestHelpers with Matchers { log.info( s"Block ${block.id} with ${block.transactions.size} transactions at height ${block.header.height} generated") - val newState = state.applyModifier(block, None)(_ => ()).get - history.reportModifierIsValid(block).get - loop(newState, outToPassNext, Some(block.header), acc :+ block.id)(history) + loop(state.applyModifier(block, None)(_ => ()).get, outToPassNext, Some(block.header), acc :+ block.id)(history) } else { acc } From a0e936e20dfb3995b77640a2f0058fb99a121aa5 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:55:29 +0200 Subject: [PATCH 37/46] test: keep deferred catch-up regression --- .../extra/ExtraIndexerSpecification.scala | 654 ++---------------- 1 file changed, 43 insertions(+), 611 deletions(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala index 3d29dc8437..ec80f880b7 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala @@ -1,10 +1,8 @@ package org.ergoplatform.nodeView.history.extra -import akka.actor.{Actor, ActorIdentity, ActorRef, ActorSystem, Identify, Props} -import akka.testkit.TestProbe +import akka.actor.{ActorRef, ActorSystem, Props} import org.ergoplatform.ErgoAddressEncoder import org.ergoplatform.http.api.SortDirection -import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.network.ErgoNodeViewSynchronizerMessages.{RemoteBlockApplied, Rollback} import org.ergoplatform.nodeView.history.extra.ExtraIndexer.ReceivableMessages.Index @@ -12,16 +10,13 @@ import org.ergoplatform.nodeView.history.extra.IndexedContractTemplateSerializer import org.ergoplatform.nodeView.history.extra.IndexedErgoAddressSerializer.hashErgoTree import org.ergoplatform.nodeView.history.extra.SegmentSerializer.{boxSegmentId, txSegmentId} import org.ergoplatform.nodeView.history.{ErgoHistory, ErgoHistoryReader} -import org.ergoplatform.nodeView.history.storage.HistoryStorage import org.ergoplatform.nodeView.mempool.ErgoMemPool -import org.ergoplatform.settings.{ErgoSettings, NetworkType} +import org.ergoplatform.settings.ErgoSettings import org.ergoplatform.utils.ErgoCorePropertyTest import scorex.util.{ModifierId, bytesToId} import spire.implicits.cfor import java.util.concurrent.locks.{Condition, ReentrantLock} -import java.nio.ByteBuffer -import java.nio.file.Files import scala.collection.mutable import scala.concurrent.duration.DurationInt import scala.reflect.ClassTag @@ -35,14 +30,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { case class ExtendDB(blockCount: Int) case class Reset() case class GenerateBetterChainTip() - case class CacheBlockTransactions(height: Int, transactions: BlockTransactions) - case class DeferNextHeaderOnce(height: Int) - case class DeferBlockTransactionsOnce(height: Int) - case class Reload() - case class ForceRollback(height: Int) - case class GetLoadedState() - case class FailNextRollbackRemoval(probe: ActorRef) - case class PauseBufferedCatchUpAt(height: Int, saveLimit: Int, probe: ActorRef) + case class SetCaughtUp(caughtUp: Boolean) type ID_LL = mutable.HashMap[ModifierId,(Long,Long)] @@ -56,19 +44,6 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { var _history: ErgoHistory = _ def history: ErgoHistoryReader = _history.getReader - def fullChainHeaderAt(height: Int): Header = { - val bestFullBlock = history.bestFullBlockOpt.get - history.headerChainBack(bestFullBlock.height - height + 1, bestFullBlock.header, _.height == height) - .headers - .find(_.height == height) - .get - } - - def fullChainTransactionsAt(height: Int): BlockTransactions = { - val header = fullChainHeaderAt(height) - history.typedModifierById[BlockTransactions](header.transactionsId).get - } - val lock: ReentrantLock = new ReentrantLock() val done: Condition = lock.newCondition() val created: Condition = lock.newCondition() @@ -90,8 +65,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { val templates: ID_LL = mutable.HashMap[ModifierId, (Long, Long)]() val indexedTokens: ID_LL = mutable.HashMap[ModifierId, (Long, Long)]() cfor(1)(_ <= limit, _ + 1) { i => - val header = fullChainHeaderAt(i) - val block = history.getFullBlock(header) + val header = history.headerIdsAtHeight(i).last + val block = history.getFullBlock(history.typedModifierById[Header](header).get) block.get.transactions.foreach { tx => txsIndexed += 1 if (i != 1) { @@ -200,8 +175,9 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { val (addresses, templates, indexedTokens, txsIndexed, boxesIndexed) = manualIndex(n) // perform rollback - indexer ! ForceRollback(n) - awaitCondition(done) + indexer ! Rollback(history.bestHeaderIdAtHeight(n).get) + lock.lock() + done.await() state = IndexerState.fromHistory(_history) // address balances @@ -245,7 +221,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { println(s"Generate to $n") indexer ! CreateDB(n) indexer ! Index() - awaitCondition(done) + lock.lock() + done.await() val (addresses, _, _, _, _) = manualIndex(n) @@ -298,438 +275,11 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { indexer ! Reset() } - property("catches up past a direct block event when history is already ahead") { - indexer ! CreateDB(HEIGHT) - indexer ! Index() - awaitCondition(done) - - indexer ! ExtendDB(HEIGHT + 2) - awaitCondition(created) - val firstHeader = fullChainHeaderAt(HEIGHT + 1) - indexer ! RemoteBlockApplied(firstHeader, history.getFullBlock(firstHeader).get.transactions.map(_.id)) - - org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { - val state = IndexerState.fromHistory(_history) - state.indexedHeight shouldBe HEIGHT + 2 - state.indexedHeaderId shouldBe Some(fullChainHeaderAt(HEIGHT + 2).id) - } - indexer ! Reset() - } - - property("restores the exact persisted indexed header after the best header changes") { - indexer ! CreateDB(HEIGHT) - indexer ! Index() - awaitCondition(done) - - val persistedState = IndexerState.fromHistory(_history) - persistedState.indexedHeaderId shouldBe history.bestHeaderIdAtHeight(HEIGHT) - - indexer ! GenerateBetterChainTip() - awaitCondition(created) - indexer ! CreateDB(HEIGHT + 1) - awaitCondition(created) - history.bestHeaderIdAtHeight(HEIGHT) should not be persistedState.indexedHeaderId - - IndexerState.fromHistory(_history).indexedHeaderId shouldBe persistedState.indexedHeaderId - indexer ! Reset() - } - - property("rebuilds legacy, malformed, and interrupted checkpoints") { - def intBytes(value: Int): Array[Byte] = ByteBuffer.allocate(4).putInt(value).array - def longBytes(value: Long): Array[Byte] = ByteBuffer.allocate(8).putLong(value).array - val schema = ExtraIndexer.SchemaVersionKey -> intBytes(ExtraIndexer.NewestVersion) - val emptyMetadata = Array( - ExtraIndexer.IndexedHeightKey -> intBytes(0), - ExtraIndexer.GlobalTxIndexKey -> longBytes(0), - ExtraIndexer.GlobalBoxIndexKey -> longBytes(0), - ExtraIndexer.RollbackToKey -> intBytes(0) - ) - val invalidCheckpoints = Seq[(String, Array[(Array[Byte], Array[Byte])])]( - "legacy non-empty" -> Array( - ExtraIndexer.SchemaVersionKey -> intBytes(6), - ExtraIndexer.IndexedHeightKey -> intBytes(1), - ExtraIndexer.GlobalTxIndexKey -> longBytes(0), - ExtraIndexer.GlobalBoxIndexKey -> longBytes(0), - ExtraIndexer.RollbackToKey -> intBytes(0) - ), - "legacy height zero with stale counters" -> Array( - ExtraIndexer.SchemaVersionKey -> intBytes(6), - ExtraIndexer.IndexedHeightKey -> intBytes(0), - ExtraIndexer.GlobalTxIndexKey -> longBytes(7), - ExtraIndexer.GlobalBoxIndexKey -> longBytes(9), - ExtraIndexer.RollbackToKey -> intBytes(0) - ), - "missing header id" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> intBytes(1))), - "short header id" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> intBytes(1)) ++ - Array(ExtraIndexer.IndexedHeaderIdKey -> Array[Byte](1, 2, 3))), - "unknown header id" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> intBytes(1)) ++ - Array(ExtraIndexer.IndexedHeaderIdKey -> Array.fill[Byte](32)(1))), - "interrupted rollback" -> (Array(schema) ++ emptyMetadata.updated(3, ExtraIndexer.RollbackToKey -> intBytes(1))), - "short indexed height" -> (Array(schema) ++ emptyMetadata.updated(0, ExtraIndexer.IndexedHeightKey -> Array[Byte](1))), - "long transaction index" -> (Array(schema) ++ emptyMetadata.updated(1, ExtraIndexer.GlobalTxIndexKey -> Array.fill[Byte](9)(1))), - "short box index" -> (Array(schema) ++ emptyMetadata.updated(2, ExtraIndexer.GlobalBoxIndexKey -> Array[Byte](1))), - "short rollback target" -> (Array(schema) ++ emptyMetadata.updated(3, ExtraIndexer.RollbackToKey -> Array[Byte](1))), - "current schema height zero with stale transaction counter" -> - (Array(schema) ++ emptyMetadata.updated(1, ExtraIndexer.GlobalTxIndexKey -> longBytes(1))), - "current schema height zero with stale box counter" -> - (Array(schema) ++ emptyMetadata.updated(2, ExtraIndexer.GlobalBoxIndexKey -> longBytes(1))), - "negative box index" -> (Array(schema) ++ emptyMetadata.updated(2, ExtraIndexer.GlobalBoxIndexKey -> longBytes(-1))) - ) - - invalidCheckpoints.foreach { case (name, entries) => - val dbDir = Files.createTempDirectory("extra-indexer-checkpoint").toFile - val dbSettings = initSettings.copy( - directory = dbDir.getAbsolutePath, - nodeSettings = initSettings.nodeSettings.copy(extraIndex = true) - ) - val db = HistoryStorage(dbSettings) - db.insertExtraTry(entries, Array.empty).get - db.close() - - val probe = TestProbe()(system) - system.actorOf(Props(new Actor { - override def preStart(): Unit = { - val reloaded = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, reloaded).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, reloaded).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, reloaded).getLong, - ExtraIndexer.getIndex(ExtraIndexer.RollbackToKey, reloaded).getInt, - reloaded.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - reloaded.closeStorage() - context.stop(self) - } - - override def receive: Receive = Actor.emptyBehavior - })) - withClue(name) { - probe.expectMsg((0, 0L, 0L, 0, None)) - } - } - } - - property("preserves a valid non-empty checkpoint across storage reopen") { - val dbDir = Files.createTempDirectory("extra-indexer-valid-checkpoint").toFile - val dbSettings = initSettings.copy( - directory = dbDir.getAbsolutePath, - networkType = NetworkType.TestNet, - nodeSettings = initSettings.nodeSettings.copy(extraIndex = true, headerChainDiff = 5000) - ) - val generationSettings = dbSettings.copy( - nodeSettings = dbSettings.nodeSettings.copy(extraIndex = false) - ) - val probe = TestProbe()(system) - system.actorOf(Props(new Actor { - override def preStart(): Unit = { - val generatedHistory = ErgoHistory.readOrGenerate(generationSettings)(context) - ChainGenerator.generate(1, dbDir, generatedHistory, None) - generatedHistory.closeStorage() - - val indexedHistory = ErgoHistory.readOrGenerate(dbSettings)(context) - val header = indexedHistory.bestFullBlockOpt.get.header - val blockTransactions = indexedHistory.typedModifierById[BlockTransactions](header.transactionsId).get - val txCount = blockTransactions.txs.size.toLong - val boxCount = blockTransactions.txs.map(_.outputs.size.toLong).sum - val lastTx = blockTransactions.txs.last - val lastTxIndex = txCount - 1 - val lastBoxIndex = boxCount - 1 - val outputIndexes = Array.tabulate(lastTx.outputs.size) { i => - boxCount - lastTx.outputs.size.toLong + i.toLong - } - val indexedTx = IndexedErgoTransaction.fromTx( - lastTx, - blockTransactions.txs.size - 1, - header.height, - lastTxIndex, - Array.fill(lastTx.inputs.size)(0L), - outputIndexes - ) - val indexedBoxes = lastTx.outputs.zip(outputIndexes).map { case (output, outputIndex) => - new IndexedErgoBox( - header.height, - None, - None, - None, - output, - outputIndex - ) - }.toArray - val numericBoxes = indexedBoxes.map(box => NumericBoxIndex(box.globalIndex, box.id)) - val lastBox = indexedBoxes.last - val numericTx = NumericTxIndex(lastTxIndex, lastTx.id) - val numericBox = numericBoxes.last - val checkpointMetadata = Array( - ExtraIndexer.SchemaVersionKey -> ByteBuffer.allocate(4).putInt(ExtraIndexer.NewestVersion).array, - ExtraIndexer.IndexedHeightKey -> ByteBuffer.allocate(4).putInt(header.height).array, - ExtraIndexer.GlobalTxIndexKey -> ByteBuffer.allocate(8).putLong(txCount).array, - ExtraIndexer.GlobalBoxIndexKey -> ByteBuffer.allocate(8).putLong(boxCount).array, - ExtraIndexer.RollbackToKey -> ByteBuffer.allocate(4).putInt(0).array, - ExtraIndexer.IndexedHeaderIdKey -> ExtraIndexer.fastIdToBytes(header.id) - ) - val checkpointObjects = Array[ExtraIndex](numericTx, indexedTx) ++ numericBoxes ++ indexedBoxes - indexedHistory.historyStorage.insertExtraTry( - checkpointMetadata, - checkpointObjects - ).get - indexedHistory.closeStorage() - - val reloaded = ErgoHistory.readOrGenerate(dbSettings)(context) - val state = IndexerState.fromHistory(reloaded) - val terminalRowsPreserved = - reloaded.typedExtraIndexById[NumericTxIndex](numericTx.id).contains(numericTx) && - reloaded.typedExtraIndexById[IndexedErgoTransaction](indexedTx.id).exists { tx => - tx.txid == indexedTx.txid && tx.globalIndex == indexedTx.globalIndex && - tx.height == indexedTx.height && tx.outputNums.sameElements(indexedTx.outputNums) - } && - reloaded.typedExtraIndexById[NumericBoxIndex](numericBox.id).contains(numericBox) && - reloaded.typedExtraIndexById[IndexedErgoBox](lastBox.id).exists(_.globalIndex == lastBoxIndex) - probe.ref ! state - probe.ref ! terminalRowsPreserved - reloaded.closeStorage() - - val interrupted = HistoryStorage(dbSettings) - interrupted.insertExtraTry( - Array(ExtraIndexer.RollbackToKey -> ByteBuffer.allocate(4).putInt(header.height).array), - Array.empty - ).get - interrupted.close() - - val rebuiltAfterInterruptedRollback = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterInterruptedRollback).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterInterruptedRollback).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterInterruptedRollback).getLong, - rebuiltAfterInterruptedRollback.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuiltAfterInterruptedRollback.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get - rebuiltAfterInterruptedRollback.closeStorage() - - val wrongSchema = HistoryStorage(dbSettings) - wrongSchema.insertExtraTry( - Array(ExtraIndexer.SchemaVersionKey -> ByteBuffer.allocate(4).putInt(ExtraIndexer.NewestVersion - 1).array), - Array.empty - ).get - wrongSchema.close() - - val rebuiltAfterSchemaMismatch = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterSchemaMismatch).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterSchemaMismatch).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterSchemaMismatch).getLong, - rebuiltAfterSchemaMismatch.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuiltAfterSchemaMismatch.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get - rebuiltAfterSchemaMismatch.closeStorage() - - val malformed = HistoryStorage(dbSettings) - val malformedTx = indexedTx.copy(outputNums = indexedTx.outputNums ++ Array(lastBoxIndex)) - malformed.insertExtraTry(Array.empty, Array(malformedTx)).get - malformed.close() - - val rebuiltAfterMalformedTx = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterMalformedTx).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterMalformedTx).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterMalformedTx).getLong, - rebuiltAfterMalformedTx.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuiltAfterMalformedTx.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get - rebuiltAfterMalformedTx.closeStorage() - - val malformedInputs = HistoryStorage(dbSettings) - val malformedInputTx = indexedTx.copy(inputNums = indexedTx.inputNums.map(_ + 1L)) - malformedInputs.insertExtraTry(Array.empty, Array(malformedInputTx)).get - malformedInputs.close() - - val rebuiltAfterMalformedInputs = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterMalformedInputs).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterMalformedInputs).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterMalformedInputs).getLong, - rebuiltAfterMalformedInputs.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuiltAfterMalformedInputs.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get - rebuiltAfterMalformedInputs.closeStorage() - - val malformedSpentOutput = HistoryStorage(dbSettings) - val spentTerminalBox = new IndexedErgoBox( - header.height, - Some(lastTx.id), - Some(header.height + 1), - None, - lastTx.outputs.last, - lastBoxIndex - ) - malformedSpentOutput.insertExtraTry(Array.empty, Array(spentTerminalBox)).get - malformedSpentOutput.close() - - val rebuiltAfterSpentOutput = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterSpentOutput).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterSpentOutput).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterSpentOutput).getLong, - rebuiltAfterSpentOutput.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuiltAfterSpentOutput.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get - rebuiltAfterSpentOutput.closeStorage() - - val corrupted = HistoryStorage(dbSettings) - corrupted.removeExtraTry(Array(numericBox.id)).get - corrupted.close() - - val rebuilt = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuilt).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuilt).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuilt).getLong, - rebuilt.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuilt.closeStorage() - - val invalidatedHeaderHistory = ErgoHistory.readOrGenerate(dbSettings)(context) - invalidatedHeaderHistory.historyStorage.insertExtraTry(checkpointMetadata, checkpointObjects).get - invalidatedHeaderHistory.historyStorage.insert( - Array(invalidatedHeaderHistory.validityKey(header.id) -> Array(0.toByte)), - org.ergoplatform.modifiers.BlockSection.emptyArray - ).get - invalidatedHeaderHistory.closeStorage() - - val rebuiltAfterInvalidatedHeader = ErgoHistory.readOrGenerate(dbSettings)(context) - probe.ref ! (( - ExtraIndexer.getIndex(ExtraIndexer.IndexedHeightKey, rebuiltAfterInvalidatedHeader).getInt, - ExtraIndexer.getIndex(ExtraIndexer.GlobalTxIndexKey, rebuiltAfterInvalidatedHeader).getLong, - ExtraIndexer.getIndex(ExtraIndexer.GlobalBoxIndexKey, rebuiltAfterInvalidatedHeader).getLong, - rebuiltAfterInvalidatedHeader.historyStorage.modifierBytesById(bytesToId(ExtraIndexer.IndexedHeaderIdKey)) - )) - rebuiltAfterInvalidatedHeader.closeStorage() - context.stop(self) - } - - override def receive: Receive = Actor.emptyBehavior - })) - val preserved = probe.expectMsgType[IndexerState] - preserved.indexedHeight shouldBe 1 - preserved.indexedHeaderId should not be empty - preserved.globalTxIndex should be > 0L - preserved.globalBoxIndex should be > 0L - probe.expectMsg(true) - probe.expectMsg((0, 0L, 0L, None)) - probe.expectMsg((0, 0L, 0L, None)) - probe.expectMsg((0, 0L, 0L, None)) - probe.expectMsg((0, 0L, 0L, None)) - probe.expectMsg((0, 0L, 0L, None)) - probe.expectMsg((0, 0L, 0L, None)) - probe.expectMsg((0, 0L, 0L, None)) - } - - property("binds cached transactions to the selected header") { - indexer ! CreateDB(HEIGHT) - indexer ! Index() - awaitCondition(done) - val originalTransactions = history.bestBlockTransactionsAt(HEIGHT).get - val staleTransactions = originalTransactions.copy( - txs = history.bestBlockTransactionsAt(HEIGHT - 1).get.txs - ) - - indexer ! ForceRollback(HEIGHT - 1) - awaitCondition(done) - val branchState = IndexerState.fromHistory(_history) - - indexer ! GenerateBetterChainTip() - awaitCondition(created) - indexer ! CreateDB(HEIGHT + 1) - awaitCondition(created) - val selectedTransactions = history.bestBlockTransactionsAt(HEIGHT).get - selectedTransactions.headerId should not be originalTransactions.headerId - selectedTransactions.txs.head.id should not be staleTransactions.txs.head.id - - indexer ! CacheBlockTransactions(HEIGHT, staleTransactions) - awaitCondition(created) - indexer ! Index() - awaitCondition(done) - - NumericTxIndex.getTxByNumber(history, branchState.globalTxIndex).map(_.id) shouldBe - Some(selectedTransactions.txs.head.id) - indexer ! Reset() - } - - property("retries catch-up after a transient non-extending header") { - indexer ! CreateDB(HEIGHT) - awaitCondition(created) - indexer ! DeferNextHeaderOnce(2) - awaitCondition(created) - indexer ! Index() - - org.ergoplatform.utils.untilTimeout(3.seconds, 50.millis) { - val state = IndexerState.fromHistory(_history) - state.indexedHeight shouldBe HEIGHT - state.indexedHeaderId shouldBe history.bestHeaderIdAtHeight(HEIGHT) - } - indexer ! Reset() - } - - property("retries catch-up when the selected block transactions are temporarily unavailable") { - indexer ! CreateDB(HEIGHT) - awaitCondition(created) - indexer ! DeferBlockTransactionsOnce(2) - awaitCondition(created) - indexer ! Index() - - org.ergoplatform.utils.untilTimeout(3.seconds, 50.millis) { - val state = IndexerState.fromHistory(_history) - state.indexedHeight shouldBe HEIGHT - state.indexedHeaderId shouldBe Some(fullChainHeaderAt(HEIGHT).id) - } - indexer ! Reset() - } - - property("the production actor starts catch-up from the event stream") { - val dbDir = Files.createTempDirectory("extra-indexer-production-start").toFile - val dbSettings = initSettings.copy( - directory = dbDir.getAbsolutePath, - networkType = NetworkType.TestNet, - nodeSettings = initSettings.nodeSettings.copy(extraIndex = true, headerChainDiff = 5000) - ) - val setupProbe = TestProbe()(system) - system.actorOf(Props(new Actor { - override def preStart(): Unit = { - val generatedHistory = ErgoHistory.readOrGenerate(dbSettings)(context) - ChainGenerator.generate(5, dbDir, generatedHistory, None) - setupProbe.ref ! generatedHistory - context.stop(self) - } - - override def receive: Receive = Actor.emptyBehavior - })) - val history = setupProbe.expectMsgType[ErgoHistory](30.seconds) - IndexerState.fromHistory(history).indexedHeight shouldBe 0 - - val productionIndexer = system.actorOf(Props(new ExtraIndexer( - dbSettings.cacheSettings, - dbSettings.chainSettings.addressEncoder - ))) - val probe = TestProbe()(system) - probe.send(productionIndexer, Identify("started")) - probe.expectMsg(ActorIdentity("started", Some(productionIndexer))) - system.eventStream.publish(ExtraIndexer.ReceivableMessages.StartExtraIndexer(history)) - - org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { - val state = IndexerState.fromHistory(history) - state.indexedHeight shouldBe history.fullBlockHeight - state.indexedHeaderId shouldBe Some(history.bestFullBlockOpt.get.header.id) - } - - probe.watch(productionIndexer) - system.stop(productionIndexer) - probe.expectTerminated(productionIndexer) - history.closeStorage() - } - property("transactions") { indexer ! CreateDB(HEIGHT) indexer ! Index() - awaitCondition(done) + lock.lock() + done.await() val state = IndexerState.fromHistory(_history) cfor(0)(_ < state.globalTxIndex, _ + 1) { n => val id = history.typedExtraIndexById[NumericTxIndex](bytesToId(NumericTxIndex.indexToBytes(n))) @@ -742,7 +292,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("boxes") { indexer ! CreateDB(HEIGHT) indexer ! Index() - awaitCondition(done) + lock.lock() + done.await() val state = IndexerState.fromHistory(_history) cfor(0)(_ < state.globalBoxIndex, _ + 1) { n => val id = history.typedExtraIndexById[NumericBoxIndex](bytesToId(NumericBoxIndex.indexToBytes(n))) @@ -755,7 +306,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("addresses") { indexer ! CreateDB(HEIGHT) indexer ! Index() - awaitCondition(done) + lock.lock() + done.await() val (addresses, _, _, _, _) = manualIndex(HEIGHT) checkAddresses(addresses) shouldBe 0 indexer ! Reset() @@ -764,7 +316,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("templates") { indexer ! CreateDB(HEIGHT) indexer ! Index() - awaitCondition(done) + lock.lock() + done.await() val (_, templates, _, _, _) = manualIndex(HEIGHT) checkTemplates(templates) shouldBe 0 indexer ! Reset() @@ -773,7 +326,8 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { property("tokens") { indexer ! CreateDB(HEIGHT) indexer ! Index() - awaitCondition(done) + lock.lock() + done.await() val (_, _, indexedTokens, _, _) = manualIndex(HEIGHT) checkTokens(indexedTokens) shouldBe 0 indexer ! Reset() @@ -799,173 +353,51 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { rollbackWithPattern("G-5;G-15;R-5;G-20;G-25;R-15;G-30;R-10;G-50;R-25") } - property("uses the production rollback point when the indexed tip becomes invalid") { + property("indexes replacement blocks after rolling back an orphan block") { indexer ! CreateDB(HEIGHT) indexer ! Index() awaitCondition(done) - val originalTipId = IndexerState.fromHistory(_history).indexedHeaderId.get - indexer ! GenerateBetterChainTip() - awaitCondition(created) - indexer ! ExtendDB(HEIGHT + 1) - awaitCondition(created) - val branchPoint = fullChainHeaderAt(HEIGHT - 1) - val replacementHeader = fullChainHeaderAt(HEIGHT) - val replacementTip = fullChainHeaderAt(HEIGHT + 1) - - _history.historyStorage.insert( - Array(_history.validityKey(originalTipId) -> Array(0.toByte)), - org.ergoplatform.modifiers.BlockSection.emptyArray - ).get - val eventProbe = TestProbe()(system) - eventProbe.send(indexer, RemoteBlockApplied( - replacementHeader, - history.getFullBlock(replacementHeader).get.transactions.map(_.id) - )) - eventProbe.send(indexer, RemoteBlockApplied( - replacementTip, - history.getFullBlock(replacementTip).get.transactions.map(_.id) - )) - eventProbe.send(indexer, Rollback(branchPoint.id)) - - org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { - val state = IndexerState.fromHistory(_history) - state.indexedHeight shouldBe HEIGHT + 1 - state.indexedHeaderId shouldBe Some(replacementTip.id) - } - indexer ! Reset() - } - - property("persists buffered catch-up rows before processing a reorg") { - indexer ! CreateDB(HEIGHT) - awaitCondition(created) - val pauseProbe = TestProbe()(system) - pauseProbe.send(indexer, PauseBufferedCatchUpAt(HEIGHT, Int.MaxValue, pauseProbe.ref)) - pauseProbe.expectMsg("configured") + lock.lock() + created.await() + val newBestHeaderOpt = history.typedModifierById[Header](history.headerIdsAtHeight(history.fullBlockHeight).last) + indexer ! RemoteBlockApplied(newBestHeaderOpt.get, Seq.empty) // will be ignored + indexer ! CreateDB(HEIGHT + 1) + lock.lock() + created.await() indexer ! Index() - - val bufferedState = pauseProbe.expectMsgType[IndexerState](10.seconds) - bufferedState.indexedHeight shouldBe HEIGHT - IndexerState.fromHistory(_history).indexedHeight shouldBe 0 - val originalTipId = bufferedState.indexedHeaderId.get - - indexer ! GenerateBetterChainTip() - awaitCondition(created) - indexer ! ExtendDB(HEIGHT + 1) - awaitCondition(created) - val branchPoint = fullChainHeaderAt(HEIGHT - 1) - val replacementHeader = fullChainHeaderAt(HEIGHT) - val replacementTip = fullChainHeaderAt(HEIGHT + 1) - replacementHeader.id should not be originalTipId - - val eventProbe = TestProbe()(system) - eventProbe.send(indexer, RemoteBlockApplied( - replacementHeader, - history.getFullBlock(replacementHeader).get.transactions.map(_.id) - )) - eventProbe.send(indexer, RemoteBlockApplied( - replacementTip, - history.getFullBlock(replacementTip).get.transactions.map(_.id) - )) - eventProbe.send(indexer, Rollback(branchPoint.id)) - - org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { - val state = IndexerState.fromHistory(_history) - state.indexedHeight shouldBe HEIGHT + 1 - state.indexedHeaderId shouldBe Some(replacementTip.id) - } - - val expectedTransactions = (1 to HEIGHT + 1).flatMap(fullChainTransactionsAt(_).txs) - val expectedBoxes = expectedTransactions.flatMap(_.outputs) - val state = IndexerState.fromHistory(_history) - state.globalTxIndex shouldBe expectedTransactions.size - state.globalBoxIndex shouldBe expectedBoxes.size - expectedTransactions.zipWithIndex.foreach { case (tx, index) => - NumericTxIndex.getTxByNumber(history, index).map(_.id) shouldBe Some(tx.id) - } - expectedBoxes.zipWithIndex.foreach { case (box, index) => - NumericBoxIndex.getBoxByNumber(history, index).map(_.id) shouldBe Some(bytesToId(box.id)) - } - val (addresses, templates, indexedTokens, _, _) = manualIndex(HEIGHT + 1) - checkAddresses(addresses) shouldBe 0 - checkTemplates(templates) shouldBe 0 + lock.lock() + done.await() + indexer ! Rollback(history.bestHeaderIdAtHeight(HEIGHT).get) + lock.lock() + done.await() + val (_, _, indexedTokens, _, _) = manualIndex(HEIGHT) checkTokens(indexedTokens) shouldBe 0 indexer ! Reset() } - property("requests only extra indexer stop when final removal fails after partial rollback writes") { - val failingIndexer = system.actorOf(Props.create(classOf[ExtraIndexerTestActor], this)) - val lifecycleProbe = TestProbe()(system) - lifecycleProbe.watch(failingIndexer) - - failingIndexer ! CreateDB(HEIGHT) - failingIndexer ! Index() - awaitCondition(done) - val spentInputId = bytesToId(fullChainTransactionsAt(HEIGHT).txs.flatMap(_.inputs).head.boxId) - history.typedExtraIndexById[IndexedErgoBox](spentInputId).exists(_.isSpent) shouldBe true - - val probe = TestProbe()(system) - failingIndexer ! FailNextRollbackRemoval(probe.ref) - awaitCondition(created) - failingIndexer ! ForceRollback(HEIGHT - 1) - - probe.expectMsg("indexer-stop-requested") - lifecycleProbe.expectTerminated(failingIndexer) - system.whenTerminated.isCompleted shouldBe false - ExtraIndexer.getIndex(ExtraIndexer.RollbackToKey, _history).getInt shouldBe HEIGHT - 1 - _history.historyStorage.invalidateExtraCache(Seq(spentInputId)) - history.typedExtraIndexById[IndexedErgoBox](spentInputId).exists(_.isSpent) shouldBe false - } - - property("recovers a reloaded checkpoint without waiting for block or rollback events") { + property("resumes catch-up from a deferred state on FullBlockApplied") { indexer ! CreateDB(HEIGHT) indexer ! Index() awaitCondition(done) - val originalTipId = IndexerState.fromHistory(_history).indexedHeaderId - val branchPointId = fullChainHeaderAt(HEIGHT - 1).id - indexer ! GenerateBetterChainTip() - awaitCondition(created) indexer ! ExtendDB(HEIGHT + 1) awaitCondition(created) + val nextHeader = history.typedModifierById[Header](history.bestHeaderIdAtHeight(HEIGHT + 1).get).get - val replacementHeader = fullChainHeaderAt(HEIGHT) - val replacementChild = fullChainHeaderAt(HEIGHT + 1) - replacementHeader.id should not be originalTipId.get + // Simulate the state after a headers-only fork briefly became the best chain + // and then lost: caughtUp=false, but the indexed tip is still on the main chain. + indexer ! SetCaughtUp(caughtUp = false) - indexer ! Reload() - awaitCondition(created) + // Without the FullBlockApplied handler for !caughtUp, this event would be + // dropped and the indexer would stay stalled. + indexer ! RemoteBlockApplied(nextHeader, history.getFullBlock(nextHeader).get.transactions.map(_.id)) org.ergoplatform.utils.untilTimeout(10.seconds, 50.millis) { val state = IndexerState.fromHistory(_history) state.indexedHeight shouldBe HEIGHT + 1 - state.indexedHeaderId shouldBe Some(replacementChild.id) + state.caughtUp shouldBe true } - - val expectedTransactions = (1 to HEIGHT + 1).flatMap(fullChainTransactionsAt(_).txs) - val expectedBoxes = expectedTransactions.flatMap(_.outputs) - val state = IndexerState.fromHistory(_history) - state.globalTxIndex shouldBe expectedTransactions.size - state.globalBoxIndex shouldBe expectedBoxes.size - expectedTransactions.zipWithIndex.foreach { case (tx, index) => - NumericTxIndex.getTxByNumber(history, index).map(_.id) shouldBe Some(tx.id) - } - expectedBoxes.zipWithIndex.foreach { case (box, index) => - NumericBoxIndex.getBoxByNumber(history, index).map(_.id) shouldBe Some(bytesToId(box.id)) - } - - val (addresses, templates, indexedTokens, _, _) = manualIndex(HEIGHT + 1) - checkAddresses(addresses) shouldBe 0 - checkTemplates(templates) shouldBe 0 - checkTokens(indexedTokens) shouldBe 0 - - val probe = TestProbe()(system) - probe.send(indexer, Rollback(branchPointId)) - probe.send(indexer, GetLoadedState()) - val stateAfterLateRollback = probe.expectMsgType[IndexerState] - stateAfterLateRollback.indexedHeight shouldBe HEIGHT + 1 - stateAfterLateRollback.indexedHeaderId shouldBe Some(replacementChild.id) - IndexerState.fromHistory(_history) shouldBe stateAfterLateRollback indexer ! Reset() } } From cfd6eae38c070f527edf5a3b3897f4f8988e456a Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:56:23 +0200 Subject: [PATCH 38/46] test: keep deferred catch-up test seam --- .../history/extra/ExtraIndexerTestActor.scala | 129 ++---------------- 1 file changed, 9 insertions(+), 120 deletions(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala index d525ffbd6c..3b904f1c7e 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerTestActor.scala @@ -1,21 +1,18 @@ package org.ergoplatform.nodeView.history.extra -import akka.actor.ActorRef import org.ergoplatform._ import org.ergoplatform.modifiers.history.header.Header -import org.ergoplatform.modifiers.history.BlockTransactions import org.ergoplatform.modifiers.mempool.ErgoTransaction import org.ergoplatform.nodeView.history.ErgoHistory import org.ergoplatform.nodeView.mempool.ErgoMemPoolUtils.SortingOption import org.ergoplatform.nodeView.state._ import org.ergoplatform.settings._ import org.ergoplatform.wallet.utils.FileUtils -import scorex.util.{ModifierId, bytesToId} +import scorex.util.ModifierId import java.io.File import scala.collection.mutable import scala.concurrent.duration.DurationInt -import scala.util.{Failure, Try} class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexerBase with FileUtils { @@ -24,19 +21,13 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe case test.ExtendDB(blockCount: Int) => extendDB(blockCount) case test.Reset() => reset() case test.GenerateBetterChainTip() => GenerateBetterChainTip() - case test.CacheBlockTransactions(height, transactions) => cacheBlockTransactions(height, transactions) - case test.DeferNextHeaderOnce(height) => deferNextHeaderOnce(height) - case test.DeferBlockTransactionsOnce(height) => deferBlockTransactionsOnce(height) - case test.Reload() => reload() - case test.FailNextRollbackRemoval(probe) => failNextRollbackRemoval(probe) - case test.PauseBufferedCatchUpAt(height, limit, probe) => pauseBufferedCatchUpAt(height, limit, probe) } - override protected def loaded(state: IndexerState): Receive = ({ - case test.ForceRollback(height) => - beginRollback(state, fullChainHeaderAtHeight(height).get, resume = false) - case test.GetLoadedState() => sender ! state - }: Receive).orElse(super.loaded(state)) + override protected def loaded(state: IndexerState): Receive = { + case test.SetCaughtUp(caughtUp: Boolean) => + context.become(receive.orElse(loaded(state.copy(caughtUp = caughtUp)))) + case x => super.loaded(state)(x) + } override def caughtUpHook(height: Int = 0): Unit = { if(height > 0 && height < chainHeight) return @@ -46,18 +37,16 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe } override def getLastTxForHeight(height: Int): ErgoTransaction = { - val header = fullChainHeaderAtHeight(height).get - val block = history.getFullBlock(header) + val header = history.headerIdsAtHeight(height).last + val block = history.getFullBlock(history.typedModifierById[Header](header).get) block.get.transactions.last } type ID_LL = mutable.HashMap[ModifierId,(Long,Long)] - private var configuredSaveLimit: Int = 1 - override protected def saveLimit: Int = configuredSaveLimit + override protected val saveLimit: Int = 1 // save every block override protected implicit val segmentThreshold: Int = 8 // split to smaller segments override protected implicit val addressEncoder: ErgoAddressEncoder = test.initSettings.chainSettings.addressEncoder - override protected val retryDelay = 50.millis val nodeSettings: NodeConfigurationSettings = NodeConfigurationSettings(StateType.Utxo, verifyTransactions = true, -1, UtxoSettings(utxoBootstrap = false, 0, 2), NipopowSettings(nipopowBootstrap = false, 1), mining = false, @@ -68,52 +57,6 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe private var dir: File = _ private var stateOpt: Option[UtxoState] = None - private var deferredHeaderHeightOpt: Option[Int] = None - private var deferredTransactionsHeightOpt: Option[Int] = None - private var rollbackFailureProbeOpt: Option[ActorRef] = None - private var failRollbackRemoval: Boolean = false - private var pauseCatchUpAtHeightOpt: Option[Int] = None - private var catchUpPauseProbeOpt: Option[ActorRef] = None - - override protected def continueCatchUpAfterIndex(state: IndexerState): Boolean = { - if (pauseCatchUpAtHeightOpt.contains(state.indexedHeight)) { - pauseCatchUpAtHeightOpt = None - catchUpPauseProbeOpt.foreach(_ ! state) - catchUpPauseProbeOpt = None - false - } else true - } - - override protected def removeRollbackIndexes(ids: Array[ModifierId]): Try[Unit] = - if (failRollbackRemoval) { - failRollbackRemoval = false - Failure(new IllegalStateException("injected final rollback removal failure")) - } else super.removeRollbackIndexes(ids) - - override protected def stopIndexer(): Unit = { - rollbackFailureProbeOpt.foreach(_ ! "indexer-stop-requested") - rollbackFailureProbeOpt = None - super.stopIndexer() - } - - override protected def fullChainHeaderAtHeight(height: Int): Option[Header] = { - val headerOpt = super.fullChainHeaderAtHeight(height) - if (deferredHeaderHeightOpt.contains(height)) { - deferredHeaderHeightOpt = None - headerOpt.map(_.copy(parentId = bytesToId(Array.fill(32)(0x7f.toByte)))) - } else { - headerOpt - } - } - - override protected def blockTransactionsForHeader(header: Header): Option[BlockTransactions] = { - if (deferredTransactionsHeightOpt.contains(header.height)) { - deferredTransactionsHeightOpt = None - None - } else { - super.blockTransactionsForHeader(header) - } - } def createDB(blockCount: Int): Unit = { if(stateOpt.isEmpty) { @@ -143,22 +86,13 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe } def reset(): Unit = { - resetTransientState() stateOpt = None test._history = null general.clear() boxes.clear() trees.clear() - templates.clear() tokens.clear() segments.clear() - deferredHeaderHeightOpt = None - deferredTransactionsHeightOpt = None - rollbackFailureProbeOpt = None - failRollbackRemoval = false - configuredSaveLimit = 1 - pauseCatchUpAtHeightOpt = None - catchUpPauseProbeOpt = None context.become(receive.orElse(loaded(IndexerState(0, 0, 0, 0, caughtUp = false)))) } @@ -170,49 +104,4 @@ class ExtraIndexerTestActor(test: ExtraIndexerSpecification) extends ExtraIndexe test.lock.unlock() } - private def cacheBlockTransactions(height: Int, transactions: BlockTransactions): Unit = { - putBlockTransactionsInCache(height, transactions) - test.lock.lock() - test.created.signal() - test.lock.unlock() - } - - private def deferNextHeaderOnce(height: Int): Unit = { - deferredHeaderHeightOpt = Some(height) - test.lock.lock() - test.created.signal() - test.lock.unlock() - } - - private def deferBlockTransactionsOnce(height: Int): Unit = { - deferredTransactionsHeightOpt = Some(height) - test.lock.lock() - test.created.signal() - test.lock.unlock() - } - - private def reload(): Unit = { - resetTransientState() - context.become(receive.orElse(loaded(IndexerState.fromHistory(_history)))) - self ! ExtraIndexer.ReceivableMessages.Index() - test.lock.lock() - test.created.signal() - test.lock.unlock() - } - - private def failNextRollbackRemoval(probe: ActorRef): Unit = { - rollbackFailureProbeOpt = Some(probe) - failRollbackRemoval = true - test.lock.lock() - test.created.signal() - test.lock.unlock() - } - - private def pauseBufferedCatchUpAt(height: Int, limit: Int, probe: ActorRef): Unit = { - configuredSaveLimit = limit - pauseCatchUpAtHeightOpt = Some(height) - catchUpPauseProbeOpt = Some(probe) - probe ! "configured" - } - } From af792983a8015124d0f62332bfa87eb957c18322 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 22:56:31 +0200 Subject: [PATCH 39/46] test: restore history storage specification --- .../history/storage/HistoryStorageSpec.scala | 107 +----------------- 1 file changed, 1 insertion(+), 106 deletions(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala b/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala index 81703ff099..eb94a87a75 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageSpec.scala @@ -4,21 +4,12 @@ import org.ergoplatform.modifiers.BlockSection import org.ergoplatform.modifiers.history.ADProofs import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.nodeView.history.ErgoHistoryUtils._ -import org.ergoplatform.nodeView.history.extra.{ExtraIndex, IndexedErgoBox} import org.ergoplatform.settings.Algos import org.ergoplatform.utils.ErgoCorePropertyTest import org.scalacheck.Gen -import scorex.db.{ByteArrayWrapper, LDBFactory, LDBKVStore} +import scorex.db.ByteArrayWrapper import scorex.util.{ModifierId, idToBytes} -import java.io.IOException -import java.nio.file.Files -import java.util.concurrent.{CountDownLatch, TimeUnit} -import org.iq80.leveldb.Options -import scala.concurrent.duration.DurationInt -import scala.concurrent.{Await, ExecutionContext, Future} -import scala.util.Try - class HistoryStorageSpec extends ErgoCorePropertyTest { import org.ergoplatform.utils.ErgoNodeTestConstants._ import org.ergoplatform.utils.generators.ErgoCoreGenerators._ @@ -49,100 +40,4 @@ class HistoryStorageSpec extends ErgoCorePropertyTest { indexes.forall(i => !db.getIndex(i._1).exists(_.nonEmpty)) shouldBe true } - property("recursive extra index deletion propagates a file failure") { - val root = Files.createTempDirectory("extra-index-delete-failure") - val sentinel = Files.createFile(root.resolve("sentinel")) - - val result = HistoryStorage.deleteRecursively(root, path => { - if (path == sentinel) throw new IOException("injected deletion failure") - Files.delete(path) - }) - - result shouldBe 'failure - Files.exists(sentinel) shouldBe true - Files.delete(sentinel) - Files.delete(root) - } - - property("extra serialization failure invalidates mutated cached objects") { - import org.ergoplatform.utils.generators.ErgoNodeTransactionGenerators.ergoBoxGenNoProp - - val indexedBox = new IndexedErgoBox(1, None, None, None, ergoBoxGenNoProp.sample.get, 0L) - db.insertExtraTry(Array.empty, Array(indexedBox)).get - val cachedBox = db.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox] - cachedBox.spendingHeightOpt = Some(2) - val unsupported = new ExtraIndex { - override def serializedId: Array[Byte] = Array.fill[Byte](32)(0x55.toByte) - } - - db.insertExtraTry(Array.empty, Array[ExtraIndex](cachedBox, unsupported)) shouldBe 'failure - db.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox].spendingHeightOpt shouldBe None - db.insertExtraTry(Array.empty, Array(cachedBox)).get - db.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox].spendingHeightOpt shouldBe Some(2) - } - - property("an in-flight cache miss cannot restore stale data after a successful write") { - import org.ergoplatform.utils.generators.ErgoNodeTransactionGenerators.ergoBoxGenNoProp - - implicit val executionContext: ExecutionContext = ExecutionContext.global - val root = Files.createTempDirectory("extra-index-cache-race") - val oldValueRead = new CountDownLatch(1) - val resumeOldRead = new CountDownLatch(1) - val writerStarted = new CountDownLatch(1) - val writeCommitted = new CountDownLatch(1) - @volatile var pauseNextRead = false - @volatile var observeWrite = false - - val options = new Options().createIfMissing(true) - val indexStore = LDBFactory.createKvDb(root.resolve("index").toString) - val objectsStore = LDBFactory.createKvDb(root.resolve("objects").toString) - val rawExtraDb = LDBFactory.factory.open(root.resolve("extra").toFile, options) - val extraStore = new LDBKVStore(rawExtraDb) { - override def get(key: Array[Byte]): Option[Array[Byte]] = { - val value = super.get(key) - if (pauseNextRead) { - pauseNextRead = false - oldValueRead.countDown() - require(resumeOldRead.await(5, TimeUnit.SECONDS), "timed out waiting to resume cache-miss read") - } - value - } - - override def update(toInsertKeys: Array[Array[Byte]], - toInsertValues: Array[Array[Byte]], - toRemove: Array[Array[Byte]]): Try[Unit] = { - val result = super.update(toInsertKeys, toInsertValues, toRemove) - if (observeWrite && result.isSuccess) writeCommitted.countDown() - result - } - } - val concurrentStorage = new HistoryStorage(indexStore, objectsStore, extraStore, settings.cacheSettings) - - try { - val indexedBox = new IndexedErgoBox(1, None, None, None, ergoBoxGenNoProp.sample.get, 0L) - concurrentStorage.insertExtraTry(Array.empty, Array(indexedBox)).get - pauseNextRead = true - val staleRead = Future(concurrentStorage.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox]) - oldValueRead.await(5, TimeUnit.SECONDS) shouldBe true - - indexedBox.spendingHeightOpt = Some(2) - observeWrite = true - val write = Future { - writerStarted.countDown() - concurrentStorage.insertExtraTry(Array.empty, Array(indexedBox)).get - } - writerStarted.await(5, TimeUnit.SECONDS) shouldBe true - val committedWhileReadWasPaused = writeCommitted.await(200, TimeUnit.MILLISECONDS) - resumeOldRead.countDown() - - Await.result(staleRead, 5.seconds).spendingHeightOpt shouldBe None - Await.result(write, 5.seconds) - committedWhileReadWasPaused shouldBe false - concurrentStorage.getExtraIndex(indexedBox.id).get.asInstanceOf[IndexedErgoBox].spendingHeightOpt shouldBe Some(2) - } finally { - resumeOldRead.countDown() - concurrentStorage.close() - } - } - } From 458af20411b938ca19ef2fd75101d59ff2515bfa Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Wed, 19 Aug 2026 23:46:04 +0200 Subject: [PATCH 40/46] test: clarify deferred catch-up fixture --- .../nodeView/history/extra/ExtraIndexerSpecification.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala index ec80f880b7..ff97d8cda5 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala @@ -386,7 +386,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { val nextHeader = history.typedModifierById[Header](history.bestHeaderIdAtHeight(HEIGHT + 1).get).get // Simulate the state after a headers-only fork briefly became the best chain - // and then lost: caughtUp=false, but the indexed tip is still on the main chain. + // and then lost: caughtUp = false, while the indexed tip remains on the main chain. indexer ! SetCaughtUp(caughtUp = false) // Without the FullBlockApplied handler for !caughtUp, this event would be From 4130b6b0467db33c28fd64c198ce754c989c47b6 Mon Sep 17 00:00:00 2001 From: "A. Shannon" Date: Thu, 20 Aug 2026 00:16:01 +0200 Subject: [PATCH 41/46] Update comment for clarity on fork state Clarify comment regarding state after headers-only fork. --- .../nodeView/history/extra/ExtraIndexerSpecification.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala index ff97d8cda5..ec80f880b7 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/extra/ExtraIndexerSpecification.scala @@ -386,7 +386,7 @@ class ExtraIndexerSpecification extends ErgoCorePropertyTest { val nextHeader = history.typedModifierById[Header](history.bestHeaderIdAtHeight(HEIGHT + 1).get).get // Simulate the state after a headers-only fork briefly became the best chain - // and then lost: caughtUp = false, while the indexed tip remains on the main chain. + // and then lost: caughtUp=false, but the indexed tip is still on the main chain. indexer ! SetCaughtUp(caughtUp = false) // Without the FullBlockApplied handler for !caughtUp, this event would be From 5f0e92a57456d43111dbf8b3f766144168c40b04 Mon Sep 17 00:00:00 2001 From: kushti Date: Fri, 21 Aug 2026 16:17:13 +0300 Subject: [PATCH 42/46] adressing review comments --- .../modifiers/history/popow/PoPowParams.scala | 3 + src/it/resources/parameters-template.txt | 198 ------------------ .../org/ergoplatform/it/OpenApiSpec.scala | 75 ------- .../it/container/ApiChecker.scala | 5 - .../ergoplatform/it/container/Docker.scala | 47 +---- .../nodeView/mempool/MemPoolStatistics.scala | 2 +- .../nodeView/mempool/OrderedTxPool.scala | 5 + 7 files changed, 10 insertions(+), 325 deletions(-) delete mode 100644 src/it/resources/parameters-template.txt delete mode 100644 src/it/scala/org/ergoplatform/it/OpenApiSpec.scala delete mode 100644 src/it/scala/org/ergoplatform/it/container/ApiChecker.scala diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala index 659fe3c8e8..151b4f4fe4 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala @@ -12,6 +12,9 @@ import scala.util.Try * to calculate difficulty to be added to the proof). One-shot use means using the proof to just * to prove that a best chain contains some header (e.g. to work with a transaction corresponding * to the block header) + * @param minChainLength - minimal length of a proof's header chain acceptable for the params, always m + k. + * Kept as groundwork for the proof-length checks of the NiPoPoW parsing rework (#2461), + * not read by validation yet * */ final class PoPowParams private (val m: Int, val k: Int, val continuous: Boolean, val minChainLength: Int) diff --git a/src/it/resources/parameters-template.txt b/src/it/resources/parameters-template.txt deleted file mode 100644 index 17473bdc9c..0000000000 --- a/src/it/resources/parameters-template.txt +++ /dev/null @@ -1,198 +0,0 @@ -paths: - - /blocks: - post: - - | - { - "header": { - "votes": "000000", - "difficulty": "291", - "timestamp": 1538572701768, - "size": 607, - "stateRoot": "c2d3cfb7482c9edc4b1214b830032b93556af6a4a9224c7154cbf185bc00c15316", - "height": 32693, - "nBits": 33628928, - "version": 1, - "id": "00d2a8d21113598ea924329f9520905693e914bac6235255b74fd3b8016171aa", - "adProofsRoot": "a84f62a669fb3684308ea609af6cd831b939b70210307e143a05321ce8efeda2", - "transactionsRoot": "cc32add0ada11b6a81f07b61a6c606b2277af9999b453538d32b3c409630bce1", - "extensionHash": "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8", - "powSolutions" : { - "pk" : "0350e25cee8562697d55275c96bb01b34228f9bd68fd9933f2a25ff195526864f5", - "w" : "032e3f5edb88f3cc7384bfedc892fc8dcb10d7a3bf3741d08a62cc701848d0932c", - "n" : "0000000000000000", - "d" : 603083798111021851164432213586916186738093029948325633833495911523787854249 - }, - "parentId": "002390f165396f855f53b928e469ba89a2107784479423a2db66b3acccef78e9" - }, - "blockTransactions": { - "headerId": "00d2a8d21113598ea924329f9520905693e914bac6235255b74fd3b8016171aa", - "transactions": [ - { - "id": "20687df938ee4b3c50b7e145f84bb881f9518465c0877bf77a681241fe1a60e6", - "inputs": [ - { - "boxId": "0ab3aa13f4a82cfb4e8031a08f0ebdf2e969350deb897a171ec888bb8a6cc2a0", - "spendingProof": { - "proofBytes": "e8f50a65ee577e55b6ef100bfe97597e717b6792c94c749407d37547a31a9bba133be88e0a634bb479d3caffe9567059816cb8780a3bb373", - "extension": {} - } - }, - { - "boxId": "2a14b5969a287413918e9af76f939b47c6395ae84c65fa040f11e769e754c474", - "spendingProof": { - "proofBytes": "4b40eb0ca07bff34b4c07cc8c6a3eabc4f7634641e3941670734f59d68a2a946cf1e2a222f08a642c6b5228d331d9ab3120f10d1958a5659", - "extension": {} - } - } - ], - "dataInputs" : [], - "outputs": [ - { - "boxId": "08667225fa6beb560627ba02b5389929db756add5b65bda8aea7920088b2cc8c", - "value": 100000, - "ergoTree": "100101017300", - "assets": [], - "additionalRegisters": {}, - "creationHeight": 1149 - }, - { - "boxId": "a27ff81f905bfec9e4a551d95322a0c53259e44cd263cf72fa7d1eabc715fefe", - "value": 900000000, - "ergoTree": "1001070361a3df05f414e9b01394487c4cc9838857575408677ddc166047a52f93e4ed26cd7300", - "assets": [], - "additionalRegisters": {}, - "creationHeight": 1149 - } - ], - "size": 363 - }, - { - "id": "e0b057427cbe654bc872631e06bb0d96f91eb5a9779f4b4b1503c377582ca636", - "inputs": [ - { - "boxId": "aaacb6f598587059b770289abcbae0bc3dd949f527052f2ff82230f8411e67b1", - "spendingProof": { - "proofBytes": "28a6e09627092c63151fc10025abe30f368153e4255f26b90768bf60023d321ebde22692f2b412a7e964030333f615dc783b001bcb4865f7", - "extension": {} - } - } - ], - "dataInputs" : [ - { - "boxId" : "fec8fa6499dc086098d1589fdeefdd195def862098b84c9a7d85aaab28e92d0e" - }, - { - "boxId" : "fecf6c5508e87f7e71170f4fc88053539b7b70831a72b42090d7d480ae69e52f" - }, - { - "boxId" : "fee3087d7ecd4fbef2bea80dae2d0ef86e690c26a68084123c9fa1036761e82b" - } - ], - "outputs": [ - { - "boxId": "74c503605218f6e382710bc0cb6716a63e887a2a10252cdbb4662d49d660069c", - "value": 100000, - "ergoTree": "100101017300", - "assets": [], - "additionalRegisters": {}, - "creationHeight": 1149 - }, - { - "boxId": "cf5a2dbc79d3152305804e7109743d2ed538a5ee73e7fe7f366213c4d028f683", - "value": 500000000, - "ergoTree": "1001070361a3df05f414e9b01394487c4cc9838857575408677ddc166047a52f93e4ed26cd7300", - "assets": [], - "additionalRegisters": {}, - "creationHeight": 1149 - } - ], - "size": 184 - } - ], - "size": 4054 - }, - "extension": { - "headerId": "00d2a8d21113598ea924329f9520905693e914bac6235255b74fd3b8016171aa", - "digest": "0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8", - "fields": [] - }, - "adProofs": { - "headerId": "00d2a8d21113598ea924329f9520905693e914bac6235255b74fd3b8016171aa", - "proofBytes": "f7ab7f72f0bd0a761308dc0ebe5d9011745fd2aea137918208987aae87a9169cc5f7ff488e5333b6c535d2ce0a03cfbb8265d95a93323323", - "digest": "a84f62a669fb3684308ea609af6cd831b939b70210307e143a05321ce8efeda2", - "size": 28540 - }, - "size": 33201 - } - - /blocks/lastHeaders/{count}: - path_parameters: - - count: @lastHeadersCount - - /blocks/at/{blockHeight}: - path_parameters: - - blockHeight: @blockHeight - - /blocks/{headerId}: - path_parameters: - - headerId: @headerId - - /blocks/{headerId}/header: - path_parameters: - - headerId: @headerId - - /blocks/{headerId}/transactions: - path_parameters: - - headerId: @headerId - - /transactions: - post: - - | - { - "id": "2ab9da11fc216660e974842cc3b7705e62ebb9e0bf5ff78e53f9cd40abadd117", - "inputs": [ - { - "boxId": "1ab9da11fc216660e974842cc3b7705e62ebb9e0bf5ff78e53f9cd40abadd117", - "spendingProof": { - "proofBytes": "46dc83d572290479218cfe2a8cb9a0d979de4a61da6a96260a53479683f55a2b561ba4ec1faea4a0d4d19efc960a188ebe3fb8fb83e39d3b", - "extension": { - "1": "0101" - } - } - } - ], - "dataInputs" : [ - { - "boxId" : "fee3087d7ecd4fbef2bea80dae2d0ef86e690c26a68084123c9fa1036761e82b" - } - ], - "outputs": [ - { - "boxId": "1ab9da11fc216660e974842cc3b7705e62ebb9e0bf5ff78e53f9cd40abadd117", - "value": 147, - "ergoTree": "1001070361a3df05f414e9b01394487c4cc9838857575408677ddc166047a52f93e4ed26cd7300", - "assets": [ - { - "tokenId": "4ab9da11fc216660e974842cc3b7705e62ebb9e0bf5ff78e53f9cd40abadd117", - "amount": 1000 - } - ], - "additionalRegisters": { - "R4": "0101" - }, - "creationHeight": 1149 - } - ], - "size": 0 - } - - /utils/hash/blake2b: - post: - - '"123qwe"' - - '""' - - '"aaaaaaaaaa"' - - /utils/seed/{length}: - path_parameters: - - length: 123 \ No newline at end of file diff --git a/src/it/scala/org/ergoplatform/it/OpenApiSpec.scala b/src/it/scala/org/ergoplatform/it/OpenApiSpec.scala deleted file mode 100644 index 68653494c4..0000000000 --- a/src/it/scala/org/ergoplatform/it/OpenApiSpec.scala +++ /dev/null @@ -1,75 +0,0 @@ -package org.ergoplatform.it - -import java.io.{File, PrintWriter} - -import com.typesafe.config.Config -import org.ergoplatform.it.container.{ - ApiChecker, - ApiCheckerConfig, - IntegrationSuite, - Node -} -import org.scalatest.flatspec.AnyFlatSpec - -import scala.concurrent.duration._ -import scala.concurrent.{Await, Future} -import scala.io.Source - -class OpenApiSpec extends AnyFlatSpec with IntegrationSuite { - - val expectedHeight: Int = 2 - val paramsFilePath: String = "/tmp/parameters.yaml" - val paramsTemplatePath: String = "src/it/resources/parameters-template.txt" - - val offlineGeneratingPeer: Config = offlineGeneratingPeerConfig - .withFallback(nodeSeedConfigs.head) - .withFallback(allowLocalConfig) - - // `lazy` so the container is only started when a test actually touches `node`. - // The single test below is currently `ignore`d (the openapi-checker image is gone), - // so without `lazy` we would start and tear down a node for nothing. - lazy val node: Node = docker.startDevNetNode(offlineGeneratingPeer).get - - def renderTemplate(template: String, varMapping: Map[String, String]): String = - varMapping - .foldLeft(template) { case (s, (k, v)) => s.replaceAll(s"@$k", v) } - - def createParamsFile(params: Map[String, String]): Unit = { - val template: String = - Source.fromFile(paramsTemplatePath).getLines.map(_ + "\n").mkString - val writer: PrintWriter = new PrintWriter(new File(paramsFilePath)) - writer.write(renderTemplate(template, params)) - writer.close() - } - - it should "OpenApi specification check" ignore { - val result: Future[Unit] = node - .waitForHeight(expectedHeight) - .flatMap { _ => - node.headerIdsByHeight(expectedHeight) - } - .map { headerIds => - createParamsFile( - Map( - "blockHeight" -> expectedHeight.toString, - "lastHeadersCount" -> expectedHeight.toString, - "headerId" -> headerIds.head - ) - ) - - val apiAddressToCheck: String = - s"${node.nodeInfo.networkIpAddress}:${node.nodeInfo.containerApiPort}" - val specFilePath: String = - new File("src/main/resources/api/openapi.yaml").getAbsolutePath - val checker: ApiChecker = docker - .startOpenApiChecker( - ApiCheckerConfig(apiAddressToCheck, specFilePath, paramsFilePath) - ) - .get - - docker.waitContainer(checker.containerId).awaitStatusCode() shouldBe 0 - } - - Await.result(result, 2.minutes) - } -} diff --git a/src/it/scala/org/ergoplatform/it/container/ApiChecker.scala b/src/it/scala/org/ergoplatform/it/container/ApiChecker.scala deleted file mode 100644 index 6c9d8bfe32..0000000000 --- a/src/it/scala/org/ergoplatform/it/container/ApiChecker.scala +++ /dev/null @@ -1,5 +0,0 @@ -package org.ergoplatform.it.container - -case class ApiCheckerConfig(apiAddressToCheck: String, specFilePath: String, paramsFilePath: String) - -case class ApiChecker(containerId: String, config: ApiCheckerConfig) diff --git a/src/it/scala/org/ergoplatform/it/container/Docker.scala b/src/it/scala/org/ergoplatform/it/container/Docker.scala index c392420e6d..4c9e5f0d2f 100644 --- a/src/it/scala/org/ergoplatform/it/container/Docker.scala +++ b/src/it/scala/org/ergoplatform/it/container/Docker.scala @@ -67,7 +67,6 @@ class Docker( private val client: DockerClient = DockerClientImpl.getInstance(configStandart, httpDockerClient) private var nodeRepository = Seq.empty[Node] - private var apiCheckerOpt: Option[ApiChecker] = None private val isStopped = new AtomicBoolean(false) // This should be called after client is ready but before network created. @@ -120,19 +119,6 @@ class Docker( def waitContainer(id: String): WaitContainerResultCallback = client.waitContainerCmd(id).start() - def startOpenApiChecker(checkerInfo: ApiCheckerConfig): Try[ApiChecker] = Try { - val ip: String = ipForNode(999, networkSeed) - val containerId: String = buildApiCheckerContainerCmd(checkerInfo, ip).exec().getId - connectToNetwork(containerId, ip) - client.startContainerCmd(containerId).exec() - - log.info(s"Started ApiChecker: $containerId") - - val checker: ApiChecker = ApiChecker(containerId, checkerInfo) - apiCheckerOpt = Some(checker) - checker - } - private def startNode( networkType: NetworkType, nodeSpecificConfig: Config, @@ -236,30 +222,6 @@ class Docker( actualConfig } - private def buildApiCheckerContainerCmd( - checkerInfo: ApiCheckerConfig, - ip: String - ): CreateContainerCmd = { - val hostConfig: HostConfig = new HostConfig() - .withBinds( - new Bind(checkerInfo.specFilePath, new Volume("/opt/ergo/openapi.yaml")), - new Bind(checkerInfo.paramsFilePath, new Volume("/opt/ergo/parameters.yaml")) - ) - - client - .createContainerCmd(ApiCheckerImageStable) - .withCmd( - "openapi.yaml", - "--api", - s"http://${checkerInfo.apiAddressToCheck}", - "--parameters", - "parameters.yaml" - ) - .withHostConfig(hostConfig) - .withHostName(networkName) - .withIpv4Address(ip) - } - private def buildPeerContainerCmd( networkType: NetworkType, nodeConfig: Config, @@ -456,11 +418,6 @@ class Docker( saveNodeLogs() - apiCheckerOpt.foreach { checker => - saveLogs(checker.containerId, "openapi-checker") - client.removeContainerCmd(checker.containerId).withForce(true).exec() - } - nodeRepository foreach { node => client.removeContainerCmd(node.containerId).withForce(true).exec() } @@ -565,9 +522,7 @@ class Docker( object Docker extends IntegrationTestConstants { - val ErgoImageLatest: String = "org.ergoplatform/ergo" - val ApiCheckerImageLatest: String = "andyceo/openapi-checker" - val ApiCheckerImageStable: String = "andyceo/openapi-checker:0.1.0-openapi-core-0.5.0" // not present in docker anymore + val ErgoImageLatest: String = "org.ergoplatform/ergo" val dockerImageLabel = "ergo-integration-tests" val networkNamePrefix: String = "ergo-itest-" diff --git a/src/main/scala/org/ergoplatform/nodeView/mempool/MemPoolStatistics.scala b/src/main/scala/org/ergoplatform/nodeView/mempool/MemPoolStatistics.scala index c88f8415d2..94d5db469b 100644 --- a/src/main/scala/org/ergoplatform/nodeView/mempool/MemPoolStatistics.scala +++ b/src/main/scala/org/ergoplatform/nodeView/mempool/MemPoolStatistics.scala @@ -51,7 +51,7 @@ case class MemPoolStatistics(startMeasurement: Long, object MemPoolStatistics { // Time parameters of mempool statistics val nHistogramBins: Int = 60 /* one hour */ - val measurementIntervalMsec: Int = 60 * 1000 /* one hour */ + val measurementIntervalMsec: Int = 60 * 1000 /* one minute */ val defaultPoolHistogram: List[FeeHistogramBin] = List.fill(MemPoolStatistics.nHistogramBins)(FeeHistogramBin(0, 0)) } diff --git a/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala b/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala index 2d5527ec7a..cc26879de1 100644 --- a/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala +++ b/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala @@ -50,6 +50,10 @@ class OrderedTxPool(val orderedTransactions: TreeMap[WeightedTxId, UnconfirmedTr case None if orderedTransactions.size == transactionsRegistry.size => orderedTransactions case _ => + log.warn( + s"Mempool indices diverged (ordered=${orderedTransactions.size}, " + + s"registry=${transactionsRegistry.size}); full scan to remove $id" + ) orderedTransactions.filter { case (wtx, utx) => wtx.id != id && utx.id != id } } } @@ -62,6 +66,7 @@ class OrderedTxPool(val orderedTransactions: TreeMap[WeightedTxId, UnconfirmedTr transactionsRegistry.get(id) .flatMap(wtx => orderedTransactions.get(wtx).filter(_.id == id).map(wtx -> _)) .orElse { + log.warn(s"Mempool fast lookup failed for $id, scanning ordered transactions") orderedTransactions.iterator.collectFirst { case (wtx, utx) if wtx.id == id && utx.id == id => wtx -> utx } From 25ae3151b67f26f6f396f6b4b3dacb32840eca97 Mon Sep 17 00:00:00 2001 From: kushti Date: Fri, 21 Aug 2026 18:32:55 +0300 Subject: [PATCH 43/46] scrypto updated to 3.1.1, sigmasdk to 6.0.6 --- avldb/build.sbt | 2 +- build.sbt | 4 ++-- ergo-core/build.sbt | 6 +++--- .../modifiers/history/extension/ExtensionCandidate.scala | 6 ++---- ergo-wallet/build.sbt | 6 +++--- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/avldb/build.sbt b/avldb/build.sbt index 96cf9327ee..f837fcb19e 100644 --- a/avldb/build.sbt +++ b/avldb/build.sbt @@ -30,7 +30,7 @@ val Versions = new { libraryDependencies ++= Seq( "ch.qos.logback" % "logback-classic" % "1.2.13", "com.google.guava" % "guava" % "23.0", - "org.scorexfoundation" %% "scrypto" % "2.3.0", + "org.scorexfoundation" %% "scrypto" % "3.1.1", "org.scalatest" %% "scalatest" % "3.2.19" % Test, "org.scalacheck" %% "scalacheck" % Versions.scalacheck(scalaVersion.value) % Test, Versions.scalatestplus(scalaVersion.value), diff --git a/build.sbt b/build.sbt index 14ee2cac11..b7af236062 100644 --- a/build.sbt +++ b/build.sbt @@ -39,11 +39,11 @@ lazy val commonSettings = Seq( publishArtifact in (Compile, packageDoc) := false -val circeVersion = "0.13.0" +val circeVersion = "0.14.15" val akkaVersion = "2.6.10" val akkaHttpVersion = "10.2.4" -val sigmaStateVersion = "6.0.3" +val sigmaStateVersion = "6.0.6" val ficusVersion = "1.4.7" // for testing current sigmastate build (see sigmastate-ergo-it jenkins job) diff --git a/ergo-core/build.sbt b/ergo-core/build.sbt index 3021144c9a..91438929b8 100644 --- a/ergo-core/build.sbt +++ b/ergo-core/build.sbt @@ -8,9 +8,9 @@ val deps211 = Seq( "io.circe" %% "circe-generic" % "0.10.0", "io.circe" %% "circe-parser" % "0.10.0") val deps212 = Seq( - "io.circe" %% "circe-core" % "0.13.0", - "io.circe" %% "circe-generic" % "0.13.0", - "io.circe" %% "circe-parser" % "0.13.0") + "io.circe" %% "circe-core" % "0.14.15", + "io.circe" %% "circe-generic" % "0.14.15", + "io.circe" %% "circe-parser" % "0.14.15") publishMavenStyle := true Test / publishArtifact := false diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala index 61513e3360..1bbfffc0f2 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala @@ -7,7 +7,6 @@ import scorex.crypto.authds.merkle.{BatchMerkleProof, Leaf, MerkleProof, MerkleT import scorex.crypto.hash.Digest32 import scorex.util.ModifierId import scala.annotation.nowarn -import scala.collection.mutable /** * Extension block section with header id not provided * @@ -49,9 +48,8 @@ class ExtensionCandidate(val fields: Seq[(Array[Byte], Array[Byte])]) { val indices = keys.flatMap(key => fields.find(_._1 sameElements key) .map(Extension.kvToLeaf) .map(kv => Leaf[Digest32](LeafData @@ kv)(Algos.hash).hash) - .flatMap(leafData => interlinksMerkleTree.elementsHashIndex.get( - new mutable.WrappedArray.ofByte(leafData)))) - if (indices.isEmpty) None else interlinksMerkleTree.proofByIndices(indices)(Algos.hash) + .flatMap(leafData => interlinksMerkleTree.indexByElementHash(leafData))) + if (indices.isEmpty) None else interlinksMerkleTree.proofByIndices(indices) } } diff --git a/ergo-wallet/build.sbt b/ergo-wallet/build.sbt index af2bbb0097..ec4e16a3f7 100644 --- a/ergo-wallet/build.sbt +++ b/ergo-wallet/build.sbt @@ -8,9 +8,9 @@ val deps211 = Seq( "io.circe" %% "circe-generic" % "0.10.0", "io.circe" %% "circe-parser" % "0.10.0") val deps212 = Seq( - "io.circe" %% "circe-core" % "0.13.0", - "io.circe" %% "circe-generic" % "0.13.0", - "io.circe" %% "circe-parser" % "0.13.0") + "io.circe" %% "circe-core" % "0.14.15", + "io.circe" %% "circe-generic" % "0.14.15", + "io.circe" %% "circe-parser" % "0.14.15") libraryDependencies ++= Seq( "org.scodec" %% "scodec-bits" % "1.1.34", From 211ca58d993d0598d3f7667c37125a8e7c83e589 Mon Sep 17 00:00:00 2001 From: kushti Date: Tue, 25 Aug 2026 00:01:42 +0300 Subject: [PATCH 44/46] scala 2.13 and http-circe versions update --- .github/workflows/ci.yml | 4 ++-- avldb/build.sbt | 2 +- build.sbt | 4 ++-- ergo-core/build.sbt | 2 +- ergo-wallet/build.sbt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59387241a4..6bb76cdeca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.13.16, 2.12.20, 2.11.12] + scala: [2.13.18, 2.12.20, 2.11.12] java: [adopt@1.8] runs-on: ${{ matrix.os }} steps: @@ -64,7 +64,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.13.16, 2.12.20, 2.11.12] + scala: [2.13.18, 2.12.20, 2.11.12] java: [adopt@1.8] runs-on: ${{ matrix.os }} steps: diff --git a/avldb/build.sbt b/avldb/build.sbt index f837fcb19e..c41c4d4321 100644 --- a/avldb/build.sbt +++ b/avldb/build.sbt @@ -2,7 +2,7 @@ import sbt.Keys.testFrameworks val scala211 = "2.11.12" val scala212 = "2.12.20" -val scala213 = "2.13.16" +val scala213 = "2.13.18" name := "avldb" diff --git a/build.sbt b/build.sbt index b7af236062..be61e3d1a6 100644 --- a/build.sbt +++ b/build.sbt @@ -6,7 +6,7 @@ logLevel := Level.Debug // this values should be in sync with ergo-wallet/build.sbt val scala211 = "2.11.12" val scala212 = "2.12.20" -val scala213 = "2.13.16" +val scala213 = "2.13.18" lazy val commonSettings = Seq( organization := "org.ergoplatform", @@ -336,7 +336,7 @@ lazy val ergo = (project in file(".")) "com.github.scopt" %% "scopt" % "4.1.0", // API dependencies - "de.heikoseeberger" %% "akka-http-circe" % "1.20.0", + "de.heikoseeberger" %% "akka-http-circe" % "1.39.2", // app dependencies // jaxb-api is included only to avoid a runtime exception diff --git a/ergo-core/build.sbt b/ergo-core/build.sbt index 91438929b8..45c19eb120 100644 --- a/ergo-core/build.sbt +++ b/ergo-core/build.sbt @@ -1,7 +1,7 @@ // this values should be in sync with root (i.e. ../build.sbt) val scala211 = "2.11.12" val scala212 = "2.12.20" -val scala213 = "2.13.16" +val scala213 = "2.13.18" val deps211 = Seq( "io.circe" %% "circe-core" % "0.10.0", diff --git a/ergo-wallet/build.sbt b/ergo-wallet/build.sbt index ec4e16a3f7..43de051550 100644 --- a/ergo-wallet/build.sbt +++ b/ergo-wallet/build.sbt @@ -1,7 +1,7 @@ // this values should be in sync with root (i.e. ../build.sbt) val scala211 = "2.11.12" val scala212 = "2.12.20" -val scala213 = "2.13.16" +val scala213 = "2.13.18" val deps211 = Seq( "io.circe" %% "circe-core" % "0.10.0", From 4c43f0e36557b9d19126eab3f7874971f3ac511e Mon Sep 17 00:00:00 2001 From: kushti Date: Wed, 26 Aug 2026 19:20:16 +0300 Subject: [PATCH 45/46] deprecation messages fix --- .../serialization/ContextExtensionSpec.scala | 79 +++++++++++++++++++ .../network/message/BasicMessagesRepo.scala | 9 +-- .../nodeView/state/SnapshotsInfo.scala | 4 +- .../nodeView/wallet/WalletTransaction.scala | 3 +- .../wallet/persistence/WalletDigest.scala | 5 +- .../ScanningPredicateSerializer.scala | 3 +- 6 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 ergo-core/src/test/scala/org/ergoplatform/serialization/ContextExtensionSpec.scala diff --git a/ergo-core/src/test/scala/org/ergoplatform/serialization/ContextExtensionSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/serialization/ContextExtensionSpec.scala new file mode 100644 index 0000000000..ec8254ad1b --- /dev/null +++ b/ergo-core/src/test/scala/org/ergoplatform/serialization/ContextExtensionSpec.scala @@ -0,0 +1,79 @@ +package org.ergoplatform.serialization + +import org.ergoplatform.modifiers.mempool.{ErgoTransaction, ErgoTransactionSerializer} +import org.ergoplatform.settings.Constants.TrueTree +import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.{ErgoBoxCandidate, Input} +import scorex.crypto.authds.ADKey +import scorex.util.encode.Base16 +import sigma.ast.IntConstant +import sigma.interpreter.{ContextExtension, ProverResult} + +import scala.util.Try + +/** + * Pins the sigma-state 6.0.6 change "reject negative-id vars in ContextExtension + * deserializer" and documents its consensus impact. + * + * The wire format itself did not change: var ids are written as one raw byte via + * signed `put(id)` (verified against the published sources of both 6.0.3 and 6.0.6). + * What changed is the reader only: 6.0.6 adds an explicit + * "Negative id of context extension variable" guard, so transactions carrying such + * extensions now fail at parse time with a `SerializerException`. + * + * Before 6.0.6 the same transaction could still be serialized/parsed successfully; + * negative ids were rejected later, at proving/verification time, with different + * exceptions (e.g., `NegativeArraySizeException`, `ArrayIndexOutOfBoundsException`). + * Those paths are covered by `ErgoTransactionSpec`. This spec adds the missing + * serializer-level and transaction-parser-level coverage that directly answers the + * review's confirm-item. + * + * Consensus impact: a hand-crafted transaction with a negative extension id could, + * in principle, be mined by an un-upgraded node and rejected by upgraded nodes. + * All known wallets/SDKs use small non-negative var ids, so no such transactions + * are known; the change is a soft-fork-style tightening that is safe once the + * majority of hashrate is upgraded. + */ +class ContextExtensionSpec extends ErgoCorePropertyTest { + + private val serializer = ContextExtension.serializer + + property("ContextExtension deserialization rejects negative var id") { + // wire layout: [values count][id][serialized value] + val bytes = serializer.toBytes(ContextExtension(Map(56.toByte -> IntConstant(0)))) + bytes(1) = 0xC8.toByte // id 56 -> -56 as signed byte + val parsed = Try(serializer.fromBytes(bytes)) + parsed.isFailure shouldBe true + parsed.failed.get.getMessage.contains("Negative id") shouldBe true + } + + property("ContextExtension serialization writes var id byte raw") { + // pins that the rejected wire bytes are producible by ordinary node code; + // the writer does not range-check the id + val bytes = serializer.toBytes(ContextExtension(Map((-56).toByte -> IntConstant(0)))) + bytes(1) shouldBe 0xC8.toByte + } + + property("ContextExtension valid ids round-trip") { + Seq(0.toByte, 127.toByte).foreach { id => + val ce = ContextExtension(Map(id -> IntConstant(1))) + serializer.fromBytes(serializer.toBytes(ce)) shouldBe ce + } + } + + property("ErgoTransaction parsing rejects negative context extension var id") { + val boxId = ADKey @@ Base16.decode("c95c2ccf55e03cac6659f71ca4df832d28e2375569cec178dcb17f3e2e5f7742").get + val input = Input( + boxId, + ProverResult(Array.emptyByteArray, ContextExtension(Map((-10).toByte -> IntConstant(0)))) + ) + val output = new ErgoBoxCandidate(1000000000L, TrueTree, 0) + val tx = ErgoTransaction(IndexedSeq(input), IndexedSeq.empty, IndexedSeq(output)) + + val bytes = ErgoTransactionSerializer.toBytes(tx) + val parsed = Try(ErgoTransactionSerializer.parseBytes(bytes)) + parsed.isFailure shouldBe true + parsed.failed.get.getMessage.contains("Negative id") shouldBe true + } + +} diff --git a/src/main/scala/org/ergoplatform/network/message/BasicMessagesRepo.scala b/src/main/scala/org/ergoplatform/network/message/BasicMessagesRepo.scala index b7b5842154..151d61099f 100644 --- a/src/main/scala/org/ergoplatform/network/message/BasicMessagesRepo.scala +++ b/src/main/scala/org/ergoplatform/network/message/BasicMessagesRepo.scala @@ -5,7 +5,6 @@ import org.ergoplatform.nodeView.state.SnapshotsInfo import org.ergoplatform.nodeView.state.UtxoState.{ManifestId, SubtreeId} import org.ergoplatform.network.message.MessageConstants.MessageCode import scorex.crypto.hash.Digest32 -import scorex.util.Extensions._ import scorex.util.serialization.{Reader, Writer} import org.ergoplatform.sdk.wallet.Constants.ModifierIdLength @@ -53,7 +52,7 @@ class PeersSpec(peersLimit: Int) extends MessageSpecV1[Seq[PeerSpec]] { } override def parse(r: Reader): Seq[PeerSpec] = { - val length = r.getUInt().toIntExact + val length = r.getUIntExact() require(length <= peersLimit, s"Too many peers. $length exceeds limit $peersLimit") (0 until length).map { _ => PeerSpecSerializer.parse(r) @@ -101,7 +100,7 @@ object SnapshotsInfoSpec extends MessageSpecV1[SnapshotsInfo] { override def parse(r: Reader): SnapshotsInfo = { require(r.remaining <= SizeLimit, s"Too big SnapshotsInfo message: ${r.remaining} bytes found, $SizeLimit max expected.") - val length = r.getUInt().toIntExact + val length = r.getUIntExact() val manifests = (0 until length).map { _ => val height = r.getInt() val manifest = Digest32 @@ r.getBytes(ModifierIdLength) @@ -151,7 +150,7 @@ object ManifestSpec extends MessageSpecV1[Array[Byte]] { override def parse(r: Reader): Array[Byte] = { require(r.remaining <= SizeLimit, s"Too big Manifest message.") - val length = r.getUInt().toIntExact + val length = r.getUIntExact() r.getBytes(length) } @@ -196,7 +195,7 @@ object UtxoSnapshotChunkSpec extends MessageSpecV1[Array[Byte]] { override def parse(r: Reader): Array[Byte] = { require(r.remaining <= SizeLimit, s"Too big UtxoSnapshotChunk message.") - val length = r.getUInt().toIntExact + val length = r.getUIntExact() r.getBytes(length) } diff --git a/src/main/scala/org/ergoplatform/nodeView/state/SnapshotsInfo.scala b/src/main/scala/org/ergoplatform/nodeView/state/SnapshotsInfo.scala index 990359091c..73ffbe28db 100644 --- a/src/main/scala/org/ergoplatform/nodeView/state/SnapshotsInfo.scala +++ b/src/main/scala/org/ergoplatform/nodeView/state/SnapshotsInfo.scala @@ -46,9 +46,9 @@ object SnapshotsInfoSerializer extends ErgoSerializer[SnapshotsInfo] { } override def parse(r: Reader): SnapshotsInfo = { - val manifestsCount = r.getUInt().toInt // we read from trusted source, no need for extra checks + val manifestsCount = r.getUIntExact() // we read from trusted source, no need for extra checks val manifests = (1 to manifestsCount).map { _ => - val h = r.getUInt().toInt + val h = r.getUIntExact() val manifestId = Digest32 @@ r.getBytes(Constants.HashLength) h -> manifestId }.toMap diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/WalletTransaction.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/WalletTransaction.scala index 1ed83dab11..852154a5e2 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/WalletTransaction.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/WalletTransaction.scala @@ -6,7 +6,6 @@ import org.ergoplatform.wallet.Constants.ScanId import org.ergoplatform.serialization.ErgoSerializer import scorex.util.ModifierId import scorex.util.serialization.{Reader, Writer} -import scorex.util.Extensions._ import sigma.VersionContext /** @@ -57,7 +56,7 @@ object WalletTransactionSerializer extends ErgoSerializer[WalletTransaction] { (0 until scansCount).map(_ => ScanId @@ r.getShort()) } - val txBytesLen = r.getUInt().toIntExact + val txBytesLen = r.getUIntExact() // we use max supported script/tree to always parse everything there val tx = (VersionContext.withVersions(VersionContext.MaxSupportedScriptVersion, VersionContext.MaxSupportedScriptVersion) { ErgoTransactionSerializer.parseBytes(r.getBytes(txBytesLen)) diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletDigest.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletDigest.scala index 1b96025f1f..0c502e8c2b 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletDigest.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/persistence/WalletDigest.scala @@ -4,7 +4,6 @@ import org.ergoplatform.nodeView.history.ErgoHistoryUtils._ import org.ergoplatform.nodeView.wallet.IdUtils._ import org.ergoplatform.settings.Constants import org.ergoplatform.serialization.ErgoSerializer -import scorex.util.Extensions._ import scorex.util.serialization.{Reader, Writer} import sigmastate.eval.Extensions.ArrayByteOps @@ -42,10 +41,10 @@ object WalletDigestSerializer extends ErgoSerializer[WalletDigest] { } override def parse(r: Reader): WalletDigest = { - val height = r.getUInt().toIntExact + val height = r.getUIntExact() val balance = r.getULong() - val walletAssetBalancesSize = r.getUInt().toIntExact + val walletAssetBalancesSize = r.getUIntExact() val walletAssetBalances = mutable.LinkedHashMap.empty[EncodedTokenId, Long] (0 until walletAssetBalancesSize).foreach { _ => diff --git a/src/main/scala/org/ergoplatform/nodeView/wallet/scanning/ScanningPredicateSerializer.scala b/src/main/scala/org/ergoplatform/nodeView/wallet/scanning/ScanningPredicateSerializer.scala index ec1a6b4dda..ea1f0e0694 100644 --- a/src/main/scala/org/ergoplatform/nodeView/wallet/scanning/ScanningPredicateSerializer.scala +++ b/src/main/scala/org/ergoplatform/nodeView/wallet/scanning/ScanningPredicateSerializer.scala @@ -3,7 +3,6 @@ package org.ergoplatform.nodeView.wallet.scanning import org.ergoplatform.ErgoBox import org.ergoplatform.ErgoBox.RegisterId import org.ergoplatform.serialization.ErgoSerializer -import scorex.util.Extensions._ import scorex.util.serialization.{Reader, Writer} import sigma.ast.{EvaluatedValue, SType} import sigma.serialization.ValueSerializer @@ -55,7 +54,7 @@ object ScanningPredicateSerializer extends ErgoSerializer[ScanningPredicate] { def parseRegisterAndBytes(r: Reader): (RegisterId, EvaluatedValue[_ <: SType]) = { val reg = ErgoBox.registerByIndex(r.getByte()) - val len = r.getUInt().toIntExact + val len = r.getUIntExact() val bs = r.getBytes(len) val vs = ValueSerializer.deserialize(bs) reg -> vs.asInstanceOf[EvaluatedValue[SType]] From 7db01c7b3d82dad5224590f3cfaeadad8e47691b Mon Sep 17 00:00:00 2001 From: kushti Date: Thu, 3 Sep 2026 16:48:17 +0300 Subject: [PATCH 46/46] addressing review comments --- .gitignore | 1 + .../http/api/ErgoPeersApiRoute.scala | 25 +-- .../network/peer/PeerDatabase.scala | 154 +++++++++++++----- .../network/peer/PeerManager.scala | 10 ++ .../http/routes/ErgoPeersApiRouteSpec.scala | 56 +------ .../network/peer/PeerDatabaseSpec.scala | 86 +++++++++- .../network/peer/PeerManagerSpec.scala | 80 ++++++++- 7 files changed, 287 insertions(+), 125 deletions(-) diff --git a/.gitignore b/.gitignore index aad6dae921..eb475981a5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ devnet .ensime .ensime_cache/ scorex.yaml +*.iml # LLM reports on code analysis etc llm_generated diff --git a/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala b/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala index b6fbf24f9c..6fa0b79f80 100644 --- a/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala +++ b/src/main/scala/org/ergoplatform/http/api/ErgoPeersApiRoute.scala @@ -29,8 +29,6 @@ class ErgoPeersApiRoute(peerManager: ActorRef, override implicit lazy val timeout: Timeout = Timeout(1.minute) - private val DefaultPeersPageSize = 50 - override lazy val route: Route = pathPrefix("peers") { allPeers ~ connectedPeers ~ blacklistedPeers ~ connect ~ peersStatus ~ syncInfo ~ trackInfo } @@ -43,22 +41,15 @@ class ErgoPeersApiRoute(peerManager: ActorRef, ApiResponse(deliveryTracker.fullInfo) } - def allPeers: Route = (path("all") & get & parameters("limit".as[Int].optional, "offset".as[Int].optional)) { - (limitOpt, offsetOpt) => - val limit = limitOpt.getOrElse(DefaultPeersPageSize) - val offset = offsetOpt.getOrElse(0) - validate(offset >= 0 && limit > 0, - "limit and offset must be non-negative and limit must be positive") { - val result = askActor[Map[InetSocketAddress, PeerInfo]](peerManager, GetAllPeers).map { peers => - peers.toSeq - .sortBy(_._1.toString) - .slice(offset, offset + limit) - .map { case (address, peerInfo) => - PeerInfoResponse.fromAddressAndInfo(address, peerInfo) - } + def allPeers: Route = (path("all") & get) { + val result = askActor[Map[InetSocketAddress, PeerInfo]](peerManager, GetAllPeers).map { peers => + peers.toSeq + .sortBy(_._1.toString) + .map { case (address, peerInfo) => + PeerInfoResponse.fromAddressAndInfo(address, peerInfo) } - ApiResponse(result) - } + } + ApiResponse(result) } def connectedPeers: Route = (path("connected") & get) { diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala index 958deaf254..c3295c1582 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerDatabase.scala @@ -12,6 +12,7 @@ import org.ergoplatform.settings.ErgoSettings import scorex.db.LDBFactory import scorex.util.ScorexLogging +import scala.collection.mutable import scala.concurrent.duration._ import scala.util.{Failure, Success, Try} @@ -25,19 +26,6 @@ final class PeerDatabase( private val persistentStore = LDBFactory.createKvDb(s"${settings.directory}/peers") - /** - * Serialized peer info size must stay below this bound. The value is twice - * the maximum handshake size (8KB) to leave a comfortable margin while still - * preventing a single malformed/crafted entry from consuming a lot of memory. - */ - private val MaxSerializedPeerInfoSize = 16384 - - /** - * Serialized peer address (InetSocketAddress Java serialization) size bound. - * Legitimate hostnames can be up to 253 characters, so leave plenty of headroom. - */ - private val MaxSerializedPeerAddressSize = 1024 - private case class LoadedPeer( lastHandshake: Long, address: InetSocketAddress, @@ -85,44 +73,97 @@ final class PeerDatabase( ois.readObject() } + /* + * Number of store keys removed per batch while loading peers, so that + * cleanup of a severely oversized database does not build one huge in-memory batch. + */ + private val RemovalBatchSize = 1024 + /* * Load peers from persistent storage. * - * Enforces the in-memory cap and per-entry size limits at load time so a - * pre-existing or malformed DB cannot OOM the node on startup. Oversized or - * excess entries are dropped from the loaded set (and excess keys are removed - * from the store to keep the DB trimmed). + * Retention is driven by recency, not store iteration order: while streaming over + * the store we keep at most `maxKnownPeers` entries with the newest `lastHandshake` + * values, using a bounded min-heap keyed by `lastHandshake`, so peak extra memory + * stays O(maxKnownPeers + RemovalBatchSize) and an oversized or malformed database + * cannot OOM the node on startup. + * + * Oversized, unparseable, duplicated (same address, older handshake) and excess + * entries are physically removed from the store in bounded batches during the scan. */ private def loadPeers: Try[Map[InetSocketAddress, PeerInfo]] = Try { - val (oversizedKeys, validPeers) = - persistentStore.getAll.toVector.foldLeft( - (List.empty[Array[Byte]], List.empty[LoadedPeer]) - ) { case ((badKeys, goodPeers), (addr, peer)) => - if (addr.length > MaxSerializedPeerAddressSize || peer.length > MaxSerializedPeerInfoSize) { - log.warn( - s"Dropping oversized peer entry from database: key=${addr.length} bytes, " + - s"value=${peer.length} bytes" - ) - (addr :: badKeys, goodPeers) - } else { - val addressTry = Try(deserialize(addr).asInstanceOf[InetSocketAddress]) - val peerInfoTry = PeerInfoSerializer.parseBytesTry(peer) - (addressTry, peerInfoTry) match { - case (Success(address), Success(peerInfo)) => - val loaded = LoadedPeer(peerInfo.lastHandshake, address, peerInfo, addr) - (badKeys, loaded :: goodPeers) - case _ => - log.warn(s"Unable to deserialize peer entry from database, skipping it") - (badKeys, goodPeers) - } - } + val kept = mutable.HashMap.empty[InetSocketAddress, LoadedPeer] + // min-heap by lastHandshake (oldest at the head); may contain stale entries + // superseded by a newer record for the same address, cleaned lazily on eviction + val oldestFirst = + mutable.PriorityQueue.empty[LoadedPeer](Ordering.by[LoadedPeer, Long](_.lastHandshake).reverse) + val keysToRemove = mutable.ArrayBuffer.empty[Array[Byte]] + var removedRecords = 0L + + def flushRemovalBuffer(force: Boolean = false): Unit = { + if (keysToRemove.nonEmpty && (force || keysToRemove.length >= RemovalBatchSize)) { + flushKeysToRemove(keysToRemove.toArray) + removedRecords += keysToRemove.length + keysToRemove.clear() } + } + + def dropKey(key: Array[Byte]): Unit = { + keysToRemove += key + flushRemovalBuffer() + } - val (kept, drop) = validPeers.splitAt(maxKnownPeers) - val keysToRemove = oversizedKeys ++ drop.map(_.keyBytes) + persistentStore.getAll.foreach { case (key, value) => + if (key.length > PeerDatabase.MaxSerializedPeerAddressSize || + value.length > PeerDatabase.MaxSerializedPeerInfoSize) { + log.warn( + s"Dropping oversized peer entry from database: key=${key.length} bytes, " + + s"value=${value.length} bytes" + ) + dropKey(key) + } else { + val addressTry = Try(deserialize(key).asInstanceOf[InetSocketAddress]) + val peerInfoTry = PeerInfoSerializer.parseBytesTry(value) + (addressTry, peerInfoTry) match { + case (Success(address), Success(peerInfo)) => + val loaded = LoadedPeer(peerInfo.lastHandshake, address, peerInfo, key) + kept.get(address) match { + case Some(existing) if existing.lastHandshake >= loaded.lastHandshake => + dropKey(key) + case Some(existing) => + kept(address) = loaded + oldestFirst.enqueue(loaded) + dropKey(existing.keyBytes) + case None if kept.size < maxKnownPeers => + kept(address) = loaded + oldestFirst.enqueue(loaded) + case None => + // evict the oldest kept peer if the loaded one is newer + while (oldestFirst.headOption.exists(p => kept.get(p.address).forall(_ != p))) { + oldestFirst.dequeue() + } + oldestFirst.headOption match { + case Some(oldest) if loaded.lastHandshake > oldest.lastHandshake => + kept -= oldest.address + kept(address) = loaded + oldestFirst.enqueue(loaded) + dropKey(oldest.keyBytes) + case _ => + dropKey(key) + } + } + case _ => + log.warn(s"Unable to deserialize peer entry from database, removing it") + dropKey(key) + } + } + } + flushRemovalBuffer(force = true) - flushKeysToRemove(keysToRemove.toArray) - kept.map(p => p.address -> p.peerInfo).toMap + if (removedRecords > 0) { + log.info(s"Removed $removedRecords malformed, oversized or excess peer entries from database on startup") + } + kept.map { case (address, loaded) => address -> loaded.peerInfo }.toMap } private def flushKeysToRemove(keys: Array[Array[Byte]]): Unit = { @@ -166,6 +207,10 @@ final class PeerDatabase( * Evict the oldest known peer (by lastHandshake) from a random sample to make room * for a new peer, but never evict a currently connected peer. * + * Note: a candidate with `lastHandshake == 0` (a peer we have not handshaked with yet) + * can never displace an existing peer. This is an intentional anti-spam policy: data + * about not-yet-verified peers must not evict verified ones. + * * @param candidateHandshake - lastHandshake of the peer we want to insert * @return true if room was made, false otherwise */ @@ -209,13 +254,18 @@ final class PeerDatabase( } /** - * Remove peers whose lastHandshake is older than 60 days, excluding connected peers. + * Remove peers whose lastHandshake is older than 60 days, excluding connected peers + * and peers without a successful handshake (`lastHandshake == 0`, e.g. discovered + * but not yet tried peers and unavailable configured seeds), so that untried peers + * are not purged shortly after being discovered. */ def removeOldPeers(connectedPeers: Set[InetSocketAddress] = Set.empty): Unit = { val cutoff = System.currentTimeMillis() - PeerDatabase.KnownPeerMaxAgeMs val toRemove = peers.collect { case (address, info) - if !connectedPeers.contains(address) && info.lastHandshake < cutoff => + if !connectedPeers.contains(address) && + info.lastHandshake != 0 && + info.lastHandshake < cutoff => address } toRemove.foreach(remove) @@ -336,8 +386,22 @@ object PeerDatabase { */ val MaxKnownPeers: Int = 32768 + /** + * Serialized peer info size must stay below this bound. The value is twice + * the maximum handshake size (8KB) to leave a comfortable margin while still + * preventing a single malformed/crafted entry from consuming a lot of memory. + */ + private[peer] val MaxSerializedPeerInfoSize: Int = 16384 + + /** + * Serialized peer address (InetSocketAddress Java serialization) size bound. + * Legitimate hostnames can be up to 253 characters, so leave plenty of headroom. + */ + private[peer] val MaxSerializedPeerAddressSize: Int = 1024 + /** * Hardcoded maximum age (60 days) for a known peer's lastHandshake. + * Peers with `lastHandshake == 0` (never handshaked) are exempt from age cleanup. */ val KnownPeerMaxAgeMs: Long = 60.days.toMillis diff --git a/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala b/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala index 7c2e3492c3..680a1b1cca 100644 --- a/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala +++ b/src/main/scala/org/ergoplatform/network/peer/PeerManager.scala @@ -85,10 +85,16 @@ class PeerManager(settings: ErgoSettings, scorexContext: ScorexContext) extends peerDatabase.removeOldPeers(connectedPeerAddresses) case HandshakedPeer(remote) => + // Track both the transport endpoint and the advertised address (the database + // key). For inbound connections they differ: the socket address carries the + // peer's ephemeral source port, while the database is keyed by the advertised + // listening address from the handshake. Both must be protected from eviction. connectedPeerAddresses += remote.connectionId.remoteAddress + remote.peerInfo.flatMap(_.peerSpec.address).foreach(connectedPeerAddresses += _) case DisconnectedPeer(connectedPeer) => connectedPeerAddresses -= connectedPeer.connectionId.remoteAddress + connectedPeer.peerInfo.flatMap(_.peerSpec.address).foreach(connectedPeerAddresses -= _) case Penalize(peer, penaltyType) => log.info(s"$peer penalized, penalty: $penaltyType") @@ -176,6 +182,10 @@ object PeerManager { * Choose at most `howMany` random peers, which were connected to our peer and weren't blacklisted. * * Used in peer propagation: peers chosen are recommended to a peer asking our node about more peers. + * + * Note: only a bounded window of the database is scanned. If that window happens + * to contain no eligible peers, the result is empty even when eligible peers exist + * elsewhere in the database. This is an accepted bounded-work tradeoff. */ case class SeenPeers(howMany: Int) extends GetPeers[Seq[PeerInfo]] with ScorexLogging { diff --git a/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala b/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala index 3e16e26b34..fc739ae2df 100644 --- a/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala +++ b/src/test/scala/org/ergoplatform/http/routes/ErgoPeersApiRouteSpec.scala @@ -91,7 +91,7 @@ class ErgoPeersApiRouteSpec extends AnyFlatSpec } } - it should "return at most 50 peers by default" in { + it should "return all known peers" in { val networkControllerProbe = TestProbe() val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route val peers = (1 to 55).map { i => @@ -105,59 +105,7 @@ class ErgoPeersApiRouteSpec extends AnyFlatSpec Get("/peers/all") ~> route ~> check { status shouldBe StatusCodes.OK - responseAs[Json].asArray.get.size shouldBe 50 - } - } - - it should "respect limit and offset query parameters" in { - val networkControllerProbe = TestProbe() - val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route - val peers = (1 to 20).map { i => - val addr = new InetSocketAddress(s"8.8.0.$i", 9000 + i) - addr -> PeerInfo.fromAddress(addr) - }.toMap - val sortedAddresses = peers.keys.toSeq.sortBy(_.toString) - Future { - peerManagerProbe.expectMsg(GetAllPeers) - peerManagerProbe.reply(peers) - } - - Get("/peers/all?limit=5&offset=10") ~> route ~> check { - status shouldBe StatusCodes.OK - val arr = responseAs[Json].asArray.get - arr.size shouldBe 5 - arr.head.hcursor.downField("address").as[String] shouldEqual Right(sortedAddresses(10).toString) - } - } - - it should "return empty array when offset is beyond peer count" in { - val networkControllerProbe = TestProbe() - val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route - val peers = (1 to 5).map { i => - val addr = new InetSocketAddress(s"8.8.0.$i", 9000 + i) - addr -> PeerInfo.fromAddress(addr) - }.toMap - Future { - peerManagerProbe.expectMsg(GetAllPeers) - peerManagerProbe.reply(peers) - } - - Get("/peers/all?offset=100") ~> route ~> check { - status shouldBe StatusCodes.OK - responseAs[Json].asArray.get shouldBe empty - } - } - - it should "reject invalid pagination parameters" in { - val networkControllerProbe = TestProbe() - val route: Route = ErgoPeersApiRoute(peerManagerProbe.ref, networkControllerProbe.ref, null, null, restApiSettings).route - Future { - peerManagerProbe.expectMsg(GetAllPeers) - peerManagerProbe.reply(Map.empty[InetSocketAddress, PeerInfo]) - } - - Get("/peers/all?limit=-1") ~> Route.seal(route) ~> check { - status shouldBe StatusCodes.BadRequest + responseAs[Json].asArray.get.size shouldBe 55 } } } diff --git a/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala b/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala index 12191a91dc..98fc3486da 100644 --- a/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala +++ b/src/test/scala/org/ergoplatform/network/peer/PeerDatabaseSpec.scala @@ -5,6 +5,7 @@ import org.ergoplatform.network.PeerSpec import org.ergoplatform.settings.ErgoSettings import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.ErgoNodeTestConstants._ +import scorex.db.LDBFactory import java.io.File import java.net.InetSocketAddress @@ -157,25 +158,102 @@ class PeerDatabaseSpec extends ErgoCorePropertyTest with DBSpec { val dir = createTempDir val dbSettings = testSettings(dir) val addresses = (1 to 5).map(i => new InetSocketAddress(s"8.8.8.$i", 9000 + i)) + // timestamps permuted independently of insertion/address ordering: the newest + // timestamps belong to addresses(2), addresses(4) and addresses(0) + val timestamps = Map( + addresses(0) -> 40L, + addresses(1) -> 10L, + addresses(2) -> 50L, + addresses(3) -> 20L, + addresses(4) -> 30L + ) try { val db1 = new PeerDatabase(dbSettings, maxKnownPeers = 5) - addresses.zip(Seq(10L, 20L, 30L, 40L, 50L)).foreach { case (addr, ts) => - db1.addOrUpdateKnownPeer(peerInfo(addr, ts)) + addresses.foreach { addr => + db1.addOrUpdateKnownPeer(peerInfo(addr, timestamps(addr))) } db1.knownPeers should have size 5 db1.close() + // cap shrunk to 3: retention must be driven by timestamps, not store order val db2 = new PeerDatabase(dbSettings, maxKnownPeers = 3) db2.knownPeers should have size 3 + db2.knownPeers.keys should contain(addresses(0)) db2.knownPeers.keys should contain(addresses(2)) - db2.knownPeers.keys should contain(addresses(3)) db2.knownPeers.keys should contain(addresses(4)) - db2.knownPeers.keys should not contain addresses(0) db2.knownPeers.keys should not contain addresses(1) + db2.knownPeers.keys should not contain addresses(3) db2.close() + + // dropped records must be physically deleted from the store: reopening with + // the original cap must not resurrect them + val db3 = new PeerDatabase(dbSettings, maxKnownPeers = 5) + db3.knownPeers should have size 3 + db3.knownPeers.keys should contain(addresses(0)) + db3.knownPeers.keys should contain(addresses(2)) + db3.knownPeers.keys should contain(addresses(4)) + db3.close() } finally { deleteRecursive(dir) } } + property("PeerDatabase should physically remove malformed and oversized records on startup") { + val dir = createTempDir + val dbSettings = testSettings(dir) + val address = new InetSocketAddress("8.8.8.8", 9001) + val garbageKey = Array[Byte](1, 2, 3) + val garbageValue = Array[Byte](4, 5, 6) + val oversizedKey = new Array[Byte](PeerDatabase.MaxSerializedPeerAddressSize + 1) + val oversizedValue = new Array[Byte](PeerDatabase.MaxSerializedPeerInfoSize + 1) + try { + val db1 = new PeerDatabase(dbSettings) + db1.addOrUpdateKnownPeer(peerInfo(address, 100L)) + db1.close() + + // plant malformed records directly into the store + val rawStore = LDBFactory.createKvDb(s"${dir.getAbsolutePath}/peers") + rawStore.insert(garbageKey, garbageValue) + rawStore.insert(oversizedKey, oversizedValue) + rawStore.close() + + val db2 = new PeerDatabase(dbSettings) + // valid peer survives, malformed records are skipped + db2.knownPeers.keys should contain(address) + db2.knownPeers should have size 1 + db2.close() + + // malformed records are physically removed, not reparsed on every startup + val db3 = new PeerDatabase(dbSettings) + db3.knownPeers should have size 1 + db3.close() + + val checkStore = LDBFactory.createKvDb(s"${dir.getAbsolutePath}/peers") + checkStore.get(garbageKey) shouldBe empty + checkStore.get(oversizedKey) shouldBe empty + checkStore.close() + } finally { + deleteRecursive(dir) + } + } + + property("PeerDatabase should keep untried peers (zero lastHandshake) during cleanup") { + val untried = new InetSocketAddress("8.8.8.1", 9001) + val unavailableSeed = new InetSocketAddress("8.8.8.2", 9002) + val oldHandshaked = new InetSocketAddress("8.8.8.3", 9003) + val recent = new InetSocketAddress("8.8.8.4", 9004) + val now = System.currentTimeMillis() + withDb(maxKnownPeers = 100) { db => + db.addOrUpdateKnownPeer(peerInfo(untried, 0L)) + db.addOrUpdateKnownPeer(peerInfo(unavailableSeed, 0L)) + db.addOrUpdateKnownPeer(peerInfo(oldHandshaked, now - PeerDatabase.KnownPeerMaxAgeMs - 1000)) + db.addOrUpdateKnownPeer(peerInfo(recent, now - 1000)) + db.removeOldPeers() + db.knownPeers.keys should contain(untried) + db.knownPeers.keys should contain(unavailableSeed) + db.knownPeers.keys should contain(recent) + db.knownPeers.keys should not contain oldHandshaked + } + } + } diff --git a/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala b/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala index 43f406551e..c643130aeb 100644 --- a/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala +++ b/src/test/scala/org/ergoplatform/network/peer/PeerManagerSpec.scala @@ -12,7 +12,7 @@ import org.ergoplatform.settings.ErgoSettings import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.ErgoNodeTestConstants._ import scorex.core.app.ScorexContext -import scorex.core.network.{ConnectionDirection, ConnectionId, ConnectedPeer, Outgoing} +import scorex.core.network.{ConnectionDirection, ConnectionId, ConnectedPeer, Incoming, Outgoing} import scorex.testkit.utils.AkkaFixture import java.io.File @@ -24,7 +24,7 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { import PeerManager.ReceivableMessages._ - private class PeerManagerFixture extends AkkaFixture { + private class PeerManagerFixture(knownPeers: Seq[InetSocketAddress] = Seq.empty) extends AkkaFixture { val dir: File = createTempDir val settings: ErgoSettings = { @@ -32,7 +32,7 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { base.copy( scorexSettings = base.scorexSettings.copy( network = base.scorexSettings.network.copy( - knownPeers = Seq.empty + knownPeers = knownPeers ) ) ) @@ -82,7 +82,7 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { ) } - property("PeerManager should keep a connected peer during old-peer cleanup") { + property("PeerManager should keep a connected peer during old-peer cleanup and not purge untried peers") { withFixture { f => import f._ val address = new InetSocketAddress("8.8.8.8", 9001) @@ -104,7 +104,62 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { probe.send(peerManager, CleanupOldPeers) probe.send(peerManager, GetAllPeers) val peers3 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] - peers3.keys should not contain address + // the peer was never handshaked with (lastHandshake == 0), so it is exempt + // from handshake-age cleanup + peers3.keys should contain(address) + } + } + + property("PeerManager should protect an inbound peer whose advertised address differs from the socket address") { + withFixture { f => + import f._ + // for an inbound connection the transport endpoint carries the peer's ephemeral + // source port, while the peer database is keyed by the advertised listening address + val socketAddress = new InetSocketAddress("8.8.8.8", 54321) + val advertised = new InetSocketAddress("8.8.8.8", 9001) + val oldTs = System.currentTimeMillis() - PeerDatabase.KnownPeerMaxAgeMs - 1000 + val probe = TestProbe() + + probe.send(peerManager, AddOrUpdatePeer(peerInfo(advertised, lastHandshake = oldTs))) + + val localAddress = new InetSocketAddress("127.0.0.1", 9002) + val inbound = ConnectedPeer( + ConnectionId(socketAddress, localAddress, Incoming), + ActorRef.noSender, + Some(peerInfo(advertised, lastHandshake = oldTs)) + ) + probe.send(peerManager, HandshakedPeer(inbound)) + probe.send(peerManager, CleanupOldPeers) + probe.send(peerManager, GetAllPeers) + val peers1 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers1.keys should contain(advertised) + + probe.send(peerManager, DisconnectedPeer(inbound)) + probe.send(peerManager, CleanupOldPeers) + probe.send(peerManager, GetAllPeers) + val peers2 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers2.keys should not contain advertised + } + } + + property("PeerManager should keep unavailable configured seed peers during cleanup") { + val seed = new InetSocketAddress("8.8.8.8", 9001) + val f = new PeerManagerFixture(Seq(seed)) + try { + import f._ + val probe = TestProbe() + + // the seed is added on startup with lastHandshake == 0 + probe.send(peerManager, GetAllPeers) + val peers1 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers1.keys should contain(seed) + + probe.send(peerManager, CleanupOldPeers) + probe.send(peerManager, GetAllPeers) + val peers2 = probe.expectMsgType[Map[InetSocketAddress, PeerInfo]] + peers2.keys should contain(seed) + } finally { + Await.result(f.system.terminate(), Duration.Inf) } } @@ -166,4 +221,19 @@ class PeerManagerSpec extends ErgoCorePropertyTest with DBSpec { seenPeers(10, peers).size shouldBe 5 } + property("SeenPeers may skip eligible peers outside the bounded scan window") { + val eligibleAddr = address(1) + val eligible = Map(eligibleAddr -> peerInfo(eligibleAddr, lastHandshake = 1L)) + // all other peers are ineligible (never handshaked, no connection record) + val ineligible = (2 to 2000).map(i => address(i) -> peerInfo(address(i))).toMap + val peers = eligible ++ ineligible + // With a single eligible peer among 2000, most random scan windows contain only + // ineligible records and yield an empty result; windows that do cover the eligible + // peer return only it. This is the accepted bounded-work tradeoff: the scan never + // examines more than a bounded window even though eligible peers may be missed. + val chosen = (1 to 20).flatMap(_ => seenPeers(5, peers)) + chosen.size should be <= 20 + chosen.forall(_.peerSpec.declaredAddress.contains(eligibleAddr)) shouldBe true + } + }