From b8eb8f402ef0ac1f5ba17b923a340a7c4e645de3 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:06:13 +0200 Subject: [PATCH] fix: refresh expired mining candidates --- .../mining/CandidateGenerator.scala | 10 +- .../mining/CandidateGeneratorSpec.scala | 121 +++++++++++++++++- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala b/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala index 080c64409b..bab7c7b6c5 100644 --- a/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala +++ b/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala @@ -199,7 +199,15 @@ class CandidateGenerator( case gen @ GenerateCandidate(txsToInclude, reply, forced, optPk) => val senderOpt = if (reply) Some(sender()) else None val effectiveMinerPk = optPk.getOrElse(minerPk) - if (!forced && cachedFor(state.cachedCandidate, txsToInclude, effectiveMinerPk)) { + if ( + !forced && + cachedFor(state.cachedCandidate, txsToInclude, effectiveMinerPk) && + !hasCandidateExpired( + state.cachedCandidate, + state.solvedBlock, + candidateGenInterval + ) + ) { senderOpt.foreach(_ ! StatusReply.success(state.cachedCandidate.get)) } else { val start = System.currentTimeMillis() diff --git a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala index 26787c525c..457aa00489 100644 --- a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala +++ b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala @@ -331,6 +331,117 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp system.terminate() } + it should "refresh an expired candidate after an earlier mempool change" in new TestKit( + ActorSystem() + ) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val regenerationInterval = 5.seconds + val testDir = + s"${defaultSettings.directory}-expired-cache-${System.currentTimeMillis()}" + val testSettings = ErgoSettingsReader.read() + .copy( + nodeSettings = defaultSettings.nodeSettings + .copy(blockCandidateGenerationInterval = regenerationInterval), + chainSettings = defaultSettings.chainSettings.copy(blockInterval = 1.seconds), + directory = testDir + ) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(testSettings) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + val candidateGenerator: ActorRef = CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + testSettings + ) + + val powScheme = testSettings.chainSettings.powScheme + + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false), + testProbe.ref + ) + val initialCandidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + val initialBlock = powScheme + .proveCandidate(initialCandidate.candidateBlock, defaultMinerSecret.w, 0, 1000) + .get + candidateGenerator.tell(initialBlock.header.powSolution, testProbe.ref) + + var ackSeen = false + var appliedSeen = false + testProbe.fishForMessage(blockValidationDelay) { + case StatusReply.Success(()) => + ackSeen = true + ackSeen && appliedSeen + case FullBlockApplied(header) if header.id == initialBlock.header.id => + appliedSeen = true + ackSeen && appliedSeen + case _ => false + } + + val readers: Readers = await((readersHolderRef ? GetReaders).mapTo[Readers]) + val prop = DLogProverInput( + BigIntegers.fromUnsignedByteArray("expired-cache-test".getBytes()) + ).publicImage + val rewardBox = readers.h.bestFullBlockOpt.get.transactions.last.outputs.last + val unsignedTx = new UnsignedErgoTransaction( + IndexedSeq(Input(rewardBox.id, emptyProverResult)), + IndexedSeq(), + IndexedSeq( + new ErgoBoxCandidate( + rewardBox.value, + ErgoTree.fromSigmaBoolean(prop), + readers.s.stateContext.currentHeight + ) + ) + ) + val tx = ErgoTransaction( + defaultProver + .sign(unsignedTx, IndexedSeq(rewardBox), IndexedSeq(), readers.s.stateContext) + .get + ) + + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = true), + testProbe.ref + ) + val freshCandidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + val poolWithTx = ErgoMemPool.empty(testSettings).put(UnconfirmedTransaction(tx, None)) + candidateGenerator.tell(ChangedMempool(poolWithTx), testProbe.ref) + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false), + testProbe.ref + ) + val unexpiredCandidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + unexpiredCandidate.candidateBlock shouldBe freshCandidate.candidateBlock + unexpiredCandidate.candidateBlock.transactions.map(_.id) should not contain tx.id + + val remainingMillis = regenerationInterval.toMillis - + (System.currentTimeMillis() - freshCandidate.candidateBlock.timestamp) + 200 + remainingMillis should be > 200L + testProbe.expectNoMessage(remainingMillis.millis) + + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false), + testProbe.ref + ) + val regeneratedCandidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + regeneratedCandidate.candidateBlock.transactions.map(_.id) should contain(tx.id) + system.terminate() + } + it should "accept solution for previous candidate after regeneration" in new TestKit(ActorSystem()) { val testProbe = new TestProbe(system) system.eventStream.subscribe(testProbe.ref, newBlockSignal) @@ -831,17 +942,17 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp system.eventStream.subscribe(testProbe.ref, newBlockSignal) val testDir = s"${defaultSettings.directory}-ignore-cache-${System.currentTimeMillis()}" - val settingsWithShortRegeneration: ErgoSettings = + val settingsWithLongCache: ErgoSettings = ErgoSettingsReader.read() .copy( nodeSettings = defaultSettings.nodeSettings - .copy(blockCandidateGenerationInterval = 1.millis), + .copy(blockCandidateGenerationInterval = 1.minute), chainSettings = ErgoSettingsReader.read().chainSettings.copy(blockInterval = 1.seconds), directory = testDir ) - val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithShortRegeneration) + val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithLongCache) val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) val candidateGenerator: ActorRef = @@ -849,10 +960,10 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp defaultMinerSecret.publicImage, readersHolderRef, viewHolderRef, - settingsWithShortRegeneration + settingsWithLongCache ) - val powScheme = settingsWithShortRegeneration.chainSettings.powScheme + val powScheme = settingsWithLongCache.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)