diff --git a/.gitignore b/.gitignore index 313a6221d1..aad6dae921 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ devnet .ensime_cache/ scorex.yaml +# LLM reports on code analysis etc +llm_generated + # scala build folders target diff --git a/build.sbt b/build.sbt index 2fb3ba62fb..6c7996fb99 100644 --- a/build.sbt +++ b/build.sbt @@ -43,7 +43,7 @@ val circeVersion = "0.13.0" val akkaVersion = "2.6.10" val akkaHttpVersion = "10.2.4" -val sigmaStateVersion = "6.0.2" +val sigmaStateVersion = "6.0.3" val ficusVersion = "1.4.7" // for testing current sigmastate build (see sigmastate-ergo-it jenkins job) @@ -185,7 +185,7 @@ docker / dockerfile := { val configMainNet = (IntegrationTest / resourceDirectory).value / "mainnetTemplate.conf" new Dockerfile { - from("openjdk:11-jre-slim") + from("eclipse-temurin:11-jre-jammy") label("ergo-integration-tests", "ergo-integration-tests") add(assembly.value, "/opt/ergo/ergo.jar") add(Seq(configDevNet), "/opt/ergo") diff --git a/ergo-core/src/main/scala/org/ergoplatform/settings/LaunchParameters.scala b/ergo-core/src/main/scala/org/ergoplatform/settings/LaunchParameters.scala index 38db6151e1..8c02a538f1 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/settings/LaunchParameters.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/settings/LaunchParameters.scala @@ -13,8 +13,8 @@ object MainnetLaunchParameters extends Parameters(height = 0, * Parameters corresponding to the genesis block in the public testnet */ object TestnetLaunchParameters extends Parameters(height = 0, - parametersTable = Parameters.DefaultParameters, - proposedUpdate = ErgoValidationSettingsUpdate.empty) + parametersTable = Parameters.DefaultParameters.updated(Parameters.BlockVersion, Header.Interpreter60Version), + proposedUpdate = ErgoValidationSettingsUpdate(Seq(215, 409), Seq.empty)) /** * Initial parameters corresponding to a devnet which is starting with 5.0 activated diff --git a/ergo-core/src/test/scala/org/ergoplatform/settings/LaunchParametersSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/settings/LaunchParametersSpec.scala new file mode 100644 index 0000000000..882edb26c5 --- /dev/null +++ b/ergo-core/src/test/scala/org/ergoplatform/settings/LaunchParametersSpec.scala @@ -0,0 +1,93 @@ +package org.ergoplatform.settings + +import org.ergoplatform.modifiers.history.header.Header +import org.ergoplatform.utils.ErgoCorePropertyTest + +class LaunchParametersSpec extends ErgoCorePropertyTest { + + property("MainnetLaunchParameters should have default block version") { + MainnetLaunchParameters.blockVersion shouldBe Parameters.DefaultParameters(Parameters.BlockVersion) + } + + property("MainnetLaunchParameters should have empty validation settings update") { + MainnetLaunchParameters.proposedUpdate shouldBe ErgoValidationSettingsUpdate.empty + } + + property("MainnetLaunchParameters should have height 0") { + MainnetLaunchParameters.height shouldBe 0 + } + + property("TestnetLaunchParameters should have block version set to Interpreter60Version") { + TestnetLaunchParameters.blockVersion shouldBe Header.Interpreter60Version + } + + property("TestnetLaunchParameters should have validation settings update with rules 215 and 409") { + TestnetLaunchParameters.proposedUpdate.rulesToDisable should contain theSameElementsAs Seq(215, 409) + } + + property("TestnetLaunchParameters should have empty status updates") { + TestnetLaunchParameters.proposedUpdate.statusUpdates shouldBe empty + } + + property("TestnetLaunchParameters should have height 0") { + TestnetLaunchParameters.height shouldBe 0 + } + + property("DevnetLaunchParameters should have block version set to Interpreter50Version") { + DevnetLaunchParameters.blockVersion shouldBe Header.Interpreter50Version + } + + property("DevnetLaunchParameters should have empty validation settings update") { + DevnetLaunchParameters.proposedUpdate shouldBe ErgoValidationSettingsUpdate.empty + } + + property("DevnetLaunchParameters should have height 0") { + DevnetLaunchParameters.height shouldBe 0 + } + + property("Devnet60LaunchParameters should have block version set to Interpreter60Version") { + Devnet60LaunchParameters.blockVersion shouldBe Header.Interpreter60Version + } + + property("Devnet60LaunchParameters should have empty validation settings update") { + Devnet60LaunchParameters.proposedUpdate shouldBe ErgoValidationSettingsUpdate.empty + } + + property("Devnet60LaunchParameters should have height 0") { + Devnet60LaunchParameters.height shouldBe 0 + } + + property("all launch parameters should have valid height") { + Seq( + MainnetLaunchParameters, + TestnetLaunchParameters, + DevnetLaunchParameters, + Devnet60LaunchParameters + ).foreach(_.height shouldBe 0) + } + + property("TestnetLaunchParameters should differ from MainnetLaunchParameters") { + TestnetLaunchParameters.blockVersion should not be MainnetLaunchParameters.blockVersion + TestnetLaunchParameters.proposedUpdate should not be MainnetLaunchParameters.proposedUpdate + } + + property("Devnet60LaunchParameters should have same block version as TestnetLaunchParameters") { + Devnet60LaunchParameters.blockVersion shouldBe TestnetLaunchParameters.blockVersion + } + + property("DevnetLaunchParameters should have different block version than Devnet60LaunchParameters") { + DevnetLaunchParameters.blockVersion should not be Devnet60LaunchParameters.blockVersion + } + + property("parameters table should contain BlockVersion for all launch parameters") { + Seq( + MainnetLaunchParameters, + TestnetLaunchParameters, + DevnetLaunchParameters, + Devnet60LaunchParameters + ).foreach { params => + params.parametersTable should contain key Parameters.BlockVersion + } + } + +} diff --git a/src/it/scala/org/ergoplatform/it/container/Docker.scala b/src/it/scala/org/ergoplatform/it/container/Docker.scala index e7e79fc08a..9d213c8531 100644 --- a/src/it/scala/org/ergoplatform/it/container/Docker.scala +++ b/src/it/scala/org/ergoplatform/it/container/Docker.scala @@ -21,7 +21,7 @@ import com.typesafe.config.{Config, ConfigFactory, ConfigRenderOptions} import net.ceedubs.ficus.Ficus._ import org.apache.commons.io.FileUtils import org.asynchttpclient.Dsl.{config, _} -import org.ergoplatform.settings.NetworkType.{DevNet, DevNet60, MainNet, TestNet} +import org.ergoplatform.settings.NetworkType.{DevNet, DevNet60, MainNet, TestNet, Tests} import org.ergoplatform.settings.{ErgoSettings, ErgoSettingsReader, NetworkType} import scorex.util.ScorexLogging @@ -145,7 +145,7 @@ class Docker( val networkPort = initialSettings.scorexSettings.network.bindAddress.getPort val nodeConfig: Config = - enrichNodeConfig(networkType, nodeSpecificConfig, extraConfig, ip, networkPort) + enrichNodeConfig(networkType, nodeSpecificConfig, extraConfig) val settings: ErgoSettings = buildErgoSettings(networkType, nodeConfig) val containerBuilder: CreateContainerCmd = buildPeerContainerCmd(networkType, nodeConfig, settings, ip, specialVolumeOpt) @@ -219,9 +219,7 @@ class Docker( private def enrichNodeConfig( networkType: NetworkType, nodeConfig: Config, - extraConfig: ExtraConfig, - ip: String, - port: Int + extraConfig: ExtraConfig ) = { val publicPeerConfig = nodeConfig //.withFallback(declaredAddressConfig(ip, port)) val withPeerConfig = nodeRepository.headOption.fold(publicPeerConfig) { node => @@ -296,6 +294,7 @@ class Docker( val networkTypeCmdOption = networkType match { case MainNet => "--mainnet" case TestNet => "--testnet" + case Tests => "--testnet" case DevNet => "" case DevNet60 => "" } diff --git a/src/main/resources/api/openapi-ai.yaml b/src/main/resources/api/openapi-ai.yaml index 755c0454dc..a92d54ed84 100644 --- a/src/main/resources/api/openapi-ai.yaml +++ b/src/main/resources/api/openapi-ai.yaml @@ -1,7 +1,7 @@ openapi: "3.0.2" info: - version: "6.0.2" + version: "6.0.3" title: Ergo Node API description: Specification of Ergo Node API for ChatGPT plugin. The following endpoints supported diff --git a/src/main/resources/api/openapi.yaml b/src/main/resources/api/openapi.yaml index 19acb8188e..d6e4455c2e 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.2" + version: "6.0.3" 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 5d72eca86c..cebff855d6 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -245,6 +245,7 @@ ergo { activationEpochs = 32 # Activation height for protocol version 2 (client version 4.0.0 hard-fork) + # Relevant for the mainnet only atm version2ActivationHeight = 417792 # Difficulty for Autolykos version 2 activation (corresponding to ~ 1 TH/s hashrate) @@ -445,7 +446,7 @@ scorex { nodeName = "ergo-node" # Network protocol version to be sent in handshakes - appVersion = 6.0.2 + appVersion = 6.0.3 # Network agent name. May contain information about client code # stack, starting from core code-base up to the end graphical interface. diff --git a/src/main/resources/testnet.conf b/src/main/resources/testnet.conf index c84208f14f..b2e61a203a 100644 --- a/src/main/resources/testnet.conf +++ b/src/main/resources/testnet.conf @@ -20,14 +20,7 @@ ergo { # Dump ADProofs only for the suffix given during bootstrapping adProofsSuffixLength = 114688 // 112k - - # As some v.3 blocks in the PaiNet are violating monotonic creation height rule (due to 5.0 being activated before - # the monotonic introduced), this checkpoint is mandatory - checkpoint = { - height = 91320 - blockId = "fd06abdf0e6558ebaaf524b654c922a1cb42e542ae49d1c4a79397a077209278" - } - } + } chain { protocolVersion = 4 # 6.0 soft-fork @@ -57,27 +50,23 @@ ergo { # Voting epochs to activate a soft-fork after acceptance activationEpochs = 32 - - # Activation height for testnet protocol version 2 (client version 4.0.0 hard-fork) - version2ActivationHeight = 128 - - version2ActivationDifficultyHex = "20" } reemission { checkReemissionRules = false - emissionNftId = "06f29034fb69b23d519f84c4811a19694b8cdc2ce076147aaa050276f0b840f4" + # emissionNftId = "06f29034fb69b23d519f84c4811a19694b8cdc2ce076147aaa050276f0b840f4" - reemissionTokenId = "01345f0ed87b74008d1c46aefd3e7ad6ee5909a2324f2899031cdfee3cc1e022" + # reemissionTokenId = "01345f0ed87b74008d1c46aefd3e7ad6ee5909a2324f2899031cdfee3cc1e022" - reemissionNftId = "06f2c3adfe52304543f7b623cc3fccddc0174a7db52452fef8e589adacdfdfee" + # reemissionNftId = "06f2c3adfe52304543f7b623cc3fccddc0174a7db52452fef8e589adacdfdfee" + # no re-emission activationHeight = 100000001 reemissionStartHeight = 1860400 - injectionBoxBytesEncoded = "a0f9e1b5fb011003040005808098f4e9b5ca6a0402d1ed91c1b2a4730000730193c5a7c5b2a4730200f6ac0b0201345f0ed87b74008d1c46aefd3e7ad6ee5909a2324f2899031cdfee3cc1e02280808cfaf49aa53506f29034fb69b23d519f84c4811a19694b8cdc2ce076147aaa050276f0b840f40100325c3679e7e0e2f683e4a382aa74c2c1cb989bb6ad6a1d4b1c5a021d7b410d0f00" + # injectionBoxBytesEncoded = "a0f9e1b5fb011003040005808098f4e9b5ca6a0402d1ed91c1b2a4730000730193c5a7c5b2a4730200f6ac0b0201345f0ed87b74008d1c46aefd3e7ad6ee5909a2324f2899031cdfee3cc1e02280808cfaf49aa53506f29034fb69b23d519f84c4811a19694b8cdc2ce076147aaa050276f0b840f40100325c3679e7e0e2f683e4a382aa74c2c1cb989bb6ad6a1d4b1c5a021d7b410d0f00" } # Base16 representation of genesis state roothash @@ -85,7 +74,7 @@ ergo { } voting { - 120 = 1 // vote for 5.0 soft-fork, the vote will not be given before block 4,096 + # 120 = 1 // vote for a soft-fork } wallet.secretStorage.secretDir = ${ergo.directory}"/wallet/keystore" @@ -93,15 +82,16 @@ ergo { scorex { network { - magicBytes = [2, 0, 2, 3] - bindAddress = "0.0.0.0:9022" + magicBytes = [2, 3, 2, 3] + bindAddress = "0.0.0.0:9023" nodeName = "ergo-testnet-"${scorex.network.appVersion} nodeName = ${?NODENAME} knownPeers = [ - "213.239.193.208:9022", - "168.138.185.215:9022", - "192.234.196.165:9022" + "213.239.193.208:9023", + "168.138.185.215:9023", + "192.234.196.165:9023" ] + penaltyScoreThreshold = 500000 } restApi { # Hex-encoded Blake2b256 hash of an API key. Should be 64-chars long Base16 string. diff --git a/src/main/scala/org/ergoplatform/http/api/MiningApiRoute.scala b/src/main/scala/org/ergoplatform/http/api/MiningApiRoute.scala index ce0c10f204..b155dea89a 100644 --- a/src/main/scala/org/ergoplatform/http/api/MiningApiRoute.scala +++ b/src/main/scala/org/ergoplatform/http/api/MiningApiRoute.scala @@ -4,7 +4,10 @@ import akka.actor.{ActorRef, ActorRefFactory} import akka.http.scaladsl.server.Route import akka.pattern.ask import io.circe.syntax._ -import io.circe.{Encoder, Json} +import io.circe.Encoder +import io.circe.Json +import org.bouncycastle.util.encoders.Hex +import org.ergoplatform.http.api.requests.MiningRequest import org.ergoplatform.mining.CandidateGenerator.Candidate import org.ergoplatform.mining.{AutolykosSolution, CandidateGenerator, ErgoMiner} import org.ergoplatform.modifiers.mempool.ErgoTransaction @@ -13,8 +16,10 @@ import org.ergoplatform.settings.{ErgoSettings, RESTApiSettings} import org.ergoplatform.{ErgoAddress, ErgoTreePredef, Pay2SAddress} import scorex.core.api.http.ApiResponse import sigma.data.ProveDlog +import sigma.serialization.GroupElementSerializer import scala.concurrent.Future +import scala.util.{Failure, Success, Try} case class MiningApiRoute(miner: ActorRef, ergoSettings: ErgoSettings) @@ -27,6 +32,7 @@ case class MiningApiRoute(miner: ActorRef, override val route: Route = pathPrefix("mining") { candidateR ~ candidateWithTxsR ~ + candidateWithTxsAndPkR ~ solutionR ~ rewardAddressR ~ rewardPublicKeyR @@ -53,6 +59,20 @@ case class MiningApiRoute(miner: ActorRef, ApiResponse(candidateF) } + def candidateWithTxsAndPkR: Route = (path("candidateWithTxsAndPk") + & post & entity(as[MiningRequest]) & withAuth) { txsAndPk => + val tryPk = Try(GroupElementSerializer.fromBytes(Hex.decode(txsAndPk.pk))) + val result = tryPk match { + case Failure(_) => + Future.failed(new Exception("Could not decode hexadecimal string for given public key")) + case Success(pk) => + val prepareCmd = CandidateGenerator.GenerateCandidate(txsAndPk.txs, reply = true, + forced = false, Some(ProveDlog.apply(pk))) + miner.askWithStatus(prepareCmd).mapTo[Candidate].map(_.externalVersion) + } + ApiResponse(result) + } + def solutionR: Route = (path("solution") & post & entity(as[AutolykosSolution])) { solution => val result = if (ergoSettings.nodeSettings.useExternalMiner) { miner.askWithStatus(solution).mapTo[Unit] diff --git a/src/main/scala/org/ergoplatform/http/api/ScriptApiRoute.scala b/src/main/scala/org/ergoplatform/http/api/ScriptApiRoute.scala index 2e4d8a0a5a..5d7cac0ea2 100644 --- a/src/main/scala/org/ergoplatform/http/api/ScriptApiRoute.scala +++ b/src/main/scala/org/ergoplatform/http/api/ScriptApiRoute.scala @@ -53,9 +53,9 @@ case class ScriptApiRoute(readersHolder: ActorRef, ergoSettings: ErgoSettings) keys.zipWithIndex.map { case (pk, i) => s"myPubKey_$i" -> pk }.toMap } - private def compileSource(source: String, env: Map[String, Any], treeVersion: Byte = 0): Try[ErgoTree] = { + private def compileSource(source: String, env: Map[String, Any], treeVersion: Byte): Try[ErgoTree] = { val compiler = new SigmaCompiler(ergoSettings.chainSettings.addressPrefix) - val ergoTreeHeader = ErgoTree.defaultHeaderWithVersion(treeVersion.toByte) + val ergoTreeHeader = ErgoTree.defaultHeaderWithVersion(treeVersion) Try(compiler.compile(env, source)(new CompiletimeIRContext)).flatMap { case CompilerResult(_, _, _, script: Value[SSigmaProp.type@unchecked]) if script.tpe == SSigmaProp => Success(ErgoTree.fromProposition(ergoTreeHeader, script)) @@ -77,7 +77,7 @@ case class ScriptApiRoute(readersHolder: ActorRef, ergoSettings: ErgoSettings) val scriptVersion = Header.scriptFromBlockVersion(bv) val treeVersion = compileRequest.treeVersion VersionContext.withVersions(scriptVersion, treeVersion) { - compileSource(compileRequest.source, keysToEnv(addrs.map(_.pubkey))).map(Pay2SAddress.apply).fold( + compileSource(compileRequest.source, keysToEnv(addrs.map(_.pubkey)), treeVersion).map(Pay2SAddress.apply).fold( e => BadRequest(e.getMessage), address => ApiResponse(addressResponse(address)) ) @@ -93,7 +93,7 @@ case class ScriptApiRoute(readersHolder: ActorRef, ergoSettings: ErgoSettings) val scriptVersion = Header.scriptFromBlockVersion(bv) val treeVersion = compileRequest.treeVersion VersionContext.withVersions(scriptVersion, treeVersion) { - compileSource(compileRequest.source, keysToEnv(addrs.map(_.pubkey))).map(Pay2SHAddress.apply).fold( + compileSource(compileRequest.source, keysToEnv(addrs.map(_.pubkey)), treeVersion).map(Pay2SHAddress.apply).fold( e => BadRequest(e.getMessage), address => ApiResponse(addressResponse(address)) ) diff --git a/src/main/scala/org/ergoplatform/http/api/requests/MiningRequest.scala b/src/main/scala/org/ergoplatform/http/api/requests/MiningRequest.scala new file mode 100644 index 0000000000..0c1bc49bf7 --- /dev/null +++ b/src/main/scala/org/ergoplatform/http/api/requests/MiningRequest.scala @@ -0,0 +1,31 @@ +package org.ergoplatform.http.api.requests + +import io.circe.Decoder +import io.circe.Encoder +import io.circe.syntax._ +import io.circe.Json +import org.ergoplatform.modifiers.mempool.ErgoTransaction + +/** + * Represents a request to generate a candidate with the given transactions and miner public key. + * + * @param txs Transactions to include in the block candidate + * @param pk String Hexadecimal representation of public key to use as minerPk + */ +case class MiningRequest(txs: Seq[ErgoTransaction], pk: String) + +object MiningRequest { + implicit val miningRequestEncoder: Encoder[MiningRequest] = { request => + Json.obj( + "txs" -> request.txs.asJson, + "pk" -> Json.fromString(request.pk) + ) + } + + implicit val miningRequestDecoder: Decoder[MiningRequest] = { cursor => + for { + txs <- cursor.downField("txs").as[Seq[ErgoTransaction]] + pk <- cursor.downField("pk").as[String] + } yield MiningRequest(txs, pk) + } +} diff --git a/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala b/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala index bfa0fe6162..eecef9716a 100644 --- a/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala +++ b/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala @@ -152,7 +152,7 @@ class CandidateGenerator( context.become(initialized(state)) } - case gen @ GenerateCandidate(txsToInclude, reply, forced) => + case gen @ GenerateCandidate(txsToInclude, reply, forced, optPk) => val senderOpt = if (reply) Some(sender()) else None if (!forced && cachedFor(state.cachedCandidate, txsToInclude)) { senderOpt.foreach(_ ! StatusReply.success(state.cachedCandidate.get)) @@ -162,7 +162,7 @@ class CandidateGenerator( state.hr, state.sr, state.mpr, - minerPk, + optPk.getOrElse(minerPk), txsToInclude, ergoSettings ) match { @@ -260,7 +260,8 @@ object CandidateGenerator extends ScorexLogging { case class GenerateCandidate( txsToInclude: Seq[ErgoTransaction], reply: Boolean, - forced: Boolean + forced: Boolean, + optPk: Option[ProveDlog] = None ) /** Local state of candidate generator to avoid mutable vars */ diff --git a/src/main/scala/org/ergoplatform/mining/ErgoMiner.scala b/src/main/scala/org/ergoplatform/mining/ErgoMiner.scala index e2dc8060c7..23e02a0b28 100644 --- a/src/main/scala/org/ergoplatform/mining/ErgoMiner.scala +++ b/src/main/scala/org/ergoplatform/mining/ErgoMiner.scala @@ -117,25 +117,41 @@ class ErgoMiner( b.isNew(ergoSettings.chainSettings.blockInterval * 2) } + /** Check if blockchain is almost synced (headers height is within 6 blocks of full blocks height) */ + private def isBlockchainNearlySynced(headersHeight: Int, fullBlockHeight: Int): Boolean = { + headersHeight < fullBlockHeight + 6 + } + /** Let's wait for a signal to start mining, either from ErgoApp or when a latest blocks get applied to blockchain */ def starting(minerState: MinerState): Receive = { case StartMining if minerState.secretKeyOpt.isDefined || ergoSettings.nodeSettings.useExternalMiner => - if (!ergoSettings.nodeSettings.useExternalMiner && ergoSettings.nodeSettings.internalMinersCount != 0) { - log.info( - s"Starting ${ergoSettings.nodeSettings.internalMinersCount} native miner(s)" - ) - (1 to ergoSettings.nodeSettings.internalMinersCount) foreach { _ => - ErgoMiningThread( - ergoSettings, - minerState.candidateGeneratorRef, - minerState.secretKeyOpt.get.w - )(context) + // Check if blockchain is synced before starting mining + viewHolderRef ! GetDataFromCurrentView[DigestState, Unit] { v => + val headersHeight = v.history.headersHeight + val fullBlockHeight = v.history.fullBlockHeight + if (isBlockchainNearlySynced(headersHeight, fullBlockHeight)) { + log.info(s"Blockchain is (almost) synced (headers: $headersHeight, full blocks: $fullBlockHeight), starting mining") + if (!ergoSettings.nodeSettings.useExternalMiner && ergoSettings.nodeSettings.internalMinersCount != 0) { + log.info( + s"Starting ${ergoSettings.nodeSettings.internalMinersCount} native miner(s)" + ) + (1 to ergoSettings.nodeSettings.internalMinersCount) foreach { _ => + ErgoMiningThread( + ergoSettings, + minerState.candidateGeneratorRef, + minerState.secretKeyOpt.get.w + )(context) + } + } + context.system.eventStream + .unsubscribe(self, classOf[FullBlockApplied]) + context.become(started(minerState)) + } else { + log.info(s"Blockchain not synced yet (headers: $headersHeight, full blocks: $fullBlockHeight), waiting for sync") + // Stay in `starting` state and keep listening for FullBlockApplied to re-check sync status } } - context.system.eventStream - .unsubscribe(self, classOf[FullBlockApplied]) - context.become(started(minerState)) case StartMining => // unexpected, we made sure that either external mining is used or secret key is set at this state for internal mining @@ -152,11 +168,10 @@ class ErgoMiner( * This block could be either genesis or generated by another node. */ case FullBlockApplied(header) if shouldStartMine(header) => - - log.info("Starting mining triggered by incoming block") + log.info(s"Block ${header.id} applied, checking sync status for mining") self ! StartMining - case GenerateCandidate(_, _, _) => + case GenerateCandidate(_, _, _, _) => sender() ! StatusReply.error("Miner has not started yet") } @@ -164,7 +179,7 @@ class ErgoMiner( * The reason is that replying is optional and it is not possible to obtain a sender reference from MiningApiRoute 'ask'. */ def started(minerState: MinerState): Receive = { - case genCandidate @ GenerateCandidate(_, _, _) => + case genCandidate @ GenerateCandidate(_, _, _, _) => minerState.candidateGeneratorRef forward genCandidate case solution: AutolykosSolution => diff --git a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala index 684c973cab..cf44ef893e 100644 --- a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala +++ b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala @@ -783,7 +783,8 @@ class ErgoNodeViewSynchronizer(networkControllerRef: ActorRef, case _ => // Penalize peer and do nothing - it will be switched to correct state on CheckDelivery penalizeMisbehavingPeer(remote) - log.warn(s"Failed to parse transaction with declared id ${encoder.encodeId(id)} from ${remote.toString}") + log.warn(s"Failed to parse transaction with declared id ${encoder.encodeId(id)} " + + s"from ${remote.toString}, reason: ${parseResult.map(_.id)}") } } } diff --git a/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala b/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala index 127edd8201..cb1092857f 100644 --- a/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala +++ b/src/main/scala/org/ergoplatform/nodeView/mempool/OrderedTxPool.scala @@ -105,7 +105,7 @@ class OrderedTxPool(val orderedTransactions: TreeMap[WeightedTxId, UnconfirmedTr */ def remove(tx: ErgoTransaction): OrderedTxPool = { transactionsRegistry.get(tx.id) match { - case Some(wtx) => + case Some(wtx) if orderedTransactions.contains(wtx) => new OrderedTxPool( orderedTransactions - wtx, transactionsRegistry - tx.id, @@ -113,7 +113,18 @@ class OrderedTxPool(val orderedTransactions: TreeMap[WeightedTxId, UnconfirmedTr outputs -- tx.outputs.map(_.id), inputs -- tx.inputs.map(_.boxId) ).updateFamily(tx, -wtx.weight, System.currentTimeMillis(), depth = 0) - case None => this + case _ => + if (orderedTransactions.valuesIterator.exists(_.id == tx.id)) { + new OrderedTxPool( + orderedTransactions.filter(_._2.id != tx.id), + transactionsRegistry - tx.id, + invalidatedTxIds, + outputs -- tx.outputs.map(_.id), + inputs -- tx.inputs.map(_.boxId) + ) + } else { + this + } } } @@ -125,7 +136,7 @@ class OrderedTxPool(val orderedTransactions: TreeMap[WeightedTxId, UnconfirmedTr def invalidate(unconfirmedTx: UnconfirmedTransaction): OrderedTxPool = { val tx = unconfirmedTx.transaction transactionsRegistry.get(tx.id) match { - case Some(wtx) => + case Some(wtx) if orderedTransactions.contains(wtx) => new OrderedTxPool( orderedTransactions - wtx, transactionsRegistry - tx.id, @@ -133,7 +144,7 @@ class OrderedTxPool(val orderedTransactions: TreeMap[WeightedTxId, UnconfirmedTr outputs -- tx.outputs.map(_.id), inputs -- tx.inputs.map(_.boxId) ).updateFamily(tx, -wtx.weight, System.currentTimeMillis(), depth = 0) - case None => + case _ => if (orderedTransactions.valuesIterator.exists(utx => utx.id == tx.id)) { new OrderedTxPool( orderedTransactions.filter(_._2.id != tx.id), diff --git a/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala b/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala index d891a6e30e..a2c10bd248 100644 --- a/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala +++ b/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala @@ -142,7 +142,7 @@ trait UtxoStateReader extends ErgoStateReader with UtxoSetSnapshotPersistence { * @param txs - transactions to generate proofs * @return proof for specified transactions and new state digest */ - def proofsForTransactions(txs: Seq[ErgoTransaction]): Try[(SerializedAdProof, ADDigest)] = synchronized { + def proofsForTransactions(txs: Seq[ErgoTransaction]): Try[(SerializedAdProof, ADDigest)] = persistentProver.synchronized { val rootHash = persistentProver.digest log.trace(s"Going to create proof for ${txs.length} transactions at root ${Algos.encode(rootHash)}") if (txs.isEmpty) { diff --git a/src/main/scala/org/ergoplatform/settings/ErgoSettings.scala b/src/main/scala/org/ergoplatform/settings/ErgoSettings.scala index 1ed69b305d..4d2deb7259 100644 --- a/src/main/scala/org/ergoplatform/settings/ErgoSettings.scala +++ b/src/main/scala/org/ergoplatform/settings/ErgoSettings.scala @@ -39,6 +39,8 @@ case class ErgoSettings(directory: String, Devnet60LaunchParameters } else if (networkType == NetworkType.TestNet) { TestnetLaunchParameters + } else if (networkType == NetworkType.Tests) { + MainnetLaunchParameters } else { MainnetLaunchParameters } diff --git a/src/main/scala/org/ergoplatform/settings/NetworkType.scala b/src/main/scala/org/ergoplatform/settings/NetworkType.scala index e985c6f87d..d89f685eb8 100644 --- a/src/main/scala/org/ergoplatform/settings/NetworkType.scala +++ b/src/main/scala/org/ergoplatform/settings/NetworkType.scala @@ -13,7 +13,10 @@ object NetworkType { def all: Seq[NetworkType] = Seq(MainNet, TestNet, DevNet) - def fromString(name: String): Option[NetworkType] = all.find(_.verboseName == name) + def fromString(name: String): Option[NetworkType] = { + val allIncludingSynthetic: Seq[NetworkType] = all ++ Seq(DevNet60) + allIncludingSynthetic.find(_.verboseName == name) + } case object MainNet extends NetworkType { override val verboseName: String = "mainnet" @@ -29,6 +32,14 @@ object NetworkType { override val addressPrefix: Byte = ErgoAddressEncoder.TestnetNetworkPrefix } + // Synthetic network type + case object Tests extends NetworkType { + override val verboseName: String = "tests" + override val isMainNet: Boolean = false + override val isTestNet: Boolean = true + override val addressPrefix: Byte = ErgoAddressEncoder.TestnetNetworkPrefix + } + // devnet which is starting from 5.0 activated since genesis block case object DevNet extends NetworkType { diff --git a/src/test/scala/org/ergoplatform/http/api/requests/MiningRequestSpec.scala b/src/test/scala/org/ergoplatform/http/api/requests/MiningRequestSpec.scala new file mode 100644 index 0000000000..fbb20a7f90 --- /dev/null +++ b/src/test/scala/org/ergoplatform/http/api/requests/MiningRequestSpec.scala @@ -0,0 +1,115 @@ +package org.ergoplatform.http.api.requests + +import io.circe.Json +import io.circe.parser.decode +import io.circe.syntax._ +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class MiningRequestSpec extends AnyFlatSpec with Matchers { + + "MiningRequest" should "decode valid JSON with empty transactions" in { + val json = Json.obj( + "txs" -> Json.arr(), + "pk" -> Json.fromString("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + ) + + val result = decode[MiningRequest](json.noSpaces) + + result shouldBe 'right + result.right.get.txs shouldBe empty + result.right.get.pk shouldBe "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + + it should "fail decoding when pk is missing" in { + val json = Json.obj("txs" -> Json.arr()) + + val result = decode[MiningRequest](json.noSpaces) + + result shouldBe 'left + } + + it should "fail decoding when txs is missing" in { + val json = Json.obj("pk" -> Json.fromString("0123456789abcdef")) + + val result = decode[MiningRequest](json.noSpaces) + + result shouldBe 'left + } + + it should "fail decoding when both fields are missing" in { + val json = Json.obj() + + val result = decode[MiningRequest](json.noSpaces) + + result shouldBe 'left + } + + it should "fail decoding with invalid pk type" in { + val json = Json.obj( + "txs" -> Json.arr(), + "pk" -> Json.fromInt(12345) + ) + + val result = decode[MiningRequest](json.noSpaces) + + result shouldBe 'left + } + + it should "fail decoding with invalid txs type" in { + val json = Json.obj( + "txs" -> Json.fromString("not_an_array"), + "pk" -> Json.fromString("0123456789abcdef") + ) + + val result = decode[MiningRequest](json.noSpaces) + + result shouldBe 'left + } + + it should "encode to JSON correctly" in { + val request = MiningRequest(Seq.empty, "abcdef0123456789") + + val json = request.asJson + + json.hcursor.downField("txs").as[Seq[Json]] shouldBe 'right + json.hcursor.downField("pk").as[String] shouldBe Right("abcdef0123456789") + } + + it should "preserve transaction order when encoding/decoding" in { + // Use simple valid transaction JSON structure with all required fields + val tx1 = Json.obj( + "id" -> Json.fromString("tx1"), + "inputs" -> Json.arr(), + "dataInputs" -> Json.arr(), + "outputCandidates" -> Json.arr(), + "outputs" -> Json.arr() + ) + val tx2 = Json.obj( + "id" -> Json.fromString("tx2"), + "inputs" -> Json.arr(), + "dataInputs" -> Json.arr(), + "outputCandidates" -> Json.arr(), + "outputs" -> Json.arr() + ) + val tx3 = Json.obj( + "id" -> Json.fromString("tx3"), + "inputs" -> Json.arr(), + "dataInputs" -> Json.arr(), + "outputCandidates" -> Json.arr(), + "outputs" -> Json.arr() + ) + + val json = Json.obj( + "txs" -> Json.arr(tx1, tx2, tx3), + "pk" -> Json.fromString("fedcba9876543210") + ) + val decoded = decode[MiningRequest](json.noSpaces) + + decoded shouldBe 'right + val decodedRequest = decoded.right.get + decodedRequest.txs should have size 3 + decodedRequest.pk shouldBe "fedcba9876543210" + } + +} diff --git a/src/test/scala/org/ergoplatform/http/routes/MiningApiRouteSpec.scala b/src/test/scala/org/ergoplatform/http/routes/MiningApiRouteSpec.scala index 2731827bcf..2194d6c7aa 100644 --- a/src/test/scala/org/ergoplatform/http/routes/MiningApiRouteSpec.scala +++ b/src/test/scala/org/ergoplatform/http/routes/MiningApiRouteSpec.scala @@ -7,6 +7,7 @@ import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport import io.circe.Json import io.circe.syntax._ import org.ergoplatform.http.api.MiningApiRoute +import org.ergoplatform.http.api.requests.MiningRequest import org.ergoplatform.mining.AutolykosSolution import org.ergoplatform.settings.ErgoSettings import org.ergoplatform.utils.Stubs @@ -25,7 +26,6 @@ class MiningApiRouteSpec with FailFastCirceSupport { import org.ergoplatform.utils.ErgoNodeTestConstants._ - import org.ergoplatform.utils.generators.ErgoCoreGenerators._ val prefix = "/mining" @@ -34,6 +34,9 @@ class MiningApiRouteSpec val solution = AutolykosSolution(genECPoint.sample.get, genECPoint.sample.get, Array.fill(32)(9: Byte), BigInt(0)) + // Valid compressed public key hex (33 bytes = 66 hex chars) - using a valid secp256k1 point + val validPkHex = "020000000000000000000000000000000000000000000000000000000000000001" + it should "return requested candidate" in { Get(prefix + "/candidate") ~> route ~> check { status shouldBe StatusCodes.OK @@ -56,4 +59,24 @@ class MiningApiRouteSpec } } + it should "return candidate with valid custom miner public key" in { + val request = MiningRequest(Seq.empty, validPkHex) + + Post(prefix + "/candidateWithTxsAndPk", request.asJson) ~> route ~> check { + status shouldBe StatusCodes.OK + Try(responseAs[Json]) shouldBe 'success + } + } + + it should "encode and decode MiningRequest correctly" in { + val request = MiningRequest(Seq.empty, validPkHex) + + val json = request.asJson + val decodedTxs = json.hcursor.downField("txs").as[Seq[Json]] + val decodedPk = json.hcursor.downField("pk").as[String] + + decodedTxs shouldBe 'right + decodedPk shouldBe Right(validPkHex) + } + } diff --git a/src/test/scala/org/ergoplatform/http/routes/ScriptApiRouteSpec.scala b/src/test/scala/org/ergoplatform/http/routes/ScriptApiRouteSpec.scala index 235303cc7a..cb366b33b6 100644 --- a/src/test/scala/org/ergoplatform/http/routes/ScriptApiRouteSpec.scala +++ b/src/test/scala/org/ergoplatform/http/routes/ScriptApiRouteSpec.scala @@ -148,4 +148,118 @@ class ScriptApiRouteSpec extends AnyFlatSpec Get(s"$prefix/$suffix/$p2s") ~> route ~> check(assertion(responseAs[Json], p2s)) } + it should "generate addresses with different tree versions" in { + val p2sSuffix = "/p2sAddress" + val p2shSuffix = "/p2shAddress" + + var p2sAddressV0: String = "" + var p2shAddressV0: String = "" + var p2sAddressV1: String = "" + var p2shAddressV1: String = "" + + // Test with tree version 0 + Post(prefix + p2sSuffix, Json.obj("source" -> scriptSource.asJson, "treeVersion" -> 0.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr = responseAs[Json].hcursor.downField("address").as[String].right.get + addressEncoder.fromString(addressStr).get.addressTypePrefix shouldEqual Pay2SAddress.addressTypePrefix + p2sAddressV0 = addressStr + } + + Post(prefix + p2shSuffix, Json.obj("source" -> scriptSource.asJson, "treeVersion" -> 0.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr = responseAs[Json].hcursor.downField("address").as[String].right.get + addressEncoder.fromString(addressStr).get.addressTypePrefix shouldEqual Pay2SHAddress.addressTypePrefix + p2shAddressV0 = addressStr + } + + // Test with tree version 1 + Post(prefix + p2sSuffix, Json.obj("source" -> scriptSource.asJson, "treeVersion" -> 1.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr = responseAs[Json].hcursor.downField("address").as[String].right.get + addressEncoder.fromString(addressStr).get.addressTypePrefix shouldEqual Pay2SAddress.addressTypePrefix + p2sAddressV1 = addressStr + } + + Post(prefix + p2shSuffix, Json.obj("source" -> scriptSource.asJson, "treeVersion" -> 1.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr = responseAs[Json].hcursor.downField("address").as[String].right.get + addressEncoder.fromString(addressStr).get.addressTypePrefix shouldEqual Pay2SHAddress.addressTypePrefix + p2shAddressV1 = addressStr + } + + // Get the actual Ergo trees and verify they have different version bytes + val p2sTreeV0 = addressEncoder.fromString(p2sAddressV0).get.script + val p2sTreeV1 = addressEncoder.fromString(p2sAddressV1).get.script + val p2shTreeV0 = addressEncoder.fromString(p2shAddressV0).get.script + val p2shTreeV1 = addressEncoder.fromString(p2shAddressV1).get.script + + // Check that the trees have different version bytes + p2sTreeV0.bytes should not equal p2sTreeV1.bytes + p2shTreeV0.bytes shouldBe p2shTreeV1.bytes + + // Specifically check the version byte (first byte of ErgoTree) + p2sTreeV0.bytes.head should not equal p2sTreeV1.bytes.head + p2shTreeV0.bytes.head shouldBe p2shTreeV1.bytes.head + + // Verify the actual version bytes match what we requested + p2sTreeV0.bytes.head shouldEqual 16 + p2sTreeV1.bytes.head shouldEqual 25 + p2shTreeV0.bytes.head shouldEqual 0 + p2shTreeV1.bytes.head shouldEqual 0 + } + + it should "handle tree version 2 for P2SH address" in { + val suffix = "/p2shAddress" + Post(prefix + suffix, Json.obj("source" -> scriptSourceSigProp.asJson, "treeVersion" -> 2.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr = responseAs[Json].hcursor.downField("address").as[String].right.get + addressEncoder.fromString(addressStr).get.addressTypePrefix shouldEqual Pay2SHAddress.addressTypePrefix + + // P2SH should always have version 0 regardless of treeVersion parameter + val tree = addressEncoder.fromString(addressStr).get.script + tree.bytes.head shouldEqual 0 + } + } + + it should "generate consistent addresses for same script and version" in { + val suffix = "/p2sAddress" + + Post(prefix + suffix, Json.obj("source" -> scriptSourceSigProp.asJson, "treeVersion" -> 1.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr1 = responseAs[Json].hcursor.downField("address").as[String].right.get + + Post(prefix + suffix, Json.obj("source" -> scriptSourceSigProp.asJson, "treeVersion" -> 1.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr2 = responseAs[Json].hcursor.downField("address").as[String].right.get + addressStr1 shouldEqual addressStr2 + } + } + } + + it should "generate different addresses for different tree versions" in { + val suffix = "/p2sAddress" + + Post(prefix + suffix, Json.obj("source" -> scriptSourceSigProp.asJson, "treeVersion" -> 0.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr0 = responseAs[Json].hcursor.downField("address").as[String].right.get + + Post(prefix + suffix, Json.obj("source" -> scriptSourceSigProp.asJson, "treeVersion" -> 1.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr1 = responseAs[Json].hcursor.downField("address").as[String].right.get + addressStr0 should not equal addressStr1 + } + } + } + + it should "handle P2SH with tree version 1 (should still use version 0)" in { + val suffix = "/p2shAddress" + Post(prefix + suffix, Json.obj("source" -> scriptSourceSigProp.asJson, "treeVersion" -> 1.asJson)) ~> route ~> check { + status shouldBe StatusCodes.OK + val addressStr = responseAs[Json].hcursor.downField("address").as[String].right.get + val tree = addressEncoder.fromString(addressStr).get.script + // P2SH always uses version 0 + tree.bytes.head shouldEqual 0 + } + } + } diff --git a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala index 1c394b2817..0ee0ead277 100644 --- a/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala +++ b/src/test/scala/org/ergoplatform/mining/CandidateGeneratorSpec.scala @@ -476,12 +476,12 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp system.terminate() } - it should "6.0 pool transactions should be removed from pool when 5.0 block is mined" in new TestKit( + it should "6.0 pool transactions should be added to 6.0 block" in new TestKit( ActorSystem() ) { val testProbe = new TestProbe(system) system.eventStream.subscribe(testProbe.ref, newBlockSignal) - val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings) + val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings60) val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) val candidateGenerator: ActorRef = @@ -489,7 +489,7 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp defaultMinerSecret.publicImage, readersHolderRef, viewHolderRef, - defaultSettings + defaultSettings60 ) val readers: Readers = await((readersHolderRef ? GetReaders).mapTo[Readers]) @@ -588,19 +588,20 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp .filter(_.blockTransactions.transactions.map(_.id).contains(tx.id)) val txs: Seq[ErgoTransaction] = blocks.flatMap(_.blockTransactions.transactions) - val txIds = txs.map(_.id) - txIds.contains(tx.id) shouldBe true - txIds.contains(tx2.id) shouldBe false - txs should have length 2 // 1 rewards and one regular tx, no fee collection + + txs should have length 3 // 1 rewards and two regular txs, no fee collection + system.terminate() } - it should "6.0 pool transactions should be added to 6.0 block" in new TestKit( - ActorSystem() - ) { + it should "use custom miner public key when provided via optPk" in new TestKit(ActorSystem()) { + import sigmastate.crypto.DLogProtocol.DLogProverInput + import org.bouncycastle.util.BigIntegers + val testProbe = new TestProbe(system) system.eventStream.subscribe(testProbe.ref, newBlockSignal) - val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings60) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings) val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) val candidateGenerator: ActorRef = @@ -608,26 +609,473 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp defaultMinerSecret.publicImage, readersHolderRef, viewHolderRef, - defaultSettings60 + defaultSettings ) - val readers: Readers = await((readersHolderRef ? GetReaders).mapTo[Readers]) + // Generate custom key pair + val customKey = DLogProverInput(BigIntegers.fromUnsignedByteArray("custom_test_key".getBytes())) + val customPk = customKey.publicImage - val history: ErgoHistoryReader = readers.h - val startBlock: Option[Header] = history.bestHeaderOpt + // Request candidate with custom public key + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false, optPk = Some(customPk)), + testProbe.ref + ) + + val candidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + // Verify candidate was generated successfully + candidate should not be null + candidate.candidateBlock should not be null + + system.terminate() + } + + it should "use default minerPk when optPk is None" in new TestKit(ActorSystem()) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + defaultSettings + ) + + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false, optPk = None), + testProbe.ref + ) + + val candidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + // Candidate should be generated successfully with default minerPk + candidate should not be null + candidate.candidateBlock should not be null + + system.terminate() + } + + it should "generate different candidates for different optPk values" in new TestKit(ActorSystem()) { + import sigmastate.crypto.DLogProtocol.DLogProverInput + import org.bouncycastle.util.BigIntegers + + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + defaultSettings + ) + + // Generate custom key pair + val customKey = DLogProverInput(BigIntegers.fromUnsignedByteArray("another_test_key".getBytes())) + val customPk = customKey.publicImage + + // Get candidate with default pk + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false, optPk = None), + testProbe.ref + ) + val candidate1 = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + // Get candidate with custom pk + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false, optPk = Some(customPk)), + testProbe.ref + ) + val candidate2 = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + // Both candidates should be generated successfully + candidate1 should not be null + candidate2 should not be null + + system.terminate() + } + + it should "handle optPk with empty transactions" in new TestKit(ActorSystem()) { + import sigmastate.crypto.DLogProtocol.DLogProverInput + import org.bouncycastle.util.BigIntegers + + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(defaultSettings) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + defaultSettings + ) + + // Generate custom key pair + val customKey = DLogProverInput(BigIntegers.fromUnsignedByteArray("tx_test_key".getBytes())) + val customPk = customKey.publicImage + + // Request candidate with custom pk and empty transactions + candidateGenerator.tell( + GenerateCandidate(Seq.empty, reply = true, forced = false, optPk = Some(customPk)), + testProbe.ref + ) + + val candidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + // Candidate should be generated successfully + candidate should not be null + candidate.txsToInclude shouldBe empty + + system.terminate() + } + + it should "ignore cached candidate when forced = true" in new TestKit(ActorSystem()) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val testDir = s"${defaultSettings.directory}-ignore-cache-${System.currentTimeMillis()}" + val settingsWithShortRegeneration: ErgoSettings = + ErgoSettingsReader.read() + .copy( + nodeSettings = defaultSettings.nodeSettings + .copy(blockCandidateGenerationInterval = 1.millis), + chainSettings = + ErgoSettingsReader.read().chainSettings.copy(blockInterval = 1.seconds), + directory = testDir + ) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithShortRegeneration) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + settingsWithShortRegeneration + ) + + 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 + } + + // 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 + } + // 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.fishForMessage(candidateGenDelay) { + case StatusReply.Success(_: Candidate) => true + case _: FullBlockApplied => false + } match { + case StatusReply.Success(c: Candidate) => c + } + + // 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()) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val testDir = s"${defaultSettings.directory}-preserve-candidate-${System.currentTimeMillis()}" + val settingsWithShortRegeneration: ErgoSettings = + ErgoSettingsReader.read() + .copy( + nodeSettings = defaultSettings.nodeSettings + .copy(blockCandidateGenerationInterval = 1.millis), + chainSettings = + ErgoSettingsReader.read().chainSettings.copy(blockInterval = 1.seconds), + directory = testDir + ) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithShortRegeneration) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + settingsWithShortRegeneration + ) + + 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) + // Wait for block application - can receive either StatusReply or FullBlockApplied first + testProbe.fishForMessage(blockValidationDelay) { + case StatusReply.Success(()) => true + case _: FullBlockApplied => true + case _ => false + } + + // 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 + } + + // Force regeneration - this should preserve candidate1 as cachedPreviousCandidate + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = true), testProbe.ref) + val candidate2 = testProbe.fishForMessage(candidateGenDelay) { + case StatusReply.Success(_: Candidate) => true + case _: FullBlockApplied => false + } match { + case StatusReply.Success(c: Candidate) => c + } + + // candidate2 should be different from candidate1 (regenerated) + candidate2.candidateBlock.timestamp should be >= candidate1.candidateBlock.timestamp + + // Solve a block using candidate1 (the "previous" candidate) + val solvedBlock = powScheme + .proveCandidate(candidate1.candidateBlock, defaultMinerSecret.w, 0, 1000) + .get + + // Submit solution - should succeed because candidate1 should be in cachedPreviousCandidate + candidateGenerator.tell(solvedBlock.header.powSolution, testProbe.ref) + + // Should successfully apply the block + testProbe.fishForMessage(blockValidationDelay) { + case StatusReply.Success(()) => true + case _: FullBlockApplied => true + case _ => false + } + + system.terminate() + } + + it should "handle multiple consecutive forced regenerations correctly" in new TestKit(ActorSystem()) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + // Use unique directory to avoid state conflicts + val testDir = s"${defaultSettings.directory}-multi-forced-${System.currentTimeMillis()}" + val settingsWithShortRegeneration: ErgoSettings = + ErgoSettingsReader.read() + .copy( + nodeSettings = defaultSettings.nodeSettings + .copy(blockCandidateGenerationInterval = 1.millis), + chainSettings = + ErgoSettingsReader.read().chainSettings.copy(blockInterval = 1.seconds), + directory = testDir + ) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithShortRegeneration) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + settingsWithShortRegeneration + ) + + val powScheme = settingsWithShortRegeneration.chainSettings.powScheme + + // First mine a block to establish chain + 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) + // Wait for both StatusReply and FullBlockApplied messages + testProbe.fishForMessage(blockValidationDelay) { + case StatusReply.Success(()) => true + case _: FullBlockApplied => true + case _ => false + } + // Try to consume the second message if it exists + try { + testProbe.expectMsgClass(1.second, classOf[Any]) + } catch { + case _: AssertionError => // No more messages, that's fine + } + + // Now get 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 + } + + // Force regenerate first time + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = true), testProbe.ref) + val candidate2 = testProbe.fishForMessage(candidateGenDelay) { + case StatusReply.Success(_: Candidate) => true + case _: FullBlockApplied => false + } match { + case StatusReply.Success(c: Candidate) => c + } + + // Force regenerate second time + 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 + } + + // All candidates should have increasing or equal timestamps + candidate2.candidateBlock.timestamp should be >= candidate1.candidateBlock.timestamp + candidate3.candidateBlock.timestamp should be >= candidate2.candidateBlock.timestamp + + // Solve block with candidate2 (should be in cachedPreviousCandidate after candidate3 generation) + val solvedBlock = powScheme + .proveCandidate(candidate2.candidateBlock, defaultMinerSecret.w, 0, 1000) + .get + + candidateGenerator.tell(solvedBlock.header.powSolution, testProbe.ref) + + // Should successfully apply the block + testProbe.fishForMessage(blockValidationDelay) { + case StatusReply.Success(()) => true + case _: FullBlockApplied => true + case _ => false + } + + system.terminate() + } + + it should "return cached candidate immediately when forced = false" in new TestKit(ActorSystem()) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val testDir = s"${defaultSettings.directory}-cache-test-${System.currentTimeMillis()}" + val testSettings = defaultSettings.copy(directory = testDir) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(testSettings) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + testSettings + ) + + // Get first candidate + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) + val candidate1 = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + + // Multiple requests with forced = false should return cached candidate immediately + val start = System.currentTimeMillis() + (1 to 10).foreach { i => + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) + val candidate = testProbe.expectMsgPF(100.millis) { + case StatusReply.Success(c: Candidate) => c + } + candidate.candidateBlock.timestamp shouldBe candidate1.candidateBlock.timestamp + } + val elapsed = System.currentTimeMillis() - start + + // Should be very fast since all are cached (no regeneration) + elapsed should be < 500L + + system.terminate() + } + + it should "accept solution for previous candidate after forced regeneration triggered by mempool" in new TestKit(ActorSystem()) { + val testProbe = new TestProbe(system) + system.eventStream.subscribe(testProbe.ref, newBlockSignal) + + val testDir = s"${defaultSettings.directory}-mempool-forced-${System.currentTimeMillis()}" + val settingsWithShortRegeneration: ErgoSettings = + ErgoSettingsReader.read() + .copy( + nodeSettings = defaultSettings.nodeSettings + .copy(blockCandidateGenerationInterval = 100.millis), + chainSettings = + ErgoSettingsReader.read().chainSettings.copy(blockInterval = 1.seconds), + directory = testDir + ) + + val viewHolderRef: ActorRef = ErgoNodeViewRef(settingsWithShortRegeneration) + val readersHolderRef: ActorRef = ErgoReadersHolderRef(viewHolderRef) + + val candidateGenerator: ActorRef = + CandidateGenerator( + defaultMinerSecret.publicImage, + readersHolderRef, + viewHolderRef, + settingsWithShortRegeneration + ) + + val readers: Readers = await((readersHolderRef ? GetReaders).mapTo[Readers]) + val powScheme = settingsWithShortRegeneration.chainSettings.powScheme // generate block to use reward as our tx input candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) testProbe.expectMsgPF(candidateGenDelay) { case StatusReply.Success(candidate: Candidate) => - val block = defaultSettings.chainSettings.powScheme + val block = powScheme .proveCandidate(candidate.candidateBlock, defaultMinerSecret.w, 0, 1000) .get - // let's pretend we are mining at least a bit so it is realistic - expectNoMessage(200.millis) candidateGenerator.tell(block.header.powSolution, testProbe.ref) - - // we fish either for ack or SSM as the order is non-deterministic testProbe.fishForMessage(blockValidationDelay) { case StatusReply.Success(()) => testProbe.expectMsgPF(candidateGenDelay) { @@ -640,75 +1088,57 @@ class CandidateGeneratorSpec extends AnyFlatSpec with Matchers with ErgoTestHelp } } - // build new transaction that uses miner's reward as input - val newlyMinedBlock = readers.h.bestFullBlockOpt.get + // Get candidate and solve it + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) + val candidateToSolve = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c + } + val solvedBlock = powScheme + .proveCandidate(candidateToSolve.candidateBlock, defaultMinerSecret.w, 0, 1000) + .get + + // Build new transaction to trigger mempool change + val prop: ProveDlog = + DLogProverInput(BigIntegers.fromUnsignedByteArray("forced-mempool-test".getBytes())).publicImage + val newlyMinedBlock = readers.h.bestFullBlockOpt.get val rewardBox: ErgoBox = newlyMinedBlock.transactions.last.outputs.last - rewardBox.propositionBytes shouldBe ErgoTreePredef - .rewardOutputScript(emission.settings.minerRewardDelay, defaultMinerPk) - .bytes val input = Input(rewardBox.id, emptyProverResult) - - // sigmaProp(Global.serialize(2).size > 0) - val bs = "1b110204040400d191b1dc6a03dd0173007301" - val tree = ErgoTreeSerializer.DefaultSerializer.deserializeErgoTree(Base16.decode(bs).get) - val outputs = IndexedSeq( - new ErgoBoxCandidate(rewardBox.value, tree, readers.s.stateContext.currentHeight) + new ErgoBoxCandidate(rewardBox.value, ErgoTree.fromSigmaBoolean(prop), readers.s.stateContext.currentHeight) ) val unsignedTx = new UnsignedErgoTransaction(IndexedSeq(input), IndexedSeq(), outputs) - val tx = ErgoTransaction( defaultProver .sign(unsignedTx, IndexedSeq(rewardBox), IndexedSeq(), readers.s.stateContext) .get ) - val spendingBox = tx.outputs.head - val o2 = new ErgoBoxCandidate(spendingBox.value, tree, spendingBox.creationHeight, spendingBox.additionalTokens, spendingBox.additionalRegisters) - val tx2 = tx.copy( - inputs = IndexedSeq(new Input(spendingBox.id, emptyProverResult)), - outputCandidates = IndexedSeq(o2)) + // Submit transaction to mempool + viewHolderRef ! LocallyGeneratedTransaction(UnconfirmedTransaction(tx, None)) + // Wait for candidate to expire and trigger forced regeneration testProbe.expectNoMessage(200.millis) - // mine a block with that transaction - candidateGenerator.tell(GenerateCandidate(Seq(tx, tx2), reply = true, forced = false), testProbe.ref) - testProbe.expectMsgPF(candidateGenDelay) { - case StatusReply.Success(candidate: Candidate) => - val block = defaultSettings.chainSettings.powScheme - .proveCandidate(candidate.candidateBlock, defaultMinerSecret.w, 0, 1000) - .get - testProbe.expectNoMessage(200.millis) - candidateGenerator.tell(block.header.powSolution, testProbe.ref) - // we fish either for ack or SSM as the order is non-deterministic - testProbe.fishForMessage(blockValidationDelay) { - case StatusReply.Success(()) => - testProbe.expectMsgPF(candidateGenDelay) { - case FullBlockApplied(header) if header.id != block.header.parentId => - } - true - case FullBlockApplied(header) if header.id != block.header.parentId => - testProbe.expectMsg(StatusReply.Success(())) - true - } + // Request candidate - should be force regenerated due to expiration + candidateGenerator.tell(GenerateCandidate(Seq.empty, reply = true, forced = false), testProbe.ref) + val regeneratedCandidate = testProbe.expectMsgPF(candidateGenDelay) { + case StatusReply.Success(c: Candidate) => c } - // new transactions should be cleared from pool after applying new block - await((readersHolderRef ? GetReaders).mapTo[Readers]).m.size shouldBe 0 - - // validate total amount of transactions created - val blocks: IndexedSeq[ErgoFullBlock] = readers.h - .chainToHeader(startBlock, readers.h.bestHeaderOpt.get) - ._2 - .headers - .flatMap(readers.h.getFullBlock) - .filter(_.blockTransactions.transactions.map(_.id).contains(tx.id)) + // Should be different from the one we're about to solve + regeneratedCandidate.candidateBlock.transactions.size should be >= candidateToSolve.candidateBlock.transactions.size - val txs: Seq[ErgoTransaction] = blocks.flatMap(_.blockTransactions.transactions) + // Submit solution for the old candidate (should still work via cachedPreviousCandidate) + candidateGenerator.tell(solvedBlock.header.powSolution, testProbe.ref) - txs should have length 3 // 1 rewards and two regular txs, no fee collection + // Should successfully apply the block + testProbe.fishForMessage(blockValidationDelay) { + case StatusReply.Success(()) => true + case FullBlockApplied(header) if header.id != solvedBlock.header.parentId => true + case _ => false + } system.terminate() } diff --git a/src/test/scala/org/ergoplatform/mining/ErgoMinerSpec.scala b/src/test/scala/org/ergoplatform/mining/ErgoMinerSpec.scala index 2161f0893f..ea240b7785 100644 --- a/src/test/scala/org/ergoplatform/mining/ErgoMinerSpec.scala +++ b/src/test/scala/org/ergoplatform/mining/ErgoMinerSpec.scala @@ -369,64 +369,4 @@ class ErgoMinerSpec extends AnyFlatSpec with ErgoTestHelpers with Eventually { system.terminate() } - it should "mine after HF" in new TestKit(ActorSystem()) { - val forkHeight = 3 - - val testProbe = new TestProbe(system) - system.eventStream.subscribe(testProbe.ref, newBlockSignal) - - val forkSettings: ErgoSettings = { - val empty = ErgoSettingsReader.read() - - val nodeSettings = empty.nodeSettings.copy(mining = true, - stateType = StateType.Utxo, - internalMinerPollingInterval = 2.second, - offlineGeneration = true, - verifyTransactions = true) - val chainSettings = empty.chainSettings.copy( - blockInterval = 2.seconds, - epochLength = forkHeight, - voting = empty.chainSettings.voting.copy( - version2ActivationHeight = forkHeight, - version2ActivationDifficultyHex = "10", - votingLength = forkHeight) - ) - empty.copy(nodeSettings = nodeSettings, chainSettings = chainSettings, directory = createTempDir.getAbsolutePath) - } - - val nodeViewHolderRef: ActorRef = ErgoNodeViewRef(forkSettings) - val readersHolderRef: ActorRef = ErgoReadersHolderRef(nodeViewHolderRef) - - val minerRef: ActorRef = ErgoMiner( - forkSettings, - nodeViewHolderRef, - readersHolderRef, - Some(defaultMinerSecret) - ) - - minerRef ! StartMining - - testProbe.expectMsgClass(newBlockDelay, newBlockSignal) - testProbe.expectMsgClass(newBlockDelay, newBlockSignal) - testProbe.expectMsgClass(newBlockDelay, newBlockSignal) - testProbe.expectMsgClass(newBlockDelay, newBlockSignal) - - val wm1 = getWorkMessage(minerRef, Seq.empty) - (wm1.h.get >= forkHeight) shouldBe true - - testProbe.expectMsgClass(newBlockDelay, newBlockSignal) - implicit val patienceConfig: PatienceConfig = PatienceConfig(1.seconds, 50.millis) - eventually { - val wm2 = getWorkMessage(minerRef, Seq.empty) - (wm2.h.get >= forkHeight) shouldBe true - wm1.msg.sameElements(wm2.msg) shouldBe false - - val v2Block = testProbe.expectMsgClass(newBlockDelay, newBlockSignal) - - val h2 = v2Block.header - h2.version shouldBe 2 - h2.minerPk shouldBe defaultMinerPk.value - } - } - } diff --git a/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala b/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala index 42fd3ca55d..54019f9027 100644 --- a/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala +++ b/src/test/scala/org/ergoplatform/nodeView/mempool/ErgoMemPoolSpec.scala @@ -15,6 +15,9 @@ import sigma.ast.ByteArrayConstant import sigma.interpreter.{ContextExtension, ProverResult} import sigma.serialization.{ErgoTreeSerializer, SerializerException} +import scala.collection.immutable.TreeMap +import org.ergoplatform.nodeView.mempool.OrderedTxPool.WeightedTxId + class ErgoMemPoolSpec extends AnyFlatSpec with ErgoTestHelpers with ScalaCheckPropertyChecks { @@ -526,4 +529,74 @@ class ErgoMemPoolSpec extends AnyFlatSpec outcome2.isInstanceOf[ProcessingOutcome.Invalidated] shouldBe true } + it should "not produce duplicate ids when stale registry entry prevents proper removal" in { + // TreeMap requires an Ordering for WeightedTxId keys + implicit val wtxOrdering: Ordering[WeightedTxId] = Ordering.by(wtx => (-wtx.weight, wtx.id)) + + val tx = invalidErgoTransactionGen.sample.get + val now = System.currentTimeMillis() + val utx = UnconfirmedTransaction(tx, None) + + // Create two WeightedTxIds for the same transaction with different weights. + // WeightedTxId.equals uses only 'id', but Ordering[WeightedTxId] compares (-weight, id). + // Therefore TreeMap treats them as distinct keys, allowing the same transaction + // to exist under multiple keys. + val wtxStale = WeightedTxId(tx.id, 100, 100, now) + val wtxActual = WeightedTxId(tx.id, 200, 200, now) + + // Verify the structural vulnerability + wtxStale shouldBe wtxActual + wtxOrdering.compare(wtxStale, wtxActual) should not be 0 + + // Simulate an out-of-sync state: registry points to wtxStale, + // but orderedTransactions stores the tx under wtxActual. + // This can happen after updateFamily or other weight changes + // fail to keep the two collections in sync. + val emptyPool = OrderedTxPool.empty(settings) + val brokenPool = new OrderedTxPool( + TreeMap(wtxActual -> utx), + TreeMap(tx.id -> wtxStale), + emptyPool.invalidatedTxIds, + emptyPool.outputs, + emptyPool.inputs + )(settings) + + // pool.get traverses registry -> wtxStale -> orderedTransactions, + // but wtxStale is not a key in orderedTransactions, so get returns None. + brokenPool.get(tx.id) shouldBe None + + // Yet the transaction IS present under wtxActual + brokenPool.orderedTransactions.valuesIterator.toSeq.map(_.id) should contain(tx.id) + + val mempool = new ErgoMemPool( + brokenPool, + MemPoolStatistics(now, 0, now, 0), + SortingOption.FeePerByte + )(settings) + + // invalidate() first tries pool.get (returns None), then falls back to + // scanning orderedTransactions.valuesIterator. It finds the tx and calls + // OrderedTxPool.invalidate(utx). Inside that method, + // transactionsRegistry.get(tx.id) returns Some(wtxStale). + // With the fix, the stale entry is detected (wtxStale not in orderedTransactions) + // and the fallback path filters orderedTransactions by transaction id. + val afterInvalidate = mempool.invalidate(tx.id) + + // Transaction is properly removed from orderedTransactions despite stale registry + afterInvalidate.pool.orderedTransactions.valuesIterator.toSeq.map(_.id) should not contain(tx.id) + afterInvalidate.pool.transactionsRegistry.contains(tx.id) shouldBe false + + // Now put the same transaction again. With no registry entry, put() + // creates a NEW WeightedTxId based on the actual feeFactor. + val afterPut = afterInvalidate.put(utx) + + // Only ONE entry for the transaction ID exists + afterPut.pool.orderedTransactions.valuesIterator.toSeq.count(_.id == tx.id) shouldBe 1 + + // getAll (used by /transactions/unconfirmed/transactionIds) returns no duplicates + val all = afterPut.getAll + all.count(_.id == tx.id) shouldBe 1 + all.map(_.id).distinct.size shouldBe 1 + } + } diff --git a/src/test/scala/org/ergoplatform/settings/NetworkTypeSpec.scala b/src/test/scala/org/ergoplatform/settings/NetworkTypeSpec.scala new file mode 100644 index 0000000000..3ecf780830 --- /dev/null +++ b/src/test/scala/org/ergoplatform/settings/NetworkTypeSpec.scala @@ -0,0 +1,135 @@ +package org.ergoplatform.settings + +import org.ergoplatform.ErgoAddressEncoder +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class NetworkTypeSpec extends AnyFlatSpec with Matchers { + + "NetworkType.MainNet" should "have correct verboseName" in { + NetworkType.MainNet.verboseName shouldBe "mainnet" + } + + it should "be marked as mainnet" in { + NetworkType.MainNet.isMainNet shouldBe true + NetworkType.MainNet.isTestNet shouldBe false + } + + it should "use mainnet address prefix" in { + NetworkType.MainNet.addressPrefix shouldBe ErgoAddressEncoder.MainnetNetworkPrefix + } + + "NetworkType.TestNet" should "have correct verboseName" in { + NetworkType.TestNet.verboseName shouldBe "testnet" + } + + it should "be marked as testnet" in { + NetworkType.TestNet.isMainNet shouldBe false + NetworkType.TestNet.isTestNet shouldBe true + } + + it should "use testnet address prefix" in { + NetworkType.TestNet.addressPrefix shouldBe ErgoAddressEncoder.TestnetNetworkPrefix + } + + "NetworkType.Tests" should "have correct verboseName" in { + NetworkType.Tests.verboseName shouldBe "tests" + } + + it should "be marked as testnet" in { + NetworkType.Tests.isMainNet shouldBe false + NetworkType.Tests.isTestNet shouldBe true + } + + it should "use testnet address prefix" in { + NetworkType.Tests.addressPrefix shouldBe ErgoAddressEncoder.TestnetNetworkPrefix + } + + "NetworkType.DevNet" should "have correct verboseName" in { + NetworkType.DevNet.verboseName shouldBe "devnet" + } + + it should "not be marked as mainnet or testnet" in { + NetworkType.DevNet.isMainNet shouldBe false + NetworkType.DevNet.isTestNet shouldBe false + } + + it should "use devnet address prefix" in { + NetworkType.DevNet.addressPrefix shouldBe 32 + } + + "NetworkType.DevNet60" should "have correct verboseName" in { + NetworkType.DevNet60.verboseName shouldBe "devnet60" + } + + it should "not be marked as mainnet or testnet" in { + NetworkType.DevNet60.isMainNet shouldBe false + NetworkType.DevNet60.isTestNet shouldBe false + } + + it should "use devnet address prefix" in { + NetworkType.DevNet60.addressPrefix shouldBe 32 + } + + "NetworkType.all" should "include main network types" in { + NetworkType.all should contain theSameElementsAs Seq( + NetworkType.MainNet, + NetworkType.TestNet, + NetworkType.DevNet + ) + } + + it should "not include Tests (synthetic type)" in { + NetworkType.all should not contain (NetworkType.Tests) + } + + it should "not include DevNet60" in { + NetworkType.all should not contain (NetworkType.DevNet60) + } + + "NetworkType.fromString" should "recognize 'mainnet'" in { + NetworkType.fromString("mainnet") shouldBe Some(NetworkType.MainNet) + } + + it should "recognize 'testnet'" in { + NetworkType.fromString("testnet") shouldBe Some(NetworkType.TestNet) + } + + it should "recognize 'devnet'" in { + NetworkType.fromString("devnet") shouldBe Some(NetworkType.DevNet) + } + + it should "recognize 'devnet60'" in { + NetworkType.fromString("devnet60") shouldBe Some(NetworkType.DevNet60) + } + + it should "return None for invalid name" in { + NetworkType.fromString("invalid") shouldBe None + } + + it should "be case-sensitive" in { + NetworkType.fromString("MainNet") shouldBe None + NetworkType.fromString("MAINNET") shouldBe None + NetworkType.fromString("TestNet") shouldBe None + NetworkType.fromString("DevNet") shouldBe None + } + + it should "return None for empty string" in { + NetworkType.fromString("") shouldBe None + } + + "NetworkType equality" should "work correctly for same types" in { + NetworkType.MainNet shouldBe NetworkType.MainNet + NetworkType.TestNet shouldBe NetworkType.TestNet + NetworkType.Tests shouldBe NetworkType.Tests + NetworkType.DevNet shouldBe NetworkType.DevNet + NetworkType.DevNet60 shouldBe NetworkType.DevNet60 + } + + it should "work correctly for different types" in { + NetworkType.MainNet should not be NetworkType.TestNet + NetworkType.TestNet should not be NetworkType.DevNet + NetworkType.Tests should not be NetworkType.MainNet + } + +} diff --git a/src/test/scala/org/ergoplatform/utils/ErgoNodeTestConstants.scala b/src/test/scala/org/ergoplatform/utils/ErgoNodeTestConstants.scala index ca390e21da..e78f4f53da 100644 --- a/src/test/scala/org/ergoplatform/utils/ErgoNodeTestConstants.scala +++ b/src/test/scala/org/ergoplatform/utils/ErgoNodeTestConstants.scala @@ -7,6 +7,7 @@ import org.ergoplatform.settings.Parameters.{MaxBlockCostIncrease, MinValuePerBy import org.ergoplatform.settings._ import org.ergoplatform.wallet.interpreter.ErgoInterpreter import org.ergoplatform.ErgoBox +import org.ergoplatform.settings.NetworkType.Tests import scorex.util.ScorexLogging import scala.concurrent.duration._ @@ -23,7 +24,9 @@ object ErgoNodeTestConstants extends ScorexLogging { Parameters(0, Parameters.DefaultParameters ++ extension, ErgoValidationSettingsUpdate.empty) } - val initSettings: ErgoSettings = ErgoSettingsReader.read(Args(Some("src/test/resources/application.conf"), None)) + val initSettings: ErgoSettings = ErgoSettingsReader + .read(Args(Some("src/test/resources/application.conf"), None)) + .copy(networkType = Tests) implicit val settings: ErgoSettings = initSettings diff --git a/src/test/scala/org/ergoplatform/utils/Stubs.scala b/src/test/scala/org/ergoplatform/utils/Stubs.scala index 698bc64257..85a34c17c7 100644 --- a/src/test/scala/org/ergoplatform/utils/Stubs.scala +++ b/src/test/scala/org/ergoplatform/utils/Stubs.scala @@ -112,7 +112,7 @@ trait Stubs extends ErgoTestHelpers with TestFileUtils { class MinerStub extends Actor { def receive: Receive = { - case CandidateGenerator.GenerateCandidate(_, reply, _) => + case CandidateGenerator.GenerateCandidate(_, reply, _, _) => if (reply) { val candidate = Candidate(null, externalWorkMessage, Seq.empty) // API does not use CandidateBlock sender() ! StatusReply.success(candidate) diff --git a/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala b/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala index dfd8a27def..5766cc1ad9 100644 --- a/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala +++ b/src/test/scala/org/ergoplatform/utils/generators/ErgoNodeTransactionGenerators.scala @@ -190,9 +190,10 @@ object ErgoNodeTransactionGenerators extends ScorexLogging { } while (assetsMap.nonEmpty && availableTokenSlots > 0) } + val creationHeight = boxesToSpend.map(_.creationHeight).max val newBoxes = outputAmounts.zip(tokenAmounts.toIndexedSeq).map { case (amt, tokens) => val normalizedTokens = tokens.toSeq.map(t => t._1.data.toTokenId -> t._2) - testBox(amt, outputsProposition, 0, normalizedTokens) + testBox(amt, outputsProposition, creationHeight, normalizedTokens) } val inputs = boxesToSpend.map(b => Input(b.id, emptyProverResult)) val dataInputs = dataBoxes.map(b => DataInput(b.id))