diff --git a/src/it/scala/org/ergoplatform/it/util/ConvergenceObservations.scala b/src/it/scala/org/ergoplatform/it/util/ConvergenceObservations.scala new file mode 100644 index 0000000000..09ce30420b --- /dev/null +++ b/src/it/scala/org/ergoplatform/it/util/ConvergenceObservations.scala @@ -0,0 +1,100 @@ +package org.ergoplatform.it.util + +import java.util.concurrent.{ScheduledThreadPoolExecutor, ThreadFactory, TimeUnit, TimeoutException} +import org.ergoplatform.it.api.NodeApi.NodeInfo + +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.concurrent.duration._ +import scala.util.{Failure, Success, Try} + +/** Bounded, single-flight observations for integration assertions. */ +final class ConvergenceObservations(implicit ec: ExecutionContext) extends AutoCloseable { + private val timer = new ScheduledThreadPoolExecutor(1, new ThreadFactory { + override def newThread(runnable: Runnable): Thread = { + val thread = new Thread(runnable, "convergence-observations") + thread.setDaemon(true) + thread + } + }) + timer.setRemoveOnCancelPolicy(true) + + private def bounded[A](future: Future[A], budget: FiniteDuration): Future[A] = { + val result = Promise[A]() + val timeout = timer.schedule(new Runnable { + override def run(): Unit = result.tryFailure(new TimeoutException("Observation deadline")) + }, math.max(0L, budget.toNanos), TimeUnit.NANOSECONDS) + future.onComplete { value => + result.tryComplete(value) + timeout.cancel(false) + } + result.future + } + + final class Probe[A](request: () => Future[A]) { + private var pending: Option[Future[A]] = None + + def sample(budget: FiniteDuration): Future[Either[String, A]] = { + val response = synchronized { + val current = pending.filterNot(_.isCompleted).getOrElse { + Try(request()) match { + case Success(value) => value + case Failure(error) => Future.failed(error) + } + } + pending = Some(current) + current + } + bounded(response, budget).map(value => Right(value): Either[String, A]).recover { + case scala.util.control.NonFatal(error) => Left(error.getClass.getSimpleName) + } + } + } + + def probe[A](request: => Future[A]): Probe[A] = new Probe(() => request) + + def until[A](deadline: Deadline, interval: FiniteDuration, sampleBudget: FiniteDuration) + (observe: FiniteDuration => Future[A])(accept: A => Boolean) + (failure: => String): Future[A] = { + def expired: Future[A] = Future.failed(new TimeoutException(failure)) + + def loop(): Future[A] = { + if (deadline.isOverdue()) expired + else { + val remaining = deadline.timeLeft + bounded(observe(sampleBudget.min(remaining)), remaining).flatMap { value => + if (deadline.isOverdue()) expired + else if (accept(value)) Future.successful(value) + else { + val next = Promise[Unit]() + timer.schedule(new Runnable { + override def run(): Unit = next.trySuccess(()) + }, interval.min(deadline.timeLeft).max(Duration.Zero).toNanos, TimeUnit.NANOSECONDS) + next.future.flatMap(_ => loop()) + } + }.recoverWith { + case _: TimeoutException => expired + } + } + } + + loop() + } + + override def close(): Unit = timer.shutdownNow() +} + +object ConvergenceObservations { + def sameBestBlock(infoA: NodeInfo, infoB: NodeInfo, minHeight: Int): Boolean = { + val sameHeight = infoA.bestBlockHeightOpt.nonEmpty && infoA.bestBlockHeightOpt == infoB.bestBlockHeightOpt + val sameBlock = infoA.bestBlockIdOpt.nonEmpty && infoA.bestBlockIdOpt == infoB.bestBlockIdOpt + val highEnough = infoA.bestBlockHeightOpt.exists(_ >= minHeight) + sameHeight && sameBlock && highEnough + } + + def selectedHeadersAgree(headers: Seq[Seq[String]]): Boolean = + headers.nonEmpty && headers.forall(_.headOption.exists(_.nonEmpty)) && + headers.map(_.head).distinct.size == 1 + + def headerId(value: String): String = + if (value.matches("[0-9a-fA-F]{64}")) value else "invalid-header-id" +} diff --git a/src/it/scala/org/ergoplatform/it/util/ConvergenceObservationsSpec.scala b/src/it/scala/org/ergoplatform/it/util/ConvergenceObservationsSpec.scala new file mode 100644 index 0000000000..bdbcfcc3ed --- /dev/null +++ b/src/it/scala/org/ergoplatform/it/util/ConvergenceObservationsSpec.scala @@ -0,0 +1,96 @@ +package org.ergoplatform.it.util + +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicInteger + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.ergoplatform.it.api.NodeApi.NodeInfo + +import scala.concurrent.{Await, ExecutionContext, Future, Promise} +import scala.concurrent.duration._ + +class ConvergenceObservationsSpec extends AnyFlatSpec with Matchers { + implicit private val ec: ExecutionContext = ExecutionContext.global + + private def withObserver(test: ConvergenceObservations => Unit): Unit = { + val observer = new ConvergenceObservations + try test(observer) + finally observer.close() + } + + "Selected header agreement" should "accept retained alternatives only when every first ID agrees" in { + ConvergenceObservations.selectedHeadersAgree(Seq(Seq("a", "b"), Seq("a", "c"))) shouldBe true + ConvergenceObservations.selectedHeadersAgree(Seq(Seq("a", "b"), Seq("b", "a"))) shouldBe false + ConvergenceObservations.selectedHeadersAgree(Seq(Seq("a"), Seq.empty)) shouldBe false + ConvergenceObservations.selectedHeadersAgree(Seq(Seq(""), Seq(""))) shouldBe false + ConvergenceObservations.selectedHeadersAgree(Seq.empty) shouldBe false + } + + "Full block agreement" should "require both heights and IDs and the original minimum height" in { + val info = NodeInfo(Some("header"), Some("block"), Some(60), Some(50), None, None) + ConvergenceObservations.sameBestBlock(info, info, 50) shouldBe true + ConvergenceObservations.sameBestBlock(info, info, 51) shouldBe false + ConvergenceObservations.sameBestBlock(info, info.copy(bestBlockHeightOpt = Some(51)), 50) shouldBe false + ConvergenceObservations.sameBestBlock(info, info.copy(bestBlockIdOpt = Some("other")), 50) shouldBe false + ConvergenceObservations.sameBestBlock(info.copy(bestBlockHeightOpt = None), info, 50) shouldBe false + ConvergenceObservations.sameBestBlock(info, info.copy(bestBlockHeightOpt = None), 50) shouldBe false + ConvergenceObservations.sameBestBlock(info.copy(bestBlockIdOpt = None), info, 50) shouldBe false + ConvergenceObservations.sameBestBlock(info, info.copy(bestBlockIdOpt = None), 50) shouldBe false + } + + it should "resample the entire group until its current selections agree" in withObserver { observer => + val samples = new AtomicInteger() + val result = observer.until(2.seconds.fromNow, 1.millis, 100.millis) { _ => + val headers = if (samples.incrementAndGet() == 1) Seq(Seq("a", "b"), Seq("b", "a")) + else Seq(Seq("b", "a"), Seq("b")) + Future.successful(headers) + }(ConvergenceObservations.selectedHeadersAgree)("selected headers disagree") + Await.result(result, 3.seconds).map(_.head) shouldBe Seq("b", "b") + samples.get() shouldBe 2 + } + + it should "fail persistent disagreement within the original deadline with recent evidence" in withObserver { observer => + var recent = "none" + val result = observer.until(100.millis.fromNow, 1.millis, 20.millis) { _ => + recent = "node0=a node1=b" + Future.successful(Seq(Seq("a"), Seq("b"))) + }(ConvergenceObservations.selectedHeadersAgree)(s"last: $recent") + intercept[TimeoutException](Await.result(result, 2.seconds)).getMessage should include("node0=a node1=b") + } + + "Observation probes" should "bound a stalled endpoint and not start overlapping requests" in withObserver { observer => + val calls = new AtomicInteger() + val never = Promise[Int]() + val probe = observer.probe { calls.incrementAndGet(); never.future } + Await.result(probe.sample(20.millis), 2.seconds) shouldBe Left("TimeoutException") + Await.result(probe.sample(20.millis), 2.seconds) shouldBe Left("TimeoutException") + calls.get() shouldBe 1 + never.success(3) + Await.result(never.future, 2.seconds) shouldBe 3 + Await.result(probe.sample(100.millis), 2.seconds) shouldBe Right(3) + calls.get() shouldBe 2 + } + + it should "keep successful status available while the peer sample times out" in withObserver { observer => + val status = observer.probe(Future.successful(42)).sample(100.millis) + val peers = observer.probe(Promise[Int]().future).sample(30.millis) + Await.result(status, 2.seconds) shouldBe Right(42) + Await.result(status.zip(peers), 2.seconds) shouldBe (Right(42) -> Left("TimeoutException")) + } + + it should "retain only an error class for endpoint failures" in withObserver { observer => + val result = observer.probe(Future.failed[Int](new IllegalArgumentException("private diagnostic payload"))) + Await.result(result.sample(100.millis), 2.seconds) shouldBe Left("IllegalArgumentException") + } + + it should "enforce the convergence deadline even when observation never returns" in withObserver { observer => + val result = observer.until(30.millis.fromNow, 1.millis, 10.millis)(_ => Promise[Boolean]().future)(identity)("recent status") + intercept[TimeoutException](Await.result(result, 2.seconds)).getMessage shouldBe "recent status" + } + + "Observation identifiers" should "exclude arbitrary response text" in { + ConvergenceObservations.headerId("ab" * 32) shouldBe "ab" * 32 + ConvergenceObservations.headerId("unexpected response text") shouldBe "invalid-header-id" + } +} diff --git a/src/main/scala/org/ergoplatform/nodeView/ErgoNodeViewHolder.scala b/src/main/scala/org/ergoplatform/nodeView/ErgoNodeViewHolder.scala index 0363d7bd26..7d1884259c 100644 --- a/src/main/scala/org/ergoplatform/nodeView/ErgoNodeViewHolder.scala +++ b/src/main/scala/org/ergoplatform/nodeView/ErgoNodeViewHolder.scala @@ -19,7 +19,7 @@ import org.ergoplatform.nodeView.mempool.ErgoMemPoolUtils.ProcessingOutcome import org.ergoplatform.nodeView.state._ import org.ergoplatform.nodeView.wallet.ErgoWallet import org.ergoplatform.settings.{Algos, Constants, ErgoSettings, NetworkType, ScorexSettings} -import org.ergoplatform.utils.ScorexEncoding +import org.ergoplatform.utils.{LoggingUtil, ScorexEncoding} import org.ergoplatform.validation.{MalformedModifierError, RecoverableModifierError} import org.ergoplatform.wallet.utils.FileUtils import scorex.util.{ModifierId, ScorexLogging} @@ -809,9 +809,13 @@ object ErgoNodeViewHolder { history.bestFullBlockOpt .filter(_.id != lastMod.id) .fold("")(fb => s"\n best full block: $fb") - val repairNeeded = ErgoHistory.repairIfNeeded(history) + val repairStatus = ErgoHistory.repairIfNeeded(history) match { + case Success(false) => "repair not needed" + case Success(true) => "repair completed" + case Failure(error) => s"repair failed: ${LoggingUtil.getReasonMsg(error)}" + } ChainIsStuck(s"Chain not modified for $chainUpdateDelay ms, headers-height: $headersHeight, " + - s"block-height $blockHeight, chain synced: $chainSynced, repair needed: $repairNeeded, " + + s"block-height $blockHeight, chain synced: $chainSynced, $repairStatus, " + s"last modifier applied: $lastMod, " + s"possible best full block $bestFullBlockOpt") } else { diff --git a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala index c001dd8e64..837da2f63c 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala @@ -203,24 +203,23 @@ trait ErgoHistory /** * Remove header, corresponding block parts, and corresponding indexes from storage and caches * @param headerId - header id - * @return + * @return Success after every removal, or the first removal failure */ - def forgetHeader(headerId: ModifierId): Try[Unit] = Try { - val hOpt = typedModifierById[Header](headerId) - val hRes = historyStorage.remove( + def forgetHeader(headerId: ModifierId): Try[Unit] = Try(typedModifierById[Header](headerId)).flatMap { hOpt => + // Keep the header until its sections are removed so a retry can still discover their identifiers. + hOpt.toSeq.flatMap(requiredModifiersForHeader).foldLeft[Try[Unit]](Success(())) { + case (result, (_, mId)) => result.flatMap { _ => + val removal = historyStorage.remove(Array(validityKey(mId)), Array(mId)) + log.info(s"Result of removing modifier $mId: " + removal) + removal + } + }.flatMap { _ => + val removal = historyStorage.remove( indicesToRemove = Array(validityKey(headerId), headerHeightKey(headerId), headerScoreKey(headerId)), idsToRemove = Array(headerId) ) - log.info(s"Result of removing header $headerId: " + hRes) - - hOpt.foreach { h => - requiredModifiersForHeader(h).foreach { case (_, mId) => - val mRes = historyStorage.remove( - indicesToRemove = Array(validityKey(mId)), - idsToRemove = Array(mId) - ) - log.info(s"Result of removing modifier $mId: " + mRes) - } + log.info(s"Result of removing header $headerId: " + removal) + removal } } @@ -239,30 +238,34 @@ object ErgoHistory extends ScorexLogging { dir } - // check if there is possible database corruption when there is header after - // recognized blockchain tip marked as invalid - protected[nodeView] def repairIfNeeded(history: ErgoHistory): Boolean = history.historyStorage.synchronized { + // Success(false) means no repair was needed; Success(true) means all removals completed. + // Failure preserves the first error; the height index is removed only after all headers are forgotten. + protected[nodeView] def repairIfNeeded(history: ErgoHistory): Try[Boolean] = Try(history.historyStorage.synchronized { val bestHeaderHeight = history.headersHeight val bestFullBlockHeight = history.bestFullBlockOpt.map(_.height).getOrElse(-1) val afterHeaders = history.headerIdsAtHeight(bestHeaderHeight + 1) if (bestHeaderHeight == bestFullBlockHeight && afterHeaders.nonEmpty) { log.warn("Found suspicious continuation, clearing it...") - afterHeaders.map { hId => - history.forgetHeader(hId) - } - history.historyStorage.remove(Array(history.heightIdsKey(bestHeaderHeight + 1)), Array.empty[ModifierId]) - true + afterHeaders.foldLeft[Try[Unit]](Success(())) { (result, hId) => + result.flatMap(_ => history.forgetHeader(hId)) + }.flatMap { _ => + history.historyStorage.remove(Array(history.heightIdsKey(bestHeaderHeight + 1)), Array.empty[ModifierId]) + }.map(_ => true) } else { - false + Success(false) } - } + }).flatten /** * @return ErgoHistory instance with new database or database read from existing folder */ - def readOrGenerate(ergoSettings: ErgoSettings)(implicit context: ActorContext): ErgoHistory = { - var db = HistoryStorage(ergoSettings) + def readOrGenerate(ergoSettings: ErgoSettings)(implicit context: ActorContext): ErgoHistory = + readOrGenerate(ergoSettings, HistoryStorage(ergoSettings)) + + private[history] def readOrGenerate(ergoSettings: ErgoSettings, + storage: HistoryStorage)(implicit context: ActorContext): ErgoHistory = { + var db = storage // ExtraIndexer db check if(ergoSettings.nodeSettings.extraIndex) { // check db schema @@ -292,7 +295,14 @@ object ErgoHistory extends ScorexLogging { } } - repairIfNeeded(history) + repairIfNeeded(history) match { + case Failure(error) => + Try(history.closeStorage()).failed.foreach { closeError => + if (closeError ne error) error.addSuppressed(closeError) + } + throw error + case Success(_) => + } log.info("History database read") if(ergoSettings.nodeSettings.extraIndex) // start extra indexer, if enabled 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..1ba1173713 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/HistoryStorage.scala @@ -185,7 +185,7 @@ class HistoryStorage(indexStore: LDBKVStore, objectsStore: LDBKVStore, extraStor def remove(indicesToRemove: Array[ByteArrayWrapper], idsToRemove: Array[ModifierId]): Try[Unit] = { - objectsStore.remove(idsToRemove.map(idToBytes)).map { _ => + objectsStore.remove(idsToRemove.map(idToBytes)).flatMap { _ => cfor(0)(_ < idsToRemove.length, _ + 1) { i => removeModifier(idsToRemove(i))} indexStore.remove(indicesToRemove.map(_.data)).map { _ => cfor(0)(_ < indicesToRemove.length, _ + 1) { i => indexCache.invalidate(indicesToRemove(i))} diff --git a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala index c049a80872..9a5d26e4d3 100644 --- a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala +++ b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala @@ -892,74 +892,68 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp it should "ignore cached candidate when forced = true" in new TestKit(ActorSystem()) { val testProbe = new TestProbe(system) - system.eventStream.subscribe(testProbe.ref, newBlockSignal) + val viewHolderProbe = new TestProbe(system) val testDir = s"${defaultSettings.directory}-ignore-cache-${System.currentTimeMillis()}" - val settingsWithShortRegeneration: ErgoSettings = + val settingsForExplicitForcing: ErgoSettings = ErgoSettingsReader.read() .copy( nodeSettings = defaultSettings.nodeSettings - .copy(blockCandidateGenerationInterval = 1.millis), + // Keep automatic refresh outside this test of explicit forcing. + .copy(blockCandidateGenerationInterval = 1.hour), chainSettings = ErgoSettingsReader.read().chainSettings.copy(blockInterval = 1.seconds), directory = testDir ) - val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithShortRegeneration) - val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + // Keep readers fixed: a later ChangedMempool event may legitimately regenerate the cache. + val (initialState, boxes) = createUtxoState(settingsForExplicitForcing) + val block = validFullBlock(None, initialState, boxes) + val state = initialState.applyModifier(block, None)(_ => ()).get + // Initialization computes mining-time averages from pairs of headers. + val history = historyWithBestFullBlock(Seq(block)) + val readers = Readers(history, state, ErgoMemPool.empty(settingsForExplicitForcing), walletStub) + val readersHolderRef = system.actorOf(Props(new FixedReadersHolder(readers))) val candidateGenerator: ActorRef = CandidateGenerator( defaultMinerSecret.publicImage, readersHolderRef, - viewHolderRef, - settingsWithShortRegeneration + viewHolderProbe.ref, + settingsForExplicitForcing ) - val powScheme = settingsWithShortRegeneration.chainSettings.powScheme - - // First mine a block to establish chain (needed for avg mining time calculation) - candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) - val initCandidate = testProbe.expectMsgPF(candidateGenDelay) { - case StatusReply.Success(c: Candidate) => c - } - val initBlock = powScheme - .proveCandidate(initCandidate.candidateBlock, defaultMinerSecret.w, 0, 1000) - .get - candidateGenerator.tell(initBlock.header.powSolution, testProbe.ref) - testProbe.fishForMessage(blockValidationDelay) { - case StatusReply.Success(()) => true - case FullBlockApplied(header) if header.id != initBlock.header.parentId => true - case _ => false - } + try { + // Get first candidate from the coherent, already applied block snapshot. + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) + val candidate1 = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + candidate1.candidateBlock.parentOpt.map(_.id) shouldBe Some(block.header.id) - // Get first candidate after chain is established - candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) - val candidate1 = testProbe.expectMsgPF(candidateGenDelay) { - case StatusReply.Success(c: Candidate) => c - } + // Request with forced = false should return cached candidate immediately. + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) + val candidate2 = testProbe.expectMsgPF(100.millis) { + case StatusReply.Success(c: Candidate) => c + } + candidate2 should be theSameInstanceAs candidate1 + candidate2.candidateBlock.timestamp shouldBe candidate1.candidateBlock.timestamp - // Request with forced = false should return cached candidate immediately - candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) - val candidate2 = testProbe.expectMsgPF(100.millis) { - case StatusReply.Success(c: Candidate) => c - } - // Should be the exact same cached candidate - candidate2.candidateBlock.timestamp shouldBe candidate1.candidateBlock.timestamp + // Request with forced = true should bypass cache and regenerate. + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = true), testProbe.ref) + val candidate3 = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } - // Request with forced = true should bypass cache and regenerate - candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = true), testProbe.ref) - val candidate3 = testProbe.fishForMessage(candidateGenDelay) { - case StatusReply.Success(_: Candidate) => true - case _: FullBlockApplied => false - } match { - case StatusReply.Success(c: Candidate) => c + // Identity proves regeneration even when the clock has not advanced. + candidate3 should not be theSameInstanceAs(candidate1) + candidate3.candidateBlock.parentOpt.map(_.id) shouldBe Some(block.header.id) + candidate3.candidateBlock.timestamp should be >= candidate1.candidateBlock.timestamp + } finally { + TestKit.shutdownActorSystem(system) + history.closeStorage() + state.closeStorage() } - - // candidate3 should have timestamp >= candidate1 (regenerated, possibly same or newer) - candidate3.candidateBlock.timestamp should be >= candidate1.candidateBlock.timestamp - - system.terminate() } it should "preserve previous candidate when forced regeneration occurs" in new TestKit(ActorSystem()) { diff --git a/src/test/scala/org/ergoplatform/modifiers/mempool/ErgoNodeTransactionSpec.scala b/src/test/scala/org/ergoplatform/modifiers/mempool/ErgoNodeTransactionSpec.scala index 7768778054..e1f8de0ab8 100644 --- a/src/test/scala/org/ergoplatform/modifiers/mempool/ErgoNodeTransactionSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/mempool/ErgoNodeTransactionSpec.scala @@ -16,8 +16,6 @@ import org.ergoplatform.wallet.boxes.{ErgoBoxAssetExtractor, ErgoBoxSerializer} import org.ergoplatform.wallet.interpreter.TransactionHintsBag import org.ergoplatform.wallet.protocol.context.InputContext import org.scalacheck.Gen -import sigma.util.BenchmarkUtil -import scorex.crypto.hash.Blake2b256 import scorex.util.encode.Base16 import sigma.{Colls, VersionContext} import sigma.ast.ErgoTree.DefaultHeader @@ -326,34 +324,29 @@ class ErgoNodeTransactionSpec extends ErgoCorePropertyTest with ErgoCompilerHelp property("transaction with too many inputs should be rejected") { - //we assume that verifier must finish verification of any script in less time than 250K hash calculations - // (for the Blake2b256 hash function over a single block input) - val Timeout: Long = { - val hf = Blake2b256 - - //just in case to heat up JVM - (1 to 5000000).foreach(i => hf(s"$i-$i")) - - val t0 = System.currentTimeMillis() - (1 to 250000).foreach(i => hf(s"$i")) - val t = System.currentTimeMillis() - t - t0 - } + // This test used to calibrate a wall-clock budget by timing 250K Blake2b256 hashes and then + // assert that validation fits in it. On a loaded CI machine the calibration window and the + // measurement window get different shares of the CPU, so the assertions flipped at random + // (see issue #2095). What the node is actually protected by is the block cost limit, not the + // clock, so the assertions below are on cost, which is deterministic. val gen = validErgoTransactionGenTemplate(0, 0, 2000, trueLeafGen) val (from, tx) = gen.sample.get tx.statelessValidity().isSuccess shouldBe true - //check that spam transaction is being rejected quickly implicit val verifier: ErgoInterpreter = ErgoInterpreter(parameters) - val (validity, time0) = BenchmarkUtil.measureTime(tx.statefulValidity(from, IndexedSeq(), emptyStateContext)) + + // with the block cost limit in force, the spam transaction is rejected, and validation is + // aborted as soon as the accumulated cost passes the limit + val validity = tx.statefulValidity(from, IndexedSeq(), emptyStateContext) validity.isSuccess shouldBe false - assert(time0 <= Timeout) val cause = validity.failed.get.getMessage cause should startWith(ValidationRules.errorMessage(bsBlockTransactionsCost, "", emptyModifierId, ErgoTransaction.modifierTypeId).take(30)) - //check that spam transaction validation with no cost limit is indeed taking too much time + // with a cost limit high enough to let it through, the same transaction validates, and the cost + // it accumulates is far above the block limit - that gap is what makes it spam, and it does not + // depend on how fast the machine running the test is import Parameters._ val maxCost = (Int.MaxValue - 10) / 10 // cannot use Int.MaxValue directly due to overflow when it is converted to block cost val ps = Parameters(0, DefaultParameters.updated(MaxBlockCostIncrease, maxCost), emptyVSUpdate) @@ -365,11 +358,15 @@ class ErgoNodeTransactionSpec extends ErgoCorePropertyTest with ErgoCompilerHelp Array.fill(3)(0.toByte), ErgoValidationSettingsUpdate.empty, 0.toByte) - val (_, time) = BenchmarkUtil.measureTime( - tx.statefulValidity(from, IndexedSeq(), sc)(verifier) - ) - assert(time > Timeout) + val blockCostLimit = emptyStateContext.currentParameters.maxBlockCost + val fullCost = tx.statefulValidity(from, IndexedSeq(), sc)(verifier).get + // the generator produces exactly `maxInputs` inputs, so this ratio is stable (~4.4x as of today) + fullCost.toLong should be > (blockCostLimit.toLong * 2) + + // and it is rejected by any limit below its cost, one unit below included + val justBelow = stateContextWith(Parameters(0, DefaultParameters.updated(MaxBlockCostIncrease, fullCost - 1), emptyVSUpdate)) + tx.statefulValidity(from, IndexedSeq(), justBelow)(ErgoInterpreter(justBelow.currentParameters)) shouldBe 'failure } property("transaction cost") { diff --git a/src/test/scala/org/ergoplatform/nodeView/history/HistoryRepairSpec.scala b/src/test/scala/org/ergoplatform/nodeView/history/HistoryRepairSpec.scala new file mode 100644 index 0000000000..fef4428aa2 --- /dev/null +++ b/src/test/scala/org/ergoplatform/nodeView/history/HistoryRepairSpec.scala @@ -0,0 +1,246 @@ +package org.ergoplatform.nodeView.history + +import com.google.common.primitives.Ints +import org.ergoplatform.mining.AutolykosPowScheme +import org.ergoplatform.modifiers.BlockSection +import org.ergoplatform.modifiers.history.BlockTransactions +import org.ergoplatform.modifiers.history.extension.Extension +import org.ergoplatform.modifiers.history.header.Header +import org.ergoplatform.nodeView.ErgoNodeViewHolder +import org.ergoplatform.nodeView.ErgoNodeViewHolder.ReceivableMessages.{ChainIsStuck, ChainProgress} +import org.ergoplatform.nodeView.history.storage.HistoryStorage +import org.ergoplatform.nodeView.history.storage.modifierprocessors.FullBlockSectionProcessor +import org.ergoplatform.nodeView.state.StateType +import org.ergoplatform.settings.{Algos, ErgoSettings} +import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.utils.ErgoNodeTestConstants.initSettings +import org.ergoplatform.utils.generators.ErgoCoreGenerators.{defaultHeaderGen, randomADProofsGen} +import org.ergoplatform.utils.generators.ErgoNodeTransactionGenerators.invalidErgoTransactionGen +import scorex.db.ByteArrayWrapper +import scorex.util.{ModifierId, idToBytes} + +import scala.collection.mutable +import scala.util.{Failure, Success, Try} + +class HistoryRepairSpec extends ErgoCorePropertyTest { + private val testSettings = initSettings.copy(nodeSettings = initSettings.nodeSettings.copy( + stateType = StateType.Utxo, verifyTransactions = true, extraIndex = false)) + + // Ordinary storage-return failures isolate each caller join, independently of the storage implementation. + private class TestStorage extends HistoryStorage(null, null, null, testSettings.cacheSettings) { + val indexes: mutable.Map[ByteArrayWrapper, Array[Byte]] = mutable.Map.empty + val objects: mutable.Map[ModifierId, BlockSection] = mutable.Map.empty + val removals: mutable.Buffer[(Seq[ByteArrayWrapper], Seq[ModifierId])] = mutable.Buffer.empty + val failure = new IllegalStateException("history removal sentinel") + var failAt: Int = -1 + var closed: Boolean = false + var closeFailure: Option[RuntimeException] = None + override def close(): Unit = { + closed = true + closeFailure.foreach(error => throw error) + } + + override def getIndex(key: ByteArrayWrapper): Option[Array[Byte]] = indexes.get(key) + override def modifierById(id: ModifierId): Option[BlockSection] = objects.get(id) + override def contains(id: ModifierId): Boolean = objects.contains(id) + override def remove(keys: Array[ByteArrayWrapper], ids: Array[ModifierId]): Try[Unit] = { + removals += ((keys.toSeq, ids.toSeq)) + if (removals.size == failAt) Failure(failure) + else { + keys.foreach(indexes.remove) + ids.foreach(objects.remove) + Success(()) + } + } + } + + private class Fixture(stateType: StateType = StateType.Utxo, verifyTransactions: Boolean = true) { + val fixtureSettings: ErgoSettings = testSettings.copy(nodeSettings = testSettings.nodeSettings.copy( + stateType = stateType, verifyTransactions = verifyTransactions)) + val db = new TestStorage + val history: ErgoHistory = new ErgoHistory with FullBlockSectionProcessor { + override protected val settings: ErgoSettings = fixtureSettings + override protected[history] val historyStorage: HistoryStorage = db + override val powScheme: AutolykosPowScheme = testSettings.chainSettings.powScheme + } + val seed: Header = defaultHeaderGen.sample.get.copy(height = 10) + val txs = BlockTransactions(seed.id, seed.version, Seq(invalidErgoTransactionGen.sample.get)) + val ext = Extension(seed.id, Seq.empty) + val proofs = randomADProofsGen.sample.get + val tip: Header = seed.copy(transactionsRoot = txs.digest, extensionRoot = ext.digest, ADProofsRoot = proofs.digest) + val children: Seq[Header] = (1 to 2).map(i => tip.copy( + parentId = tip.id, height = tip.height + 1, timestamp = tip.timestamp + i)) + val heightKey: ByteArrayWrapper = ByteArrayWrapper(Algos.hash(Ints.toByteArray(tip.height + 1))) + + db.objects += tip.id -> tip + db.objects += tip.transactionsId -> txs.copy(headerId = tip.id) + db.objects += tip.extensionId -> ext.copy(headerId = tip.id) + if (stateType.requireProofs) db.objects += tip.ADProofsId -> proofs.copy(headerId = tip.id) + children.foreach { h => + db.objects += h.id -> h + db.objects += h.transactionsId -> txs.copy(headerId = h.id) + db.objects += h.extensionId -> ext.copy(headerId = h.id) + if (stateType.requireProofs) db.objects += h.ADProofsId -> proofs.copy(headerId = h.id) + } + db.indexes += ByteArrayWrapper(Array.fill[Byte](32)(Header.modifierTypeId)) -> idToBytes(tip.id) + db.indexes += ByteArrayWrapper(Array.fill[Byte](32)(-1)) -> idToBytes(tip.id) + db.indexes += ByteArrayWrapper(Algos.hash("height".getBytes("UTF-8") ++ idToBytes(tip.id))) -> Ints.toByteArray(tip.height) + db.indexes += heightKey -> children.flatMap(h => idToBytes(h.id)).toArray + + def healthReason: String = { + val progress = ChainProgress(tip, tip.height, tip.height, 0L) + ErgoNodeViewHolder.checkChainIsHealthy(progress, history, testSettings) match { + case ChainIsStuck(reason) => reason + case result => fail(s"Expected a stuck chain, got $result") + } + } + } + + (1 to 3).foreach { failedRemoval => + property(s"forgetHeader propagates removal $failedRemoval and stops subsequent deletions") { + val f = new Fixture + f.db.failAt = failedRemoval + f.history.forgetHeader(f.children.head.id) shouldBe Failure(f.db.failure) + f.db.removals.size shouldBe failedRemoval + } + } + + (1 to 7).foreach { failedRemoval => + property(s"repair propagates removal $failedRemoval without clearing the retry index") { + val f = new Fixture + f.db.failAt = failedRemoval + ErgoHistory.repairIfNeeded(f.history) shouldBe Failure(f.db.failure) + f.db.removals.size shouldBe failedRemoval + f.db.indexes should contain key f.heightKey + } + } + + (1 to 3).foreach { failedRemoval => + property(s"retry discovers all sections after removal $failedRemoval fails") { + val f = new Fixture + val child = f.children.head + f.db.failAt = failedRemoval + f.history.forgetHeader(child.id) + f.db.objects.contains(child.id) shouldBe true + f.db.failAt = -1 + f.history.forgetHeader(child.id) shouldBe Success(()) + (child.id +: child.sectionIdsWithNoProof.map(_._2)).foreach { id => + f.db.objects should not contain key (id) + } + } + } + + (1 to 7).foreach { failedRemoval => + property(s"repair retry finishes after partial progress at removal $failedRemoval") { + val f = new Fixture + f.db.failAt = failedRemoval + ErgoHistory.repairIfNeeded(f.history) shouldBe Failure(f.db.failure) + f.db.failAt = -1 + ErgoHistory.repairIfNeeded(f.history) shouldBe Success(true) + f.children.flatMap(h => h.id +: h.sectionIdsWithNoProof.map(_._2)).foreach { id => + f.db.objects.contains(id) shouldBe false + } + f.db.indexes.contains(f.heightKey) shouldBe false + f.history.bestFullBlockOpt.map(_.id) shouldBe Some(f.tip.id) + } + } + + (1 to 4).foreach { failedRemoval => + property(s"digest history propagates required section or header removal $failedRemoval") { + val f = new Fixture(StateType.Digest) + f.db.failAt = failedRemoval + f.history.forgetHeader(f.children.head.id) shouldBe Failure(f.db.failure) + f.db.removals.size shouldBe failedRemoval + f.db.objects.contains(f.children.head.id) shouldBe true + f.db.failAt = -1 + f.history.forgetHeader(f.children.head.id) shouldBe Success(()) + f.children.head.sectionIds.foreach { case (_, id) => f.db.objects.contains(id) shouldBe false } + } + } + + property("header-only mode removes just the header") { + val f = new Fixture(verifyTransactions = false) + f.history.forgetHeader(f.children.head.id) shouldBe Success(()) + f.db.removals.map(_._2).toSeq shouldBe Seq(Seq(f.children.head.id)) + } + + property("missing headers still have their header indexes removed") { + val f = new Fixture + f.db.objects.remove(f.children.head.id) + f.history.forgetHeader(f.children.head.id) shouldBe Success(()) + f.db.removals.size shouldBe 1 + f.db.removals.head._1.size shouldBe 3 + f.db.removals.head._2 shouldBe Seq(f.children.head.id) + } + + property("repair reports completion only after all removals") { + val f = new Fixture + ErgoHistory.repairIfNeeded(f.history) shouldBe Success(true) + f.db.removals.size shouldBe 7 + f.db.removals.last shouldBe ((Seq(f.heightKey), Seq.empty)) + f.db.indexes should not contain key (f.heightKey) + ErgoHistory.repairIfNeeded(f.history) shouldBe Success(false) + f.db.removals.size shouldBe 7 + } + + property("repair is unnecessary when the full block tip is behind the header tip") { + val f = new Fixture + f.db.indexes.remove(ByteArrayWrapper(Array.fill[Byte](32)(-1))) + ErgoHistory.repairIfNeeded(f.history) shouldBe Success(false) + f.db.removals shouldBe empty + } + + property("health owner reports the repair failure") { + val f = new Fixture + f.db.failAt = 1 + f.healthReason should include ("repair failed: java.lang.IllegalStateException: history removal sentinel") + f.db.removals.size shouldBe 1 + } + + property("health owner distinguishes completed repair from an unnecessary repair") { + val f = new Fixture + f.healthReason should include ("repair completed") + f.healthReason should include ("repair not needed") + f.db.removals.size shouldBe 7 + } + + Seq(1, 7).foreach { failedRemoval => + property(s"startup owner propagates removal $failedRemoval failure and closes storage") { + val f = new Fixture + f.db.failAt = failedRemoval + val error = intercept[IllegalStateException] { + ErgoHistory.readOrGenerate(testSettings, f.db)(null) + } + error should be theSameInstanceAs f.db.failure + f.db.closed shouldBe true + f.db.indexes should contain key f.heightKey + } + } + + property("startup returns repaired history after all removals succeed") { + val f = new Fixture + val loaded = ErgoHistory.readOrGenerate(testSettings, f.db)(null) + loaded.bestFullBlockOpt.map(_.id) shouldBe Some(f.tip.id) + loaded.headerIdsAtHeight(f.tip.height + 1) shouldBe empty + f.db.closed shouldBe false + } + + property("startup preserves the repair error when closing storage also fails") { + val f = new Fixture + val closeError = new IllegalStateException("history close sentinel") + f.db.failAt = 1 + f.db.closeFailure = Some(closeError) + val error = intercept[IllegalStateException] { ErgoHistory.readOrGenerate(testSettings, f.db)(null) } + error should be theSameInstanceAs f.db.failure + error.getSuppressed.toSeq shouldBe Seq(closeError) + } + + property("startup avoids self-suppression if closing returns the repair error") { + val f = new Fixture + f.db.failAt = 1 + f.db.closeFailure = Some(f.db.failure) + val error = intercept[IllegalStateException] { ErgoHistory.readOrGenerate(testSettings, f.db)(null) } + error should be theSameInstanceAs f.db.failure + error.getSuppressed shouldBe empty + } +} diff --git a/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageRemoveSpec.scala b/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageRemoveSpec.scala new file mode 100644 index 0000000000..57c7403477 --- /dev/null +++ b/src/test/scala/org/ergoplatform/nodeView/history/storage/HistoryStorageRemoveSpec.scala @@ -0,0 +1,125 @@ +package org.ergoplatform.nodeView.history.storage + +import org.ergoplatform.modifiers.BlockSection +import org.ergoplatform.modifiers.history.ADProofs +import org.ergoplatform.settings.{CacheSettings, HistoryCacheSettings} +import org.scalatest.matchers.should.Matchers +import org.scalatest.propspec.AnyPropSpec +import scorex.crypto.authds.SerializedAdProof +import scorex.db.{ByteArrayWrapper, LDBKVStore} +import scorex.util.{bytesToId, idToBytes} + +import scala.collection.mutable +import scala.util.{Failure, Success, Try} + +class HistoryStorageRemoveSpec extends AnyPropSpec with Matchers { + + private val cacheSettings = CacheSettings( + HistoryCacheSettings( + blockSectionsCacheSize = 4, + extraCacheSize = 4, + headersCacheSize = 4, + indexesCacheSize = 4 + ), + network = null, + mempool = null + ) + + property("remove propagates success and invalidates modifier and index caches") { + val f = fixture() + + f.storage.remove(Array(f.indexKey), Array(f.modifier.id)) shouldBe Success(()) + + f.objectsStore.removalBatches shouldBe Vector(Vector(ByteArrayWrapper(idToBytes(f.modifier.id)))) + f.indexStore.removalBatches shouldBe Vector(Vector(f.indexKey)) + f.storage.modifierById(f.modifier.id) shouldBe None + f.objectsStore.getCalls shouldBe 1 + f.storage.getIndex(f.indexKey) shouldBe None + f.indexStore.getCalls shouldBe 1 + } + + property("remove propagates an object-store failure without changing caches or indexes") { + val objectStoreFailure = new IllegalStateException("object-store failure") + val f = fixture(objectRemoval = Failure(objectStoreFailure)) + + f.storage.remove(Array(f.indexKey), Array(f.modifier.id)) shouldBe Failure(objectStoreFailure) + + f.objectsStore.removalBatches shouldBe Vector(Vector(ByteArrayWrapper(idToBytes(f.modifier.id)))) + f.indexStore.removalBatches shouldBe empty + f.storage.modifierById(f.modifier.id) shouldBe Some(f.modifier) + f.objectsStore.getCalls shouldBe 0 + f.storage.getIndex(f.indexKey) shouldBe Some(f.indexValue) + f.indexStore.getCalls shouldBe 0 + } + + property("remove propagates an index-store failure after invalidating only modifier caches") { + val indexStoreFailure = new IllegalStateException("index-store failure") + val f = fixture(indexRemoval = Failure(indexStoreFailure)) + + f.storage.remove(Array(f.indexKey), Array(f.modifier.id)) shouldBe Failure(indexStoreFailure) + + f.objectsStore.removalBatches shouldBe Vector(Vector(ByteArrayWrapper(idToBytes(f.modifier.id)))) + f.indexStore.removalBatches shouldBe Vector(Vector(f.indexKey)) + f.storage.modifierById(f.modifier.id) shouldBe None + f.objectsStore.getCalls shouldBe 1 + f.storage.getIndex(f.indexKey) shouldBe Some(f.indexValue) + f.indexStore.getCalls shouldBe 0 + } + + private def fixture(objectRemoval: Try[Unit] = Success(()), + indexRemoval: Try[Unit] = Success(())): Fixture = { + val objectsStore = new DeterministicStore(objectRemoval) + val indexStore = new DeterministicStore(indexRemoval) + val extraStore = new DeterministicStore(Success(())) + val storage = new HistoryStorage(indexStore, objectsStore, extraStore, cacheSettings) + val modifier = ADProofs( + bytesToId(Array.fill(32)(1.toByte)), + SerializedAdProof @@ Array[Byte](2, 3, 4) + ) + val indexKey = ByteArrayWrapper(Array.fill(32)(5.toByte)) + val indexValue = Array[Byte](6, 7, 8) + + storage.insert(Array(indexKey -> indexValue), Array[BlockSection](modifier)) shouldBe Success(()) + + Fixture(storage, objectsStore, indexStore, modifier, indexKey, indexValue) + } + + private case class Fixture(storage: HistoryStorage, + objectsStore: DeterministicStore, + indexStore: DeterministicStore, + modifier: ADProofs, + indexKey: ByteArrayWrapper, + indexValue: Array[Byte]) + + private class DeterministicStore(removeResult: Try[Unit]) extends LDBKVStore(null) { + private val rows = mutable.Map.empty[ByteArrayWrapper, Array[Byte]] + + var getCalls: Int = 0 + var removalBatches: Vector[Vector[ByteArrayWrapper]] = Vector.empty + + override def get(key: Array[Byte]): Option[Array[Byte]] = { + getCalls += 1 + rows.get(ByteArrayWrapper(key)).map(_.clone()) + } + + override def insert(id: Array[Byte], value: Array[Byte]): Try[Unit] = { + rows.update(ByteArrayWrapper(id.clone()), value.clone()) + Success(()) + } + + override def insert(keys: Array[Array[Byte]], values: Array[Array[Byte]]): Try[Unit] = { + require(keys.length == values.length) + keys.indices.foreach(i => rows.update(ByteArrayWrapper(keys(i).clone()), values(i).clone())) + Success(()) + } + + override def remove(keys: Array[Array[Byte]]): Try[Unit] = { + removalBatches :+= keys.iterator.map(key => ByteArrayWrapper(key.clone())).toVector + removeResult.map { _ => + keys.foreach(key => rows.remove(ByteArrayWrapper(key))) + } + } + + override def close(): Unit = () + } +} diff --git a/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala b/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala index d35e22703d..86d25c1448 100644 --- a/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala @@ -64,8 +64,13 @@ class ErgoMemPoolSpec extends AnyFlatSpec val (us, bh) = createUtxoState(settings) val genesis = validFullBlock(None, us, bh) val wus = WrappedUtxoState(us, bh, settings).applyModifier(genesis)(_ => ()).get - val inputBox = wus.takeBoxes(1).head - val feeOut = new ErgoBoxCandidate(inputBox.value, feeProp, creationHeight = 0) + val inputBox = wus.takeBoxes(100).find(_.ergoTree == TrueTree).get + val feeOut = new ErgoBoxCandidate( + inputBox.value, + feeProp, + creationHeight = 0, + additionalTokens = inputBox.additionalTokens + ) val tx = ErgoTransaction( IndexedSeq(new Input(inputBox.id, ProverResult.empty)), IndexedSeq(feeOut) @@ -79,8 +84,9 @@ class ErgoMemPoolSpec extends AnyFlatSpec mempoolSorting = SortingOption.FeePerByte, )) - var poolSize = ErgoMemPool.empty(sortBySizeSettings) - poolSize = poolSize.process(UnconfirmedTransaction(tx, None), wus)._1 + val (poolSize, sizeOutcome) = + ErgoMemPool.empty(sortBySizeSettings).process(UnconfirmedTransaction(tx, None), wus) + sizeOutcome.isInstanceOf[ProcessingOutcome.Accepted] shouldBe true val size = tx.size poolSize.pool.orderedTransactions.firstKey.weight shouldBe OrderedTxPool.weighted(tx, size).weight @@ -89,8 +95,9 @@ class ErgoMemPoolSpec extends AnyFlatSpec mempoolSorting = SortingOption.FeePerCycle, )) - var poolCost = ErgoMemPool.empty(sortByCostSettings) - poolCost = poolCost.process(UnconfirmedTransaction(tx, None), wus)._1 + val (poolCost, costOutcome) = + ErgoMemPool.empty(sortByCostSettings).process(UnconfirmedTransaction(tx, None), wus) + costOutcome.isInstanceOf[ProcessingOutcome.Accepted] shouldBe true val validationContext = wus.stateContext.simplifiedUpcoming() val cost = wus.validateWithCost(tx, validationContext, Int.MaxValue, None).get poolCost.pool.orderedTransactions.firstKey.weight shouldBe OrderedTxPool.weighted(tx, cost).weight diff --git a/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala b/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala index 2d8e8f4359..4172bb3a95 100644 --- a/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/viewholder/ErgoNodeViewHolderSpec.scala @@ -593,7 +593,9 @@ class ErgoNodeViewHolderSpec extends ErgoCorePropertyTest with NodeViewTestOps w val wusAfterGenesis = wus.applyModifier(genesis)(_ => ()).get // Create a valid tx that pays to a FalseTree output. - val box = wusAfterGenesis.takeBoxes(1).head + val box = wusAfterGenesis.takeBoxes(wusAfterGenesis.size) + .find(_.ergoTree == TrueTree) + .getOrElse(fail("Expected an unspent TrueTree box after genesis")) val validTx = validTransactionFromBoxes(IndexedSeq(box), outputsProposition = FalseTree) val validBlock = validFullBlock(Some(genesis), wusAfterGenesis, Seq(validTx)) diff --git a/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala b/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala index 5766cc1ad9..855a848f01 100644 --- a/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala +++ b/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala @@ -57,7 +57,13 @@ object ErgoNodeTransactionGenerators extends ScorexLogging { def ergoBoxGenForTokens(tokens: Seq[(TokenId, Long)], propositionGen: Gen[ErgoTree]): Gen[ErgoBox] = { - ergoBoxGen(propGen = propositionGen, tokensGen = Gen.oneOf(tokens, tokens), heightGen = EmptyHistoryHeight) + val minValue = BoxUtils.sufficientAmount(extendedParameters) + ergoBoxGen( + propGen = propositionGen, + tokensGen = Gen.oneOf(tokens, tokens), + valueGenOpt = Some(validValueGen.map(value => Math.max(value, minValue))), + heightGen = EmptyHistoryHeight + ) } def unspendableErgoBoxGen(minValue: Long = parameters.minValuePerByte * 200, diff --git a/src/test/scala/org/ergoplatform/utils/generators/FundedBoxHolderGeneratorsSpec.scala b/src/test/scala/org/ergoplatform/utils/generators/FundedBoxHolderGeneratorsSpec.scala new file mode 100644 index 0000000000..cf50e1d882 --- /dev/null +++ b/src/test/scala/org/ergoplatform/utils/generators/FundedBoxHolderGeneratorsSpec.scala @@ -0,0 +1,45 @@ +package org.ergoplatform.utils.generators + +import org.ergoplatform.utils.BoxUtils +import org.ergoplatform.utils.generators.ErgoCoreGenerators.trueLeafGen +import org.ergoplatform.utils.ErgoNodeTestConstants.extendedParameters +import org.ergoplatform.utils.generators.ErgoNodeTransactionGenerators._ +import org.scalacheck.Gen +import org.scalacheck.rng.Seed +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sigmastate.eval.Extensions._ + +class FundedBoxHolderGeneratorsSpec extends AnyFlatSpec with Matchers { + "Generated box holders" should "fund small input groups and conserve their value" in { + val minimum = BoxUtils.sufficientAmount(extendedParameters) + // This seed includes a value below the transaction generator's conservative floor. + val holder = boxesHolderGenOfSize(5).pureApply(Gen.Parameters.default, Seed(803L)) + holder.size shouldBe 5 + val boxes = holder.boxes.values.toIndexedSeq + Seq(1, 2).foreach { inputCount => + boxes.grouped(inputCount).foreach { inputs => + val tx = validUnsignedTransactionFromBoxes(inputs, issueNew = false) + tx.inputs.map(_.boxId.toSeq) shouldBe inputs.map(_.id.toSeq) + tx.outputCandidates.map(_.value).sum shouldBe inputs.map(_.value).sum + tx.outputCandidates.foreach { output => + output.value should be >= minimum + output.additionalTokens.length shouldBe 0 + } + } + } + boxes.foreach(_.value should be >= minimum) + } + + it should "preserve supplied tokens when the node generator funds a box" in { + val token = Array.fill[Byte](32)(1).toTokenId + val box = ergoBoxGenForTokens(Seq(token -> 7L), trueLeafGen) + .pureApply(Gen.Parameters.default, Seed(803L)) + val tx = validUnsignedTransactionFromBoxes(IndexedSeq(box), issueNew = false) + val tokens = tx.outputCandidates.flatMap(_.additionalTokens.toArray) + box.value should be >= BoxUtils.sufficientAmount(extendedParameters) + tx.outputCandidates.map(_.value).sum shouldBe box.value + tokens.map(_._1.toArray.toSeq).distinct shouldBe Seq(token.toArray.toSeq) + tokens.map(_._2).sum shouldBe 7L + } +}