Skip to main content
A data application lets a metagraph define custom data types — beyond token transactions — that go through their own consensus process and are stored in every currency snapshot.

What a data application is

In a standard currency metagraph, currency snapshots contain signed Transaction objects. A data application extends this by adding a custom DataUpdate type. Users submit DataUpdate objects to the L1 HTTP API, L1 validates and batches them into DataApplicationBlock objects, and L0 processes those blocks during snapshot consensus to produce an updated DataState. The DataState has two parts:
case class DataState[A <: DataOnChainState, B <: DataCalculatedState](
  onChain: A,
  calculated: B,
  sharedArtifacts: SortedSet[SharedArtifact] = SortedSet.empty[SharedArtifact]
)
  • onChain — data committed to the currency snapshot and visible to all observers.
  • calculated — derived state computed by the L0 node, not directly stored in the snapshot hash but retrievable via API.

Lifecycle

User submits DataUpdate


L1: validateUpdate        ← your L1 implementation

        ▼ (grouped into DataApplicationBlock)
L0: validateData          ← your L0 implementation


L0: combine               ← your L0 implementation


CurrencyIncrementalSnapshot contains DataApplicationBlock hashes


onSnapshotConsensusResult ← optional hook, called after finalization

Core types

DataUpdate

Define your custom update type by extending DataUpdate:
trait DataUpdate extends DataTransaction
Your concrete type must have Circe Encoder and Decoder instances so the framework can serialize it over HTTP and into snapshots.

DataApplicationBlock

The framework groups validated DataUpdate objects into a block for consensus:
case class DataApplicationBlock(
  roundId: RoundId,
  dataTransactions: NonEmptyList[DataTransactions],
  dataTransactionsHashes: NonEmptyList[NonEmptyList[Hash]],
  updateHashes: Option[SortedSet[Hash]] = None
)

FeeTransaction

Data updates can optionally be accompanied by a fee payment:
case class FeeTransaction(
  source: Address,
  destination: Address,
  amount: Amount,
  dataUpdateRef: Hash
) extends DataTransaction
Implement validateFee in your service to enforce fee requirements.

Implementing the L0 service

Extend DataApplicationL0Service with your concrete types:
import cats.effect.IO
import cats.data.NonEmptyList
import io.constellationnetwork.currency.dataApplication._
import io.constellationnetwork.schema.SnapshotOrdinal
import io.constellationnetwork.security.hash.Hash
import io.constellationnetwork.security.signature.Signed

// Your custom types
case class MyUpdate(value: String) extends DataUpdate
case class MyOnChainState(entries: List[String]) extends DataOnChainState
case class MyCalculatedState(count: Long) extends DataCalculatedState

object MyL0Service extends DataApplicationL0Service[IO, MyUpdate, MyOnChainState, MyCalculatedState] {

  override val genesis: DataState[MyOnChainState, MyCalculatedState] =
    DataState(MyOnChainState(List.empty), MyCalculatedState(0L))

  override def validateData(
    state: DataState[MyOnChainState, MyCalculatedState],
    updates: NonEmptyList[Signed[MyUpdate]]
  )(implicit context: L0NodeContext[IO]): IO[DataApplicationValidationErrorOr[Unit]] =
    // Return valid or invalid with your rules
    ???

  override def combine(
    state: DataState[MyOnChainState, MyCalculatedState],
    updates: List[Signed[MyUpdate]]
  )(implicit context: L0NodeContext[IO]): IO[DataState[MyOnChainState, MyCalculatedState]] =
    // Apply updates to state
    ???

  override def getCalculatedState(
    implicit context: L0NodeContext[IO]
  ): IO[(SnapshotOrdinal, MyCalculatedState)] = ???

  override def setCalculatedState(
    ordinal: SnapshotOrdinal,
    state: MyCalculatedState
  )(implicit context: L0NodeContext[IO]): IO[Boolean] = ???

  override def hashCalculatedState(
    state: MyCalculatedState
  )(implicit context: L0NodeContext[IO]): IO[Hash] = ???

  // Called after every finalized snapshot
  override def onSnapshotConsensusResult(
    snapshot: Hashed[CurrencyIncrementalSnapshot]
  )(implicit A: Applicative[IO]): IO[Unit] = IO.unit
}

Implementing the L1 service

The L1 service validates individual updates before they enter consensus:
object MyL1Service extends DataApplicationL1Service[IO, MyUpdate, MyOnChainState, MyCalculatedState] {

  override def validateUpdate(
    update: MyUpdate
  )(implicit context: L1NodeContext[IO]): IO[DataApplicationValidationErrorOr[Unit]] =
    // Validate a single incoming update
    ???
}

Registering the services

Wire both services into your app classes:
// In your CurrencyL0App
override def dataApplication: Option[Resource[IO, BaseDataApplicationL0Service[IO]]] =
  Some(Resource.pure(BaseDataApplicationL0Service(MyL0Service)))

// In your CurrencyL1App
override def dataApplication: Option[Resource[IO, BaseDataApplicationL1Service[IO]]] =
  Some(Resource.pure(BaseDataApplicationL1Service(MyL1Service)))
BaseDataApplicationL0Service.apply and BaseDataApplicationL1Service.apply wrap your typed service into the base trait that the framework consumes. They require implicit ClassTag instances for your three type parameters, which the compiler derives automatically.

L0 node context

Your L0 methods receive an implicit L0NodeContext[F] that provides access to recent snapshots and cluster state:
trait L0NodeContext[F[_]] {
  def getLastSynchronizedGlobalSnapshot: F[Option[GlobalIncrementalSnapshot]]
  def getLastCurrencySnapshot: F[Option[Hashed[CurrencyIncrementalSnapshot]]]
  def getLastCurrencySnapshotCombined: F[Option[(Hashed[CurrencyIncrementalSnapshot], CurrencySnapshotInfo)]]
  def securityProvider: SecurityProvider[F]
  def getCurrencyId: F[CurrencyId]
  def getMetagraphL0Seedlist: Option[Set[SeedlistEntry]]
}

L1 node context

Your L1 methods receive an L1NodeContext[F]:
trait L1NodeContext[F[_]] {
  def getLastGlobalSnapshot: F[Option[Hashed[GlobalIncrementalSnapshot]]]
  def getLastCurrencySnapshot: F[Option[Hashed[CurrencyIncrementalSnapshot]]]
  def getLastCurrencySnapshotCombined: F[Option[(Hashed[CurrencyIncrementalSnapshot], CurrencySnapshotInfo)]]
  def securityProvider: SecurityProvider[F]
  def getCurrencyId: F[CurrencyId]
}

Validation errors

Return validation failures using ValidatedNec:
type DataApplicationValidationErrorOr[A] = ValidatedNec[DataApplicationValidationError, A]

trait DataApplicationValidationError {
  val message: String
}
Built-in error values are available in io.constellationnetwork.currency.dataApplication.Errors:
ErrorMeaning
NoopGeneric invalid update
MissingDataUpdateTransactionNo data update found
NotEnoughFeeAttached fee is too low
SourceWalletNotEnoughBalanceSource cannot cover fee
MissingFeeTransactionRequired fee transaction absent
InvalidSignatureSignature verification failed
NoValidDataTransactionsNone of the updates passed validation

Build docs developers (and LLMs) love