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

Filter by extension

Filter by extension

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

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