Skip to content

Commit

Permalink
Merge pull request #655 from ergoplatform/spam-spec
Browse files Browse the repository at this point in the history
Abort earlier during too costly block or transaction validation, output asset value could be positive only
  • Loading branch information
catena2w authored Mar 25, 2019
2 parents 3d40563 + 0a2efe4 commit 1adfe85
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 73 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import sbt._
lazy val commonSettings = Seq(
organization := "org.ergoplatform",
name := "ergo",
version := "2.0.1",
version := "2.0.2",
scalaVersion := "2.12.8",
resolvers ++= Seq("Sonatype Releases" at "https://oss.sonatype.org/content/repositories/releases/",
"SonaType" at "https://oss.sonatype.org/content/groups/public",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,18 @@ case class ErgoTransaction(override val inputs: IndexedSeq[Input],

/**
* Extracts a mapping (assets -> total amount) from a set of boxes passed as a parameter.
* That is, the method is checking amounts of assets in the boxes(i.e. that a box contains non-negative
* That is, the method is checking amounts of assets in the boxes(i.e. that a box contains positive
* amount for an asset) and then summarize and group their corresponding amounts.
*
* @param boxes - boxes to
* @param boxes - boxes to check and extract assets from
* @return a mapping from asset id to to balance and total assets number
*/
private def extractAssets(boxes: IndexedSeq[ErgoBoxCandidate]): Try[(Map[ByteArrayWrapper, Long], Int)] = Try {
val map: mutable.Map[ByteArrayWrapper, Long] = mutable.Map[ByteArrayWrapper, Long]()
val assetsNum = boxes.foldLeft(0) { case (acc, box) =>
require(box.additionalTokens.lengthCompare(ErgoTransaction.MaxAssetsPerBox) <= 0, "too many assets in one box")
box.additionalTokens.foreach { case (assetId, amount) =>
require(amount >= 0, s"negative asset amount for ${Algos.encode(assetId)}")
require(amount > 0, s"non-positive asset amount for ${Algos.encode(assetId)}")
val aiWrapped = ByteArrayWrapper(assetId)
val total = map.getOrElse(aiWrapped, 0L)
map.put(aiWrapped, Math.addExact(total, amount))
Expand All @@ -94,8 +94,11 @@ case class ErgoTransaction(override val inputs: IndexedSeq[Input],
*/
def statelessValidity: Try[Unit] = validateStateless.toTry

/** Stateless transaction validation with result returned as `ValidationResult`
/**
* Stateless transaction validation with result returned as `ValidationResult`
* to accumulate further validation results
*
* @note Consensus-critical!
*/
def validateStateless: ValidationResult[Unit] = {
failFast
Expand All @@ -109,60 +112,80 @@ case class ErgoTransaction(override val inputs: IndexedSeq[Input],
.result
}

/** Return total computation cost
/**
* Checks whether transaction is valid against input boxes to spend, and
* non-spendable data inputs.
*
* Note that this method make only checks which are possible when input boxes are available.
*
* To make full transaction validation, use (tx.statelessValidity && tx.statefulValidity(...))
*
* @note Consensus-critical!
*
* @param boxesToSpend - boxes the transaction spends (via inputs)
* @param dataBoxes - boxes the transaction only reads (via data inputs)
* @param stateContext - blockchain context at the moment of validation
* @param accumulatedCost - computational cost before validation, validation starts with this value
* @param verifier - interpreter used to check spending correctness for transaction inputs
* @return total computation cost
*/
def statefulValidity(boxesToSpend: IndexedSeq[ErgoBox],
dataBoxes: IndexedSeq[ErgoBox],
stateContext: ErgoStateContext)(implicit verifier: ErgoInterpreter): Try[Long] = {
stateContext: ErgoStateContext,
accumulatedCost: Long = 0L)(implicit verifier: ErgoInterpreter): Try[Long] = {
verifier.IR.resetContext() // ensure there is no garbage in the IRContext
lazy val inputSum = Try(boxesToSpend.map(_.value).reduce(Math.addExact(_, _)))
lazy val outputSum = Try(outputCandidates.map(_.value).reduce(Math.addExact(_, _)))

val initialCost: Long =
boxesToSpend.size * stateContext.currentParameters.inputCost +
dataBoxes.size * stateContext.currentParameters.dataInputCost +
outputCandidates.size * stateContext.currentParameters.outputCost
dataBoxes.size * stateContext.currentParameters.dataInputCost +
outputCandidates.size * stateContext.currentParameters.outputCost

// Maximum transaction cost the validation procedure could tolerate
val maxCost = verifier.maxCost - accumulatedCost

failFast
// Check that the transaction is not too big
.demand(initialCost < maxCost, s"Spam transaction detected: $this")
// Starting validation
.payload(initialCost)
// Perform cheap checks first
// Check that outputs are not dust, and not created in future
.validateSeq(outputs) { case (validationState, out) =>
validationState
.demand(out.value >= BoxUtils.minimalErgoAmount(out, stateContext.currentParameters), s"Transaction is trying to create dust: $this")
.demand(out.creationHeight <= stateContext.currentHeight, s"Box created in future: ${outputCandidates.map(_.creationHeight)} validationState ${stateContext.currentHeight}")
}
// Just to be sure, check that all the input boxes to spend (and to read) are presented.
// Normally, this check should always pass, if the client is implemented properly
// so it is not part of the protocol really.
.demand(boxesToSpend.size == inputs.size, s"boxesToSpend.size ${boxesToSpend.size} != inputs.size ${inputs.size}")
.demand(dataBoxes.size == dataInputs.size, s"dataBoxes.size ${dataBoxes.size} != dataInputs.size ${dataInputs.size}")
.validateSeq(boxesToSpend.zipWithIndex) { case (validation, (box, idx)) =>
val input = inputs(idx)
val proof = input.spendingProof
val proverExtension = proof.extension
val transactionContext = TransactionContext(boxesToSpend, dataBoxes, this, idx.toShort)
val ctx = new ErgoContext(stateContext, transactionContext, proverExtension)

val costTry = verifier.verify(box.ergoTree, ctx, proof, messageToSign)
costTry.recover { case t => t.printStackTrace() }

lazy val (isCostValid, scriptCost) = costTry.getOrElse((false, 0L))
validation
.demandEqualArrays(box.id, input.boxId, "Box id doesn't match input")
.demandSuccess(costTry, s"Invalid transaction $this")
.demand(isCostValid, s"Input script verification failed for input #$idx ($box) of tx $this: $costTry")
.map(_ + scriptCost)
}
// Check that there are no overflow in input and output values
.demandSuccess(inputSum, s"Overflow in inputs in $this")
.demandSuccess(outputSum, s"Overflow in outputs in $this")
// Check that transaction is not creating money out of thin air.
.demand(inputSum == outputSum, s"Ergo token preservation is broken in $this")
// Check that there are no more than 255 assets per box,
// and amount for each asset, its amount in a box is positive
.demandTry(outAssetsTry, outAssetsTry.toString) { case (validation, (outAssets, outAssetsNum)) =>
extractAssets(boxesToSpend) match {
case Success((inAssets, inAssetsNum)) =>
lazy val newAssetId = ByteArrayWrapper(inputs.head.boxId)
val tokenAccessCost = stateContext.currentParameters.tokenAccessCost
val totalAssetsAccessCost = (outAssetsNum + inAssetsNum) * tokenAccessCost +
(inAssets.size + outAssets.size) * tokenAccessCost

validation
// Check that transaction is not too costly considering all the assets
.demand(initialCost + totalAssetsAccessCost < maxCost, s"Spam transaction (w. assets) detected: $this")
.validateSeq(outAssets) {
case (validationState, (outAssetId, outAmount)) =>
val inAmount: Long = inAssets.getOrElse(outAssetId, -1L)

// Check that for each asset output amount is no more than input amount,
// with a possible exception for a new asset created by the transaction
validationState.validate(inAmount >= outAmount || (outAssetId == newAssetId && outAmount > 0)) {
fatal(s"Assets preservation rule is broken in $this. " +
s"Amount in: $inAmount, out: $outAmount, Allowed new asset: $newAssetId out: $outAssetId")
Expand All @@ -171,6 +194,32 @@ case class ErgoTransaction(override val inputs: IndexedSeq[Input],
.map(_ + totalAssetsAccessCost)
case Failure(e) => fatal(e.getMessage)
}
}
// Check inputs, the most expensive check usually, so done last.
.validateSeq(boxesToSpend.zipWithIndex) { case (validation, (box, idx)) =>
val input = inputs(idx)
val proof = input.spendingProof
val proverExtension = proof.extension
val transactionContext = TransactionContext(boxesToSpend, dataBoxes, this, idx.toShort)
val ctx = new ErgoContext(stateContext, transactionContext, proverExtension)

val costTry = verifier.verify(box.ergoTree, ctx, proof, messageToSign)
costTry.recover { case t => t.printStackTrace() }

lazy val (isCostValid, scriptCost) = costTry.getOrElse((false, 0L))

val currentTxCost = validation.result.payload.get

validation
// Just in case, should always be true if client implementation is correct.
.demandEqualArrays(box.id, input.boxId, "Box id doesn't match input")
// Check whether input box script interpreter raised exception
.demandSuccess(costTry, s"Transaction validation failed on input #$idx: $this")
// Check that script verification results in "true" value
.demand(isCostValid, s"Input script verification failed for input #$idx ($box) of tx $this: $costTry")
// Check that cost of the transaction after checking the input becomes too big
.demand(currentTxCost + scriptCost < maxCost, s"Too costly transaction after input #$idx: $this")
.map(_ + scriptCost)
}.toTry
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class DigestState protected(override val version: VersionTag,
val boxesFromProofs: Seq[ErgoBox] = proofs.verify(ErgoState.stateChanges(txs), rootHash, expectedHash)
.get.map(v => ErgoBoxSerializer.parseBytes(v))
val knownBoxes = (txs.flatMap(_.outputs) ++ boxesFromProofs).map(o => (ByteArrayWrapper(o.id), o)).toMap
val totalCost = txs.map { tx =>
val totalCost = txs.foldLeft(0L) { case (accumulatedCost, tx) =>
tx.statelessValidity.get
val boxesToSpend = tx.inputs.map { i =>
knownBoxes.get(ByteArrayWrapper(i.boxId)) match {
Expand All @@ -63,8 +63,9 @@ class DigestState protected(override val version: VersionTag,
case None => throw new Exception(s"Box with id ${Algos.encode(i.boxId)} not found")
}
}
tx.statefulValidity(boxesToSpend, dataBoxes, currentStateContext)(verifier).get
}.sum
val txCost = tx.statefulValidity(boxesToSpend, dataBoxes, currentStateContext, accumulatedCost)(verifier).get
accumulatedCost + txCost
}

if (totalCost > currentStateContext.currentParameters.maxBlockCost) {
throw new Exception(s"Transaction cost $totalCost exceeds limit")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class UtxoState(override val persistentProver: PersistentBatchAVLProver[Digest32
currentStateContext: ErgoStateContext) = Try {
implicit val verifier: ErgoInterpreter = ErgoInterpreter(currentStateContext.currentParameters)
val createdOutputs = transactions.flatMap(_.outputs).map(o => (ByteArrayWrapper(o.id), o)).toMap
val totalCost = transactions.map { tx =>
val totalCost = transactions.foldLeft(0L) { case (accumulatedCost, tx) =>
tx.statelessValidity.get
val boxesToSpend = tx.inputs.map { i =>
val id = i.boxId
Expand All @@ -87,8 +87,9 @@ class UtxoState(override val persistentProver: PersistentBatchAVLProver[Digest32
case None => throw new Error(s"Box with id ${Algos.encode(id)} not found")
}
}
tx.statefulValidity(boxesToSpend, dataBoxes, currentStateContext)(verifier).get
}.sum
val txCost = tx.statefulValidity(boxesToSpend, dataBoxes, currentStateContext)(verifier).get
accumulatedCost + txCost
}

if (totalCost > currentStateContext.currentParameters.maxBlockCost) {
throw new Error(s"Transaction cost $totalCost exceeds limit")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import org.ergoplatform.settings.{Constants, LaunchParameters, Parameters}
import org.ergoplatform.utils.ErgoPropertyTest
import org.ergoplatform.{ErgoBox, ErgoBoxCandidate}
import org.scalacheck.Gen
import scorex.crypto.hash.Digest32
import scalan.util.BenchmarkUtil
import scorex.crypto.hash.{Blake2b256, Digest32}
import scorex.util.encode.Base16

import scala.util.Random
Expand Down Expand Up @@ -200,9 +201,9 @@ class ErgoTransactionSpec extends ErgoPropertyTest {

// update transaction inputs and outputs accordingly
val txMod0 = tx.copy(inputs = tx.inputs.init :+ txInMod0) // new token group added to one input
val txMod1 = tx.copy(inputs = tx.inputs.init :+ txInMod1) // existing token added to one input
val txMod2 = tx.copy(inputs = tx.inputs.init :+ txInMod0, // new token group added to one input and one output
outputCandidates = tx.outputCandidates.init :+ modifiedOut0)
val txMod1 = tx.copy(inputs = tx.inputs.init :+ txInMod1) // existing token added to one input
val txMod2 = tx.copy(inputs = tx.inputs.init :+ txInMod0, // new token group added to one input and one output
outputCandidates = tx.outputCandidates.init :+ modifiedOut0)
val txMod3 = tx.copy(inputs = tx.inputs.init :+ txInMod1, // existing token added to one input and one output
outputCandidates = tx.outputCandidates.init :+ modifiedOut1)

Expand Down Expand Up @@ -240,38 +241,47 @@ class ErgoTransactionSpec extends ErgoPropertyTest {
(0 until bxsQty).map(i => inSample.copy(boxId = in(i).id))
}
val txMod = tx.copy(inputs = inputsPointers, outputCandidates = out)
val cost = txMod.statefulValidity(in, emptyDataBoxes, emptyStateContext).get
cost shouldBe > (LaunchParameters.maxBlockCost)
val validFailure = txMod.statefulValidity(in, emptyDataBoxes, emptyStateContext)
validFailure.isFailure shouldBe true
validFailure.failed.get.getMessage.startsWith("Spam") shouldBe true
}

ignore("too costly transaction should be rejected") {
/*
todo fix or remove
val groupElemGen: Gen[EcPointType] = Gen.const(CryptoConstants.dlogGroup.createRandomGenerator())
property("transaction with too many inputs should be rejected") {

val proveDiffieHellmanTupleGen = for {
gv <- groupElemGen
hv <- groupElemGen
uv <- groupElemGen
vv <- groupElemGen
} yield ProveDHTuple(gv, hv, uv, vv)
//we assume that verifier must finish verification of any script in less time than 3M hash calculations
// (for the Blake2b256 hash function over a single block input)
val Timeout: Long = {
val block = Array.fill(16)(0: Byte)
val hf = Blake2b256

//just in case to heat up JVM
(1 to 5000000).foreach(_ => hf(block))

val propositionGen = for {
proveList <- Gen.listOfN(50, proveDiffieHellmanTupleGen)
} yield OR(proveList.map(_.toSigmaProp))
val t0 = System.currentTimeMillis()
(1 to 3000000).foreach(_ => hf(block))
val t = System.currentTimeMillis()
t - t0
}

val gen = validErgoTransactionGenTemplate(1, 1, 1, 1, propositionGen)
val gen = validErgoTransactionGenTemplate(0, 0, 600, 1000, trueLeafGen)
val (from, tx) = gen.sample.get
tx.statelessValidity.isSuccess shouldBe true

forAll(gen) { case (from, tx) =>
tx.statelessValidity.isSuccess shouldBe true
val validity = tx.statefulValidity(from, emptyStateContext)
validity.isSuccess shouldBe false
val cause = validity.failed.get.getCause
Option(cause) shouldBe defined
cause.getMessage should startWith("Estimated expression complexity")
}
*/
//check that spam transaction is being rejected quickly
implicit val verifier: ErgoInterpreter = ErgoInterpreter(LaunchParameters)
val (validity, time0) = BenchmarkUtil.measureTime(tx.statefulValidity(from, IndexedSeq(), emptyStateContext))
validity.isSuccess shouldBe false
assert(time0 <= Timeout)

val cause = validity.failed.get.getMessage
cause should startWith("Spam transaction detected")

//check that spam transaction validation with no cost limit is indeed taking too much time
val relaxedParams = LaunchParameters.parametersTable.updated(Parameters.MaxBlockCostIncrease, Int.MaxValue)
val relaxedVerifier = ErgoInterpreter(Parameters(0, relaxedParams))
val (_, time) = BenchmarkUtil.measureTime(tx.statefulValidity(from, IndexedSeq(), emptyStateContext)(relaxedVerifier))

assert(time > Timeout)
}

}
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
package org.ergoplatform.nodeView.wallet

import java.nio.ByteBuffer

import org.ergoplatform._
import org.ergoplatform.modifiers.mempool.{ErgoBoxSerializer, ErgoTransaction, UnsignedErgoTransaction}
import org.ergoplatform.modifiers.mempool.ErgoTransaction
import org.ergoplatform.nodeView.ErgoInterpreter
import org.ergoplatform.nodeView.state.ErgoState
import org.ergoplatform.nodeView.state.{ErgoStateContext, VotingData}
import org.ergoplatform.nodeView.wallet.requests.{AssetIssueRequest, PaymentRequest}
import org.ergoplatform.settings.{Constants, LaunchParameters}
import org.ergoplatform.utils._
import org.scalatest.PropSpec
import scorex.crypto.authds.ADKey
import scorex.crypto.hash.{Blake2b256, Digest32}
import scorex.util.encode.Base16
import scorex.util.idToBytes
import scorex.util.serialization.VLQByteBufferReader
import sigmastate.Values.ByteArrayConstant
import sigmastate._
import sigmastate.serialization.ConstantStore
import sigmastate.utils.SigmaByteReader

import scala.concurrent.blocking
import scala.util.Random
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import scorex.crypto.authds.{ADDigest, ADKey, SerializedAdProof}
import scorex.crypto.hash.Digest32
import scorex.testkit.generators.CoreGenerators
import scorex.util.{ModifierId, _}
import sigmastate.Values.{ErgoTree, EvaluatedValue, FalseLeaf, TrueLeaf, Value}
import sigmastate.SType
import sigmastate.Values.{ErgoTree, EvaluatedValue}
import sigmastate.basics.DLogProtocol.{DLogProverInput, ProveDlog}
import sigmastate.interpreter.CryptoConstants.EcPointType
import sigmastate.interpreter.ProverResult
import sigmastate.{SBoolean, _}

import scala.util.Random

Expand Down Expand Up @@ -56,7 +56,7 @@ trait ErgoGenerators extends CoreGenerators with Matchers with ErgoTestConstants
//there are outputs in tests of 183 bytes, and maybe in some tests at least 2 outputs are required
//thus we put in an input a monetary value which is at least enough for storing 400 bytes of outputs
val minValue = parameters.minValuePerByte * 400
Gen.choose(minValue, coinsTotal)
Gen.choose(minValue, coinsTotal / 1000)
}

lazy val ergoSyncInfoGen: Gen[ErgoSyncInfo] = for {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import org.scalacheck.Arbitrary.arbByte
import org.scalacheck.{Arbitrary, Gen}
import scorex.crypto.hash.{Blake2b256, Digest32}
import scorex.util._
import sigmastate.Values.{ByteArrayConstant, CollectionConstant, ErgoTree, EvaluatedValue, FalseLeaf, TrueLeaf, Value}
import sigmastate.Values.{ByteArrayConstant, CollectionConstant, ErgoTree, EvaluatedValue, FalseLeaf, TrueLeaf}
import sigmastate._
import org.ergoplatform.settings.Parameters._

Expand Down

0 comments on commit 1adfe85

Please sign in to comment.