From 7cd8579ad496c16f3d990175f6a1facb8d6531e7 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:49:00 +0200 Subject: [PATCH 01/13] fix(nipopow): reject non-canonical interlink runs --- .../modifiers/history/popow/PoPowHeader.scala | 41 +++++++++- .../modifiers/history/PoPowHeaderSpec.scala | 74 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala index 80bcccc649..0bd73a8a05 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala @@ -42,7 +42,16 @@ case class PoPowHeader(header: Header, def height: Int = header.height - def checkInterlinksProof(): Boolean = PoPowHeader.checkInterlinksProof(interlinks, interlinksProof) + def checkInterlinksProof(): Boolean = { + val proofIsEmpty = interlinksProof.indices.isEmpty && interlinksProof.proofs.isEmpty + if (header.isGenesis) { + interlinks.isEmpty && proofIsEmpty + } else if (!PoPowHeader.hasCanonicalInterlinkRuns(interlinks)) { + false + } else { + PoPowHeader.checkInterlinksProof(interlinks, interlinksProof) + } + } } object PoPowHeader { @@ -51,6 +60,36 @@ object PoPowHeader { implicit val hf: HF = Algos.hash + private[popow] def hasCanonicalInterlinkRuns(interlinks: Seq[ModifierId]): Boolean = { + interlinks.headOption.exists { first => + var current = first + var runLength = 1 + var closedRuns = Set.empty[ModifierId] + + interlinks.iterator.zipWithIndex.drop(1).forall { case (interlink, position) => + if (interlink == current) { + if (runLength == 255) { + false + } else { + runLength += 1 + true + } + } else if (position > 255) { + false + } else { + closedRuns += current + if (closedRuns.contains(interlink)) { + false + } else { + current = interlink + runLength = 1 + true + } + } + } + } + } + /** * Validates interlinks merkle root against provided proof */ diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index 99172ff4b5..ff73097fbe 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -1,14 +1,19 @@ package org.ergoplatform.modifiers.history import org.ergoplatform.modifiers.history.popow.NipopowAlgos +import org.ergoplatform.modifiers.history.popow.PoPowHeader import org.ergoplatform.modifiers.history.popow.PoPowHeader.checkInterlinksProof import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.utils.generators.ErgoCoreGenerators.defaultHeaderGen import org.scalacheck.Gen +import scorex.util.{ModifierId, bytesToId} class PoPowHeaderSpec extends ErgoCorePropertyTest { import org.ergoplatform.utils.generators.CoreObjectGenerators._ import org.ergoplatform.utils.ErgoCoreTestConstants._ + private def deterministicId(value: Byte): ModifierId = bytesToId(Array.fill(32)(value)) + property("Check interlinks proof should be true") { forAll(Gen.nonEmptyListOf(modifierIdGen)) { interlinks => val interlinksProof = NipopowAlgos.proofForInterlinkVector(nipopowAlgos.interlinksToExtension(interlinks)).get @@ -22,4 +27,73 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { checkInterlinksProof(interlinks1, interlinksProof) shouldBe false } } + + property("empty interlinks proof is accepted for genesis") { + val extension = nipopowAlgos.interlinksToExtension(Seq.empty) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 1, extensionRoot = extension.digest), Seq.empty, proof) + .checkInterlinksProof() shouldBe true + } + } + + property("empty interlinks proof is rejected for non-genesis headers") { + val extension = nipopowAlgos.interlinksToExtension(Seq.empty) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), Seq.empty, proof) + .checkInterlinksProof() shouldBe false + } + } + + property("a canonical run of 255 identical interlinks is accepted") { + val interlinks = Seq.fill(255)(deterministicId(1)) + val extension = nipopowAlgos.interlinksToExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, proof) + .checkInterlinksProof() shouldBe true + } + } + + property("a run of 256 identical interlinks is rejected") { + val interlinks = Seq.fill(256)(deterministicId(1)) + val extension = nipopowAlgos.interlinksToExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, proof) + .checkInterlinksProof() shouldBe false + } + } + + property("a new interlink run beginning at position 256 is rejected") { + val first = deterministicId(1) + val second = deterministicId(2) + val third = deterministicId(3) + val interlinks = Seq.fill(255)(first) ++ Seq(second, third) + val extension = nipopowAlgos.interlinksToExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, proof) + .checkInterlinksProof() shouldBe false + } + } + + property("a closed interlink id cannot reopen in a later run") { + val first = deterministicId(1) + val second = deterministicId(2) + val interlinks = Seq(first, second, first) + val extension = nipopowAlgos.interlinksToExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, proof) + .checkInterlinksProof() shouldBe false + } + } } From ca51652ac442391c2a6c517f5878e3fb92a5083b Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:51:49 +0200 Subject: [PATCH 02/13] fix(nipopow): build interlink proofs from full extension tree --- .../history/extension/ExtensionCandidate.scala | 9 +++------ .../history/ExtensionCandidateTest.scala | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala index 61513e3360..370f921d7b 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/extension/ExtensionCandidate.scala @@ -6,7 +6,6 @@ import scorex.crypto.authds.LeafData import scorex.crypto.authds.merkle.{BatchMerkleProof, Leaf, MerkleProof, MerkleTree} import scorex.crypto.hash.Digest32 import scorex.util.ModifierId -import scala.annotation.nowarn import scala.collection.mutable /** * Extension block section with header id not provided @@ -38,20 +37,18 @@ class ExtensionCandidate(val fields: Seq[(Array[Byte], Array[Byte])]) { .flatMap(kv => merkleTree.proofByElement(Leaf[Digest32](LeafData @@ kv)(Algos.hash))) /** - * Constructs BatchMerkleProof for a list of interlinks - * Note - only accounts for interlink vector fields in the extension + * Constructs a BatchMerkleProof for the requested extension fields * * @param keys - array of 2-byte keys * @return BatchMerkleProof or None if keys not found */ - @nowarn def batchProofFor(keys: Array[Byte]*): Option[BatchMerkleProof[Digest32]] = { val indices = keys.flatMap(key => fields.find(_._1 sameElements key) .map(Extension.kvToLeaf) .map(kv => Leaf[Digest32](LeafData @@ kv)(Algos.hash).hash) - .flatMap(leafData => interlinksMerkleTree.elementsHashIndex.get( + .flatMap(leafData => merkleTree.elementsHashIndex.get( new mutable.WrappedArray.ofByte(leafData)))) - if (indices.isEmpty) None else interlinksMerkleTree.proofByIndices(indices)(Algos.hash) + if (indices.isEmpty) None else merkleTree.proofByIndices(indices)(Algos.hash) } } diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/ExtensionCandidateTest.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/ExtensionCandidateTest.scala index cff39a1c2e..e4ca3ec592 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/ExtensionCandidateTest.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/ExtensionCandidateTest.scala @@ -4,6 +4,7 @@ import org.ergoplatform.modifiers.history.extension.ExtensionCandidate import org.ergoplatform.modifiers.history.popow.NipopowAlgos import org.ergoplatform.utils.ErgoCorePropertyTest import org.scalacheck.Gen +import scorex.util.bytesToId class ExtensionCandidateTest extends ErgoCorePropertyTest { import org.ergoplatform.utils.generators.CoreObjectGenerators.modifierIdGen @@ -40,6 +41,22 @@ class ExtensionCandidateTest extends ErgoCorePropertyTest { } } + property("batchProofFor should bind interlinks to the complete mixed extension root") { + val interlinks = Seq( + bytesToId(Array.fill(32)(1.toByte)), + bytesToId(Array.fill(32)(2.toByte)) + ) + val interlinkFields = NipopowAlgos.packInterlinks(interlinks) + val nonInterlinkField = Array[Byte](2, 0) -> Array[Byte](1) + val ext = ExtensionCandidate(interlinkFields :+ nonInterlinkField) + + val proof = ext.batchProofFor(interlinkFields.map(_._1.clone).toArray: _*) + + proof shouldBe defined + proof.get.valid(ext.digest) shouldBe true + proof.get.valid(ext.interlinksDigest) shouldBe false + } + property("batchProofFor should return None for a empty fields") { val fields: Seq[KV] = Seq.empty val ext = ExtensionCandidate(fields) From f910c9e2e5eb4636b4b9e2c150770407147e0e86 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:04:38 +0200 Subject: [PATCH 03/13] fix(nipopow): bind interlink proofs to extension root --- .../modifiers/history/popow/PoPowHeader.scala | 32 +++-- .../modifiers/history/PoPowHeaderSpec.scala | 130 +++++++++++++++++- 2 files changed, 145 insertions(+), 17 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala index 0bd73a8a05..9379b7ecb7 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala @@ -7,13 +7,13 @@ import cats.implicits.{catsStdInstancesForEither, catsStdInstancesForList} import io.circe.{Decoder, Encoder, Json} import org.ergoplatform.core.BytesSerializable import org.ergoplatform.modifiers.ErgoFullBlock -import org.ergoplatform.modifiers.history.extension.Extension.merkleTree +import org.ergoplatform.modifiers.history.extension.Extension import org.ergoplatform.modifiers.history.header.{Header, HeaderSerializer} import org.ergoplatform.settings.Algos import org.ergoplatform.settings.Algos.HF import org.ergoplatform.serialization.ErgoSerializer -import scorex.crypto.authds.Side -import scorex.crypto.authds.merkle.BatchMerkleProof +import scorex.crypto.authds.{LeafData, Side} +import scorex.crypto.authds.merkle.{BatchMerkleProof, Leaf} import scorex.crypto.authds.merkle.serialization.BatchMerkleProofSerializer import scorex.crypto.hash.Digest32 import scorex.util.Extensions._ @@ -49,7 +49,7 @@ case class PoPowHeader(header: Header, } else if (!PoPowHeader.hasCanonicalInterlinkRuns(interlinks)) { false } else { - PoPowHeader.checkInterlinksProof(interlinks, interlinksProof) + PoPowHeader.checkInterlinksProof(interlinks, interlinksProof, header.extensionRoot) } } } @@ -91,16 +91,22 @@ object PoPowHeader { } /** - * Validates interlinks merkle root against provided proof + * Validates the exact packed interlink leaves against the full extension root */ - def checkInterlinksProof(interlinks: Seq[ModifierId], proof: BatchMerkleProof[Digest32]): Boolean = { - if (interlinks.isEmpty && proof.indices.isEmpty && proof.proofs.isEmpty) { - true - } else { - val fields = NipopowAlgos.packInterlinks(interlinks) - val tree = merkleTree(fields) - proof.valid(tree.rootHash) - } + def checkInterlinksProof(interlinks: Seq[ModifierId], + proof: BatchMerkleProof[Digest32], + extensionRoot: Digest32): Boolean = { + val expectedLeafHashes = NipopowAlgos.packInterlinks(interlinks) + .map(Extension.kvToLeaf) + .map(kv => Leaf[Digest32](LeafData @@ kv)(Algos.hash).hash) + val provenLeafHashes = proof.indices.map(_._2) + + interlinks.nonEmpty && + expectedLeafHashes.size == provenLeafHashes.size && + expectedLeafHashes.zip(provenLeafHashes).forall { case (expected, proven) => + expected sameElements proven + } && + proof.valid(extensionRoot) } /** diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index ff73097fbe..b2c52320ca 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -1,11 +1,13 @@ package org.ergoplatform.modifiers.history +import org.ergoplatform.modifiers.history.extension.ExtensionCandidate import org.ergoplatform.modifiers.history.popow.NipopowAlgos import org.ergoplatform.modifiers.history.popow.PoPowHeader import org.ergoplatform.modifiers.history.popow.PoPowHeader.checkInterlinksProof import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.generators.ErgoCoreGenerators.defaultHeaderGen import org.scalacheck.Gen +import scorex.crypto.hash.Digest32 import scorex.util.{ModifierId, bytesToId} class PoPowHeaderSpec extends ErgoCorePropertyTest { @@ -14,17 +16,137 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private def deterministicId(value: Byte): ModifierId = bytesToId(Array.fill(32)(value)) + private def mixedExtension(interlinks: Seq[ModifierId]): ExtensionCandidate = { + nipopowAlgos.interlinksToExtension(interlinks) ++ ExtensionCandidate(Seq( + Array[Byte](2, 0) -> Array[Byte](1) + )) + } + property("Check interlinks proof should be true") { forAll(Gen.nonEmptyListOf(modifierIdGen)) { interlinks => - val interlinksProof = NipopowAlgos.proofForInterlinkVector(nipopowAlgos.interlinksToExtension(interlinks)).get - checkInterlinksProof(interlinks, interlinksProof) shouldBe true + val extension = nipopowAlgos.interlinksToExtension(interlinks) + val interlinksProof = NipopowAlgos.proofForInterlinkVector(extension).get + checkInterlinksProof(interlinks, interlinksProof, extension.digest) shouldBe true } } property("Check invalid interlinks proof should be false") { forAll(Gen.nonEmptyListOf(modifierIdGen), Gen.nonEmptyListOf(modifierIdGen)) { (interlinks1, interlinks2) => - val interlinksProof = NipopowAlgos.proofForInterlinkVector(nipopowAlgos.interlinksToExtension(interlinks2)).get - checkInterlinksProof(interlinks1, interlinksProof) shouldBe false + val extension = nipopowAlgos.interlinksToExtension(interlinks2) + val interlinksProof = NipopowAlgos.proofForInterlinkVector(extension).get + checkInterlinksProof(interlinks1, interlinksProof, extension.digest) shouldBe false + } + } + + property("a mixed-extension interlinks proof is accepted against the complete header root") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + + proof.valid(extension.digest) shouldBe true + proof.valid(extension.interlinksDigest) shouldBe false + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, proof) + .checkInterlinksProof() shouldBe true + } + } + + property("a one-byte header extension root mutation is rejected") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + val wrongRootBytes = extension.digest.clone() + wrongRootBytes(0) = (wrongRootBytes(0) ^ 1).toByte + val wrongRoot = Digest32 @@ wrongRootBytes + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = wrongRoot), interlinks, proof) + .checkInterlinksProof() shouldBe false + } + } + + property("an interlink mutation retaining the original full-root proof is rejected") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + val mutatedInterlinks = interlinks.updated(1, deterministicId(3)) + + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), mutatedInterlinks, proof) + .checkInterlinksProof() shouldBe false + } + } + + property("an incomplete interlink disclosure is rejected even when it proves the full root") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val interlinkKeys = NipopowAlgos.packInterlinks(interlinks).map(_._1) + val incompleteProof = extension.batchProofFor(interlinkKeys.head).get + + incompleteProof.valid(extension.digest) shouldBe true + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, incompleteProof) + .checkInterlinksProof() shouldBe false + } + } + + property("an extra disclosed extension leaf is rejected even when it proves the full root") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val allKeys = extension.fields.map(_._1) + val overcompleteProof = extension.batchProofFor(allKeys: _*).get + + overcompleteProof.valid(extension.digest) shouldBe true + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), interlinks, overcompleteProof) + .checkInterlinksProof() shouldBe false + } + } + + property("an interlinks-only proof is rejected under a mixed extension root") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val interlinksOnlyExtension = nipopowAlgos.interlinksToExtension(interlinks) + val mixed = mixedExtension(interlinks) + val legacyProof = NipopowAlgos.proofForInterlinkVector(interlinksOnlyExtension).get + + legacyProof.valid(interlinksOnlyExtension.digest) shouldBe true + legacyProof.valid(mixed.digest) shouldBe false + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = mixed.digest), interlinks, legacyProof) + .checkInterlinksProof() shouldBe false + } + } + + property("a zero-length source run is rejected after unpacking") { + val canonicalFields = NipopowAlgos.packInterlinks(Seq(deterministicId(1), deterministicId(2))) + val zeroLengthValue = canonicalFields.head._2.clone() + zeroLengthValue(0) = 0 + val malformedFields = (canonicalFields.head._1 -> zeroLengthValue) +: canonicalFields.tail + val extension = ExtensionCandidate(malformedFields) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + val unpacked = NipopowAlgos.unpackInterlinks(malformedFields).get + + unpacked shouldBe Seq(deterministicId(2)) + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), unpacked, proof) + .checkInterlinksProof() shouldBe false + } + } + + property("a displaced source run-start key is rejected after unpacking") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val canonicalFields = NipopowAlgos.packInterlinks(interlinks) + val displacedKey = canonicalFields(1)._1.clone() + displacedKey(1) = 42 + val malformedFields = Seq(canonicalFields.head, displacedKey -> canonicalFields(1)._2) + val extension = ExtensionCandidate(malformedFields) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + val unpacked = NipopowAlgos.unpackInterlinks(malformedFields).get + + unpacked shouldBe interlinks + forAll(defaultHeaderGen) { header => + PoPowHeader(header.copy(height = 2, extensionRoot = extension.digest), unpacked, proof) + .checkInterlinksProof() shouldBe false } } From 80cd70cd6ff2a89435afac05ae1d4a84f6c57706 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:20:09 +0200 Subject: [PATCH 04/13] test(nipopow): add cross-runtime full-root fixture --- .../nipopow-full-root-mixed-popow-header.json | 26 +++++++++++++++++ .../modifiers/history/PoPowHeaderSpec.scala | 29 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 ergo-core/src/test/resources/nipopow-full-root-mixed-popow-header.json diff --git a/ergo-core/src/test/resources/nipopow-full-root-mixed-popow-header.json b/ergo-core/src/test/resources/nipopow-full-root-mixed-popow-header.json new file mode 100644 index 0000000000..5aab2d98b1 --- /dev/null +++ b/ergo-core/src/test/resources/nipopow-full-root-mixed-popow-header.json @@ -0,0 +1,26 @@ +{ + "format": "scorex-popow-header-v1", + "label": "synthetic-mixed-full-root", + "bytes_hex": "dc01026481752bace5fa5acba5d5ef7124d48826664742d46c974c98a2d60ace229a34d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc070239b8c2e51c0400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f402111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222229201000000020000000200000001fc95d4accfa4598b6151a1f9837fe4f85b028154176351a076b954abd74ae45300000002413bd0b194cedf41d6ad4ca6b0236b59ff2e070fbbfc499fda2b6f55aa27dc87580c42b47e2deed9aa364fadb6192e03715e87108fd3c96b74c21fa889baa52000000000000000000000000000000000000000000000000000000000000000000001", + "length": 435, + "sha256": "832426d3beece84df9707247919b3a62444c188ae63951273f198952e8ceb068", + "extension_root": "65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc", + "extension_fields": [ + { + "key": "0000", + "value": "2a" + }, + { + "key": "0100", + "value": "011111111111111111111111111111111111111111111111111111111111111111" + }, + { + "key": "0101", + "value": "012222222222222222222222222222222222222222222222222222222222222222" + } + ], + "interlinks": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222" + ] +} diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index b2c52320ca..6531f57d81 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -4,11 +4,16 @@ import org.ergoplatform.modifiers.history.extension.ExtensionCandidate import org.ergoplatform.modifiers.history.popow.NipopowAlgos import org.ergoplatform.modifiers.history.popow.PoPowHeader import org.ergoplatform.modifiers.history.popow.PoPowHeader.checkInterlinksProof +import org.ergoplatform.modifiers.history.popow.PoPowHeaderSerializer import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.generators.ErgoCoreGenerators.defaultHeaderGen import org.scalacheck.Gen import scorex.crypto.hash.Digest32 import scorex.util.{ModifierId, bytesToId} +import scorex.util.encode.Base16 + +import java.security.MessageDigest +import scala.io.Source class PoPowHeaderSpec extends ErgoCorePropertyTest { import org.ergoplatform.utils.generators.CoreObjectGenerators._ @@ -150,6 +155,30 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { } } + property("the cross-runtime full-root fixture round-trips and rejects mutations") { + val fixtureSource = Source.fromResource("nipopow-full-root-mixed-popow-header.json") + val fixtureText = try fixtureSource.mkString finally fixtureSource.close() + val fixture = io.circe.parser.parse(fixtureText).toOption.get.hcursor + val bytes = Base16.decode(fixture.get[String]("bytes_hex").toOption.get).get + + bytes.length shouldBe fixture.get[Int]("length").toOption.get + Base16.encode(MessageDigest.getInstance("SHA-256").digest(bytes)) shouldBe + fixture.get[String]("sha256").toOption.get + + val parsed = PoPowHeaderSerializer.parseBytes(bytes) + PoPowHeaderSerializer.toBytes(parsed) shouldBe bytes + Base16.encode(parsed.header.extensionRoot) shouldBe fixture.get[String]("extension_root").toOption.get + parsed.checkInterlinksProof() shouldBe true + + val wrongRootBytes = parsed.header.extensionRoot.clone() + wrongRootBytes(0) = (wrongRootBytes(0) ^ 1).toByte + parsed.copy(header = parsed.header.copy(extensionRoot = Digest32 @@ wrongRootBytes)) + .checkInterlinksProof() shouldBe false + + parsed.copy(interlinks = parsed.interlinks.updated(1, deterministicId(3))) + .checkInterlinksProof() shouldBe false + } + property("empty interlinks proof is accepted for genesis") { val extension = nipopowAlgos.interlinksToExtension(Seq.empty) val proof = NipopowAlgos.proofForInterlinkVector(extension).get From 53afdbb50030f91b9c9732ab4a4eb90e9fc7c9ee Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:42:09 +0200 Subject: [PATCH 05/13] fix(nipopow): validate proof parameters before comparison --- .../history/popow/NipopowAlgos.scala | 7 +- .../history/popow/NipopowProof.scala | 18 +++- .../modifiers/history/popow/PoPowParams.scala | 25 ++++- .../modifiers/history/PoPowAlgosSpec.scala | 100 +++++++++++++++++- .../serialization/SerializationTests.scala | 51 ++++++++- 5 files changed, 191 insertions(+), 10 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala index 0d76a9815e..abcee591a2 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala @@ -67,7 +67,9 @@ class NipopowAlgos(val chainSettings: ChainSettings) { */ def maxLevelOf(header: Header): Int = if (!header.isGenesis) { - val requiredTarget = org.ergoplatform.mining.q / DifficultySerializer.decodeCompactBits(header.nBits) + val decodedDifficulty = DifficultySerializer.decodeCompactBits(header.nBits) + require(decodedDifficulty > 0, "Decoded difficulty target must be positive") + val requiredTarget = org.ergoplatform.mining.q / decodedDifficulty val realTarget = powScheme.powHit(header).doubleValue val level = log2(requiredTarget.doubleValue) - log2(realTarget.doubleValue) level.toInt @@ -98,7 +100,7 @@ class NipopowAlgos(val chainSettings: ChainSettings) { * end function */ def bestArg(chain: Seq[Header])(m: Int): Int = { - require(m >= 1, s"$m < 1") + PoPowParams.requireValidM(m) @scala.annotation.tailrec def loop(level: Int, acc: Seq[(Int, Int)] = Seq.empty): Seq[(Int, Int)] = @@ -134,6 +136,7 @@ class NipopowAlgos(val chainSettings: ChainSettings) { val k = params.k val m = params.m + PoPowParams.requireValid(m, k) require(chain.lengthCompare(k + m) >= 0, s"Can not prove chain of size < ${k + m}") require(chain.head.header.isGenesis, "Can not prove non-anchored chain") diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index b922aca7c0..a293a07050 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -51,7 +51,9 @@ case class NipopowProof(popowAlgos: NipopowAlgos, */ def isBetterThan(that: NipopowProof): Boolean = { try { - if (this.isValid && that.isValid) { + if (this.m != that.m || this.k != that.k) { + false + } else if (this.isValid && that.isValid) { popowAlgos.lowestCommonAncestor(headersChain, that.headersChain) .map(h => headersChain.filter(_.height > h.height) -> that.headersChain.filter(_.height > h.height)) .exists({ case (thisDivergingChain, thatDivergingChain) => @@ -72,7 +74,7 @@ case class NipopowProof(popowAlgos: NipopowAlgos, * @return true if the proof is valid */ lazy val isValid: Boolean = { - PoPowParams.isValid(m, k) && + this.hasValidParams && this.hasValidConnections && this.hasValidHeights && this.hasValidProofs && @@ -80,6 +82,14 @@ case class NipopowProof(popowAlgos: NipopowAlgos, this.hasValidPow } + /** + * Checks proof parameters and the exact suffix cardinality before any + * parameter-dependent scoring or validation work. + */ + lazy val hasValidParams: Boolean = { + PoPowParams.areValid(m, k) && suffixTail.lengthCompare(k - 1) == 0 + } + /** * @return true if proof contains headers needed to check difficulty after the suffix, * or if the proof is for non-continuous mode, false otherwise @@ -195,6 +205,7 @@ object NipopowProof { class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[NipopowProof] { override def serialize(obj: NipopowProof, w: Writer): Unit = { + require(obj.hasValidParams, "Invalid NiPoPoW proof parameters or suffix length") w.putUInt(obj.m.toLong) w.putUInt(obj.k.toLong) w.putUInt(obj.prefix.size.toLong) @@ -218,6 +229,7 @@ class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[Ni override def parse(r: Reader): NipopowProof = { val m = r.getUInt().toIntExact val k = r.getUInt().toIntExact + PoPowParams.requireValid(m, k) val prefixSize = r.getUInt().toIntExact val prefix = (0 until prefixSize).map { _ => val size = r.getUInt().toIntExact @@ -226,6 +238,8 @@ class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[Ni val suffixHeadSize = r.getUInt().toIntExact val suffixHead = PoPowHeaderSerializer.parseBytes(r.getBytes(suffixHeadSize)) val suffixSize = r.getUInt().toIntExact + require(suffixSize == k - 1, + s"NiPoPoW suffix length ${suffixSize + 1} does not match k parameter $k") val suffixTail = (0 until suffixSize).map { _ => val size = r.getUInt().toIntExact HeaderSerializer.parseBytes(r.getBytes(size)) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala index 659fe3c8e8..cc7a79379a 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowParams.scala @@ -17,11 +17,32 @@ import scala.util.Try final class PoPowParams private (val m: Int, val k: Int, val continuous: Boolean, val minChainLength: Int) object PoPowParams { + final val MaxProofElements: Int = 20000 + + def isValidM(m: Int): Boolean = m >= 1 && m <= MaxProofElements + + def isValidK(k: Int): Boolean = k >= 1 && k <= MaxProofElements + def isValid(m: Int, k: Int): Boolean = - m >= 1 && k >= 1 && m.toLong + k.toLong <= Int.MaxValue + isValidM(m) && isValidK(k) && m.toLong + k.toLong <= Int.MaxValue + + def areValid(m: Int, k: Int): Boolean = isValid(m, k) + + def requireValidM(m: Int): Unit = + require(isValidM(m), s"m parameter $m must be in 1..=$MaxProofElements") + + def requireValidK(k: Int): Unit = + require(isValidK(k), s"k parameter $k must be in 1..=$MaxProofElements") + + def requireValid(m: Int, k: Int): Unit = { + requireValidM(m) + requireValidK(k) + require(m.toLong + k.toLong <= Int.MaxValue, + s"NiPoPoW parameter sum exceeds Int range: m=$m, k=$k") + } def apply(m: Int, k: Int, continuous: Boolean): Try[PoPowParams] = Try { - require(isValid(m, k), s"Invalid NiPoPoW parameters: m=$m, k=$k") + requireValid(m, k) new PoPowParams(m, k, continuous, m + k) } } diff --git a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala index 54c01f2ecb..7497100d41 100644 --- a/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala +++ b/src/test/scala/org/ergoplatform/modifiers/history/PoPowAlgosSpec.scala @@ -5,6 +5,7 @@ import java.util.concurrent.atomic.AtomicReference import org.ergoplatform.modifiers.history.popow.{NipopowAlgos, NipopowProof, PoPowHeader, PoPowParams} import org.ergoplatform.modifiers.ErgoFullBlock +import org.ergoplatform.modifiers.history.header.Header import org.scalacheck.Gen import org.scalatest.matchers.should.Matchers import org.scalatest.propspec.AnyPropSpec @@ -29,7 +30,7 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { PoPowParams(1, 0, continuous = false) shouldBe 'failure PoPowParams(Int.MaxValue, 1, continuous = false) shouldBe 'failure - PoPowParams.isValid(Int.MaxValue - 1, 1) shouldBe true + PoPowParams.isValid(PoPowParams.MaxProofElements, PoPowParams.MaxProofElements) shouldBe true PoPowParams(1, 1, continuous = false).get.minChainLength shouldBe 2 } @@ -54,6 +55,20 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { error.get() shouldBe a[IllegalArgumentException] } + private def validProof(m: Int = 1, k: Int = 1): NipopowProof = { + val chain = toPoPoWChain(genChain(m + k + 4)) + nipopowAlgos.prove(chain)(PoPowParams(m, k, continuous = false).get).get + } + + private class CountingNipopowAlgos extends NipopowAlgos(nipopowAlgos.chainSettings) { + var bestArgCalls: Int = 0 + + override def bestArg(chain: Seq[Header])(m: Int): Int = { + bestArgCalls += 1 + super.bestArg(chain)(m) + } + } + property("updateInterlinks") { val chain = genChain(ChainLength) val genesis = chain.head @@ -229,4 +244,87 @@ class PoPowAlgosSpec extends AnyPropSpec with Matchers { NipopowProof(nipopowAlgos, 0, 0, prefix, suffix.head, suffix.tail.map(_.header), continuous = false).hasValidConnections shouldBe false } + property("PoPowParams rejects invalid m") { + Seq(-1, 0, 20001).foreach { invalidM => + withClue(s"m=$invalidM") { + PoPowParams(invalidM, 1, continuous = false).isFailure shouldBe true + } + } + } + + property("PoPowParams rejects invalid k") { + Seq(-1, 0, 20001).foreach { invalidK => + withClue(s"k=$invalidK") { + PoPowParams(1, invalidK, continuous = false).isFailure shouldBe true + } + } + } + + property("PoPowParams accepts the sanity-bound endpoints") { + PoPowParams(1, 1, continuous = false).isSuccess shouldBe true + PoPowParams(20000, 20000, continuous = false).isSuccess shouldBe true + } + + property("NipopowProof rejects invalid m") { + val proof = validProof() + proof.isValid shouldBe true + Seq(-1, 0, 20001).foreach { invalidM => + withClue(s"m=$invalidM") { + proof.copy(m = invalidM).isValid shouldBe false + } + } + } + + property("NipopowProof rejects invalid k") { + val proof = validProof() + proof.isValid shouldBe true + Seq(-1, 0, 20001).foreach { invalidK => + withClue(s"k=$invalidK") { + proof.copy(k = invalidK).isValid shouldBe false + } + } + } + + property("NipopowProof rejects a suffix length different from k") { + val proof = validProof() + proof.isValid shouldBe true + proof.copy(k = 2).isValid shouldBe false + } + + property("NipopowProof exposes parameter and suffix validity") { + val proof = validProof() + proof.hasValidParams shouldBe true + proof.copy(m = 0).hasValidParams shouldBe false + proof.copy(k = 2).hasValidParams shouldBe false + } + + property("isBetterThan rejects unequal m before scoring") { + val chain = toPoPoWChain(genChain(12)) + val left = nipopowAlgos.prove(chain)(PoPowParams(1, 2, continuous = false).get).get + val right = nipopowAlgos.prove(chain)(PoPowParams(2, 2, continuous = false).get).get + left.isValid shouldBe true + right.isValid shouldBe true + val counting = new CountingNipopowAlgos + + left.copy(popowAlgos = counting).isBetterThan(right.copy(popowAlgos = counting)) shouldBe false + counting.bestArgCalls shouldBe 0 + } + + property("isBetterThan rejects unequal k before scoring") { + val chain = toPoPoWChain(genChain(12)) + val left = nipopowAlgos.prove(chain)(PoPowParams(1, 2, continuous = false).get).get + val right = nipopowAlgos.prove(chain)(PoPowParams(1, 3, continuous = false).get).get + left.isValid shouldBe true + right.isValid shouldBe true + val counting = new CountingNipopowAlgos + + left.copy(popowAlgos = counting).isBetterThan(right.copy(popowAlgos = counting)) shouldBe false + counting.bestArgCalls shouldBe 0 + } + + property("maxLevelOf rejects a zero decoded target") { + val nonGenesis = genChain(2).last.header.copy(nBits = 0) + an[IllegalArgumentException] should be thrownBy nipopowAlgos.maxLevelOf(nonGenesis) + } + } diff --git a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala index 8cb7de1e37..f9b0492c8b 100644 --- a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala +++ b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala @@ -1,16 +1,29 @@ package org.ergoplatform.serialization -import org.ergoplatform.modifiers.history.popow.NipopowProofSerializer +import org.ergoplatform.modifiers.history.popow.{NipopowProof, NipopowProofSerializer} import org.ergoplatform.network.ErgoNodeViewSynchronizer import org.ergoplatform.nodeView.wallet.persistence.WalletDigestSerializer import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.ErgoCoreTestConstants.nipopowAlgos -import org.ergoplatform.utils.generators.ErgoNodeGenerators.poPowProofGen +import org.ergoplatform.utils.generators.ErgoNodeGenerators.{poPowProofGen, validNiPoPowProofGen} +import scorex.util.serialization.VLQByteStringWriter class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.utils.SerializationTests { import org.ergoplatform.utils.generators.ErgoNodeWalletGenerators._ import org.ergoplatform.utils.generators.ErgoCoreTransactionGenerators._ + private val nipopowSerializer = new NipopowProofSerializer(nipopowAlgos) + + private def smallValidProof(): NipopowProof = validNiPoPowProofGen(1, 1).sample.get + + private def uintBytes(value: Long): Array[Byte] = + (new VLQByteStringWriter).putUInt(value).toBytes + + private def replaceUInt(bytes: Array[Byte], offset: Int, original: Int, replacement: Long): Array[Byte] = { + val originalLength = uintBytes(original.toLong).length + bytes.take(offset) ++ uintBytes(replacement) ++ bytes.drop(offset + originalLength) + } + property("Serializers should be defined for all block sections") { val block = invalidErgoFullBlockGen.sample.get block.toSeq.foreach { s => @@ -25,7 +38,39 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util } property("PoPowProof serialization") { - checkSerializationRoundtrip(poPowProofGen, new NipopowProofSerializer(nipopowAlgos)) + checkSerializationRoundtrip(poPowProofGen, nipopowSerializer) + } + + property("PoPowProof parser rejects invalid m") { + val proof = smallValidProof() + val bytes = nipopowSerializer.toBytes(proof) + Seq(0L, 20001L).foreach { invalidM => + withClue(s"m=$invalidM") { + val mutated = replaceUInt(bytes, offset = 0, proof.m, invalidM) + nipopowSerializer.parseBytesTry(mutated) shouldBe 'failure + } + } + } + + property("PoPowProof parser rejects invalid k") { + val proof = smallValidProof() + val bytes = nipopowSerializer.toBytes(proof) + val kOffset = uintBytes(proof.m.toLong).length + Seq(0L, 20001L).foreach { invalidK => + withClue(s"k=$invalidK") { + val mutated = replaceUInt(bytes, kOffset, proof.k, invalidK) + nipopowSerializer.parseBytesTry(mutated) shouldBe 'failure + } + } + } + + property("PoPowProof parser rejects suffix length different from k") { + val proof = smallValidProof() + val bytes = nipopowSerializer.toBytes(proof) + val kOffset = uintBytes(proof.m.toLong).length + val mutated = replaceUInt(bytes, kOffset, proof.k, proof.k + 1L) + + nipopowSerializer.parseBytesTry(mutated) shouldBe 'failure } } From e6b2d21783689a261f547972ec3fa4fb74c75afb Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:21 +0200 Subject: [PATCH 06/13] fix(nipopow): bound serialized proof resources --- .../history/popow/NipopowProof.scala | 31 ++++- .../modifiers/history/popow/PoPowHeader.scala | 42 +++++- .../modifiers/history/PoPowHeaderSpec.scala | 117 +++++++++++++++- .../serialization/SerializationTests.scala | 129 +++++++++++++++++- 4 files changed, 310 insertions(+), 9 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index a293a07050..e000d77d20 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -202,7 +202,24 @@ object NipopowProof { } +object NipopowProofSerializer { + private val MaxProofElements = PoPowParams.MaxProofElements + + private def requireWithinLimit(value: Int, limit: Int, what: String): Unit = + require(value <= limit, s"$what $value exceeds sanity limit $limit") + + private def readFrame[T](r: Reader, + limit: Int, + what: String) + (parse: Array[Byte] => T): T = { + val size = r.getUInt().toIntExact + requireWithinLimit(size, limit, s"$what size") + parse(r.getBytes(size)) + } +} + class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[NipopowProof] { + import NipopowProofSerializer._ override def serialize(obj: NipopowProof, w: Writer): Unit = { require(obj.hasValidParams, "Invalid NiPoPoW proof parameters or suffix length") @@ -231,18 +248,20 @@ class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[Ni val k = r.getUInt().toIntExact PoPowParams.requireValid(m, k) val prefixSize = r.getUInt().toIntExact + requireWithinLimit(prefixSize, MaxProofElements, "prefix count") val prefix = (0 until prefixSize).map { _ => - val size = r.getUInt().toIntExact - PoPowHeaderSerializer.parseBytes(r.getBytes(size)) + readFrame(r, PoPowHeaderSerializer.MaxSerializedBytes, "prefix element frame")( + PoPowHeaderSerializer.parseBytes) } - val suffixHeadSize = r.getUInt().toIntExact - val suffixHead = PoPowHeaderSerializer.parseBytes(r.getBytes(suffixHeadSize)) + val suffixHead = readFrame(r, PoPowHeaderSerializer.MaxSerializedBytes, "suffix-head frame")( + PoPowHeaderSerializer.parseBytes) val suffixSize = r.getUInt().toIntExact + requireWithinLimit(suffixSize, MaxProofElements, "suffix count") require(suffixSize == k - 1, s"NiPoPoW suffix length ${suffixSize + 1} does not match k parameter $k") val suffixTail = (0 until suffixSize).map { _ => - val size = r.getUInt().toIntExact - HeaderSerializer.parseBytes(r.getBytes(size)) + readFrame(r, PoPowHeaderSerializer.MaxHeaderFrameBytes, "suffix-tail frame")( + HeaderSerializer.parseBytes) } val continuous = if (r.getByte() == 1) true else false NipopowProof(poPowAlgos, m, k, prefix, suffixHead, suffixTail, continuous) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala index 9379b7ecb7..a1f20adb59 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala @@ -20,6 +20,7 @@ import scorex.util.Extensions._ import scorex.util.serialization.{Reader, Writer} import scorex.util.{ModifierId, bytesToId, idToBytes} +import java.nio.ByteBuffer import scala.util.Try /** @@ -187,9 +188,43 @@ object PoPowHeader { object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { import org.ergoplatform.sdk.wallet.Constants.ModifierIdLength + // Generous wire sanity limits shared with the sigma-rust NiPoPoW parser. + private[ergoplatform] final val MaxHeaderFrameBytes = 10000 + private[ergoplatform] final val MaxInterlinks = 10000 + private[ergoplatform] final val MaxMerkleProofFrameBytes = 1000000 + private[ergoplatform] final val MaxSerializedBytes = + MaxHeaderFrameBytes + MaxInterlinks * ModifierIdLength + MaxMerkleProofFrameBytes + 64 + + private val MerkleProofCountBytes = 8 + private val MerkleIndexBytes = 36 + private val MerkleProofNodeBytes = 33 + implicit val hf: HF = Algos.hash val merkleProofSerializer = new BatchMerkleProofSerializer[Digest32, HF] + private def requireWithinLimit(value: Int, limit: Int, what: String): Unit = + require(value <= limit, s"$what $value exceeds sanity limit $limit") + + private def validateMerkleProofFrame(bytes: Array[Byte]): Unit = { + require(bytes.length >= MerkleProofCountBytes, + s"Merkle proof counts require at least $MerkleProofCountBytes bytes") + // BatchMerkleProofSerializer stores both counts as fixed-width big-endian ints. + val counts = ByteBuffer.wrap(bytes) + val indexCount = counts.getInt + val proofCount = counts.getInt + require(indexCount >= 0 && proofCount >= 0, + "Merkle proof counts must be non-negative") + + val indexBytes = Math.multiplyExact(indexCount.toLong, MerkleIndexBytes.toLong) + val proofBytes = Math.multiplyExact(proofCount.toLong, MerkleProofNodeBytes.toLong) + val requiredBytes = Math.addExact( + MerkleProofCountBytes.toLong, + Math.addExact(indexBytes, proofBytes) + ) + require(requiredBytes == bytes.length.toLong, + s"Merkle proof counts require $requiredBytes bytes, frame has ${bytes.length}") + } + override def serialize(obj: PoPowHeader, w: Writer): Unit = { val headerBytes = obj.header.bytes w.putUInt(headerBytes.length.toLong) @@ -203,11 +238,16 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { override def parse(r: Reader): PoPowHeader = { val headerSize = r.getUInt().toIntExact + requireWithinLimit(headerSize, MaxHeaderFrameBytes, "header frame size") val header = HeaderSerializer.parseBytes(r.getBytes(headerSize)) val linksQty = r.getUInt().toIntExact + requireWithinLimit(linksQty, MaxInterlinks, "interlink count") val interlinks = (0 until linksQty).map(_ => bytesToId(r.getBytes(ModifierIdLength))) val interlinksProofSize = r.getUInt().toIntExact - val interlinksProof = merkleProofSerializer.deserialize(r.getBytes(interlinksProofSize)).get + requireWithinLimit(interlinksProofSize, MaxMerkleProofFrameBytes, "Merkle proof frame size") + val proofBytes = r.getBytes(interlinksProofSize) + validateMerkleProofFrame(proofBytes) + val interlinksProof = merkleProofSerializer.deserialize(proofBytes).get PoPowHeader(header, interlinks, interlinksProof) } diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index 6531f57d81..6d9b5db6c4 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -9,9 +9,11 @@ import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.generators.ErgoCoreGenerators.defaultHeaderGen import org.scalacheck.Gen import scorex.crypto.hash.Digest32 -import scorex.util.{ModifierId, bytesToId} +import scorex.util.serialization.VLQByteBufferWriter +import scorex.util.{ByteArrayBuilder, ModifierId, bytesToId, idToBytes} import scorex.util.encode.Base16 +import java.nio.ByteBuffer import java.security.MessageDigest import scala.io.Source @@ -21,6 +23,55 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private def deterministicId(value: Byte): ModifierId = bytesToId(Array.fill(32)(value)) + private val MaxHeaderFrameBytes = PoPowHeaderSerializer.MaxHeaderFrameBytes + private val MaxInterlinks = PoPowHeaderSerializer.MaxInterlinks + private val MaxMerkleProofFrameBytes = PoPowHeaderSerializer.MaxMerkleProofFrameBytes + + private lazy val framedHeader: PoPowHeader = { + val source = Source.fromResource("nipopow-full-root-mixed-popow-header.json") + val fixtureText = try source.mkString finally source.close() + val fixture = io.circe.parser.parse(fixtureText).toOption.get.hcursor + val bytes = Base16.decode(fixture.get[String]("bytes_hex").toOption.get).get + PoPowHeaderSerializer.parseBytes(bytes) + } + + private def serializeWithNestedFrames(value: PoPowHeader, + headerFrame: Array[Byte], + proofFrame: Array[Byte]): Array[Byte] = { + writerBytes { writer => + writer.putUInt(headerFrame.length.toLong) + writer.putBytes(headerFrame) + writer.putUInt(value.interlinks.length.toLong) + value.interlinks.foreach(id => writer.putBytes(idToBytes(id))) + writer.putUInt(proofFrame.length.toLong) + writer.putBytes(proofFrame) + } + } + + private def writerBytes(write: VLQByteBufferWriter => Unit): Array[Byte] = { + val writer = new VLQByteBufferWriter(new ByteArrayBuilder) + write(writer) + writer.result().toBytes + } + + private def minimalPoPowHeader(proofFrame: Array[Byte]): Array[Byte] = { + val headerFrame = framedHeader.header.bytes + writerBytes { writer => + writer.putUInt(headerFrame.length.toLong) + writer.putBytes(headerFrame) + writer.putUInt(0) + writer.putUInt(proofFrame.length.toLong) + writer.putBytes(proofFrame) + } + } + + private def assertParseFailureContains(bytes: Array[Byte], expected: String): Unit = { + val failure = PoPowHeaderSerializer.parseBytesTry(bytes).failed.get + failure.toString should include(expected) + } + + private def intBytes(value: Int): Array[Byte] = ByteBuffer.allocate(4).putInt(value).array() + private def mixedExtension(interlinks: Seq[ModifierId]): ExtensionCandidate = { nipopowAlgos.interlinksToExtension(interlinks) ++ ExtensionCandidate(Seq( Array[Byte](2, 0) -> Array[Byte](1) @@ -179,6 +230,70 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { .checkInterlinksProof() shouldBe false } + property("a nested header frame accepts trailing padding") { + val headerFrame = framedHeader.header.bytes :+ 0x7f.toByte + val proofFrame = PoPowHeaderSerializer.merkleProofSerializer.serialize(framedHeader.interlinksProof) + val bytes = serializeWithNestedFrames(framedHeader, headerFrame, proofFrame) + + PoPowHeaderSerializer.parseBytes(bytes) shouldBe framedHeader + } + + property("a nested Merkle proof frame rejects trailing padding") { + val headerFrame = framedHeader.header.bytes + val proofFrame = + PoPowHeaderSerializer.merkleProofSerializer.serialize(framedHeader.interlinksProof) :+ 0x7f.toByte + val bytes = serializeWithNestedFrames(framedHeader, headerFrame, proofFrame) + + assertParseFailureContains(bytes, "Merkle proof counts") + } + + property("PoPowHeader rejects an oversized nested header before reading its frame") { + val bytes = writerBytes(_.putUInt(MaxHeaderFrameBytes + 1L)) + + assertParseFailureContains(bytes, "header frame size") + } + + property("PoPowHeader rejects an oversized interlink count before reading ids") { + val headerFrame = framedHeader.header.bytes + val bytes = writerBytes { writer => + writer.putUInt(headerFrame.length.toLong) + writer.putBytes(headerFrame) + writer.putUInt(MaxInterlinks + 1L) + } + + assertParseFailureContains(bytes, "interlink count") + } + + property("PoPowHeader rejects an oversized Merkle proof before reading its frame") { + val headerFrame = framedHeader.header.bytes + val bytes = writerBytes { writer => + writer.putUInt(headerFrame.length.toLong) + writer.putBytes(headerFrame) + writer.putUInt(0) + writer.putUInt(MaxMerkleProofFrameBytes + 1L) + } + + assertParseFailureContains(bytes, "Merkle proof frame size") + } + + property("PoPowHeader rejects an index count that cannot fit its proof frame") { + val proofFrame = intBytes(1) ++ intBytes(0) + + assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") + } + + property("PoPowHeader rejects a proof-node count that cannot fit its proof frame") { + val proofFrame = intBytes(0) ++ intBytes(1) + + assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") + } + + property("PoPowHeader rejects extreme Merkle counts with checked arithmetic") { + val proofFrame = intBytes(Int.MaxValue) ++ intBytes(Int.MaxValue) + + assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") + } + property("empty interlinks proof is accepted for genesis") { val extension = nipopowAlgos.interlinksToExtension(Seq.empty) val proof = NipopowAlgos.proofForInterlinkVector(extension).get diff --git a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala index f9b0492c8b..93114e198d 100644 --- a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala +++ b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala @@ -1,6 +1,6 @@ package org.ergoplatform.serialization -import org.ergoplatform.modifiers.history.popow.{NipopowProof, NipopowProofSerializer} +import org.ergoplatform.modifiers.history.popow.{NipopowProof, NipopowProofSerializer, PoPowHeaderSerializer, PoPowParams} import org.ergoplatform.network.ErgoNodeViewSynchronizer import org.ergoplatform.nodeView.wallet.persistence.WalletDigestSerializer import org.ergoplatform.utils.ErgoCorePropertyTest @@ -14,8 +14,24 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util private val nipopowSerializer = new NipopowProofSerializer(nipopowAlgos) + private sealed trait FrameSite + private case object PrefixFrame extends FrameSite + private case object SuffixHeadFrame extends FrameSite + private case object SuffixTailFrame extends FrameSite + + private val MaxProofElements = PoPowParams.MaxProofElements + private val MaxHeaderFrameBytes = PoPowHeaderSerializer.MaxHeaderFrameBytes + private val MaxPoPowHeaderFrameBytes = PoPowHeaderSerializer.MaxSerializedBytes + private def smallValidProof(): NipopowProof = validNiPoPowProofGen(1, 1).sample.get + private lazy val framedProof: NipopowProof = { + val proof = validNiPoPowProofGen(1, 2).sample.get + require(proof.prefix.nonEmpty, "framing fixture needs a prefix element") + require(proof.suffixTail.nonEmpty, "framing fixture needs a suffix-tail element") + proof + } + private def uintBytes(value: Long): Array[Byte] = (new VLQByteStringWriter).putUInt(value).toBytes @@ -24,6 +40,49 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util bytes.take(offset) ++ uintBytes(replacement) ++ bytes.drop(offset + originalLength) } + private def writerBytes(write: VLQByteStringWriter => Unit): Array[Byte] = { + val writer = new VLQByteStringWriter + write(writer) + writer.toBytes + } + + private def putFrame(writer: VLQByteStringWriter, + bytes: Array[Byte], + mutate: Boolean, + declaredDelta: Int, + fillerLength: Int): Unit = { + val declaredSize = bytes.length + (if (mutate) declaredDelta else 0) + require(declaredSize >= 0) + writer.putUInt(declaredSize.toLong) + writer.putBytes(bytes) + if (mutate && fillerLength > 0) { + writer.putBytes(Array.fill(fillerLength)(0x7f.toByte)) + } + } + + private def serializeWithFrameMutation(proof: NipopowProof, + site: FrameSite, + declaredDelta: Int, + fillerLength: Int): Array[Byte] = writerBytes { writer => + writer.putUInt(proof.m.toLong) + writer.putUInt(proof.k.toLong) + writer.putUInt(proof.prefix.length.toLong) + proof.prefix.zipWithIndex.foreach { case (header, index) => + putFrame(writer, header.bytes, site == PrefixFrame && index == 0, declaredDelta, fillerLength) + } + putFrame(writer, proof.suffixHead.bytes, site == SuffixHeadFrame, declaredDelta, fillerLength) + writer.putUInt(proof.suffixTail.length.toLong) + proof.suffixTail.zipWithIndex.foreach { case (header, index) => + putFrame(writer, header.bytes, site == SuffixTailFrame && index == 0, declaredDelta, fillerLength) + } + writer.put(if (proof.continuous) 1 else 0) + } + + private def assertProofParseFailureContains(bytes: Array[Byte], expected: String): Unit = { + val failure = nipopowSerializer.parseBytesTry(bytes).failed.get + failure.toString should include(expected) + } + property("Serializers should be defined for all block sections") { val block = invalidErgoFullBlockGen.sample.get block.toSeq.foreach { s => @@ -73,4 +132,72 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util nipopowSerializer.parseBytesTry(mutated) shouldBe 'failure } + property("PoPowProof outer element frames preserve authoritative slicing") { + Seq(PrefixFrame, SuffixHeadFrame, SuffixTailFrame).foreach { site => + withClue(s"site=$site canonical") { + nipopowSerializer.parseBytes(serializeWithFrameMutation(framedProof, site, 0, 0)) shouldBe framedProof + } + withClue(s"site=$site under-declared") { + nipopowSerializer.parseBytesTry(serializeWithFrameMutation(framedProof, site, -1, 0)) shouldBe 'failure + } + withClue(s"site=$site over-declared without filler") { + nipopowSerializer.parseBytesTry(serializeWithFrameMutation(framedProof, site, 1, 0)) shouldBe 'failure + } + withClue(s"site=$site over-declared with matching filler") { + nipopowSerializer.parseBytes(serializeWithFrameMutation(framedProof, site, 1, 1)) shouldBe framedProof + } + } + } + + property("PoPowProof rejects an oversized prefix count before iterating") { + val bytes = writerBytes { writer => + writer.putUInt(1) + writer.putUInt(1) + writer.putUInt(MaxProofElements + 1L) + } + + assertProofParseFailureContains(bytes, "prefix count") + } + + property("PoPowProof rejects an oversized suffix count before iterating") { + val proof = smallValidProof() + val bytes = writerBytes { writer => + writer.putUInt(proof.m.toLong) + writer.putUInt(proof.k.toLong) + writer.putUInt(proof.prefix.length.toLong) + proof.prefix.foreach(header => putFrame(writer, header.bytes, false, 0, 0)) + putFrame(writer, proof.suffixHead.bytes, false, 0, 0) + writer.putUInt(MaxProofElements + 1L) + } + + assertProofParseFailureContains(bytes, "suffix count") + } + + property("PoPowProof rejects oversized outer frames before reading them") { + val prefixBytes = writerBytes { writer => + writer.putUInt(1) + writer.putUInt(1) + writer.putUInt(1) + writer.putUInt(MaxPoPowHeaderFrameBytes + 1L) + } + val suffixHeadBytes = writerBytes { writer => + writer.putUInt(1) + writer.putUInt(1) + writer.putUInt(0) + writer.putUInt(MaxPoPowHeaderFrameBytes + 1L) + } + val suffixTailBytes = writerBytes { writer => + writer.putUInt(1) + writer.putUInt(2) + writer.putUInt(0) + putFrame(writer, framedProof.suffixHead.bytes, false, 0, 0) + writer.putUInt(1) + writer.putUInt(MaxHeaderFrameBytes + 1L) + } + + assertProofParseFailureContains(prefixBytes, "prefix element frame size") + assertProofParseFailureContains(suffixHeadBytes, "suffix-head frame size") + assertProofParseFailureContains(suffixTailBytes, "suffix-tail frame size") + } + } From 5fb8d9542fabdb1814f518ba04271cb2879f3f9c Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:38:04 +0200 Subject: [PATCH 07/13] fix(nipopow): version cached proof bytes after root cutover --- .../network/ErgoNodeViewSynchronizer.scala | 8 +- .../nodeView/history/ErgoHistory.scala | 32 ++++ .../nodeView/history/ErgoHistoryReader.scala | 4 +- .../modifierprocessors/PopowProcessor.scala | 9 +- .../nodeView/NodeViewSynchronizerTests.scala | 51 +++++- .../history/PopowProcessorSpecification.scala | 153 +++++++++++++++++- 6 files changed, 244 insertions(+), 13 deletions(-) diff --git a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala index ced63d1e8a..9264f4cd47 100644 --- a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala +++ b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala @@ -1061,12 +1061,12 @@ class ErgoNodeViewSynchronizer(networkControllerRef: ActorRef, */ private def sendNipopowProof(data: NipopowProofData, hr: ErgoHistory, peer: ConnectedPeer): Unit = { if (data.m == hr.P2PNipopowProofM && data.k == hr.P2PNipopowProofK && data.headerIdBytesOpt.isEmpty) { - hr.readPopowProofBytesFromDb() match { - case Some(proofBytes) => + hr.cachedOrGeneratePopowProofBytes() match { + case Success(proofBytes) => val msg = Message(NipopowProofSpec, Right(proofBytes), None) networkControllerRef ! SendToNetwork(msg, SendToPeer(peer)) - case None => - log.warn("No Nipopow Proof available") + case Failure(e) => + log.warn("Failed to generate or persist Nipopow proof", e) } } else { // for now, we are serving proofs for concrete params only diff --git a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala index c001dd8e64..d48912d621 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala @@ -67,6 +67,38 @@ trait ErgoHistory historyStorage.insert(mId, bytes) } + /** + * Return the current cached P2P NiPoPoW proof, generating and persisting it on a cache miss. + * Proof bytes are returned only after the V2 cache write succeeds. + */ + def cachedOrGeneratePopowProofBytes(): Try[Array[Byte]] = { + cachedOrGeneratePopowProofBytes(popowProofBytes()) + } + + private[history] def cachedOrGeneratePopowProofBytes( + generateProofBytes: => Try[Array[Byte]] + ): Try[Array[Byte]] = { + cachedOrGeneratePopowProofBytes( + generateProofBytes, + proofBytes => historyStorage.insert( + Array(NipopowProofV2Key -> proofBytes), + BlockSection.emptyArray + ) + ) + } + + private[history] def cachedOrGeneratePopowProofBytes( + generateProofBytes: => Try[Array[Byte]], + persistProofBytes: Array[Byte] => Try[Unit] + ): Try[Array[Byte]] = synchronized { + Try(readPopowProofBytesFromDb()).flatMap { + case Some(proofBytes) => Success(proofBytes) + case None => Try(generateProofBytes).flatten.flatMap { proofBytes => + persistProofBytes(proofBytes).map(_ => proofBytes) + } + } + } + /** * Append ErgoPersistentModifier to History if valid */ diff --git a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistoryReader.scala b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistoryReader.scala index 9f7a45f0fe..fbe6ca5e6e 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistoryReader.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistoryReader.scala @@ -587,10 +587,10 @@ trait ErgoHistoryReader } /** - * @return serialized NiPoPoW proof store in database + * @return serialized NiPoPoW proof stored under the current P2P cache key */ def readPopowProofBytesFromDb(): Option[Array[Byte]] = { - historyStorage.getIndex(NipopowSnapshotHeightKey) + historyStorage.getIndex(NipopowProofV2Key) } } diff --git a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala index 5e2ebd2183..86b1260be3 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/storage/modifierprocessors/PopowProcessor.scala @@ -43,7 +43,14 @@ trait PopowProcessor extends BasicReaders with ScorexLogging { private lazy val nipopowVerifier = new NipopowVerifier(chainSettings.genesisId.orElse(bestHeaderIdAtHeight(ErgoHistoryUtils.GenesisHeight))) - protected val NipopowSnapshotHeightKey: ByteArrayWrapper = ByteArrayWrapper(Array.fill(HashLength)(50: Byte)) + private[history] val LegacyNipopowSnapshotKey: ByteArrayWrapper = + ByteArrayWrapper(Array.fill(HashLength)(50: Byte)) + + private[history] val NipopowProofV2Key: ByteArrayWrapper = + ByteArrayWrapper(Array.fill(HashLength)(51: Byte)) + + // Periodic snapshot production and P2P reads use V2. The legacy row remains untouched. + protected val NipopowSnapshotHeightKey: ByteArrayWrapper = NipopowProofV2Key /** * Minimal superchain length ('m' in KMZ17 paper) value used in NiPoPoW proofs for bootstrapping diff --git a/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala b/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala index b81c49672b..cb97ad0a15 100644 --- a/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala +++ b/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala @@ -144,14 +144,15 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec } } - property("NodeViewSynchronizer: GetNipopowProof") { + property("NodeViewSynchronizer: GetNipopowProof generates and reuses the V2 cache") { withFixture { ctx => import ctx._ // Generate history chain val emptyHistory = historyGen.sample.get - val prefix = blockStream(None).take(settings.chainSettings.makeSnapshotEvery) + val prefix = blockStream(None).take(settings.chainSettings.makeSnapshotEvery / 2) val fullHistory = applyChain(emptyHistory, prefix) + fullHistory.readPopowProofBytesFromDb() shouldBe None // Broadcast updated history node ! ChangedHistory(fullHistory) @@ -161,15 +162,57 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec val msgBytes = spec.toBytes(NipopowProofData(m = emptyHistory.P2PNipopowProofM, k = emptyHistory.P2PNipopowProofK, headerId = None)) node ! Message[NipopowProofData](spec, Left(msgBytes), Option(peer)) - // Listen for NipopowProofSpec response - ncProbe.fishForMessage(5 seconds) { + // Listen for the generated NipopowProofSpec response + val firstResponse = ncProbe.fishForMessage(5 seconds) { case stn: SendToNetwork => stn.message.spec match { case _: NipopowProofSpec.type => true case _ => false } case _: Any => false + }.asInstanceOf[SendToNetwork] + val firstBytes = firstResponse.message.data.get.asInstanceOf[Array[Byte]] + fullHistory.readPopowProofBytesFromDb().get.toSeq shouldBe firstBytes.toSeq + + // A second request must reuse the persisted bytes exactly. + node ! Message[NipopowProofData](spec, Left(msgBytes), Option(peer)) + val secondResponse = ncProbe.fishForMessage(5 seconds) { + case stn: SendToNetwork => stn.message.spec.isInstanceOf[NipopowProofSpec.type] + case _: Any => false + }.asInstanceOf[SendToNetwork] + secondResponse.message.data.get.asInstanceOf[Array[Byte]].toSeq shouldBe firstBytes.toSeq + } + } + + property("NodeViewSynchronizer: GetNipopowProof sends nothing when generation fails") { + withFixture { ctx => + import ctx._ + + val emptyHistory = org.ergoplatform.utils.HistoryTestHelpers.generateHistory( + verifyTransactions = true, + StateType.Utxo, + PoPoWBootstrap = false, + blocksToKeep = -1 + ) + emptyHistory.readPopowProofBytesFromDb() shouldBe None + node ! ChangedHistory(emptyHistory) + ncProbe.receiveWhile(max = 1.second, idle = 200.millis) { case message => message } + + val spec = GetNipopowProofSpec + val msgBytes = spec.toBytes(NipopowProofData( + m = emptyHistory.P2PNipopowProofM, + k = emptyHistory.P2PNipopowProofK, + headerId = None + )) + node ! Message[NipopowProofData](spec, Left(msgBytes), Option(peer)) + + val responses = ncProbe.receiveWhile(max = 2.seconds, idle = 500.millis) { + case message => message } + responses.exists { + case stn: SendToNetwork => stn.message.spec.isInstanceOf[NipopowProofSpec.type] + case _ => false + } shouldBe false } } diff --git a/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala index 93e113a14b..54771ea19c 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala @@ -1,14 +1,17 @@ package org.ergoplatform.nodeView.history import org.ergoplatform.mining.AutolykosPowScheme -import org.ergoplatform.modifiers.ErgoFullBlock +import org.ergoplatform.modifiers.{BlockSection, ErgoFullBlock} import org.ergoplatform.modifiers.history.popow.PoPowHeader import org.ergoplatform.nodeView.state.StateType import org.ergoplatform.settings.NipopowSettings -import org.ergoplatform.utils.ErgoCorePropertyTest +import org.ergoplatform.utils.{ErgoCorePropertyTest, ErgoNodeTestConstants} import org.ergoplatform.wallet.utils.FileUtils import scorex.util.ModifierId +import java.nio.charset.StandardCharsets +import scala.util.{Failure, Success} + class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { import org.ergoplatform.utils.HistoryTestHelpers._ import org.ergoplatform.utils.ErgoNodeTestConstants.{settings => baseSettings} @@ -36,6 +39,152 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { val toPoPoWChain = (c: Seq[ErgoFullBlock]) => c.map(b => PoPowHeader.fromBlock(b).get) + private val legacySentinel = + "legacy-nipopow-proof-must-not-be-served".getBytes(StandardCharsets.UTF_8) + + property("legacy NiPoPoW cache bytes are ignored") { + val history = genHistory(None, popowBootstrap = false) + try { + history.LegacyNipopowSnapshotKey.data.toSeq shouldBe Seq.fill(32)(50.toByte) + history.NipopowProofV2Key.data.toSeq shouldBe Seq.fill(32)(51.toByte) + history.LegacyNipopowSnapshotKey should not equal history.NipopowProofV2Key + + history.historyStorage.insert( + Array(history.LegacyNipopowSnapshotKey -> legacySentinel), + BlockSection.emptyArray + ).get + + history.readPopowProofBytesFromDb() shouldBe None + history.historyStorage + .getIndex(history.LegacyNipopowSnapshotKey) + .get + .toSeq shouldBe legacySentinel.toSeq + } finally { + history.closeStorage() + } + } + + property("V2 NiPoPoW cache miss generates and persists proof bytes") { + val history = genHistory(None, popowBootstrap = false) + val generatedBytes = Array[Byte](1, 3, 3, 7) + var generationCount = 0 + try { + val result = history.cachedOrGeneratePopowProofBytes { + generationCount += 1 + Success(generatedBytes) + } + + result.get.toSeq shouldBe generatedBytes.toSeq + generationCount shouldBe 1 + history.readPopowProofBytesFromDb().get.toSeq shouldBe generatedBytes.toSeq + } finally { + history.closeStorage() + } + } + + property("V2 NiPoPoW cache hit reuses byte-identical proof without generation") { + val history = genHistory(None, popowBootstrap = false) + val cachedBytes = Array[Byte](2, 4, 6, 8) + try { + history.cachedOrGeneratePopowProofBytes(Success(cachedBytes)).get + + val result = history.cachedOrGeneratePopowProofBytes( + Failure(new IllegalStateException("cache hit must not regenerate")) + ) + + result.get.toSeq shouldBe cachedBytes.toSeq + } finally { + history.closeStorage() + } + } + + property("NiPoPoW cache generation failure never falls back to legacy bytes") { + val history = genHistory(None, popowBootstrap = false) + val generationFailure = new IllegalStateException("proof generation failed") + try { + history.historyStorage.insert( + Array(history.LegacyNipopowSnapshotKey -> legacySentinel), + BlockSection.emptyArray + ).get + + val result = history.cachedOrGeneratePopowProofBytes(Failure(generationFailure)) + + result.failed.get shouldBe generationFailure + history.readPopowProofBytesFromDb() shouldBe None + history.historyStorage + .getIndex(history.LegacyNipopowSnapshotKey) + .get + .toSeq shouldBe legacySentinel.toSeq + } finally { + history.closeStorage() + } + } + + property("NiPoPoW cache persistence failure exposes no proof bytes") { + val history = genHistory(None, popowBootstrap = false) + val generatedBytes = Array[Byte](9, 7, 5, 3) + val persistenceFailure = new IllegalStateException("proof persistence failed") + try { + val result = history.cachedOrGeneratePopowProofBytes( + Success(generatedBytes), + _ => Failure(persistenceFailure) + ) + + result.failed.get shouldBe persistenceFailure + history.readPopowProofBytesFromDb() shouldBe None + } finally { + history.closeStorage() + } + } + + property("V2 NiPoPoW cache bytes survive history restart") { + val directory = createTempDir + val baseSettings = ErgoNodeTestConstants.initSettings + val historySettings = baseSettings.copy( + directory = directory.getAbsolutePath, + nodeSettings = baseSettings.nodeSettings.copy(extraIndex = false) + ) + val cachedBytes = Array[Byte](10, 20, 30, 40) + val firstHistory = ErgoHistory.readOrGenerate(historySettings)(null) + try { + firstHistory.cachedOrGeneratePopowProofBytes(Success(cachedBytes)).get + } finally { + firstHistory.closeStorage() + } + + val reopenedHistory = ErgoHistory.readOrGenerate(historySettings)(null) + try { + reopenedHistory.readPopowProofBytesFromDb().get.toSeq shouldBe cachedBytes.toSeq + reopenedHistory.cachedOrGeneratePopowProofBytes( + Failure(new IllegalStateException("restart cache hit must not regenerate")) + ).get.toSeq shouldBe cachedBytes.toSeq + } finally { + reopenedHistory.closeStorage() + } + } + + property("periodic NiPoPoW snapshots refresh V2 without rewriting legacy bytes") { + val history = genHistory(None, popowBootstrap = false) + try { + history.historyStorage.insert( + Array(history.LegacyNipopowSnapshotKey -> legacySentinel), + BlockSection.emptyArray + ).get + + val chain = blockStream(None) + .take(ErgoNodeTestConstants.settings.chainSettings.makeSnapshotEvery) + applyChain(history, chain) + + history.readPopowProofBytesFromDb().isDefined shouldBe true + history.historyStorage + .getIndex(history.LegacyNipopowSnapshotKey) + .get + .toSeq shouldBe legacySentinel.toSeq + } finally { + history.closeStorage() + } + } + property("popow proof application") { val senderHistory = genHistory(None, popowBootstrap = false) val senderChain = genChain(5000, senderHistory) From 2bd086a8b3117bc5e5d5b698417fbdfd73cd7606 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:51:58 +0200 Subject: [PATCH 08/13] test(nipopow): add complete cross-runtime proof fixture --- ...nipopow-full-root-mixed-nipopow-proof.json | 13 + .../history/NipopowProofInteropSpec.scala | 249 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 ergo-core/src/test/resources/nipopow-full-root-mixed-nipopow-proof.json create mode 100644 ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala diff --git a/ergo-core/src/test/resources/nipopow-full-root-mixed-nipopow-proof.json b/ergo-core/src/test/resources/nipopow-full-root-mixed-nipopow-proof.json new file mode 100644 index 0000000000..1a2f585ece --- /dev/null +++ b/ergo-core/src/test/resources/nipopow-full-root-mixed-nipopow-proof.json @@ -0,0 +1,13 @@ +{ + "format": "scorex-nipopow-proof-with-jvm-mode-v1", + "m": 1, + "k": 2, + "prefix_count": 1, + "suffix_count": 2, + "suffix_tail_count": 1, + "bytes_hex": "010201e601da01020000000000000000000000000000000000000000000000000000000000000000d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8070239b8010400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f400080000000000000000b103da01028022567408919e0c11029c17f56ee3f1c9567eee06dbf265ab68129d6fa2e6e0d882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc070239b8020400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f402111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222222222229201000000020000000200000001fc95d4accfa4598b6151a1f9837fe4f85b028154176351a076b954abd74ae45300000002413bd0b194cedf41d6ad4ca6b0236b59ff2e070fbbfc499fda2b6f55aa27dc87580c42b47e2deed9aa364fadb6192e03715e87108fd3c96b74c21fa889baa5200000000000000000000000000000000000000000000000000000000000000000000101da01029e3967f6e21ebe3558fa02626efe43df348cb5a7b2eacd4c2853f1df9e34c20dd882aaf42e0a95eb95fcce5c3705adf758e591532f733efe790ac3c404730c3963eaa9aff76a1de3d71c81e4b2d92e8d97ae572a8e9ab9e66599ed0912dd2f8b8ad868627ea4f7de6e2a2fe3f98fafe57f914e0f2ef3331c006def36c697f92713f884ebfd8e2f65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc070239b8030400000002b3a06d6eaa8671431ba1db4dd427a77f75a5c2acbd71bfb725d38adc2b55f6695939ecfee6b0d7f400", + "rust_core_length": 891, + "terminal_continuous_byte": 0, + "sha256": "7dc6238407b20e62ef5188b331ac789836a9f43d35603e5cb21138ae6b57c0fb", + "extension_root": "65fbd9d46a9392fbb9dfd6a7119516353e32755e9f4321c3810787b59cbaeccc" +} diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala new file mode 100644 index 0000000000..f84f8c1efe --- /dev/null +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala @@ -0,0 +1,249 @@ +package org.ergoplatform.modifiers.history + +import io.circe.Decoder +import io.circe.HCursor +import org.ergoplatform.modifiers.history.header.Header +import org.ergoplatform.modifiers.history.popow.NipopowAlgos +import org.ergoplatform.modifiers.history.popow.NipopowProof +import org.ergoplatform.modifiers.history.popow.NipopowProofSerializer +import org.ergoplatform.modifiers.history.popow.PoPowHeader +import org.ergoplatform.modifiers.history.popow.PoPowHeaderSerializer +import org.ergoplatform.utils.ErgoCorePropertyTest +import scorex.util.ModifierId +import scorex.util.bytesToId +import scorex.util.encode.Base16 + +import java.security.MessageDigest +import scala.io.Source +import scala.util.Try + +class NipopowProofInteropSpec extends ErgoCorePropertyTest { + import org.ergoplatform.utils.ErgoCoreTestConstants._ + + private case class ByteRange(start: Int, endExclusive: Int) + + private val FixtureResource = "nipopow-full-root-mixed-nipopow-proof.json" + private val HeaderFixtureResource = "nipopow-full-root-mixed-popow-header.json" + private val FixtureFormat = "scorex-nipopow-proof-with-jvm-mode-v1" + + private val serializer = new NipopowProofSerializer(nipopowAlgos) + + private def resourceCursor(resource: String): HCursor = { + val source = Source.fromResource(resource) + val text = try source.mkString finally source.close() + io.circe.parser.parse(text).fold(error => throw error, _.hcursor) + } + + private lazy val fixture = resourceCursor(FixtureResource) + + private def fixtureValue[A: Decoder](field: String): A = + fixture.get[A](field).fold(error => throw error, identity) + + private lazy val fixtureBytes: Array[Byte] = + Base16.decode(fixtureValue[String]("bytes_hex")).get + + private lazy val rustCoreLength: Int = fixtureValue[Int]("rust_core_length") + + private lazy val terminalMode: Int = fixtureValue[Int]("terminal_continuous_byte") + + private def deterministicId(value: Byte): ModifierId = bytesToId(Array.fill(32)(value)) + + private def deterministicProof(): NipopowProof = { + val mixedFixture = resourceCursor(HeaderFixtureResource) + val mixedBytes = Base16.decode( + mixedFixture.get[String]("bytes_hex").fold(error => throw error, identity)).get + val mixedHeader = PoPowHeaderSerializer.parseBytes(mixedBytes) + + val emptyExtension = nipopowAlgos.interlinksToExtension(Seq.empty) + val emptyProof = NipopowAlgos.proofForInterlinkVector(emptyExtension).get + val genesisHeader: Header = mixedHeader.header.copy( + parentId = deterministicId(0), + height = 1, + extensionRoot = emptyExtension.digest, + sizeOpt = None + ) + val genesis = PoPowHeader(genesisHeader, Seq.empty, emptyProof) + val suffixHead = mixedHeader.copy(header = mixedHeader.header.copy( + parentId = genesis.id, + height = 2, + sizeOpt = None + )) + val suffixTail = mixedHeader.header.copy( + parentId = suffixHead.id, + height = 3, + sizeOpt = None + ) + + NipopowProof( + nipopowAlgos, + m = 1, + k = 2, + prefix = Seq(genesis), + suffixHead = suffixHead, + suffixTail = Seq(suffixTail), + continuous = false + ) + } + + /** Validate the fixture envelope without changing generic parser semantics. */ + private def parseFixtureEnvelope(bytes: Array[Byte], + coreLength: Int, + expectedTerminalMode: Int): Try[NipopowProof] = Try { + require(expectedTerminalMode == 0 || expectedTerminalMode == 1, + s"invalid JVM terminal mode $expectedTerminalMode") + require(bytes.length == coreLength + 1, + s"expected one terminal byte after $coreLength core bytes, got ${bytes.length}") + val actualTerminalMode = bytes(coreLength) & 0xff + require(actualTerminalMode == expectedTerminalMode, + s"JVM terminal mode $actualTerminalMode does not match $expectedTerminalMode") + + val parsed = serializer.parseBytes(bytes) + require(parsed.continuous == (expectedTerminalMode == 1), + "parsed JVM terminal mode differs from the fixture envelope") + require(serializer.toBytes(parsed).sameElements(bytes), + "JVM proof does not reserialize to the exact fixture envelope") + require(parsed.isValid, "parsed NiPoPoW proof is invalid") + parsed + } + + private def readFixtureVlq(bytes: Array[Byte], initialOffset: Int): (Long, Int) = { + var value = 0L + var offset = initialOffset + var shift = 0 + while (shift < 35) { + require(offset < bytes.length, "truncated fixture VLQ") + val next = bytes(offset) & 0xff + offset += 1 + value |= (next & 0x7f).toLong << shift + if ((next & 0x80) == 0) return value -> offset + shift += 7 + } + throw new IllegalArgumentException("fixture VLQ exceeds u32") + } + + private def suffixHeadRange(bytes: Array[Byte]): ByteRange = { + var offset = 0 + offset = readFixtureVlq(bytes, offset)._2 + offset = readFixtureVlq(bytes, offset)._2 + val (prefixCount, afterPrefixCount) = readFixtureVlq(bytes, offset) + offset = afterPrefixCount + (0L until prefixCount).foreach { _ => + val (frameLength, afterFrameLength) = readFixtureVlq(bytes, offset) + offset = afterFrameLength + require(frameLength <= bytes.length - offset, "prefix frame exceeds fixture bytes") + offset += frameLength.toInt + } + val (suffixHeadLength, suffixHeadStart) = readFixtureVlq(bytes, offset) + require(suffixHeadLength <= bytes.length - suffixHeadStart, + "suffix-head frame exceeds fixture bytes") + ByteRange(suffixHeadStart, suffixHeadStart + suffixHeadLength.toInt) + } + + private def singleSubsliceOffset(bytes: Array[Byte], + range: ByteRange, + needle: Array[Byte]): Int = { + require(needle.nonEmpty, "fixture mutation target cannot be empty") + require(needle.length <= range.endExclusive - range.start, + "fixture mutation target exceeds its search range") + val offsets = (range.start to range.endExclusive - needle.length).filter { offset => + bytes.slice(offset, offset + needle.length).sameElements(needle) + } + require(offsets.length == 1, "fixture mutation target must be unique") + offsets.head + } + + property("the JVM producer reproduces the complete frozen NiPoPoW fixture") { + val produced = serializer.toBytes(deterministicProof()) + + produced shouldBe fixtureBytes + Base16.encode(MessageDigest.getInstance("SHA-256").digest(produced)) shouldBe + fixtureValue[String]("sha256") + } + + property("the complete fixture round-trips at the explicit Rust core boundary") { + fixtureValue[String]("format") shouldBe FixtureFormat + fixtureBytes.length shouldBe rustCoreLength + 1 + (fixtureBytes(rustCoreLength) & 0xff) shouldBe terminalMode + + val parsed = parseFixtureEnvelope(fixtureBytes, rustCoreLength, terminalMode).get + parsed.m shouldBe fixtureValue[Int]("m") + parsed.k shouldBe fixtureValue[Int]("k") + parsed.prefix.size shouldBe fixtureValue[Int]("prefix_count") + parsed.suffixHeaders.size shouldBe fixtureValue[Int]("suffix_count") + parsed.suffixTail.size shouldBe fixtureValue[Int]("suffix_tail_count") + Base16.encode(parsed.suffixHead.header.extensionRoot) shouldBe + fixtureValue[String]("extension_root") + parsed.suffixHead.interlinks shouldBe + Seq(deterministicId(0x11), deterministicId(0x22)) + parsed.prefix.head.checkInterlinksProof() shouldBe true + parsed.suffixHead.checkInterlinksProof() shouldBe true + parsed.hasValidParams shouldBe true + parsed.isValid shouldBe true + } + + property("the complete fixture rejects an extension-root mutation") { + val mutated = fixtureBytes.clone() + val extensionRoot = Base16.decode(fixtureValue[String]("extension_root")).get + val rootOffset = + singleSubsliceOffset(mutated, suffixHeadRange(mutated), extensionRoot) + mutated(rootOffset) = (mutated(rootOffset) ^ 1).toByte + + parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true + } + + property("the complete fixture rejects a disclosed-interlink mutation") { + val mutated = fixtureBytes.clone() + val interlink = Array.fill(32)(0x22.toByte) + val interlinkOffset = + singleSubsliceOffset(mutated, suffixHeadRange(mutated), interlink) + mutated(interlinkOffset) = (mutated(interlinkOffset) ^ 1).toByte + + parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true + } + + property("the complete fixture rejects an m mutation") { + val mutated = fixtureBytes.clone() + mutated(0) shouldBe 1.toByte + mutated(0) = 0 + + parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true + } + + property("the complete fixture rejects a k mutation") { + val mutated = fixtureBytes.clone() + mutated.take(2) shouldBe Array[Byte](1, 2) + mutated(1) = 1 + + parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true + } + + property("the complete fixture rejects a nested header-frame mutation") { + val mutated = fixtureBytes.clone() + val nestedSizeOffset = suffixHeadRange(mutated).start + readFixtureVlq(mutated, nestedSizeOffset)._1 shouldBe 218L + mutated(nestedSizeOffset) shouldBe 0xda.toByte + mutated(nestedSizeOffset) = 0xd9.toByte + + parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true + } + + property("the complete fixture rejects a missing terminal byte") { + parseFixtureEnvelope(fixtureBytes.dropRight(1), rustCoreLength, terminalMode) + .isFailure shouldBe true + } + + property("the complete fixture rejects an extra terminal byte") { + parseFixtureEnvelope( + fixtureBytes :+ terminalMode.toByte, rustCoreLength, terminalMode) + .isFailure shouldBe true + } + + property("the complete fixture rejects terminal-mode mutations") { + val mutated = fixtureBytes.clone() + mutated(mutated.length - 1) = 1 + parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true + + mutated(mutated.length - 1) = 2 + parseFixtureEnvelope(mutated, rustCoreLength, 2).isFailure shouldBe true + } +} From ab5d3c9360bdf8a61963551389cd2b14219ba081 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:37:30 +0200 Subject: [PATCH 09/13] fix(nipopow): reject invalid continuous mode bytes --- .../modifiers/history/popow/NipopowProof.scala | 5 ++++- .../ergoplatform/serialization/SerializationTests.scala | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index e000d77d20..0c52f42a1e 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -263,7 +263,10 @@ class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[Ni readFrame(r, PoPowHeaderSerializer.MaxHeaderFrameBytes, "suffix-tail frame")( HeaderSerializer.parseBytes) } - val continuous = if (r.getByte() == 1) true else false + val continuousByte = r.getByte() + require(continuousByte == 0 || continuousByte == 1, + s"invalid NiPoPoW continuous mode byte ${continuousByte & 0xff}") + val continuous = continuousByte == 1 NipopowProof(poPowAlgos, m, k, prefix, suffixHead, suffixTail, continuous) } diff --git a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala index 93114e198d..e0995838d0 100644 --- a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala +++ b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala @@ -132,6 +132,13 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util nipopowSerializer.parseBytesTry(mutated) shouldBe 'failure } + property("PoPowProof parser rejects an invalid continuous mode byte") { + val bytes = nipopowSerializer.toBytes(smallValidProof()) + bytes(bytes.length - 1) = 2 + + assertProofParseFailureContains(bytes, "continuous mode") + } + property("PoPowProof outer element frames preserve authoritative slicing") { Seq(PrefixFrame, SuffixHeadFrame, SuffixTailFrame).foreach { site => withClue(s"site=$site canonical") { From 30501433372cce493dd6ed471357aaffdb06818e Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:01:55 +0200 Subject: [PATCH 10/13] test(nipopow): align CI compatibility --- .../history/NipopowProofInteropSpec.scala | 4 ++- .../modifiers/history/PoPowHeaderSpec.scala | 31 ++++++++++++------- .../local/NipopowVerifierSpec.scala | 17 +++++----- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala index f84f8c1efe..9a75834c9c 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala @@ -29,7 +29,9 @@ class NipopowProofInteropSpec extends ErgoCorePropertyTest { private val serializer = new NipopowProofSerializer(nipopowAlgos) private def resourceCursor(resource: String): HCursor = { - val source = Source.fromResource(resource) + val stream = Option(getClass.getClassLoader.getResourceAsStream(resource)) + .getOrElse(throw new IllegalArgumentException(s"Missing resource: $resource")) + val source = Source.fromInputStream(stream, "UTF-8") val text = try source.mkString finally source.close() io.circe.parser.parse(text).fold(error => throw error, _.hcursor) } diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index 6d9b5db6c4..9ddcbc79b3 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -1,5 +1,6 @@ package org.ergoplatform.modifiers.history +import io.circe.{Decoder, HCursor} import org.ergoplatform.modifiers.history.extension.ExtensionCandidate import org.ergoplatform.modifiers.history.popow.NipopowAlgos import org.ergoplatform.modifiers.history.popow.PoPowHeader @@ -27,11 +28,20 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private val MaxInterlinks = PoPowHeaderSerializer.MaxInterlinks private val MaxMerkleProofFrameBytes = PoPowHeaderSerializer.MaxMerkleProofFrameBytes + private def resourceCursor(resource: String): HCursor = { + val stream = Option(getClass.getClassLoader.getResourceAsStream(resource)) + .getOrElse(throw new IllegalArgumentException(s"Missing resource: $resource")) + val source = Source.fromInputStream(stream, "UTF-8") + val text = try source.mkString finally source.close() + io.circe.parser.parse(text).fold(error => throw error, value => value.hcursor) + } + + private def fixtureValue[A: Decoder](fixture: HCursor, field: String): A = + fixture.get[A](field).fold(error => throw error, value => value) + private lazy val framedHeader: PoPowHeader = { - val source = Source.fromResource("nipopow-full-root-mixed-popow-header.json") - val fixtureText = try source.mkString finally source.close() - val fixture = io.circe.parser.parse(fixtureText).toOption.get.hcursor - val bytes = Base16.decode(fixture.get[String]("bytes_hex").toOption.get).get + val fixture = resourceCursor("nipopow-full-root-mixed-popow-header.json") + val bytes = Base16.decode(fixtureValue[String](fixture, "bytes_hex")).get PoPowHeaderSerializer.parseBytes(bytes) } @@ -207,18 +217,17 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { } property("the cross-runtime full-root fixture round-trips and rejects mutations") { - val fixtureSource = Source.fromResource("nipopow-full-root-mixed-popow-header.json") - val fixtureText = try fixtureSource.mkString finally fixtureSource.close() - val fixture = io.circe.parser.parse(fixtureText).toOption.get.hcursor - val bytes = Base16.decode(fixture.get[String]("bytes_hex").toOption.get).get + val fixture = resourceCursor("nipopow-full-root-mixed-popow-header.json") + val bytes = Base16.decode(fixtureValue[String](fixture, "bytes_hex")).get - bytes.length shouldBe fixture.get[Int]("length").toOption.get + bytes.length shouldBe fixtureValue[Int](fixture, "length") Base16.encode(MessageDigest.getInstance("SHA-256").digest(bytes)) shouldBe - fixture.get[String]("sha256").toOption.get + fixtureValue[String](fixture, "sha256") val parsed = PoPowHeaderSerializer.parseBytes(bytes) PoPowHeaderSerializer.toBytes(parsed) shouldBe bytes - Base16.encode(parsed.header.extensionRoot) shouldBe fixture.get[String]("extension_root").toOption.get + Base16.encode(parsed.header.extensionRoot) shouldBe + fixtureValue[String](fixture, "extension_root") parsed.checkInterlinksProof() shouldBe true val wrongRootBytes = parsed.header.extensionRoot.clone() diff --git a/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala b/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala index e4b8543b8d..4d8e153bc6 100644 --- a/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala +++ b/src/test/scala/org/ergoplatform/local/NipopowVerifierSpec.scala @@ -57,12 +57,12 @@ class NipopowVerifierSpec extends AnyPropSpec with Matchers { proof.copy(k = 0), proof.copy(m = Int.MaxValue, k = 1) ).foreach { invalidProof => - val proofBytes = invalidProof.serializer.toBytes(invalidProof) - val receivedProof = invalidProof.serializer.parseBytes(proofBytes) - receivedProof.isValid shouldBe false + invalidProof.isValid shouldBe false + an[IllegalArgumentException] should be thrownBy + invalidProof.serializer.toBytes(invalidProof) val verifier = new NipopowVerifier(Some(baseChain.head.id)) - verifier.process(receivedProof) shouldBe ValidationError + verifier.process(invalidProof) shouldBe ValidationError verifier.bestChain shouldBe empty } } @@ -71,16 +71,17 @@ class NipopowVerifierSpec extends AnyPropSpec with Matchers { val baseChain = genChain(100) val params = PoPowParams(5, 5, continuous = false).get val invalidProof = nipopowAlgos.prove(toPoPoWChain(baseChain))(params).get.copy(m = 0) - val proofBytes = invalidProof.serializer.toBytes(invalidProof) - val receivedProof = invalidProof.serializer.parseBytes(proofBytes) + invalidProof.isValid shouldBe false + an[IllegalArgumentException] should be thrownBy + invalidProof.serializer.toBytes(invalidProof) val verifier = new NipopowVerifier(Some(baseChain.head.id)) - val firstResult = verifier.process(receivedProof) + val firstResult = verifier.process(invalidProof) val secondResult = new AtomicReference[NipopowProofVerificationResult]() val completed = new CountDownLatch(1) val worker = new Thread(new Runnable { override def run(): Unit = - try secondResult.set(verifier.process(receivedProof)) + try secondResult.set(verifier.process(invalidProof)) finally completed.countDown() }) worker.setDaemon(true) From 0e83f1b5b4227f8fb869b3fe6b4c13c720a8b8a8 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:08:39 +0200 Subject: [PATCH 11/13] chore: remove test-only development restriction --- AGENTS.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6cfb76e489..0b1f28559a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,3 @@ - Follow existing test patterns in similar files - Type annotations for public methods - Prefer immutable data structures and functional patterns - -## Development Restrictions -- **Code Changes**: Only modify code in `src/test/` folders -- **Production Code**: Do not touch production code in `src/main/` directories -- **Test Focus**: All development work should be test-related only \ No newline at end of file From a2d30905e40691ff35c59ce54072e2c2038b6c8a Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:08:57 +0200 Subject: [PATCH 12/13] fix(nipopow): bound merkle validation and serve snapshots only --- .../modifiers/history/popow/PoPowHeader.scala | 76 ++++++++++++++++--- .../modifiers/history/PoPowHeaderSpec.scala | 73 ++++++++++++++++++ .../network/ErgoNodeViewSynchronizer.scala | 8 +- .../nodeView/history/ErgoHistory.scala | 32 -------- .../nodeView/NodeViewSynchronizerTests.scala | 37 +++++---- .../history/PopowProcessorSpecification.scala | 59 ++++---------- 6 files changed, 179 insertions(+), 106 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala index a1f20adb59..38ca3a8488 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala @@ -1,7 +1,6 @@ package org.ergoplatform.modifiers.history.popow import cats.syntax.either._ -import sigmastate.utils.Helpers._ import cats.Traverse import cats.implicits.{catsStdInstancesForEither, catsStdInstancesForList} import io.circe.{Decoder, Encoder, Json} @@ -97,17 +96,26 @@ object PoPowHeader { def checkInterlinksProof(interlinks: Seq[ModifierId], proof: BatchMerkleProof[Digest32], extensionRoot: Digest32): Boolean = { - val expectedLeafHashes = NipopowAlgos.packInterlinks(interlinks) - .map(Extension.kvToLeaf) - .map(kv => Leaf[Digest32](LeafData @@ kv)(Algos.hash).hash) - val provenLeafHashes = proof.indices.map(_._2) - - interlinks.nonEmpty && - expectedLeafHashes.size == provenLeafHashes.size && - expectedLeafHashes.zip(provenLeafHashes).forall { case (expected, proven) => - expected sameElements proven - } && - proof.valid(extensionRoot) + if (!PoPowHeaderSerializer.hasValidMerkleProofStructure( + proof.indices.map(_._1), + proof.proofs.size + )) { + false + } else { + Try { + val expectedLeafHashes = NipopowAlgos.packInterlinks(interlinks) + .map(Extension.kvToLeaf) + .map(kv => Leaf[Digest32](LeafData @@ kv)(Algos.hash).hash) + val provenLeafHashes = proof.indices.map(_._2) + + interlinks.nonEmpty && + expectedLeafHashes.size == provenLeafHashes.size && + expectedLeafHashes.zip(provenLeafHashes).forall { case (expected, proven) => + expected sameElements proven + } && + proof.valid(extensionRoot) + }.getOrElse(false) + } } /** @@ -198,6 +206,9 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { private val MerkleProofCountBytes = 8 private val MerkleIndexBytes = 36 private val MerkleProofNodeBytes = 33 + private[ergoplatform] final val MaxMerkleProofDepth = + Extension.FieldKeySize * java.lang.Byte.SIZE + private val MaxMerkleLeafIndex = (1 << MaxMerkleProofDepth) - 1 implicit val hf: HF = Algos.hash val merkleProofSerializer = new BatchMerkleProofSerializer[Digest32, HF] @@ -205,6 +216,37 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { private def requireWithinLimit(value: Int, limit: Int, what: String): Unit = require(value <= limit, s"$what $value exceeds sanity limit $limit") + private[ergoplatform] def hasValidMerkleProofStructure(indices: Seq[Int], + proofCount: Int): Boolean = { + if (indices.isEmpty) { + proofCount == 0 + } else if (proofCount < 0 || + indices.exists(index => index < 0 || index > MaxMerkleLeafIndex) || + indices.distinct.size != indices.size) { + false + } else { + var current = indices.sorted.toVector + var remainingProofs = proofCount + var depth = 0 + var valid = true + + while (valid && !(current.size == 1 && current.head == 0 && remainingProofs == 0) && + depth < MaxMerkleProofDepth) { + val currentIndices = current.toSet + val missingSiblings = current.count(index => !currentIndices.contains(index ^ 1)) + if (missingSiblings > remainingProofs) { + valid = false + } else { + remainingProofs -= missingSiblings + current = current.map(_ / 2).distinct + depth += 1 + } + } + + valid && current.size == 1 && current.head == 0 && remainingProofs == 0 + } + } + private def validateMerkleProofFrame(bytes: Array[Byte]): Unit = { require(bytes.length >= MerkleProofCountBytes, s"Merkle proof counts require at least $MerkleProofCountBytes bytes") @@ -223,6 +265,16 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { ) require(requiredBytes == bytes.length.toLong, s"Merkle proof counts require $requiredBytes bytes, frame has ${bytes.length}") + + val indices = Vector.newBuilder[Int] + var index = 0 + while (index < indexCount) { + indices += counts.getInt + counts.position(counts.position() + MerkleIndexBytes - Integer.BYTES) + index += 1 + } + require(hasValidMerkleProofStructure(indices.result(), proofCount), + "Merkle proof structure exceeds the extension key space") } override def serialize(obj: PoPowHeader, w: Writer): Unit = { diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index 9ddcbc79b3..c507f1bbd3 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -9,6 +9,7 @@ import org.ergoplatform.modifiers.history.popow.PoPowHeaderSerializer import org.ergoplatform.utils.ErgoCorePropertyTest import org.ergoplatform.utils.generators.ErgoCoreGenerators.defaultHeaderGen import org.scalacheck.Gen +import scorex.crypto.authds.merkle.BatchMerkleProof import scorex.crypto.hash.Digest32 import scorex.util.serialization.VLQByteBufferWriter import scorex.util.{ByteArrayBuilder, ModifierId, bytesToId, idToBytes} @@ -82,6 +83,20 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private def intBytes(value: Int): Array[Byte] = ByteBuffer.allocate(4).putInt(value).array() + private def merkleProofFrame(indices: Seq[Int], proofCount: Int): Array[Byte] = { + val serializedIndices: Array[Byte] = indices.iterator + .flatMap(index => (intBytes(index) ++ Array.fill[Byte](32)(1)).iterator) + .toArray + + intBytes(indices.size) ++ + intBytes(proofCount) ++ + serializedIndices ++ + Array.fill[Byte](proofCount * 33)(0) + } + + private def singletonProofFrame(depth: Int): Array[Byte] = + merkleProofFrame(Seq(0), depth) + private def mixedExtension(interlinks: Seq[ModifierId]): ExtensionCandidate = { nipopowAlgos.interlinksToExtension(interlinks) ++ ExtensionCandidate(Seq( Array[Byte](2, 0) -> Array[Byte](1) @@ -297,6 +312,39 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") } + property("PoPowHeader rejects a singleton proof deeper than the extension key space") { + val impossibleDepth = java.lang.Byte.SIZE * 2 + 1 + + assertParseFailureContains( + minimalPoPowHeader(singletonProofFrame(impossibleDepth)), + "Merkle proof structure" + ) + } + + property("PoPowHeader accepts a singleton proof at the extension key-space depth") { + val maximumDepth = java.lang.Byte.SIZE * 2 + + PoPowHeaderSerializer.parseBytes( + minimalPoPowHeader(singletonProofFrame(maximumDepth)) + ).interlinksProof.proofs.size shouldBe maximumDepth + } + + property("PoPowHeader rejects a Merkle index outside the extension key space") { + val firstInvalidIndex = 1 << PoPowHeaderSerializer.MaxMerkleProofDepth + + assertParseFailureContains( + minimalPoPowHeader(merkleProofFrame(Seq(firstInvalidIndex), 0)), + "Merkle proof structure" + ) + } + + property("PoPowHeader rejects duplicate Merkle indices") { + assertParseFailureContains( + minimalPoPowHeader(merkleProofFrame(Seq(0, 0), 0)), + "Merkle proof structure" + ) + } + property("PoPowHeader rejects extreme Merkle counts with checked arithmetic") { val proofFrame = intBytes(Int.MaxValue) ++ intBytes(Int.MaxValue) @@ -323,6 +371,31 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { } } + property("Merkle library validation exceptions reject the interlinks proof") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + proof.proofs should not be empty + val malformedProof = BatchMerkleProof[Digest32]( + proof.indices, + (null.asInstanceOf[Digest32] -> proof.proofs.head._2) +: proof.proofs.tail + )(org.ergoplatform.settings.Algos.hash) + + checkInterlinksProof(interlinks, malformedProof, extension.digest) shouldBe false + } + + property("missing Merkle proof nodes reject the interlinks proof") { + val interlinks = Seq(deterministicId(1), deterministicId(2)) + val extension = mixedExtension(interlinks) + val proof = NipopowAlgos.proofForInterlinkVector(extension).get + val incompleteProof = BatchMerkleProof[Digest32]( + proof.indices, + Seq.empty + )(org.ergoplatform.settings.Algos.hash) + + checkInterlinksProof(interlinks, incompleteProof, extension.digest) shouldBe false + } + property("a canonical run of 255 identical interlinks is accepted") { val interlinks = Seq.fill(255)(deterministicId(1)) val extension = nipopowAlgos.interlinksToExtension(interlinks) diff --git a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala index 9264f4cd47..a5d18a6f8a 100644 --- a/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala +++ b/src/main/scala/org/ergoplatform/network/ErgoNodeViewSynchronizer.scala @@ -1061,12 +1061,12 @@ class ErgoNodeViewSynchronizer(networkControllerRef: ActorRef, */ private def sendNipopowProof(data: NipopowProofData, hr: ErgoHistory, peer: ConnectedPeer): Unit = { if (data.m == hr.P2PNipopowProofM && data.k == hr.P2PNipopowProofK && data.headerIdBytesOpt.isEmpty) { - hr.cachedOrGeneratePopowProofBytes() match { - case Success(proofBytes) => + hr.readPopowProofBytesFromDb() match { + case Some(proofBytes) => val msg = Message(NipopowProofSpec, Right(proofBytes), None) networkControllerRef ! SendToNetwork(msg, SendToPeer(peer)) - case Failure(e) => - log.warn("Failed to generate or persist Nipopow proof", e) + case None => + log.warn("No cached Nipopow proof available") } } else { // for now, we are serving proofs for concrete params only diff --git a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala index d48912d621..c001dd8e64 100644 --- a/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala +++ b/src/main/scala/org/ergoplatform/nodeView/history/ErgoHistory.scala @@ -67,38 +67,6 @@ trait ErgoHistory historyStorage.insert(mId, bytes) } - /** - * Return the current cached P2P NiPoPoW proof, generating and persisting it on a cache miss. - * Proof bytes are returned only after the V2 cache write succeeds. - */ - def cachedOrGeneratePopowProofBytes(): Try[Array[Byte]] = { - cachedOrGeneratePopowProofBytes(popowProofBytes()) - } - - private[history] def cachedOrGeneratePopowProofBytes( - generateProofBytes: => Try[Array[Byte]] - ): Try[Array[Byte]] = { - cachedOrGeneratePopowProofBytes( - generateProofBytes, - proofBytes => historyStorage.insert( - Array(NipopowProofV2Key -> proofBytes), - BlockSection.emptyArray - ) - ) - } - - private[history] def cachedOrGeneratePopowProofBytes( - generateProofBytes: => Try[Array[Byte]], - persistProofBytes: Array[Byte] => Try[Unit] - ): Try[Array[Byte]] = synchronized { - Try(readPopowProofBytesFromDb()).flatMap { - case Some(proofBytes) => Success(proofBytes) - case None => Try(generateProofBytes).flatten.flatMap { proofBytes => - persistProofBytes(proofBytes).map(_ => proofBytes) - } - } - } - /** * Append ErgoPersistentModifier to History if valid */ diff --git a/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala b/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala index cb97ad0a15..5ed7f095a9 100644 --- a/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala +++ b/src/test/scala/org/ergoplatform/nodeView/NodeViewSynchronizerTests.scala @@ -144,25 +144,28 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec } } - property("NodeViewSynchronizer: GetNipopowProof generates and reuses the V2 cache") { + property("NodeViewSynchronizer: GetNipopowProof serves and reuses the V2 cache") { withFixture { ctx => import ctx._ - // Generate history chain val emptyHistory = historyGen.sample.get - val prefix = blockStream(None).take(settings.chainSettings.makeSnapshotEvery / 2) - val fullHistory = applyChain(emptyHistory, prefix) - fullHistory.readPopowProofBytesFromDb() shouldBe None + val chain = blockStream(None).take(settings.chainSettings.makeSnapshotEvery) + val history = applyChain(emptyHistory, chain) + val cachedBytes = history.readPopowProofBytesFromDb().get // Broadcast updated history - node ! ChangedHistory(fullHistory) + node ! ChangedHistory(history) // Build and send GetNipopowProofSpec request val spec = GetNipopowProofSpec - val msgBytes = spec.toBytes(NipopowProofData(m = emptyHistory.P2PNipopowProofM, k = emptyHistory.P2PNipopowProofK, headerId = None)) + val msgBytes = spec.toBytes(NipopowProofData( + m = history.P2PNipopowProofM, + k = history.P2PNipopowProofK, + headerId = None + )) node ! Message[NipopowProofData](spec, Left(msgBytes), Option(peer)) - // Listen for the generated NipopowProofSpec response + // Listen for the cached NipopowProofSpec response val firstResponse = ncProbe.fishForMessage(5 seconds) { case stn: SendToNetwork => stn.message.spec match { @@ -172,7 +175,7 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec case _: Any => false }.asInstanceOf[SendToNetwork] val firstBytes = firstResponse.message.data.get.asInstanceOf[Array[Byte]] - fullHistory.readPopowProofBytesFromDb().get.toSeq shouldBe firstBytes.toSeq + firstBytes.toSeq shouldBe cachedBytes.toSeq // A second request must reuse the persisted bytes exactly. node ! Message[NipopowProofData](spec, Left(msgBytes), Option(peer)) @@ -181,10 +184,11 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec case _: Any => false }.asInstanceOf[SendToNetwork] secondResponse.message.data.get.asInstanceOf[Array[Byte]].toSeq shouldBe firstBytes.toSeq + history.readPopowProofBytesFromDb().get.toSeq shouldBe cachedBytes.toSeq } } - property("NodeViewSynchronizer: GetNipopowProof sends nothing when generation fails") { + property("NodeViewSynchronizer: GetNipopowProof sends nothing when the V2 cache is missing") { withFixture { ctx => import ctx._ @@ -194,14 +198,18 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec PoPoWBootstrap = false, blocksToKeep = -1 ) - emptyHistory.readPopowProofBytesFromDb() shouldBe None - node ! ChangedHistory(emptyHistory) + val history = applyChain( + emptyHistory, + blockStream(None).take(settings.chainSettings.makeSnapshotEvery / 2) + ) + history.readPopowProofBytesFromDb() shouldBe None + node ! ChangedHistory(history) ncProbe.receiveWhile(max = 1.second, idle = 200.millis) { case message => message } val spec = GetNipopowProofSpec val msgBytes = spec.toBytes(NipopowProofData( - m = emptyHistory.P2PNipopowProofM, - k = emptyHistory.P2PNipopowProofK, + m = history.P2PNipopowProofM, + k = history.P2PNipopowProofK, headerId = None )) node ! Message[NipopowProofData](spec, Left(msgBytes), Option(peer)) @@ -213,6 +221,7 @@ trait NodeViewSynchronizerTests[ST <: ErgoState[ST]] extends AnyPropSpec case stn: SendToNetwork => stn.message.spec.isInstanceOf[NipopowProofSpec.type] case _ => false } shouldBe false + history.readPopowProofBytesFromDb() shouldBe None } } diff --git a/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala b/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala index 54771ea19c..69959f5d36 100644 --- a/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala +++ b/src/test/scala/org/ergoplatform/nodeView/history/PopowProcessorSpecification.scala @@ -10,7 +10,6 @@ import org.ergoplatform.wallet.utils.FileUtils import scorex.util.ModifierId import java.nio.charset.StandardCharsets -import scala.util.{Failure, Success} class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { import org.ergoplatform.utils.HistoryTestHelpers._ @@ -64,19 +63,11 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { } } - property("V2 NiPoPoW cache miss generates and persists proof bytes") { + property("V2 NiPoPoW cache miss remains empty until a scheduled snapshot") { val history = genHistory(None, popowBootstrap = false) - val generatedBytes = Array[Byte](1, 3, 3, 7) - var generationCount = 0 try { - val result = history.cachedOrGeneratePopowProofBytes { - generationCount += 1 - Success(generatedBytes) - } - - result.get.toSeq shouldBe generatedBytes.toSeq - generationCount shouldBe 1 - history.readPopowProofBytesFromDb().get.toSeq shouldBe generatedBytes.toSeq + history.readPopowProofBytesFromDb() shouldBe None + history.readPopowProofBytesFromDb() shouldBe None } finally { history.closeStorage() } @@ -86,30 +77,26 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { val history = genHistory(None, popowBootstrap = false) val cachedBytes = Array[Byte](2, 4, 6, 8) try { - history.cachedOrGeneratePopowProofBytes(Success(cachedBytes)).get - - val result = history.cachedOrGeneratePopowProofBytes( - Failure(new IllegalStateException("cache hit must not regenerate")) - ) + history.historyStorage.insert( + Array(history.NipopowProofV2Key -> cachedBytes), + BlockSection.emptyArray + ).get - result.get.toSeq shouldBe cachedBytes.toSeq + history.readPopowProofBytesFromDb().get.toSeq shouldBe cachedBytes.toSeq + history.readPopowProofBytesFromDb().get.toSeq shouldBe cachedBytes.toSeq } finally { history.closeStorage() } } - property("NiPoPoW cache generation failure never falls back to legacy bytes") { + property("V2 NiPoPoW cache miss never falls back to legacy bytes") { val history = genHistory(None, popowBootstrap = false) - val generationFailure = new IllegalStateException("proof generation failed") try { history.historyStorage.insert( Array(history.LegacyNipopowSnapshotKey -> legacySentinel), BlockSection.emptyArray ).get - val result = history.cachedOrGeneratePopowProofBytes(Failure(generationFailure)) - - result.failed.get shouldBe generationFailure history.readPopowProofBytesFromDb() shouldBe None history.historyStorage .getIndex(history.LegacyNipopowSnapshotKey) @@ -120,23 +107,6 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { } } - property("NiPoPoW cache persistence failure exposes no proof bytes") { - val history = genHistory(None, popowBootstrap = false) - val generatedBytes = Array[Byte](9, 7, 5, 3) - val persistenceFailure = new IllegalStateException("proof persistence failed") - try { - val result = history.cachedOrGeneratePopowProofBytes( - Success(generatedBytes), - _ => Failure(persistenceFailure) - ) - - result.failed.get shouldBe persistenceFailure - history.readPopowProofBytesFromDb() shouldBe None - } finally { - history.closeStorage() - } - } - property("V2 NiPoPoW cache bytes survive history restart") { val directory = createTempDir val baseSettings = ErgoNodeTestConstants.initSettings @@ -147,7 +117,10 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { val cachedBytes = Array[Byte](10, 20, 30, 40) val firstHistory = ErgoHistory.readOrGenerate(historySettings)(null) try { - firstHistory.cachedOrGeneratePopowProofBytes(Success(cachedBytes)).get + firstHistory.historyStorage.insert( + Array(firstHistory.NipopowProofV2Key -> cachedBytes), + BlockSection.emptyArray + ).get } finally { firstHistory.closeStorage() } @@ -155,9 +128,7 @@ class PopowProcessorSpecification extends ErgoCorePropertyTest with FileUtils { val reopenedHistory = ErgoHistory.readOrGenerate(historySettings)(null) try { reopenedHistory.readPopowProofBytesFromDb().get.toSeq shouldBe cachedBytes.toSeq - reopenedHistory.cachedOrGeneratePopowProofBytes( - Failure(new IllegalStateException("restart cache hit must not regenerate")) - ).get.toSeq shouldBe cachedBytes.toSeq + reopenedHistory.readPopowProofBytesFromDb().get.toSeq shouldBe cachedBytes.toSeq } finally { reopenedHistory.closeStorage() } From 57d7f04c80544d84c39c0a4c31f8e8f5817a9f50 Mon Sep 17 00:00:00 2001 From: "A. Shannon" <217575710+a-shannon@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:41:13 +0200 Subject: [PATCH 13/13] refactor(nipopow): clarify length-prefixed parsing terms --- .../history/popow/NipopowProof.scala | 20 ++-- .../modifiers/history/popow/PoPowHeader.scala | 24 ++-- .../history/NipopowProofInteropSpec.scala | 12 +- .../modifiers/history/PoPowHeaderSpec.scala | 106 +++++++++--------- .../serialization/SerializationTests.scala | 81 +++++++------ 5 files changed, 125 insertions(+), 118 deletions(-) diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala index 0c52f42a1e..2a8838bf2e 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowProof.scala @@ -208,13 +208,13 @@ object NipopowProofSerializer { private def requireWithinLimit(value: Int, limit: Int, what: String): Unit = require(value <= limit, s"$what $value exceeds sanity limit $limit") - private def readFrame[T](r: Reader, - limit: Int, - what: String) - (parse: Array[Byte] => T): T = { - val size = r.getUInt().toIntExact - requireWithinLimit(size, limit, s"$what size") - parse(r.getBytes(size)) + private def readLengthPrefixed[T](r: Reader, + limit: Int, + what: String) + (parse: Array[Byte] => T): T = { + val declaredLength = r.getUInt().toIntExact + requireWithinLimit(declaredLength, limit, s"$what length") + parse(r.getBytes(declaredLength)) } } @@ -250,17 +250,17 @@ class NipopowProofSerializer(poPowAlgos: NipopowAlgos) extends ErgoSerializer[Ni val prefixSize = r.getUInt().toIntExact requireWithinLimit(prefixSize, MaxProofElements, "prefix count") val prefix = (0 until prefixSize).map { _ => - readFrame(r, PoPowHeaderSerializer.MaxSerializedBytes, "prefix element frame")( + readLengthPrefixed(r, PoPowHeaderSerializer.MaxSerializedBytes, "prefix element")( PoPowHeaderSerializer.parseBytes) } - val suffixHead = readFrame(r, PoPowHeaderSerializer.MaxSerializedBytes, "suffix-head frame")( + val suffixHead = readLengthPrefixed(r, PoPowHeaderSerializer.MaxSerializedBytes, "suffix head")( PoPowHeaderSerializer.parseBytes) val suffixSize = r.getUInt().toIntExact requireWithinLimit(suffixSize, MaxProofElements, "suffix count") require(suffixSize == k - 1, s"NiPoPoW suffix length ${suffixSize + 1} does not match k parameter $k") val suffixTail = (0 until suffixSize).map { _ => - readFrame(r, PoPowHeaderSerializer.MaxHeaderFrameBytes, "suffix-tail frame")( + readLengthPrefixed(r, PoPowHeaderSerializer.MaxHeaderBytes, "suffix tail")( HeaderSerializer.parseBytes) } val continuousByte = r.getByte() diff --git a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala index 38ca3a8488..8bd1598cca 100644 --- a/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala +++ b/ergo-core/src/main/scala/org/ergoplatform/modifiers/history/popow/PoPowHeader.scala @@ -197,11 +197,11 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { import org.ergoplatform.sdk.wallet.Constants.ModifierIdLength // Generous wire sanity limits shared with the sigma-rust NiPoPoW parser. - private[ergoplatform] final val MaxHeaderFrameBytes = 10000 + private[ergoplatform] final val MaxHeaderBytes = 10000 private[ergoplatform] final val MaxInterlinks = 10000 - private[ergoplatform] final val MaxMerkleProofFrameBytes = 1000000 + private[ergoplatform] final val MaxMerkleProofBytes = 1000000 private[ergoplatform] final val MaxSerializedBytes = - MaxHeaderFrameBytes + MaxInterlinks * ModifierIdLength + MaxMerkleProofFrameBytes + 64 + MaxHeaderBytes + MaxInterlinks * ModifierIdLength + MaxMerkleProofBytes + 64 private val MerkleProofCountBytes = 8 private val MerkleIndexBytes = 36 @@ -247,7 +247,7 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { } } - private def validateMerkleProofFrame(bytes: Array[Byte]): Unit = { + private def validateMerkleProofPayload(bytes: Array[Byte]): Unit = { require(bytes.length >= MerkleProofCountBytes, s"Merkle proof counts require at least $MerkleProofCountBytes bytes") // BatchMerkleProofSerializer stores both counts as fixed-width big-endian ints. @@ -264,7 +264,7 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { Math.addExact(indexBytes, proofBytes) ) require(requiredBytes == bytes.length.toLong, - s"Merkle proof counts require $requiredBytes bytes, frame has ${bytes.length}") + s"Merkle proof counts require $requiredBytes bytes, payload has ${bytes.length}") val indices = Vector.newBuilder[Int] var index = 0 @@ -289,16 +289,16 @@ object PoPowHeaderSerializer extends ErgoSerializer[PoPowHeader] { } override def parse(r: Reader): PoPowHeader = { - val headerSize = r.getUInt().toIntExact - requireWithinLimit(headerSize, MaxHeaderFrameBytes, "header frame size") - val header = HeaderSerializer.parseBytes(r.getBytes(headerSize)) + val headerLength = r.getUInt().toIntExact + requireWithinLimit(headerLength, MaxHeaderBytes, "header length") + val header = HeaderSerializer.parseBytes(r.getBytes(headerLength)) val linksQty = r.getUInt().toIntExact requireWithinLimit(linksQty, MaxInterlinks, "interlink count") val interlinks = (0 until linksQty).map(_ => bytesToId(r.getBytes(ModifierIdLength))) - val interlinksProofSize = r.getUInt().toIntExact - requireWithinLimit(interlinksProofSize, MaxMerkleProofFrameBytes, "Merkle proof frame size") - val proofBytes = r.getBytes(interlinksProofSize) - validateMerkleProofFrame(proofBytes) + val interlinksProofLength = r.getUInt().toIntExact + requireWithinLimit(interlinksProofLength, MaxMerkleProofBytes, "Merkle proof length") + val proofBytes = r.getBytes(interlinksProofLength) + validateMerkleProofPayload(proofBytes) val interlinksProof = merkleProofSerializer.deserialize(proofBytes).get PoPowHeader(header, interlinks, interlinksProof) } diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala index 9a75834c9c..48c1833f75 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/NipopowProofInteropSpec.scala @@ -130,14 +130,14 @@ class NipopowProofInteropSpec extends ErgoCorePropertyTest { val (prefixCount, afterPrefixCount) = readFixtureVlq(bytes, offset) offset = afterPrefixCount (0L until prefixCount).foreach { _ => - val (frameLength, afterFrameLength) = readFixtureVlq(bytes, offset) - offset = afterFrameLength - require(frameLength <= bytes.length - offset, "prefix frame exceeds fixture bytes") - offset += frameLength.toInt + val (payloadLength, afterPayloadLength) = readFixtureVlq(bytes, offset) + offset = afterPayloadLength + require(payloadLength <= bytes.length - offset, "prefix payload exceeds fixture bytes") + offset += payloadLength.toInt } val (suffixHeadLength, suffixHeadStart) = readFixtureVlq(bytes, offset) require(suffixHeadLength <= bytes.length - suffixHeadStart, - "suffix-head frame exceeds fixture bytes") + "suffix-head payload exceeds fixture bytes") ByteRange(suffixHeadStart, suffixHeadStart + suffixHeadLength.toInt) } @@ -219,7 +219,7 @@ class NipopowProofInteropSpec extends ErgoCorePropertyTest { parseFixtureEnvelope(mutated, rustCoreLength, terminalMode).isFailure shouldBe true } - property("the complete fixture rejects a nested header-frame mutation") { + property("the complete fixture rejects a nested header-length mutation") { val mutated = fixtureBytes.clone() val nestedSizeOffset = suffixHeadRange(mutated).start readFixtureVlq(mutated, nestedSizeOffset)._1 shouldBe 218L diff --git a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala index c507f1bbd3..8e4911ca94 100644 --- a/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala +++ b/ergo-core/src/test/scala/org/ergoplatform/modifiers/history/PoPowHeaderSpec.scala @@ -25,9 +25,9 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private def deterministicId(value: Byte): ModifierId = bytesToId(Array.fill(32)(value)) - private val MaxHeaderFrameBytes = PoPowHeaderSerializer.MaxHeaderFrameBytes + private val MaxHeaderBytes = PoPowHeaderSerializer.MaxHeaderBytes private val MaxInterlinks = PoPowHeaderSerializer.MaxInterlinks - private val MaxMerkleProofFrameBytes = PoPowHeaderSerializer.MaxMerkleProofFrameBytes + private val MaxMerkleProofBytes = PoPowHeaderSerializer.MaxMerkleProofBytes private def resourceCursor(resource: String): HCursor = { val stream = Option(getClass.getClassLoader.getResourceAsStream(resource)) @@ -40,22 +40,22 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private def fixtureValue[A: Decoder](fixture: HCursor, field: String): A = fixture.get[A](field).fold(error => throw error, value => value) - private lazy val framedHeader: PoPowHeader = { + private lazy val sampleHeader: PoPowHeader = { val fixture = resourceCursor("nipopow-full-root-mixed-popow-header.json") val bytes = Base16.decode(fixtureValue[String](fixture, "bytes_hex")).get PoPowHeaderSerializer.parseBytes(bytes) } - private def serializeWithNestedFrames(value: PoPowHeader, - headerFrame: Array[Byte], - proofFrame: Array[Byte]): Array[Byte] = { + private def serializeWithNestedPayloads(value: PoPowHeader, + headerPayload: Array[Byte], + proofPayload: Array[Byte]): Array[Byte] = { writerBytes { writer => - writer.putUInt(headerFrame.length.toLong) - writer.putBytes(headerFrame) + writer.putUInt(headerPayload.length.toLong) + writer.putBytes(headerPayload) writer.putUInt(value.interlinks.length.toLong) value.interlinks.foreach(id => writer.putBytes(idToBytes(id))) - writer.putUInt(proofFrame.length.toLong) - writer.putBytes(proofFrame) + writer.putUInt(proofPayload.length.toLong) + writer.putBytes(proofPayload) } } @@ -65,14 +65,14 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { writer.result().toBytes } - private def minimalPoPowHeader(proofFrame: Array[Byte]): Array[Byte] = { - val headerFrame = framedHeader.header.bytes + private def minimalPoPowHeader(proofPayload: Array[Byte]): Array[Byte] = { + val headerPayload = sampleHeader.header.bytes writerBytes { writer => - writer.putUInt(headerFrame.length.toLong) - writer.putBytes(headerFrame) + writer.putUInt(headerPayload.length.toLong) + writer.putBytes(headerPayload) writer.putUInt(0) - writer.putUInt(proofFrame.length.toLong) - writer.putBytes(proofFrame) + writer.putUInt(proofPayload.length.toLong) + writer.putBytes(proofPayload) } } @@ -83,7 +83,7 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { private def intBytes(value: Int): Array[Byte] = ByteBuffer.allocate(4).putInt(value).array() - private def merkleProofFrame(indices: Seq[Int], proofCount: Int): Array[Byte] = { + private def merkleProofPayload(indices: Seq[Int], proofCount: Int): Array[Byte] = { val serializedIndices: Array[Byte] = indices.iterator .flatMap(index => (intBytes(index) ++ Array.fill[Byte](32)(1)).iterator) .toArray @@ -94,8 +94,8 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { Array.fill[Byte](proofCount * 33)(0) } - private def singletonProofFrame(depth: Int): Array[Byte] = - merkleProofFrame(Seq(0), depth) + private def singletonProofPayload(depth: Int): Array[Byte] = + merkleProofPayload(Seq(0), depth) private def mixedExtension(interlinks: Seq[ModifierId]): ExtensionCandidate = { nipopowAlgos.interlinksToExtension(interlinks) ++ ExtensionCandidate(Seq( @@ -254,69 +254,69 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { .checkInterlinksProof() shouldBe false } - property("a nested header frame accepts trailing padding") { - val headerFrame = framedHeader.header.bytes :+ 0x7f.toByte - val proofFrame = PoPowHeaderSerializer.merkleProofSerializer.serialize(framedHeader.interlinksProof) - val bytes = serializeWithNestedFrames(framedHeader, headerFrame, proofFrame) + property("a nested header payload accepts trailing padding") { + val headerPayload = sampleHeader.header.bytes :+ 0x7f.toByte + val proofPayload = PoPowHeaderSerializer.merkleProofSerializer.serialize(sampleHeader.interlinksProof) + val bytes = serializeWithNestedPayloads(sampleHeader, headerPayload, proofPayload) - PoPowHeaderSerializer.parseBytes(bytes) shouldBe framedHeader + PoPowHeaderSerializer.parseBytes(bytes) shouldBe sampleHeader } - property("a nested Merkle proof frame rejects trailing padding") { - val headerFrame = framedHeader.header.bytes - val proofFrame = - PoPowHeaderSerializer.merkleProofSerializer.serialize(framedHeader.interlinksProof) :+ 0x7f.toByte - val bytes = serializeWithNestedFrames(framedHeader, headerFrame, proofFrame) + property("a nested Merkle proof payload rejects trailing padding") { + val headerPayload = sampleHeader.header.bytes + val proofPayload = + PoPowHeaderSerializer.merkleProofSerializer.serialize(sampleHeader.interlinksProof) :+ 0x7f.toByte + val bytes = serializeWithNestedPayloads(sampleHeader, headerPayload, proofPayload) assertParseFailureContains(bytes, "Merkle proof counts") } - property("PoPowHeader rejects an oversized nested header before reading its frame") { - val bytes = writerBytes(_.putUInt(MaxHeaderFrameBytes + 1L)) + property("PoPowHeader rejects an oversized nested header before reading its payload") { + val bytes = writerBytes(_.putUInt(MaxHeaderBytes + 1L)) - assertParseFailureContains(bytes, "header frame size") + assertParseFailureContains(bytes, "header length") } property("PoPowHeader rejects an oversized interlink count before reading ids") { - val headerFrame = framedHeader.header.bytes + val headerPayload = sampleHeader.header.bytes val bytes = writerBytes { writer => - writer.putUInt(headerFrame.length.toLong) - writer.putBytes(headerFrame) + writer.putUInt(headerPayload.length.toLong) + writer.putBytes(headerPayload) writer.putUInt(MaxInterlinks + 1L) } assertParseFailureContains(bytes, "interlink count") } - property("PoPowHeader rejects an oversized Merkle proof before reading its frame") { - val headerFrame = framedHeader.header.bytes + property("PoPowHeader rejects an oversized Merkle proof before reading its payload") { + val headerPayload = sampleHeader.header.bytes val bytes = writerBytes { writer => - writer.putUInt(headerFrame.length.toLong) - writer.putBytes(headerFrame) + writer.putUInt(headerPayload.length.toLong) + writer.putBytes(headerPayload) writer.putUInt(0) - writer.putUInt(MaxMerkleProofFrameBytes + 1L) + writer.putUInt(MaxMerkleProofBytes + 1L) } - assertParseFailureContains(bytes, "Merkle proof frame size") + assertParseFailureContains(bytes, "Merkle proof length") } - property("PoPowHeader rejects an index count that cannot fit its proof frame") { - val proofFrame = intBytes(1) ++ intBytes(0) + property("PoPowHeader rejects an index count that cannot fit its proof payload") { + val proofPayload = intBytes(1) ++ intBytes(0) - assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") + assertParseFailureContains(minimalPoPowHeader(proofPayload), "Merkle proof counts") } - property("PoPowHeader rejects a proof-node count that cannot fit its proof frame") { - val proofFrame = intBytes(0) ++ intBytes(1) + property("PoPowHeader rejects a proof-node count that cannot fit its proof payload") { + val proofPayload = intBytes(0) ++ intBytes(1) - assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") + assertParseFailureContains(minimalPoPowHeader(proofPayload), "Merkle proof counts") } property("PoPowHeader rejects a singleton proof deeper than the extension key space") { val impossibleDepth = java.lang.Byte.SIZE * 2 + 1 assertParseFailureContains( - minimalPoPowHeader(singletonProofFrame(impossibleDepth)), + minimalPoPowHeader(singletonProofPayload(impossibleDepth)), "Merkle proof structure" ) } @@ -325,7 +325,7 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { val maximumDepth = java.lang.Byte.SIZE * 2 PoPowHeaderSerializer.parseBytes( - minimalPoPowHeader(singletonProofFrame(maximumDepth)) + minimalPoPowHeader(singletonProofPayload(maximumDepth)) ).interlinksProof.proofs.size shouldBe maximumDepth } @@ -333,22 +333,22 @@ class PoPowHeaderSpec extends ErgoCorePropertyTest { val firstInvalidIndex = 1 << PoPowHeaderSerializer.MaxMerkleProofDepth assertParseFailureContains( - minimalPoPowHeader(merkleProofFrame(Seq(firstInvalidIndex), 0)), + minimalPoPowHeader(merkleProofPayload(Seq(firstInvalidIndex), 0)), "Merkle proof structure" ) } property("PoPowHeader rejects duplicate Merkle indices") { assertParseFailureContains( - minimalPoPowHeader(merkleProofFrame(Seq(0, 0), 0)), + minimalPoPowHeader(merkleProofPayload(Seq(0, 0), 0)), "Merkle proof structure" ) } property("PoPowHeader rejects extreme Merkle counts with checked arithmetic") { - val proofFrame = intBytes(Int.MaxValue) ++ intBytes(Int.MaxValue) + val proofPayload = intBytes(Int.MaxValue) ++ intBytes(Int.MaxValue) - assertParseFailureContains(minimalPoPowHeader(proofFrame), "Merkle proof counts") + assertParseFailureContains(minimalPoPowHeader(proofPayload), "Merkle proof counts") } property("empty interlinks proof is accepted for genesis") { diff --git a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala index e0995838d0..397a54e442 100644 --- a/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala +++ b/src/test/scala/org/ergoplatform/serialization/SerializationTests.scala @@ -14,21 +14,21 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util private val nipopowSerializer = new NipopowProofSerializer(nipopowAlgos) - private sealed trait FrameSite - private case object PrefixFrame extends FrameSite - private case object SuffixHeadFrame extends FrameSite - private case object SuffixTailFrame extends FrameSite + private sealed trait LengthPrefixedSite + private case object PrefixElement extends LengthPrefixedSite + private case object SuffixHeadElement extends LengthPrefixedSite + private case object SuffixTailElement extends LengthPrefixedSite private val MaxProofElements = PoPowParams.MaxProofElements - private val MaxHeaderFrameBytes = PoPowHeaderSerializer.MaxHeaderFrameBytes - private val MaxPoPowHeaderFrameBytes = PoPowHeaderSerializer.MaxSerializedBytes + private val MaxHeaderBytes = PoPowHeaderSerializer.MaxHeaderBytes + private val MaxPoPowHeaderBytes = PoPowHeaderSerializer.MaxSerializedBytes private def smallValidProof(): NipopowProof = validNiPoPowProofGen(1, 1).sample.get - private lazy val framedProof: NipopowProof = { + private lazy val sampleProof: NipopowProof = { val proof = validNiPoPowProofGen(1, 2).sample.get - require(proof.prefix.nonEmpty, "framing fixture needs a prefix element") - require(proof.suffixTail.nonEmpty, "framing fixture needs a suffix-tail element") + require(proof.prefix.nonEmpty, "length-prefix fixture needs a prefix element") + require(proof.suffixTail.nonEmpty, "length-prefix fixture needs a suffix-tail element") proof } @@ -46,11 +46,11 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util writer.toBytes } - private def putFrame(writer: VLQByteStringWriter, - bytes: Array[Byte], - mutate: Boolean, - declaredDelta: Int, - fillerLength: Int): Unit = { + private def putLengthPrefixed(writer: VLQByteStringWriter, + bytes: Array[Byte], + mutate: Boolean, + declaredDelta: Int, + fillerLength: Int): Unit = { val declaredSize = bytes.length + (if (mutate) declaredDelta else 0) require(declaredSize >= 0) writer.putUInt(declaredSize.toLong) @@ -60,20 +60,23 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util } } - private def serializeWithFrameMutation(proof: NipopowProof, - site: FrameSite, - declaredDelta: Int, - fillerLength: Int): Array[Byte] = writerBytes { writer => + private def serializeWithDeclaredLengthMutation(proof: NipopowProof, + site: LengthPrefixedSite, + declaredDelta: Int, + fillerLength: Int): Array[Byte] = writerBytes { writer => writer.putUInt(proof.m.toLong) writer.putUInt(proof.k.toLong) writer.putUInt(proof.prefix.length.toLong) proof.prefix.zipWithIndex.foreach { case (header, index) => - putFrame(writer, header.bytes, site == PrefixFrame && index == 0, declaredDelta, fillerLength) + putLengthPrefixed( + writer, header.bytes, site == PrefixElement && index == 0, declaredDelta, fillerLength) } - putFrame(writer, proof.suffixHead.bytes, site == SuffixHeadFrame, declaredDelta, fillerLength) + putLengthPrefixed( + writer, proof.suffixHead.bytes, site == SuffixHeadElement, declaredDelta, fillerLength) writer.putUInt(proof.suffixTail.length.toLong) proof.suffixTail.zipWithIndex.foreach { case (header, index) => - putFrame(writer, header.bytes, site == SuffixTailFrame && index == 0, declaredDelta, fillerLength) + putLengthPrefixed( + writer, header.bytes, site == SuffixTailElement && index == 0, declaredDelta, fillerLength) } writer.put(if (proof.continuous) 1 else 0) } @@ -139,19 +142,23 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util assertProofParseFailureContains(bytes, "continuous mode") } - property("PoPowProof outer element frames preserve authoritative slicing") { - Seq(PrefixFrame, SuffixHeadFrame, SuffixTailFrame).foreach { site => + property("PoPowProof declared element lengths define outer slicing") { + Seq(PrefixElement, SuffixHeadElement, SuffixTailElement).foreach { site => withClue(s"site=$site canonical") { - nipopowSerializer.parseBytes(serializeWithFrameMutation(framedProof, site, 0, 0)) shouldBe framedProof + nipopowSerializer.parseBytes( + serializeWithDeclaredLengthMutation(sampleProof, site, 0, 0)) shouldBe sampleProof } withClue(s"site=$site under-declared") { - nipopowSerializer.parseBytesTry(serializeWithFrameMutation(framedProof, site, -1, 0)) shouldBe 'failure + nipopowSerializer.parseBytesTry( + serializeWithDeclaredLengthMutation(sampleProof, site, -1, 0)) shouldBe 'failure } withClue(s"site=$site over-declared without filler") { - nipopowSerializer.parseBytesTry(serializeWithFrameMutation(framedProof, site, 1, 0)) shouldBe 'failure + nipopowSerializer.parseBytesTry( + serializeWithDeclaredLengthMutation(sampleProof, site, 1, 0)) shouldBe 'failure } withClue(s"site=$site over-declared with matching filler") { - nipopowSerializer.parseBytes(serializeWithFrameMutation(framedProof, site, 1, 1)) shouldBe framedProof + nipopowSerializer.parseBytes( + serializeWithDeclaredLengthMutation(sampleProof, site, 1, 1)) shouldBe sampleProof } } } @@ -172,39 +179,39 @@ class SerializationTests extends ErgoCorePropertyTest with org.ergoplatform.util writer.putUInt(proof.m.toLong) writer.putUInt(proof.k.toLong) writer.putUInt(proof.prefix.length.toLong) - proof.prefix.foreach(header => putFrame(writer, header.bytes, false, 0, 0)) - putFrame(writer, proof.suffixHead.bytes, false, 0, 0) + proof.prefix.foreach(header => putLengthPrefixed(writer, header.bytes, false, 0, 0)) + putLengthPrefixed(writer, proof.suffixHead.bytes, false, 0, 0) writer.putUInt(MaxProofElements + 1L) } assertProofParseFailureContains(bytes, "suffix count") } - property("PoPowProof rejects oversized outer frames before reading them") { + property("PoPowProof rejects oversized length-prefixed elements before reading payloads") { val prefixBytes = writerBytes { writer => writer.putUInt(1) writer.putUInt(1) writer.putUInt(1) - writer.putUInt(MaxPoPowHeaderFrameBytes + 1L) + writer.putUInt(MaxPoPowHeaderBytes + 1L) } val suffixHeadBytes = writerBytes { writer => writer.putUInt(1) writer.putUInt(1) writer.putUInt(0) - writer.putUInt(MaxPoPowHeaderFrameBytes + 1L) + writer.putUInt(MaxPoPowHeaderBytes + 1L) } val suffixTailBytes = writerBytes { writer => writer.putUInt(1) writer.putUInt(2) writer.putUInt(0) - putFrame(writer, framedProof.suffixHead.bytes, false, 0, 0) + putLengthPrefixed(writer, sampleProof.suffixHead.bytes, false, 0, 0) writer.putUInt(1) - writer.putUInt(MaxHeaderFrameBytes + 1L) + writer.putUInt(MaxHeaderBytes + 1L) } - assertProofParseFailureContains(prefixBytes, "prefix element frame size") - assertProofParseFailureContains(suffixHeadBytes, "suffix-head frame size") - assertProofParseFailureContains(suffixTailBytes, "suffix-tail frame size") + assertProofParseFailureContains(prefixBytes, "prefix element length") + assertProofParseFailureContains(suffixHeadBytes, "suffix head length") + assertProofParseFailureContains(suffixTailBytes, "suffix tail length") } }