diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/header/Header.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/header/Header.scala index 639f5e0c0f..81593ed336 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/header/Header.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/header/Header.scala @@ -147,6 +147,14 @@ object Header extends ApiCodecs { */ val Interpreter60Version: Byte = 4 + /** + * Block version after the storage-rent repairs soft-fork: + * 64-bit storage-fee arithmetic and the EIP-27 re-emission carve-out + * on expired-box recreation. Matches + * `org.ergoplatform.wallet.protocol.Constants.StorageRentRepairsBlockVersion`. + */ + val Interpreter70Version: Byte = 5 + def scriptFromBlockVersion(blockVersion: Byte): Byte = { (blockVersion - 1).toByte } diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/mempool/ErgoTransaction.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/mempool/ErgoTransaction.scala index a7fff2350e..2e8c7c2d62 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/mempool/ErgoTransaction.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/mempool/ErgoTransaction.scala @@ -436,7 +436,7 @@ case class ErgoTransaction(override val inputs: IndexedSeq[Input], val currentTxCost = validation.result.payload.get verifyInput(validation, boxesToSpend, dataBoxes, box, idx.toShort, stateContext, currentTxCost) } - .validate(txReemission, !stateContext.chainSettings.reemission.checkReemissionRules || + .validate(txReemission, !stateContext.shouldCheckReemissionRules || verifyReemissionSpending(boxesToSpend, outputCandidates, stateContext).isSuccess, InvalidModifier(id, id, modifierTypeId)) } diff --git a/ergo-core/src/main/scala/org/ergoplatform/nodeView/state/ErgoStateContext.scala b/ergo-core/src/main/scala/org/ergoplatform/nodeView/state/ErgoStateContext.scala index ad20641fa1..ee2b4ae4c8 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/nodeView/state/ErgoStateContext.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/nodeView/state/ErgoStateContext.scala @@ -108,6 +108,39 @@ class ErgoStateContext(val lastHeaders: Seq[Header], */ def blockVersion: Header.Version = currentParameters.blockVersion + /** + * @return id of the EIP-27 re-emission token when the storage-rent repairs + * and the ordinary post-activation EIP-27 spending rules are both + * active. Chain identity must not depend on the local + * `checkReemissionRules` flag. + */ + def storageRentReemissionTokenId: Option[sigma.Coll[Byte]] = { + val reemissionSettings = chainSettings.reemission + val tokenId = reemissionSettings.reemissionTokenIdBytes + if (blockVersion >= Header.Interpreter70Version && + currentHeight > reemissionSettings.activationHeight && + tokenId.length == Constants.ModifierIdSize) { + Some(tokenId) + } else { + None + } + } + + /** + * EIP-27 remains an optional local soft-fork check under legacy block + * versions. From the EIP-27 activation height under the repaired rules, its + * transaction-wide checks are mandatory. This includes the activation-height + * injection branch, one block before ordinary re-emission-token spending and + * the storage-rent carve-out become applicable. + */ + def shouldCheckReemissionRules: Boolean = { + val reemissionSettings = chainSettings.reemission + chainSettings.reemission.checkReemissionRules || + (blockVersion >= Header.Interpreter70Version && + currentHeight >= reemissionSettings.activationHeight && + reemissionSettings.reemissionTokenIdBytes.length == Constants.ModifierIdSize) + } + private def votingEpochLength: Int = votingSettings.votingLength def lastHeaderOpt: Option[Header] = lastHeaders.headOption diff --git a/ergo-wallet/src/main/scala/org/ergoplatform/wallet/interpreter/ErgoInterpreter.scala b/ergo-wallet/src/main/scala/org/ergoplatform/wallet/interpreter/ErgoInterpreter.scala index f27e6843a1..f7292d8c2c 100644 --- a/ergo-wallet/src/main/scala/org/ergoplatform/wallet/interpreter/ErgoInterpreter.scala +++ b/ergo-wallet/src/main/scala/org/ergoplatform/wallet/interpreter/ErgoInterpreter.scala @@ -17,8 +17,14 @@ import scala.util.Try * rules for expired boxes spending validation. * * @param params - current values of adjustable blockchain settings + * @param reemissionTokenId - id of the EIP-27 re-emission token, when re-emission rules are + * active on this chain (None otherwise). Used by the storage-rent + * repairs (block version `Constants.StorageRentRepairsBlockVersion`+) + * to drop the token from recreated expired boxes and release its + * nanoErg equivalent from the recreation floor. */ -class ErgoInterpreter(params: BlockchainParameters) +class ErgoInterpreter(params: BlockchainParameters, + val reemissionTokenId: Option[Coll[Byte]] = None) extends ErgoLikeInterpreter with ScorexLogging { /** Override default logging for all Ergo interpreters. */ @@ -40,16 +46,67 @@ class ErgoInterpreter(params: BlockchainParameters) * @return whether the box is spent properly according to the storage fee rule */ protected def checkExpiredBox(box: ErgoBox, output: ErgoBoxCandidate, currentHeight: Height): Boolean = { - val storageFee = params.storageFeeFactor * box.bytes.length + val repairsActivated = params.blockVersion >= Constants.StorageRentRepairsBlockVersion + + // From `StorageRentRepairsBlockVersion` the storage fee is computed in 64-bit + // arithmetic. Before activation the product wraps around Int as it always did: + // the wrap is consensus-observable (it changes which claims are valid), so + // blocks below the activation version must keep the legacy arithmetic. + val storageFee: Long = + if (repairsActivated) { + params.storageFeeFactor.toLong * box.bytes.length + } else { + (params.storageFeeFactor * box.bytes.length).toLong + } + + // From `StorageRentRepairsBlockVersion`, EIP-27 re-emission tokens carried by + // an expired box must be dropped from the recreated box (they may not be + // preserved: `verifyReemissionSpending` forbids any output from carrying + // them, which previously made such boxes unclaimable via storage rent). + // Their nanoErg equivalent (1 per token) is released from the recreation + // floor so the transaction can pay the burn obligation to the + // pay-to-reemission contract, which `verifyReemissionSpending` enforces + // transaction-wide. + val reemissionEntry: Option[(Coll[Byte], Long)] = if (repairsActivated) { + reemissionTokenId.flatMap { tokenId => + val debt = box.additionalTokens.toArray.iterator + .collect { case (id, amount) if id == tokenId => amount } + .foldLeft(0L)(Math.addExact) + if (debt > 0) Some(tokenId -> debt) else None + } + } else { + None + } + val reemissionDebt = reemissionEntry.map(_._2).getOrElse(0L) - val storageFeeNotCovered = box.value - storageFee <= 0 + // Compare before the second subtraction so a maximal token amount cannot + // underflow Long and turn a fully consumable box into the recreation branch. + val valueAfterStorageFee = box.value - storageFee + val storageFeeNotCovered = valueAfterStorageFee <= reemissionDebt lazy val correctCreationHeight = output.creationHeight == currentHeight - lazy val correctOutValue = output.value >= box.value - storageFee + lazy val correctOutValue = output.value >= valueAfterStorageFee - reemissionDebt - // all the registers except of R0 (monetary value) and R3 (creation height and reference) must be preserved + // all the registers except of R0 (monetary value) and R3 (creation height and reference) must be + // preserved; once the storage-rent repairs are activated, R2 (tokens) must instead equal the + // input's tokens with the re-emission token dropped (when the box carries it) lazy val correctRegisters = ErgoBox.allRegisters .iterator - .forall(rId => rId == ErgoBox.ValueRegId || rId == ErgoBox.ReferenceRegId || box.get(rId) == output.get(rId)) + .forall { rId => + rId == ErgoBox.ValueRegId || rId == ErgoBox.ReferenceRegId || { + reemissionEntry match { + case Some((tokenId, _)) if rId == ErgoBox.TokensRegId => + val expectedTokens = + box.additionalTokens.toArray.filterNot { case (id, _) => id == tokenId } + val outputTokens = output.additionalTokens.toArray + outputTokens.length == expectedTokens.length && + outputTokens.indices.forall { i => + outputTokens(i)._1 == expectedTokens(i)._1 && outputTokens(i)._2 == expectedTokens(i)._2 + } + case _ => + box.get(rId) == output.get(rId) + } + } + } storageFeeNotCovered || (correctCreationHeight && correctOutValue && correctRegisters) } @@ -99,6 +156,15 @@ object ErgoInterpreter { def apply(params: BlockchainParameters): ErgoInterpreter = new ErgoInterpreter(params) + /** + * Creates an interpreter with the given parameters and the chain's EIP-27 + * re-emission token id (when re-emission rules are active), enabling the + * storage-rent repairs semantics from + * `Constants.StorageRentRepairsBlockVersion`. + */ + def apply(params: BlockchainParameters, reemissionTokenId: Option[Coll[Byte]]): ErgoInterpreter = + new ErgoInterpreter(params, reemissionTokenId) + /** Create [[AvlTreeData]] with the given digest and all operations enabled. */ def avlTreeFromDigest(digest: Coll[Byte]): AvlTreeData = { val flags = AvlTreeFlags(insertAllowed = true, updateAllowed = true, removeAllowed = true) diff --git a/ergo-wallet/src/main/scala/org/ergoplatform/wallet/protocol/Constants.scala b/ergo-wallet/src/main/scala/org/ergoplatform/wallet/protocol/Constants.scala index 05ab48a520..afdc194e23 100644 --- a/ergo-wallet/src/main/scala/org/ergoplatform/wallet/protocol/Constants.scala +++ b/ergo-wallet/src/main/scala/org/ergoplatform/wallet/protocol/Constants.scala @@ -21,4 +21,18 @@ object Constants { val StorageContractCost: Long = 50 val StorageIndexVarId: Byte = Byte.MaxValue + + /** + * Block (protocol) version from which the storage-rent repairs apply + * (matches `Header.Interpreter70Version` on the node side): + * + * - the storage fee is computed in 64-bit arithmetic instead of the + * historical wrapping `Int` multiplication, and + * - EIP-27 re-emission tokens carried by an expired box are dropped + * from the recreated box, with 1 nanoErg per token released from the + * recreation floor to pay the burn obligation. + * + * See `ErgoInterpreter.checkExpiredBox`. + */ + val StorageRentRepairsBlockVersion: Byte = 5 } diff --git a/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala b/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala index 0a9b3888c0..ed2ac9fa43 100644 --- a/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala +++ b/src/main/scala/org/ergoplatform/mining/CandidateGenerator.scala @@ -874,7 +874,8 @@ object CandidateGenerator extends ScorexLogging { s"Assembling a block candidate for block #$nextHeight from ${transactions.length} transactions available" ) - val verifier: ErgoInterpreter = ErgoInterpreter(upcomingContext.currentParameters) + val verifier: ErgoInterpreter = + ErgoInterpreter(upcomingContext.currentParameters, upcomingContext.storageRentReemissionTokenId) @tailrec def loop( diff --git a/src/main/scala/org/ergoplatform/nodeView/state/ErgoState.scala b/src/main/scala/org/ergoplatform/nodeView/state/ErgoState.scala index 9de029c808..a42f2c2ee6 100644 --- a/src/main/scala/org/ergoplatform/nodeView/state/ErgoState.scala +++ b/src/main/scala/org/ergoplatform/nodeView/state/ErgoState.scala @@ -107,7 +107,8 @@ object ErgoState extends ScorexLogging { currentStateContext: ErgoStateContext, nodeSettings: NodeConfigurationSettings) (checkBoxExistence: ErgoBox.BoxId => Try[ErgoBox]): ValidationResult[Long] = { - val verifier: ErgoInterpreter = ErgoInterpreter(currentStateContext.currentParameters) + val verifier: ErgoInterpreter = + ErgoInterpreter(currentStateContext.currentParameters, currentStateContext.storageRentReemissionTokenId) def preAllocatedBuilder[T: ClassTag](sizeHint: Int): mutable.ArrayBuilder[T] = { val b = mutable.ArrayBuilder.make[T]() diff --git a/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala b/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala index a2c10bd248..f7d80681cb 100644 --- a/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala +++ b/src/main/scala/org/ergoplatform/nodeView/state/UtxoStateReader.scala @@ -49,7 +49,8 @@ trait UtxoStateReader extends ErgoStateReader with UtxoSetSnapshotPersistence { costLimit: Int, interpreterOpt: Option[ErgoInterpreter]): Try[Int] = { val parameters = context.currentParameters.withBlockCost(costLimit) - val verifier = interpreterOpt.getOrElse(ErgoInterpreter(parameters)) + val verifier = + interpreterOpt.getOrElse(ErgoInterpreter(parameters, context.storageRentReemissionTokenId)) tx.statelessValidity().flatMap { _ => val boxesToSpend = tx.inputs.flatMap(i => boxById(i.boxId)) diff --git a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala index 6cacff30d8..ed3aad0613 100644 --- a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala +++ b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala @@ -1,18 +1,21 @@ package org.ergoplatform.modifiers.mempool +import org.ergoplatform.modifiers.history.header.Header import org.ergoplatform.nodeView.state.{ErgoStateContext, VotingData} -import org.ergoplatform.settings.Constants +import org.ergoplatform.settings.{Constants, ErgoValidationSettingsUpdate, Parameters} import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.wallet.interpreter.ErgoInterpreter import org.ergoplatform.{ErgoBox, ErgoBoxCandidate, Input} import org.scalatest.Assertion +import scorex.util.ModifierId import scorex.util.encode.Base16 import sigma.Colls -import sigma.ast.{ErgoTree, ShortConstant} +import sigma.ast.{ByteArrayConstant, ErgoTree, ShortConstant} +import sigma.data.Digest32Coll import sigma.interpreter.{ContextExtension, ProverResult} import sigma.serialization.ErgoTreeSerializer import sigmastate.helpers.TestingHelpers._ -import org.ergoplatform.settings.Constants.TrueTree +import org.ergoplatform.settings.Constants.{FalseTree, TrueTree} class ExpirationSpecification extends ErgoCorePropertyTest { import org.ergoplatform.utils.ErgoCoreTestConstants._ @@ -37,7 +40,8 @@ class ExpirationSpecification extends ErgoCorePropertyTest { def constructTest(from: ErgoBox, heightDelta: Int, outsConstructor: Height => IndexedSeq[ErgoBoxCandidate], - expectedValidity: Boolean): Assertion = { + expectedValidity: Boolean) + (implicit verifier: ErgoInterpreter): Assertion = { // We are filtering out certain heights to avoid problems with improperly generated extension // at the beginning of a voting epoch whenever((from.creationHeight + Constants.StoragePeriod + heightDelta) % votingSettings.votingLength != 0) { @@ -168,4 +172,139 @@ class ExpirationSpecification extends ErgoCorePropertyTest { } } + // Storage-rent repairs (block version Header.Interpreter70Version+): + // 64-bit storage-fee arithmetic + the EIP-27 re-emission carve-out. + + private val repairedParameters: Parameters = Parameters( + 0, + parameters.parametersTable.updated(Parameters.BlockVersion, Header.Interpreter70Version.toInt), + ErgoValidationSettingsUpdate.empty) + + // A well-formed 32-byte token id standing in for the chain's re-emission + // token (the test application.conf leaves reemissionTokenId empty). + private val reemissionTokenIdBytes: sigma.Coll[Byte] = + Colls.fromArray(Array.fill[Byte](32)(0x2a)) + + private class StorageRentTestInterpreter(params: Parameters, + tokenId: Option[sigma.Coll[Byte]]) + extends ErgoInterpreter(params, tokenId) { + + def checkExpiredBoxForTest(box: ErgoBox, + output: ErgoBoxCandidate, + currentHeight: Height): Boolean = + checkExpiredBox(box, output, currentHeight) + } + + private val repairedVerifier = + new StorageRentTestInterpreter(repairedParameters, Some(reemissionTokenIdBytes)) + + property("storage-rent repairs: fee-overflowed box uncollectable before, fully consumable after") { + // A box big enough that `storageFeeFactor * bytes.length` wraps Int-negative. + // Legacy rules then demand a recreated value ABOVE the box's own value + // (impossible without a subsidy); from the repairs the true 64-bit fee + // exceeds the box value, so the whole box is consumable. + val bigPayload = ByteArrayConstant(Colls.fromArray(Array.fill[Byte](1800)(0x7f.toByte))) + forAll(unspendableErgoBoxGen(1000000000L, 2000000000L)) { base => + val from = testBox(base.value, FalseTree, base.creationHeight, + Seq.empty, Map(ErgoBox.R4 -> bigPayload), base.transactionId, base.index) + val wrappedFee = parameters.storageFeeFactor * from.bytes.length + val trueFee = parameters.storageFeeFactor.toLong * from.bytes.length + whenever(wrappedFee < 0 && trueFee >= from.value) { + val outs = (h: Height) => IndexedSeq(new ErgoBoxCandidate(from.value, TrueTree, h)) + constructTest(from, 0, outs, expectedValidity = false) + constructTest(from, 0, outs, expectedValidity = true)(repairedVerifier) + } + } + } + + property("storage-rent repairs: second-wrap box charged its true fee after activation") { + // A box so big the fee wraps PAST Int range back to a small positive + // number. Legacy rules accept a claim charging only the tiny wrapped fee; + // from the repairs the box owes its true 64-bit fee. + val hugePayload = ByteArrayConstant(Colls.fromArray(Array.fill[Byte](3500)(0x11.toByte))) + forAll(unspendableErgoBoxGen(5000000000L, 6000000000L)) { base => + val from = testBox(base.value, FalseTree, base.creationHeight, + Seq.empty, Map(ErgoBox.R4 -> hugePayload), base.transactionId, base.index) + val wrappedFee = parameters.storageFeeFactor * from.bytes.length + val trueFee = parameters.storageFeeFactor.toLong * from.bytes.length + whenever(wrappedFee > 0 && trueFee > wrappedFee && from.value > trueFee) { + val outs = (h: Height) => { + val recreated = new ErgoBoxCandidate(from.value - trueFee, from.ergoTree, h, + from.additionalTokens, from.additionalRegisters) + val collector = new ErgoBoxCandidate(trueFee, TrueTree, h) + IndexedSeq(recreated, collector) + } + // legacy floor is `value - wrappedFee` (tiny fee), so charging the + // true fee under-recreates and is rejected + constructTest(from, 0, outs, expectedValidity = false) + constructTest(from, 0, outs, expectedValidity = true)(repairedVerifier) + } + } + } + + property("storage-rent repairs: maximal token debt cannot reverse full consumption") { + val creationHeight = + if ((Constants.StoragePeriod + 1) % votingSettings.votingLength == 0) 2 else 1 + val from = testBox( + 1L, + FalseTree, + creationHeight, + Seq((Digest32Coll @@ reemissionTokenIdBytes) -> Long.MaxValue), + Map.empty, + ModifierId @@ ("13" * 32), + 0) + val trueFee = parameters.storageFeeFactor.toLong * from.bytes.length + trueFee should be > from.value + // The old `value - fee - debt` expression wrapped back to a positive + // remainder here and incorrectly selected the recreation branch. + (from.value - trueFee - Long.MaxValue) should be > 0L + + val h = from.creationHeight + Constants.StoragePeriod + val output = new ErgoBoxCandidate(from.value, TrueTree, h) + repairedVerifier.checkExpiredBoxForTest(from, output, h) shouldBe true + } + + property("storage-rent repairs: re-emission token box claimable with the token dropped") { + // Pre-repair rules: dropping the token violates register preservation, so + // the box is unclaimable (the EIP-27 deadlock). Post-repair rules: the + // token MUST be dropped and its nanoErg equivalent (1 per token) is + // charged on top of the storage fee, funding the pay-to-reemission burn. + val reemToken = (Digest32Coll @@ reemissionTokenIdBytes) -> 12L + forAll(unspendableErgoBoxGen(1000000000L, Long.MaxValue)) { base => + val from = testBox(base.value, FalseTree, base.creationHeight, + Seq(reemToken), Map.empty, base.transactionId, base.index) + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + whenever(fee > 0 && from.value > fee + 12L) { + val outs = (h: Height) => { + val recreated = new ErgoBoxCandidate(from.value - fee - 12L, from.ergoTree, h) + val collector = new ErgoBoxCandidate(fee + 12L, TrueTree, h) + IndexedSeq(recreated, collector) + } + constructTest(from, 0, outs, expectedValidity = false) + constructTest(from, 0, outs, expectedValidity = true)(repairedVerifier) + } + } + } + + property("storage-rent repairs: recreated box keeping the re-emission token is rejected") { + // The mirror case: preserving the token satisfies the LEGACY register + // rule (this is exactly the half of the deadlock that verifyReemissionSpending + // then kills on mainnet), but the repaired rule requires it dropped. + val reemToken = (Digest32Coll @@ reemissionTokenIdBytes) -> 12L + forAll(unspendableErgoBoxGen(1000000000L, Long.MaxValue)) { base => + val from = testBox(base.value, FalseTree, base.creationHeight, + Seq(reemToken), Map.empty, base.transactionId, base.index) + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + whenever(fee > 0 && from.value > fee + 12L) { + val outs = (h: Height) => { + val recreated = new ErgoBoxCandidate(from.value - fee, from.ergoTree, h, from.additionalTokens) + val collector = new ErgoBoxCandidate(fee, TrueTree, h) + IndexedSeq(recreated, collector) + } + constructTest(from, 0, outs, expectedValidity = true) + constructTest(from, 0, outs, expectedValidity = false)(repairedVerifier) + } + } + } + } diff --git a/src/test/scala/org/ergoplatform/modifiers/mempool/StorageRentRepairsSpecification.scala b/src/test/scala/org/ergoplatform/modifiers/mempool/StorageRentRepairsSpecification.scala new file mode 100644 index 0000000000..32129ecf79 --- /dev/null +++ b/src/test/scala/org/ergoplatform/modifiers/mempool/StorageRentRepairsSpecification.scala @@ -0,0 +1,299 @@ +package org.ergoplatform.modifiers.mempool + +import org.ergoplatform.modifiers.history.CPreHeader +import org.ergoplatform.modifiers.history.header.Header +import org.ergoplatform.nodeView.state.{ErgoState, ErgoStateContext, UpcomingStateContext, VotingData} +import org.ergoplatform.settings.Constants.{TrueTree} +import org.ergoplatform.settings.{ChainSettings, Constants, ErgoValidationSettingsUpdate, Parameters} +import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.{ErgoAddressEncoder, ErgoBox, ErgoBoxCandidate, Input} +import scorex.util.ModifierId +import scorex.util.encode.Base16 +import sigma.ast.{ErgoTree, ShortConstant} +import sigma.data.Digest32Coll +import sigma.interpreter.{ContextExtension, ProverResult} +import sigma.serialization.ErgoTreeSerializer + +import scala.util.{Failure, Success, Try} + +/** Production-wiring and release-boundary fixtures for the v5 storage-rent repairs. */ +class StorageRentRepairsSpecification extends ErgoCorePropertyTest { + import org.ergoplatform.utils.ErgoCoreTestConstants._ + import org.ergoplatform.utils.ErgoNodeTestConstants._ + import sigmastate.helpers.TestingHelpers._ + + private val RepairHeight = 1831843 + private val ReemissionDebt = 12000000000L + + private val repairedParameters = Parameters( + 0, + parameters.parametersTable.updated(Parameters.BlockVersion, Header.Interpreter70Version.toInt), + ErgoValidationSettingsUpdate.empty) + + private val configuredReemission = settings.chainSettings.reemission.copy( + checkReemissionRules = true, + emissionNftId = ModifierId @@ "20fa2bf23962cdf51b07722d6237c0c7b8a44f78856c0f7ec308dc1ef1a92a51", + reemissionTokenId = ModifierId @@ "d9a2cc8a09abfaed87afacfbb7daee79a6b26f10c6613fc13d3f3953e5521d1a", + reemissionNftId = ModifierId @@ "d3feeffa87f2df63a7a15b4905e618ae3ce4c69a7975f171bd314d0b877927b8") + + private val enforcingChain = settings.chainSettings.copy( + addressPrefix = ErgoAddressEncoder.MainnetNetworkPrefix, + reemission = configuredReemission) + private val nonEnforcingChain = enforcingChain.copy( + reemission = configuredReemission.copy(checkReemissionRules = false)) + private val reemissionTokenId = configuredReemission.reemissionTokenIdBytes + private val payToReemission = configuredReemission.reemissionRules.payToReemission + + private val ownerTree = tree( + "100204a00b08cd02a1f56716cb8df4feb9371437904b9125b82db939238cd7d948786db33de3139fea02d192a39a8cc7a70173007301") + private val collectorTree = tree( + "0008cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + + private def tree(hex: String): ErgoTree = + ErgoTreeSerializer.DefaultSerializer.deserializeErgoTree(Base16.decode(hex).get) + + private def contextWith(params: Parameters, + chain: ChainSettings, + height: Int = RepairHeight): ErgoStateContext = { + val predictedHeader = CPreHeader( + version = params.blockVersion, + parentId = Header.GenesisParentId, + timestamp = 0L, + nBits = Constants.InitialNBits, + height = height, + votes = Array.fill(3)(0.toByte), + minerPk = org.ergoplatform.mining.group.generator) + UpcomingStateContext( + Seq.empty, + None, + predictedHeader, + genesisStateDigest, + params, + validationSettingsNoIl, + VotingData.empty)(chain) + } + + private def storageInput(box: ErgoBox): Input = Input( + box.id, + ProverResult( + Array.emptyByteArray, + ContextExtension(Map(Constants.StorageIndexVarId -> ShortConstant(0))))) + + private def plainInput(box: ErgoBox): Input = + Input(box.id, ProverResult(Array.emptyByteArray, ContextExtension.empty)) + + private def accepted(tx: ErgoTransaction, + context: ErgoStateContext, + boxes: Seq[ErgoBox]): Boolean = { + def boxById(id: ErgoBox.BoxId): Try[ErgoBox] = + boxes.find(box => java.util.Arrays.equals(id, box.id)) + .map(Success(_)) + .getOrElse(Failure(new NoSuchElementException("unknown box"))) + ErgoState.execTransactions(Seq(tx), context, settings.nodeSettings)(boxById).isValid + } + + private val v4Enforcing = contextWith(parameters, enforcingChain) + private val v4NonEnforcing = contextWith(parameters, nonEnforcingChain) + private val v5Enforcing = contextWith(repairedParameters, enforcingChain) + private val v5NonEnforcing = contextWith(repairedParameters, nonEnforcingChain) + + property("storage-rent repairs: node and wallet activation versions stay aligned") { + Header.Interpreter70Version shouldBe + org.ergoplatform.wallet.protocol.Constants.StorageRentRepairsBlockVersion + } + + property("storage-rent repairs: live EIP-27 claim flips only at v5") { + enforcingChain.isMainnet shouldBe true + val from = testBox( + 63000000000L, + ownerTree, + 777693, + Seq((Digest32Coll @@ reemissionTokenId) -> ReemissionDebt), + Map.empty, + ModifierId @@ "a1eed80ffd1036add2e5ca5b25b627bd42702e776fab1aed0c3cc2ca1bf756af", + 1) + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + val input = storageInput(from) + val collector = new ErgoBoxCandidate(fee, collectorTree, RepairHeight) + val burnCompliant = ErgoTransaction( + IndexedSeq(input), + IndexedSeq.empty, + IndexedSeq( + new ErgoBoxCandidate(from.value - fee - ReemissionDebt, ownerTree, RepairHeight), + new ErgoBoxCandidate(ReemissionDebt, payToReemission, RepairHeight), + collector)) + val tokenPreserving = ErgoTransaction( + IndexedSeq(input), + IndexedSeq.empty, + IndexedSeq( + new ErgoBoxCandidate(from.value - fee, ownerTree, RepairHeight, from.additionalTokens), + collector)) + + Base16.encode(from.id) shouldBe + "fbf119cbeb73cadc5866f63931599c85e26ea603b9db6d4a97914b88f1f82cfb" + fee shouldBe 168750000L + burnCompliant.id shouldBe ModifierId @@ + "d895753f0f1e576fce59dfd2e5f1dd497726310d7bbb6639a26cf58e36799bfd" + tokenPreserving.id shouldBe ModifierId @@ + "13eed065fc5a95d959fe3618b788dfeb4b5ab909c0bd4a3567c5f2fd0afe2f8d" + + Seq(v4Enforcing, v4NonEnforcing).foreach { context => + accepted(burnCompliant, context, Seq(from)) shouldBe false + } + Seq(v5Enforcing, v5NonEnforcing).foreach { context => + accepted(burnCompliant, context, Seq(from)) shouldBe true + } + + accepted(tokenPreserving, v4NonEnforcing, Seq(from)) shouldBe true + accepted(tokenPreserving, v4Enforcing, Seq(from)) shouldBe false + accepted(tokenPreserving, v5NonEnforcing, Seq(from)) shouldBe false + accepted(tokenPreserving, v5Enforcing, Seq(from)) shouldBe false + } + + property("storage-rent repairs: v5 debt payment is independent of local EIP-27 policy") { + val from = testBox( + 63000000000L, + ownerTree, + 777693, + Seq((Digest32Coll @@ reemissionTokenId) -> ReemissionDebt), + Map.empty, + ModifierId @@ ("10" * 32), + 0) + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + val input = storageInput(from) + val recreated = new ErgoBoxCandidate( + from.value - fee - ReemissionDebt, + ownerTree, + RepairHeight) + + def claim(paymentTree: ErgoTree): ErgoTransaction = ErgoTransaction( + IndexedSeq(input), + IndexedSeq.empty, + IndexedSeq( + recreated, + new ErgoBoxCandidate(ReemissionDebt, paymentTree, RepairHeight), + new ErgoBoxCandidate(fee, collectorTree, RepairHeight))) + + Seq(v5Enforcing, v5NonEnforcing).foreach { context => + accepted(claim(payToReemission), context, Seq(from)) shouldBe true + accepted(claim(TrueTree), context, Seq(from)) shouldBe false + } + + val v5WithoutEip27 = contextWith(repairedParameters, settings.chainSettings) + accepted(claim(payToReemission), v5WithoutEip27, Seq(from)) shouldBe false + + v5Enforcing.storageRentReemissionTokenId shouldBe Some(reemissionTokenId) + v5NonEnforcing.storageRentReemissionTokenId shouldBe Some(reemissionTokenId) + v4Enforcing.storageRentReemissionTokenId shouldBe None + v4NonEnforcing.storageRentReemissionTokenId shouldBe None + v5WithoutEip27.storageRentReemissionTokenId shouldBe None + contextWith( + repairedParameters, + nonEnforcingChain, + configuredReemission.activationHeight - 1).shouldCheckReemissionRules shouldBe false + val atActivation = contextWith( + repairedParameters, + enforcingChain, + configuredReemission.activationHeight) + atActivation.storageRentReemissionTokenId shouldBe None + atActivation.shouldCheckReemissionRules shouldBe true + contextWith( + repairedParameters, + nonEnforcingChain, + configuredReemission.activationHeight).shouldCheckReemissionRules shouldBe true + contextWith( + repairedParameters, + enforcingChain, + configuredReemission.activationHeight + 1).storageRentReemissionTokenId shouldBe Some(reemissionTokenId) + } + + property("storage-rent repairs: activation-height injection checks are mandatory under v5") { + val activationHeight = configuredReemission.activationHeight + val emissionNftId = configuredReemission.emissionNftIdBytes + val emissionBox = testBox( + 100001000000000L, + TrueTree, + activationHeight, + Seq.empty, + Map.empty, + ModifierId @@ ("14" * 32), + 0) + val injectionBox = testBox( + 2000000000L, + TrueTree, + activationHeight, + Seq( + (Digest32Coll @@ emissionNftId) -> 1L, + (Digest32Coll @@ reemissionTokenId) -> 1L), + Map.empty, + ModifierId @@ ("15" * 32), + 0) + val rewardValue = 1000000000L + val tx = ErgoTransaction( + IndexedSeq(plainInput(emissionBox), plainInput(injectionBox)), + IndexedSeq.empty, + IndexedSeq( + new ErgoBoxCandidate( + emissionBox.value + injectionBox.value - rewardValue, + TrueTree, + activationHeight, + injectionBox.additionalTokens), + new ErgoBoxCandidate(rewardValue, TrueTree, activationHeight))) + val inputs = Seq(emissionBox, injectionBox) + + accepted(tx, contextWith(parameters, nonEnforcingChain, activationHeight), inputs) shouldBe true + accepted(tx, contextWith(parameters, enforcingChain, activationHeight), inputs) shouldBe false + accepted(tx, contextWith(repairedParameters, nonEnforcingChain, activationHeight), inputs) shouldBe false + accepted(tx, contextWith(repairedParameters, enforcingChain, activationHeight), inputs) shouldBe false + } + + property("storage-rent repairs: duplicate re-emission entries use the exact aggregate debt") { + val from = testBox( + 63000000000L, + ownerTree, + 777693, + Seq( + (Digest32Coll @@ reemissionTokenId) -> 7000000000L, + (Digest32Coll @@ reemissionTokenId) -> 5000000000L), + Map.empty, + ModifierId @@ ("11" * 32), + 0) + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + val input = storageInput(from) + + def claim(payment: Long): ErgoTransaction = ErgoTransaction( + IndexedSeq(input), + IndexedSeq.empty, + IndexedSeq( + new ErgoBoxCandidate(from.value - fee - ReemissionDebt, ownerTree, RepairHeight), + new ErgoBoxCandidate(payment, payToReemission, RepairHeight), + new ErgoBoxCandidate(fee + ReemissionDebt - payment, collectorTree, RepairHeight))) + + Seq(v5Enforcing, v5NonEnforcing).foreach { context => + accepted(claim(ReemissionDebt), context, Seq(from)) shouldBe true + accepted(claim(ReemissionDebt - 1), context, Seq(from)) shouldBe false + } + } + + property("storage-rent repairs: ordinary claim is unchanged across the version boundary") { + val from = testBox( + 63000000000L, + ownerTree, + 777693, + Seq.empty, + Map.empty, + ModifierId @@ ("12" * 32), + 0) + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + val claim = ErgoTransaction( + IndexedSeq(storageInput(from)), + IndexedSeq.empty, + IndexedSeq( + new ErgoBoxCandidate(from.value - fee, ownerTree, RepairHeight), + new ErgoBoxCandidate(fee, collectorTree, RepairHeight))) + + Seq(v4Enforcing, v4NonEnforcing, v5Enforcing, v5NonEnforcing).foreach { context => + accepted(claim, context, Seq(from)) shouldBe true + } + } +}