diff --git a/modules/l0/src/main/scala/xyz/kd5ujc/metagraph_l0/ML0Service.scala b/modules/l0/src/main/scala/xyz/kd5ujc/metagraph_l0/ML0Service.scala index bc7c345..9310c84 100755 --- a/modules/l0/src/main/scala/xyz/kd5ujc/metagraph_l0/ML0Service.scala +++ b/modules/l0/src/main/scala/xyz/kd5ujc/metagraph_l0/ML0Service.scala @@ -11,12 +11,14 @@ import io.constellationnetwork.currency.dataApplication._ import io.constellationnetwork.currency.dataApplication.dataApplication.DataApplicationValidationErrorOr import io.constellationnetwork.currency.schema.currency.CurrencyIncrementalSnapshot import io.constellationnetwork.metagraph_sdk.MetagraphCommonService +import io.constellationnetwork.metagraph_sdk.crypto.mpt.api.MerklePatriciaProducer import io.constellationnetwork.metagraph_sdk.lifecycle.{CheckpointService, CombinerService, ValidationService} import io.constellationnetwork.metagraph_sdk.std.Checkpoint import io.constellationnetwork.metagraph_sdk.std.JsonBinaryHasher.HasherOps import io.constellationnetwork.metagraph_sdk.syntax.all.CurrencyIncrementalSnapshotOps import io.constellationnetwork.schema.SnapshotOrdinal import io.constellationnetwork.security.hash.Hash +import io.constellationnetwork.security.hex.Hex import io.constellationnetwork.security.signature.Signed import io.constellationnetwork.security.{Hashed, SecurityProvider} @@ -144,10 +146,15 @@ object ML0Service { // same fiber are processed in sequence number order. val sortedUpdates = updates.sorted(OttochainMessage.signedOrdering) - combiner.foldLeft( - state.focus(_.onChain.latestLogs).replace(SortedMap.empty), - sortedUpdates - ) + for { + combined <- combiner.foldLeft( + state.focus(_.onChain.latestLogs).replace(SortedMap.empty), + sortedUpdates + ) + // After all updates, compute metagraphStateRoot from all fiber stateRoots + metagraphRoot <- computeMetagraphStateRoot(combined.calculated) + withRoot = combined.copy(calculated = combined.calculated.copy(metagraphStateRoot = metagraphRoot)) + } yield withRoot } override def getCalculatedState(implicit @@ -160,10 +167,38 @@ object ML0Service { ): F[Boolean] = checkpointService.set(Checkpoint(ordinal, state)) override def hashCalculatedState(state: CalculatedState)(implicit context: L0NodeContext[F]): F[Hash] = - state.computeDigest + state.metagraphStateRoot match { + case Some(root) => root.pure[F] + case None => state.computeDigest + } override def routes(implicit context: L0NodeContext[F]): HttpRoutes[F] = new ML0CustomRoutes[F](checkpointService, subscriberRegistry).public + + /** + * Compute the metagraph-level MPT root from all per-fiber stateRoots. + * + * Key: hex encoding of fiber UUID (without dashes) + * Value: the per-fiber stateRoot Hash + * + * Returns `None` when no fibers have a stateRoot yet. + */ + private def computeMetagraphStateRoot(state: CalculatedState): F[Option[Hash]] = { + val fiberRoots: Map[Hex, Hash] = state.stateMachines.collect { + case (id, fiber) if fiber.stateRoot.isDefined => + Hex(id.toString.replace("-", "")) -> fiber.stateRoot.get + } + + if (fiberRoots.isEmpty) { + Option.empty[Hash].pure[F] + } else { + MerklePatriciaProducer + .stateless[F] + .create(fiberRoots) + .map(trie => Some(trie.rootNode.digest): Option[Hash]) + .handleError(_ => Option.empty[Hash]) + } + } } ) } diff --git a/modules/models/src/main/scala/xyz/kd5ujc/schema/CalculatedState.scala b/modules/models/src/main/scala/xyz/kd5ujc/schema/CalculatedState.scala index fb16dd1..d22b6e7 100755 --- a/modules/models/src/main/scala/xyz/kd5ujc/schema/CalculatedState.scala +++ b/modules/models/src/main/scala/xyz/kd5ujc/schema/CalculatedState.scala @@ -5,14 +5,16 @@ import java.util.UUID import scala.collection.immutable.SortedMap import io.constellationnetwork.currency.dataApplication.DataCalculatedState +import io.constellationnetwork.security.hash.Hash import derevo.circe.magnolia.{decoder, encoder} import derevo.derive @derive(encoder, decoder) case class CalculatedState( - stateMachines: SortedMap[UUID, Records.StateMachineFiberRecord], - scripts: SortedMap[UUID, Records.ScriptFiberRecord] + stateMachines: SortedMap[UUID, Records.StateMachineFiberRecord], + scripts: SortedMap[UUID, Records.ScriptFiberRecord], + metagraphStateRoot: Option[Hash] = None ) extends DataCalculatedState object CalculatedState { diff --git a/modules/models/src/main/scala/xyz/kd5ujc/schema/Records.scala b/modules/models/src/main/scala/xyz/kd5ujc/schema/Records.scala index cb6255a..d85e57a 100755 --- a/modules/models/src/main/scala/xyz/kd5ujc/schema/Records.scala +++ b/modules/models/src/main/scala/xyz/kd5ujc/schema/Records.scala @@ -39,7 +39,8 @@ object Records { status: FiberStatus, lastReceipt: Option[EventReceipt] = None, parentFiberId: Option[UUID] = None, - childFiberIds: Set[UUID] = Set.empty + childFiberIds: Set[UUID] = Set.empty, + stateRoot: Option[Hash] = None ) extends FiberRecord @derive(encoder, decoder) diff --git a/modules/shared-data/src/main/scala/xyz/kd5ujc/shared_data/lifecycle/combine/FiberCombiner.scala b/modules/shared-data/src/main/scala/xyz/kd5ujc/shared_data/lifecycle/combine/FiberCombiner.scala index c58661f..141aad8 100644 --- a/modules/shared-data/src/main/scala/xyz/kd5ujc/shared_data/lifecycle/combine/FiberCombiner.scala +++ b/modules/shared-data/src/main/scala/xyz/kd5ujc/shared_data/lifecycle/combine/FiberCombiner.scala @@ -6,9 +6,13 @@ import cats.effect.Async import cats.syntax.all._ import io.constellationnetwork.currency.dataApplication.{DataState, L0NodeContext} +import io.constellationnetwork.metagraph_sdk.crypto.mpt.api.MerklePatriciaProducer +import io.constellationnetwork.metagraph_sdk.json_logic._ import io.constellationnetwork.metagraph_sdk.std.JsonBinaryHasher.HasherOps import io.constellationnetwork.schema.SnapshotOrdinal import io.constellationnetwork.security.SecurityProvider +import io.constellationnetwork.security.hash.Hash +import io.constellationnetwork.security.hex.Hex import io.constellationnetwork.security.signature.Signed import xyz.kd5ujc.schema.fiber.FiberLogEntry.EventReceipt @@ -46,6 +50,7 @@ class FiberCombiner[F[_]: Async: SecurityProvider]( currentOrdinal <- ctx.getCurrentOrdinal owners <- update.proofs.toList.traverse(_.id.toAddress).map(Set.from) initialDataHash <- update.initialData.computeDigest + initialRoot <- computeStateRoot(update.initialData) record = Records.StateMachineFiberRecord( fiberId = update.fiberId, @@ -59,7 +64,8 @@ class FiberCombiner[F[_]: Async: SecurityProvider]( sequenceNumber = FiberOrdinal.MinValue, owners = owners, status = FiberStatus.Active, - parentFiberId = update.parentFiberId + parentFiberId = update.parentFiberId, + stateRoot = initialRoot ) result <- current.withRecord[F](update.fiberId, record) @@ -154,6 +160,31 @@ class FiberCombiner[F[_]: Async: SecurityProvider]( // Private Helpers // ============================================================================ + /** + * Computes a Merkle Patricia Trie root hash over the top-level fields of a fiber's stateData. + * + * For each `(fieldName, fieldValue)` pair in a `MapValue`: + * - key = UTF-8 hex encoding of the field name + * - value = the JSON-encoded field value + * + * Returns `None` for non-Map stateData or empty maps (nothing to hash). + */ + private def computeStateRoot(stateData: JsonLogicValue): F[Option[Hash]] = + stateData match { + case MapValue(fields) if fields.nonEmpty => + val entries: Map[Hex, JsonLogicValue] = fields.map { case (k, v) => + Hex(k.getBytes("UTF-8").map("%02x".format(_)).mkString) -> v + } + MerklePatriciaProducer + .stateless[F] + .create(entries) + .map(trie => Some(trie.rootNode.digest): Option[Hash]) + .handleError(_ => Option.empty[Hash]) + + case _ => + Option.empty[Hash].pure[F] + } + /** * Handles a committed transaction outcome. * @@ -164,7 +195,16 @@ class FiberCombiner[F[_]: Async: SecurityProvider]( updatedOracles: Map[UUID, Records.ScriptFiberRecord], logEntries: List[FiberLogEntry] ): F[DataState[OnChain, CalculatedState]] = - current.withFibersAndOracles[F](updatedFibers, updatedOracles).map(_.appendLogs(logEntries)) + for { + // Recompute stateRoot for each updated fiber + fibersWithRoots <- updatedFibers.toList + .traverse { case (id, fiber) => + computeStateRoot(fiber.stateData).map(root => id -> fiber.copy(stateRoot = root)) + } + .map(_.toMap) + + result <- current.withFibersAndOracles[F](fibersWithRoots, updatedOracles).map(_.appendLogs(logEntries)) + } yield result /** * Handles an aborted transaction outcome. diff --git a/modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/MetagraphIntegrationSuite.scala b/modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/MetagraphIntegrationSuite.scala new file mode 100644 index 0000000..bcc73ab --- /dev/null +++ b/modules/shared-data/src/test/scala/xyz/kd5ujc/shared_data/MetagraphIntegrationSuite.scala @@ -0,0 +1,212 @@ +package xyz.kd5ujc.shared_data + +import java.util.UUID + +import cats.effect.IO + +import scala.collection.immutable.SortedMap + +import io.constellationnetwork.metagraph_sdk.json_logic._ +import io.constellationnetwork.schema.SnapshotOrdinal +import io.constellationnetwork.schema.address.Address +import io.constellationnetwork.security.hash.Hash + +import xyz.kd5ujc.schema.CalculatedState +import xyz.kd5ujc.schema.Records.StateMachineFiberRecord +import xyz.kd5ujc.schema.fiber._ + +import weaver.SimpleIOSuite + +/** + * Phase 1 metagraph integration tests. + * + * Verifies that: + * - StateMachineFiberRecord carries a per-fiber MPT state root + * - CalculatedState carries a metagraph-level state root + * + * These tests validate the schema additions introduced in Phase 1. + * FiberCombiner + ML0Service integration is covered by higher-level E2E tests. + */ +object MetagraphIntegrationSuite extends SimpleIOSuite { + + private val emptyData: JsonLogicValue = MapValue(Map.empty[String, JsonLogicValue]) + + private def minimalDefinition: StateMachineDefinition = { + val initial = StateId("initial") + StateMachineDefinition( + states = Map(initial -> State(initial, isFinal = false)), + initialState = initial, + transitions = Nil + ) + } + + private def sampleFiberRecord(fiberId: UUID): StateMachineFiberRecord = + StateMachineFiberRecord( + fiberId = fiberId, + creationOrdinal = SnapshotOrdinal.MinValue, + previousUpdateOrdinal = SnapshotOrdinal.MinValue, + latestUpdateOrdinal = SnapshotOrdinal.MinValue, + definition = minimalDefinition, + currentState = StateId("initial"), + stateData = emptyData, + stateDataHash = Hash.empty, + sequenceNumber = FiberOrdinal.MinValue, + owners = Set.empty[Address], + status = FiberStatus.Active + ) + + // ============================================================================ + // Group 1: StateMachineFiberRecord stateRoot Field Tests (3 tests) + // ============================================================================ + + test("StateMachineFiberRecord should have stateRoot field") { + val fiberId = UUID.randomUUID() + val record = sampleFiberRecord(fiberId) + + IO(expect(record.stateRoot.isDefined == false)) + } + + test("StateMachineFiberRecord stateRoot should be optional and default to None") { + val fiberId = UUID.randomUUID() + val record = sampleFiberRecord(fiberId) + + IO(expect(record.stateRoot == None)) + } + + test("StateMachineFiberRecord stateRoot should accept Hash values") { + val fiberId = UUID.randomUUID() + val sampleHash = Hash("a" * 64) + val record = sampleFiberRecord(fiberId).copy(stateRoot = Some(sampleHash)) + + IO(expect(record.stateRoot == Some(sampleHash))) + } + + // ============================================================================ + // Group 2: CalculatedState metagraphStateRoot Field Tests (3 tests) + // ============================================================================ + + test("CalculatedState should have metagraphStateRoot field") { + val state = CalculatedState.genesis + + IO(expect(state.metagraphStateRoot.isDefined == false)) + } + + test("CalculatedState metagraphStateRoot should be optional and default to None") { + val state = CalculatedState.genesis + + IO(expect(state.metagraphStateRoot == None)) + } + + test("CalculatedState metagraphStateRoot should accept Hash values") { + val sampleHash = Hash("b" * 64) + val state = CalculatedState.genesis.copy(metagraphStateRoot = Some(sampleHash)) + + IO(expect(state.metagraphStateRoot == Some(sampleHash))) + } + + // ============================================================================ + // Group 3: Schema Consistency Tests (3 tests) + // ============================================================================ + + test("StateMachineFiberRecord stateRoot should survive copy operations") { + val fiberId = UUID.randomUUID() + val sampleHash = Hash("c" * 64) + val record = sampleFiberRecord(fiberId).copy(stateRoot = Some(sampleHash)) + val copied = record.copy(sequenceNumber = FiberOrdinal.unsafeApply(1L)) + + IO(expect(copied.stateRoot == Some(sampleHash))) + } + + test("CalculatedState should preserve metagraphStateRoot when adding fibers") { + val fiberId = UUID.randomUUID() + val sampleHash = Hash("d" * 64) + val record = sampleFiberRecord(fiberId) + val state = CalculatedState.genesis.copy( + stateMachines = SortedMap(fiberId -> record), + metagraphStateRoot = Some(sampleHash) + ) + + IO( + expect(state.metagraphStateRoot == Some(sampleHash)) and + expect(state.stateMachines.size == 1) + ) + } + + test("CalculatedState genesis should have no fibers and no metagraphStateRoot") { + val state = CalculatedState.genesis + + IO( + expect(state.stateMachines.isEmpty) and + expect(state.scripts.isEmpty) and + expect(state.metagraphStateRoot.isEmpty) + ) + } + + // ============================================================================ + // Group 4: hashCalculatedState Conditional Logic Tests (3 tests) + // ============================================================================ + + test("metagraphStateRoot is None by default — falls back to default hashing") { + val state = CalculatedState.genesis + + // When metagraphStateRoot is absent, the ML0Service falls back to state.computeDigest. + // Here we simply verify the field is None as a pre-condition for the fallback path. + IO(expect(state.metagraphStateRoot.isEmpty)) + } + + test("metagraphStateRoot Some(_) is returned directly as the canonical hash") { + val root = Hash("e" * 64) + val state = CalculatedState.genesis.copy(metagraphStateRoot = Some(root)) + + // ML0Service.hashCalculatedState returns metagraphStateRoot.get when defined. + // Here we verify the field is set correctly as a precondition. + IO(expect(state.metagraphStateRoot == Some(root))) + } + + test("Two states with same fibers but different metagraphStateRoot are distinguishable") { + val fiberId = UUID.randomUUID() + val record = sampleFiberRecord(fiberId) + val stateA = + CalculatedState(SortedMap(fiberId -> record), SortedMap.empty, metagraphStateRoot = Some(Hash("a" * 64))) + val stateB = + CalculatedState(SortedMap(fiberId -> record), SortedMap.empty, metagraphStateRoot = Some(Hash("b" * 64))) + + IO(expect(stateA.metagraphStateRoot != stateB.metagraphStateRoot)) + } + + // ============================================================================ + // Group 5: State Proof API Readiness Tests (3 tests) + // ============================================================================ + + test("StateMachineFiberRecord with stateRoot supports proof generation precondition") { + val fiberId = UUID.randomUUID() + val root = Hash("f" * 64) + val record = sampleFiberRecord(fiberId).copy(stateRoot = Some(root)) + + // Proof generation requires a non-empty stateRoot + IO(expect(record.stateRoot.isDefined)) + } + + test("CalculatedState with metagraphStateRoot supports state proof endpoint precondition") { + val fiberId = UUID.randomUUID() + val root = Hash("0" * 64) + val record = sampleFiberRecord(fiberId).copy(stateRoot = Some(root)) + val state = CalculatedState( + stateMachines = SortedMap(fiberId -> record), + scripts = SortedMap.empty, + metagraphStateRoot = Some(Hash("1" * 64)) + ) + + IO( + expect(state.metagraphStateRoot.isDefined) and + expect(state.stateMachines.get(fiberId).flatMap(_.stateRoot).isDefined) + ) + } + + test("Non-existent fiber returns None from stateMachines lookup") { + val nonExistentId = UUID.randomUUID() + val state = CalculatedState.genesis + + IO(expect(state.stateMachines.get(nonExistentId).isEmpty)) + } +}