From eef78fe52495173f7342e4e8edccd104d281adfc Mon Sep 17 00:00:00 2001 From: arkadianet <82632361+arkadianet@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:09:40 +1000 Subject: [PATCH 1/3] Storage-rent repairs: 64-bit fee arithmetic + EIP-27 re-emission carve-out (block version 5) Two storage-rent defects strand UTXOs from the mechanism built to recirculate lost coins: 1. EIP-27 deadlock. checkExpiredBox's recreate branch requires the claimed output to preserve the box's tokens, while verifyReemissionSpending forbids any output from carrying the re-emission token. No transaction satisfies both, so any box still holding re-emission tokens (unclaimed post-777217 miner rewards) becomes permanently rent-immune once it ages past the storage period. The first such box (fbf119cb..., 63 ERG, created 777693) crossed the eligibility line at ~1828900; a live mainnet node rejects both possible claim shapes for it today. 2. Int-wrapping storage fee. storageFeeFactor * box.bytes.length wraps in Int: boxes >= ~1718 bytes (at the default factor) get a negative fee, making the recreate floor exceed the box value (rent-immune short of a subsidy); boxes >= ~3436 bytes wrap back positive and are claimable at a fee unrelated to their size. Both fixes are gated on block version 5 (Header.Interpreter70Version / Constants.StorageRentRepairsBlockVersion), because each changes which transactions are valid: below the activation version the legacy semantics (register preservation, Int wrap) are preserved exactly, so historical blocks validate unchanged and the network switches together on soft-fork activation, as with 5.0's reinterpretation of existing scripts. From block version 5, checkExpiredBox: - computes the storage fee in 64-bit arithmetic (the product cannot overflow Long within parameter bounds); - requires the recreated box to DROP the re-emission token (all other registers and tokens preserved as before), and releases 1 nanoErg per token from the recreation floor. verifyReemissionSpending is untouched: it already demands exactly that value be paid to the pay-to-reemission contract, so the rent path now executes the burn obligation instead of deadlocking against it. The freed value flows back through re-emission to future miners - restoring storage rent's purpose for exactly the boxes most likely to be abandoned. The interpreter learns the re-emission token id via a new optional constructor argument, threaded from chain settings at the three node construction sites (candidate generation, block execution, mempool validation); networks without EIP-27 pass None and are unaffected. New ExpirationSpecification properties pin all four verdict flips (first-wrap uncollectable->consumable, second-wrap true fee, token-dropped claim valid, token-kept claim invalid) and the legacy behaviors below the activation version. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PYSS7hBNFeddkwE3RadSfw --- .../modifiers/history/header/Header.scala | 8 ++ .../nodeView/state/ErgoStateContext.scala | 13 ++ .../wallet/interpreter/ErgoInterpreter.scala | 72 ++++++++++- .../wallet/protocol/Constants.scala | 14 +++ .../mining/CandidateGenerator.scala | 3 +- .../nodeView/state/ErgoState.scala | 3 +- .../nodeView/state/UtxoStateReader.scala | 3 +- .../mempool/ExpirationSpecification.scala | 114 +++++++++++++++++- 8 files changed, 217 insertions(+), 13 deletions(-) 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/nodeView/state/ErgoStateContext.scala b/ergo-core/src/main/scala/org/ergoplatform/nodeView/state/ErgoStateContext.scala index ad20641fa1..56f23a375b 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,19 @@ class ErgoStateContext(val lastHeaders: Seq[Header], */ def blockVersion: Header.Version = currentParameters.blockVersion + /** + * @return id of the EIP-27 re-emission token when re-emission rules are active + * on this chain (None otherwise). Handed to `ErgoInterpreter` so the + * storage-rent repairs (block version `Header.Interpreter70Version`+) + * can drop the token from recreated expired boxes. + */ + def storageRentReemissionTokenId: Option[sigma.Coll[Byte]] = + if (chainSettings.reemission.checkReemissionRules) { + Some(chainSettings.reemission.reemissionTokenIdBytes) + } else { + None + } + 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..095284619a 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,61 @@ 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 reemissionDebt: Long = if (repairsActivated) { + reemissionTokenId match { + case Some(tokenId) => + box.additionalTokens.toArray.collect { case (id, amount) if id == tokenId => amount }.sum + case None => 0L + } + } else { + 0L + } - val storageFeeNotCovered = box.value - storageFee <= 0 + val storageFeeNotCovered = box.value - storageFee - reemissionDebt <= 0 lazy val correctCreationHeight = output.creationHeight == currentHeight - lazy val correctOutValue = output.value >= box.value - storageFee + lazy val correctOutValue = output.value >= box.value - storageFee - 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 || { + if (reemissionDebt > 0 && rId == ErgoBox.TokensRegId) { + val expectedTokens = + box.additionalTokens.toArray.filterNot { case (id, _) => id == reemissionTokenId.get } + 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 + } + } else { + box.get(rId) == output.get(rId) + } + } + } storageFeeNotCovered || (correctCreationHeight && correctOutValue && correctRegisters) } @@ -99,6 +150,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..d00c8c4c36 100644 --- a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala +++ b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala @@ -1,18 +1,20 @@ 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.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 +39,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 +171,107 @@ 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 val repairedVerifier: ErgoInterpreter = + ErgoInterpreter(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: 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) + } + } + } + } From 869978ebce8d8ccde55902978ba9d66e41c95001 Mon Sep 17 00:00:00 2001 From: arkadianet <82632361+arkadianet@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:38:22 +1000 Subject: [PATCH 2/3] Test production context-to-interpreter wiring end-to-end Review feedback (fork PR #1): the storage-rent-repairs properties drove an independently-constructed verifier, leaving the production wiring (ErgoStateContext.storageRentReemissionTokenId and the interpreter construction sites) untested. New property runs the full claim through ErgoState.execTransactions - which builds its own interpreter from the state context - under a block-version-5, checkReemissionRules=true context: the recreated box drops the token, the UNCHANGED verifyReemissionSpending accepts the 1-nanoErg-per-token payment to the real pay-to-reemission contract, and the claimer takes the fee. The same transaction through the same wiring under legacy parameters fails, and the context helper is pinned to expose the token id exactly when re-emission rules are active. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PYSS7hBNFeddkwE3RadSfw --- .../mempool/ExpirationSpecification.scala | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala index d00c8c4c36..09ad8afb5f 100644 --- a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala +++ b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala @@ -253,6 +253,71 @@ class ExpirationSpecification extends ErgoCorePropertyTest { } } + property("storage-rent repairs: production wiring claims a re-emission box end-to-end") { + // Exercises the real context-to-interpreter thread (ErgoState.execTransactions + // builds its own verifier from the state context) rather than an + // independently-configured interpreter, and proves the headline claim: + // under block version 5, checkExpiredBox' and the UNCHANGED + // verifyReemissionSpending (checkReemissionRules = true here) cooperate in + // one valid claim: token dropped, its nanoErg equivalent paid to the + // pay-to-reemission contract, fee to the claimer. + import org.ergoplatform.nodeView.state.ErgoState + import scorex.util.ModifierId + import scala.util.{Failure, Success, Try} + + val reemChain = settings.chainSettings.copy( + reemission = settings.chainSettings.reemission.copy( + checkReemissionRules = true, + emissionNftId = ModifierId @@ ("2b" * 32), + reemissionTokenId = ModifierId @@ ("2a" * 32), + reemissionNftId = ModifierId @@ ("2c" * 32))) + val tokenIdBytes = reemChain.reemission.reemissionTokenIdBytes + val payToReemission = reemChain.reemission.reemissionRules.payToReemission + + // Reward-box-shaped input: 63 ERG carrying 12e9 re-emission tokens, + // aged past the storage period at a height above the EIP-27 activation. + val txId = ModifierId @@ Base16.encode(Array.fill(32)(7: Byte)) + val tokenAmount = 12000000000L + val from = testBox(63000000000L, FalseTree, 1, + Seq((Digest32Coll @@ tokenIdBytes) -> tokenAmount), Map.empty, txId, 0) + val h = from.creationHeight + Constants.StoragePeriod + val fee = parameters.storageFeeFactor.toLong * from.bytes.length + + val in = Input(from.id, + ProverResult(Array.emptyByteArray, ContextExtension(Map(Constants.StorageIndexVarId -> ShortConstant(0))))) + val recreated = new ErgoBoxCandidate(from.value - fee - tokenAmount, from.ergoTree, h) + val toReemission = new ErgoBoxCandidate(tokenAmount, payToReemission, h) + val collector = new ErgoBoxCandidate(fee, TrueTree, h) + val tx = ErgoTransaction(IndexedSeq(in), IndexedSeq(), IndexedSeq(recreated, toReemission, collector)) + + def contextWith(params: Parameters): ErgoStateContext = { + val fb0 = invalidErgoFullBlockGen.sample.get + val fakeHeader = fb0.header.copy(height = h - 1) + val fb = fb0.copy(fb0.header.copy(height = h, parentId = fakeHeader.id)) + new ErgoStateContext(Seq(fakeHeader), None, genesisStateDigest, params, validationSettingsNoIl, + VotingData.empty)(reemChain).appendFullBlock(fb).get + } + + def boxById(id: ErgoBox.BoxId): Try[ErgoBox] = + if (java.util.Arrays.equals(id, from.id)) Success(from) + else Failure(new NoSuchElementException("unknown box")) + + // helper exposes the token id exactly when re-emission rules are active + val v5Context = contextWith(repairedParameters) + v5Context.storageRentReemissionTokenId shouldBe Some(tokenIdBytes) + val plainContext = new ErgoStateContext(Seq.empty, None, genesisStateDigest, parameters, + validationSettingsNoIl, VotingData.empty)(settings.chainSettings) + plainContext.storageRentReemissionTokenId shouldBe None + + // production wiring, repaired rules: the claim validates end-to-end + ErgoState.execTransactions(Seq(tx), v5Context, settings.nodeSettings)(boxById) + .isValid shouldBe true + + // same transaction through the same wiring under legacy parameters fails + ErgoState.execTransactions(Seq(tx), contextWith(parameters), settings.nodeSettings)(boxById) + .isValid shouldBe false + } + 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 From 08dccb480f7a6d6fb027bdc7e0798580ceb7be9e Mon Sep 17 00:00:00 2001 From: "A. Shannon" <204582608+a-shannon@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:34:46 +0200 Subject: [PATCH 3/3] Harden v5 storage-rent re-emission validation --- .../modifiers/mempool/ErgoTransaction.scala | 2 +- .../nodeView/state/ErgoStateContext.scala | 34 +- .../wallet/interpreter/ErgoInterpreter.scala | 42 +-- .../mempool/ExpirationSpecification.scala | 102 ++---- .../StorageRentRepairsSpecification.scala | 299 ++++++++++++++++++ 5 files changed, 386 insertions(+), 93 deletions(-) create mode 100644 src/test/scala/org/ergoplatform/modifiers/mempool/StorageRentRepairsSpecification.scala 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 56f23a375b..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 @@ -109,17 +109,37 @@ class ErgoStateContext(val lastHeaders: Seq[Header], def blockVersion: Header.Version = currentParameters.blockVersion /** - * @return id of the EIP-27 re-emission token when re-emission rules are active - * on this chain (None otherwise). Handed to `ErgoInterpreter` so the - * storage-rent repairs (block version `Header.Interpreter70Version`+) - * can drop the token from recreated expired boxes. + * @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]] = - if (chainSettings.reemission.checkReemissionRules) { - Some(chainSettings.reemission.reemissionTokenIdBytes) + 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 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 095284619a..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 @@ -67,19 +67,24 @@ class ErgoInterpreter(params: BlockchainParameters, // floor so the transaction can pay the burn obligation to the // pay-to-reemission contract, which `verifyReemissionSpending` enforces // transaction-wide. - val reemissionDebt: Long = if (repairsActivated) { - reemissionTokenId match { - case Some(tokenId) => - box.additionalTokens.toArray.collect { case (id, amount) if id == tokenId => amount }.sum - case None => 0L + 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 { - 0L + None } + val reemissionDebt = reemissionEntry.map(_._2).getOrElse(0L) - val storageFeeNotCovered = box.value - storageFee - reemissionDebt <= 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 - reemissionDebt + lazy val correctOutValue = output.value >= valueAfterStorageFee - reemissionDebt // 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 @@ -88,16 +93,17 @@ class ErgoInterpreter(params: BlockchainParameters, .iterator .forall { rId => rId == ErgoBox.ValueRegId || rId == ErgoBox.ReferenceRegId || { - if (reemissionDebt > 0 && rId == ErgoBox.TokensRegId) { - val expectedTokens = - box.additionalTokens.toArray.filterNot { case (id, _) => id == reemissionTokenId.get } - 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 - } - } else { - box.get(rId) == output.get(rId) + 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) } } } diff --git a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala index 09ad8afb5f..ed3aad0613 100644 --- a/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala +++ b/src/test/scala/org/ergoplatform/modifiers/mempool/ExpirationSpecification.scala @@ -7,6 +7,7 @@ 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.{ByteArrayConstant, ErgoTree, ShortConstant} @@ -184,8 +185,18 @@ class ExpirationSpecification extends ErgoCorePropertyTest { private val reemissionTokenIdBytes: sigma.Coll[Byte] = Colls.fromArray(Array.fill[Byte](32)(0x2a)) - private val repairedVerifier: ErgoInterpreter = - ErgoInterpreter(repairedParameters, Some(reemissionTokenIdBytes)) + 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. @@ -231,6 +242,28 @@ class ExpirationSpecification extends ErgoCorePropertyTest { } } + 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 @@ -253,71 +286,6 @@ class ExpirationSpecification extends ErgoCorePropertyTest { } } - property("storage-rent repairs: production wiring claims a re-emission box end-to-end") { - // Exercises the real context-to-interpreter thread (ErgoState.execTransactions - // builds its own verifier from the state context) rather than an - // independently-configured interpreter, and proves the headline claim: - // under block version 5, checkExpiredBox' and the UNCHANGED - // verifyReemissionSpending (checkReemissionRules = true here) cooperate in - // one valid claim: token dropped, its nanoErg equivalent paid to the - // pay-to-reemission contract, fee to the claimer. - import org.ergoplatform.nodeView.state.ErgoState - import scorex.util.ModifierId - import scala.util.{Failure, Success, Try} - - val reemChain = settings.chainSettings.copy( - reemission = settings.chainSettings.reemission.copy( - checkReemissionRules = true, - emissionNftId = ModifierId @@ ("2b" * 32), - reemissionTokenId = ModifierId @@ ("2a" * 32), - reemissionNftId = ModifierId @@ ("2c" * 32))) - val tokenIdBytes = reemChain.reemission.reemissionTokenIdBytes - val payToReemission = reemChain.reemission.reemissionRules.payToReemission - - // Reward-box-shaped input: 63 ERG carrying 12e9 re-emission tokens, - // aged past the storage period at a height above the EIP-27 activation. - val txId = ModifierId @@ Base16.encode(Array.fill(32)(7: Byte)) - val tokenAmount = 12000000000L - val from = testBox(63000000000L, FalseTree, 1, - Seq((Digest32Coll @@ tokenIdBytes) -> tokenAmount), Map.empty, txId, 0) - val h = from.creationHeight + Constants.StoragePeriod - val fee = parameters.storageFeeFactor.toLong * from.bytes.length - - val in = Input(from.id, - ProverResult(Array.emptyByteArray, ContextExtension(Map(Constants.StorageIndexVarId -> ShortConstant(0))))) - val recreated = new ErgoBoxCandidate(from.value - fee - tokenAmount, from.ergoTree, h) - val toReemission = new ErgoBoxCandidate(tokenAmount, payToReemission, h) - val collector = new ErgoBoxCandidate(fee, TrueTree, h) - val tx = ErgoTransaction(IndexedSeq(in), IndexedSeq(), IndexedSeq(recreated, toReemission, collector)) - - def contextWith(params: Parameters): ErgoStateContext = { - val fb0 = invalidErgoFullBlockGen.sample.get - val fakeHeader = fb0.header.copy(height = h - 1) - val fb = fb0.copy(fb0.header.copy(height = h, parentId = fakeHeader.id)) - new ErgoStateContext(Seq(fakeHeader), None, genesisStateDigest, params, validationSettingsNoIl, - VotingData.empty)(reemChain).appendFullBlock(fb).get - } - - def boxById(id: ErgoBox.BoxId): Try[ErgoBox] = - if (java.util.Arrays.equals(id, from.id)) Success(from) - else Failure(new NoSuchElementException("unknown box")) - - // helper exposes the token id exactly when re-emission rules are active - val v5Context = contextWith(repairedParameters) - v5Context.storageRentReemissionTokenId shouldBe Some(tokenIdBytes) - val plainContext = new ErgoStateContext(Seq.empty, None, genesisStateDigest, parameters, - validationSettingsNoIl, VotingData.empty)(settings.chainSettings) - plainContext.storageRentReemissionTokenId shouldBe None - - // production wiring, repaired rules: the claim validates end-to-end - ErgoState.execTransactions(Seq(tx), v5Context, settings.nodeSettings)(boxById) - .isValid shouldBe true - - // same transaction through the same wiring under legacy parameters fails - ErgoState.execTransactions(Seq(tx), contextWith(parameters), settings.nodeSettings)(boxById) - .isValid shouldBe false - } - 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 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 + } + } +}