Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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._
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
}

}
Loading