diff --git a/build.sc b/build.sc index 9b20c525..4f6035cd 100644 --- a/build.sc +++ b/build.sc @@ -241,7 +241,7 @@ class MetronomeModule(val crossScalaVersion: String) extends CrossScalaModule { ) override def ivyDeps = super.ivyDeps() ++ Agg( - ivy"io.iohk::scalanet:${VersionOf.scalanet}" + ivy"io.iohk::mantis-rlp:${VersionOf.mantis}" ) object test extends TestModule diff --git a/metronome/checkpointing/service/src/io/iohk/metronome/checkpointing/service/models/Ledger.scala b/metronome/checkpointing/service/src/io/iohk/metronome/checkpointing/service/models/Ledger.scala new file mode 100644 index 00000000..246cede7 --- /dev/null +++ b/metronome/checkpointing/service/src/io/iohk/metronome/checkpointing/service/models/Ledger.scala @@ -0,0 +1,52 @@ +package io.iohk.metronome.checkpointing.service.models + +import io.iohk.ethereum.rlp +import io.iohk.metronome.core.Validated +import io.iohk.metronome.checkpointing.interpreter.models.Transaction +import io.iohk.metronome.crypto.hash.{Hash, Keccak256} + +/** Current state of the ledger after applying all previous blocks. + * + * Basically it's the last checkpoint, plus any accumulated proposer blocks + * since then. Initially the last checkpoint is empty; conceptually it could + * the the genesis block of the PoW chain, but we don't know what that is + * until we talk to the interpreter, and we also can't produce it on our + * own since it's opaque data. + */ +case class Ledger( + maybeLastCheckpoint: Option[Transaction.CheckpointCandidate], + proposerBlocks: Vector[Transaction.ProposerBlock] +) { + + /** Calculate the hash of the ledger so we can put it in blocks + * and refer to it when syncing state between federation members. + */ + lazy val hash: Hash = Ledger.hash(this) + + /** Apply a validated transaction to produce the next ledger state. + * + * The transaction should have been validated against the PoW ledger + * by this point, so we know for example that the new checkpoint is + * a valid extension of the previous one. + */ + def update(transaction: Validated[Transaction]): Ledger = + (transaction: Transaction) match { + case t @ Transaction.ProposerBlock(_) => + if (proposerBlocks.contains(t)) + this + else + copy(proposerBlocks = proposerBlocks :+ t) + + case t @ Transaction.CheckpointCandidate(_) => + Ledger(Some(t), Vector.empty) + } +} + +object Ledger { + val empty = Ledger(None, Vector.empty) + + def hash(ledger: Ledger): Hash = { + import RLPCodecs._ + Keccak256(rlp.encode(ledger)) + } +} diff --git a/metronome/checkpointing/service/src/io/iohk/metronome/checkpointing/service/models/RLPCodecs.scala b/metronome/checkpointing/service/src/io/iohk/metronome/checkpointing/service/models/RLPCodecs.scala new file mode 100644 index 00000000..1ed91901 --- /dev/null +++ b/metronome/checkpointing/service/src/io/iohk/metronome/checkpointing/service/models/RLPCodecs.scala @@ -0,0 +1,29 @@ +package io.iohk.metronome.checkpointing.service.models + +import io.iohk.ethereum.rlp.RLPCodec +import io.iohk.ethereum.rlp.RLPCodec.Ops +import io.iohk.ethereum.rlp.RLPImplicitDerivations._ +import io.iohk.ethereum.rlp.RLPImplicits._ +import io.iohk.metronome.checkpointing.interpreter.models.Transaction +import scodec.bits.{BitVector, ByteVector} + +object RLPCodecs { + implicit val rlpBitVector: RLPCodec[BitVector] = + implicitly[RLPCodec[Array[Byte]]].xmap(BitVector(_), _.toByteArray) + + implicit val rlpByteVector: RLPCodec[ByteVector] = + implicitly[RLPCodec[Array[Byte]]].xmap(ByteVector(_), _.toArray) + + implicit val rlpProposerBlock: RLPCodec[Transaction.ProposerBlock] = + deriveLabelledGenericRLPCodec + + implicit val rlpCheckpointCandidate + : RLPCodec[Transaction.CheckpointCandidate] = + deriveLabelledGenericRLPCodec + + implicit def rlpVector[T: RLPCodec]: RLPCodec[Vector[T]] = + seqEncDec[T]().xmap(_.toVector, _.toSeq) + + implicit val rlpLedger: RLPCodec[Ledger] = + deriveLabelledGenericRLPCodec +} diff --git a/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/ArbitraryInstances.scala b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/ArbitraryInstances.scala new file mode 100644 index 00000000..df62c32c --- /dev/null +++ b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/ArbitraryInstances.scala @@ -0,0 +1,40 @@ +package io.iohk.metronome.checkpointing.service.models + +import io.iohk.metronome.checkpointing.interpreter.models.Transaction +import org.scalacheck._ +import org.scalacheck.Arbitrary.arbitrary +import scodec.bits.BitVector + +object ArbitraryInstances { + implicit val arbBitVector: Arbitrary[BitVector] = + Arbitrary { + arbitrary[Array[Byte]].map(BitVector(_)) + } + + implicit val arbProposerBlock: Arbitrary[Transaction.ProposerBlock] = + Arbitrary { + arbitrary[BitVector].map(Transaction.ProposerBlock(_)) + } + + implicit val arbCheckpointCandidate + : Arbitrary[Transaction.CheckpointCandidate] = + Arbitrary { + arbitrary[BitVector].map(Transaction.CheckpointCandidate(_)) + } + + implicit val arbTransaction: Arbitrary[Transaction] = + Arbitrary { + Gen.frequency( + 4 -> arbitrary[Transaction.ProposerBlock], + 1 -> arbitrary[Transaction.CheckpointCandidate] + ) + } + + implicit val arbLeger: Arbitrary[Ledger] = + Arbitrary { + for { + mcp <- arbitrary[Option[Transaction.CheckpointCandidate]] + pbs <- arbitrary[Set[Transaction.ProposerBlock]].map(_.toVector) + } yield Ledger(mcp, pbs) + } +} diff --git a/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/LedgerProps.scala b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/LedgerProps.scala new file mode 100644 index 00000000..de412bb5 --- /dev/null +++ b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/LedgerProps.scala @@ -0,0 +1,34 @@ +package io.iohk.metronome.checkpointing.service.models + +import io.iohk.metronome.core.Validated +import io.iohk.metronome.checkpointing.interpreter.models.Transaction +import org.scalacheck._ +import org.scalacheck.Prop.forAll + +object LedgerProps extends Properties("Ledger") { + import ArbitraryInstances._ + + property("update") = forAll { (ledger: Ledger, transaction: Transaction) => + val updated = ledger.update(Validated[Transaction](transaction)) + + transaction match { + case _: Transaction.ProposerBlock + if ledger.proposerBlocks.contains(transaction) => + updated == ledger + + case _: Transaction.ProposerBlock => + updated.proposerBlocks.last == transaction && + updated.proposerBlocks.distinct == updated.proposerBlocks && + updated.maybeLastCheckpoint == ledger.maybeLastCheckpoint + + case _: Transaction.CheckpointCandidate => + updated.maybeLastCheckpoint == Some(transaction) && + updated.proposerBlocks.isEmpty + } + } + + property("hash") = forAll { (ledger1: Ledger, ledger2: Ledger) => + ledger1 == ledger2 && ledger1.hash == ledger2.hash || + ledger1 != ledger2 && ledger1.hash != ledger2.hash + } +} diff --git a/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/RLPCodecsProps.scala b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/RLPCodecsProps.scala new file mode 100644 index 00000000..d9e8b068 --- /dev/null +++ b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/RLPCodecsProps.scala @@ -0,0 +1,23 @@ +package io.iohk.metronome.checkpointing.service.models + +import io.iohk.ethereum.rlp +import io.iohk.ethereum.rlp.RLPCodec +import org.scalacheck._ +import org.scalacheck.Prop.forAll +import scala.reflect.ClassTag + +object RLPCodecsProps extends Properties("RLPCodecs") { + import ArbitraryInstances._ + import RLPCodecs._ + + /** Test that encoding to and decoding from RLP preserves the value. */ + def propRoundTrip[T: RLPCodec: Arbitrary: ClassTag] = + property(implicitly[ClassTag[T]].runtimeClass.getSimpleName) = forAll { + (value0: T) => + val bytes = rlp.encode(value0) + val value1 = rlp.decode[T](bytes) + value0 == value1 + } + + propRoundTrip[Ledger] +} diff --git a/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/RLPCodecsSpec.scala b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/RLPCodecsSpec.scala new file mode 100644 index 00000000..b3eae079 --- /dev/null +++ b/metronome/checkpointing/service/test/src/io/iohk/metronome/checkpointing/service/models/RLPCodecsSpec.scala @@ -0,0 +1,95 @@ +package io.iohk.metronome.checkpointing.service.models + +import io.iohk.ethereum.rlp._ +import io.iohk.metronome.checkpointing.interpreter.models.Transaction +import org.scalactic.Equality +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import scala.reflect.ClassTag +import org.scalacheck.Arbitrary +import org.scalacheck.Arbitrary.arbitrary + +/** Concrete examples of RLP encoding, so we can make sure the structure is what we expect. + * + * Complements `RLPCodecsProps` which works with arbitrary data. + */ +class RLPCodecsSpec extends AnyFlatSpec with Matchers { + import ArbitraryInstances._ + import RLPCodecs._ + + def sample[T: Arbitrary] = arbitrary[T].sample.get + + // Structrual equality checker for RLPEncodeable. + // It has different wrappers for items based on whether it was hand crafted or generated + // by codecs, and the RLPValue has mutable arrays inside. + implicit val eqRLPList = new Equality[RLPEncodeable] { + override def areEqual(a: RLPEncodeable, b: Any): Boolean = + (a, b) match { + case (a: RLPList, b: RLPList) => + a.items.size == b.items.size && a.items.zip(b.items).forall { + case (a, b) => + areEqual(a, b) + } + case (a: RLPValue, b: RLPValue) => + a.bytes.sameElements(b.bytes) + case other => + false + } + } + + abstract class Example[T: RLPCodec: ClassTag] { + def decoded: T + def encoded: RLPEncodeable + + def name = + s"RLPCodec[${implicitly[ClassTag[T]].runtimeClass.getSimpleName}]" + + def encode = RLPEncoder.encode(decoded) + def decode = RLPDecoder.decode[T](encoded) + } + + def exampleBehavior[T](example: Example[T]) = { + it should "encode the example value to the expected RLP data" in { + example.encode shouldEqual example.encoded + } + + it should "decode the example RLP data to the expected value" in { + example.decode shouldEqual example.decoded + } + } + + def test[T](example: Example[T]) = { + example.name should behave like exampleBehavior(example) + } + + test { + new Example[Ledger] { + val ledger = Ledger( + maybeLastCheckpoint = Some( + sample[Transaction.CheckpointCandidate] + ), + proposerBlocks = Vector( + sample[Transaction.ProposerBlock], + sample[Transaction.ProposerBlock] + ) + ) + + override val decoded: Ledger = ledger + + override val encoded: RLPEncodeable = + RLPList( // Ledger + RLPList( // Option + RLPList( // CheckpointCandidate + RLPValue(ledger.maybeLastCheckpoint.get.value.toByteArray) + ) + ), + RLPList( // Vector + RLPList( // ProposerBlock + RLPValue(ledger.proposerBlocks(0).value.toByteArray) + ), + RLPList(RLPValue(ledger.proposerBlocks(1).value.toByteArray)) + ) + ) + } + } +}