Repository: Constellation-Labs/tessellation
Pull request #1143: feat: add rewards-info endpoint
Author: login: scasplte2
Created: 2025-05-13T18:40:28Z | Merged: 2025-05-20T23:42:20Z



Diff:
diff --git a/doc/api/dag/l0/public.yml b/doc/api/dag/l0/public.yml
index b71211e5f3..4b6b075ee1 100644
--- a/doc/api/dag/l0/public.yml
+++ b/doc/api/dag/l0/public.yml
@@ -490,6 +490,35 @@ paths:
               $ref: '../../models/headers/X-Session-Token.yml'
             Request-Signature:
               $ref: '../../models/headers/Request-Signature.yml'
+            
+  /delegated-stakes/rewards-info:
+    get:
+      tags:
+        - Delegated stakes
+      description: Get rewards configuration information
+      responses:
+        200:
+          description: Rewards configuration information
+          content:
+            application/json:
+              schema:
+                $ref: '../../models/schemas/RewardsInfo.yml'
+          headers:
+            X-Id:
+              $ref: '../../models/headers/X-Id.yml'
+            X-Session-Token:
+              $ref: '../../models/headers/X-Session-Token.yml'
+            Request-Signature:
+              $ref: '../../models/headers/Request-Signature.yml'
+        503:
+          description: Node is not Ready yet
+          headers:
+            X-Id:
+              $ref: '../../models/headers/X-Id.yml'
+            X-Session-Token:
+              $ref: '../../models/headers/X-Session-Token.yml'
+            Request-Signature:
+              $ref: '../../models/headers/Request-Signature.yml'
 
   /node-collateral:
     post:
diff --git a/doc/api/models/schemas/NextDagPrice.yml b/doc/api/models/schemas/NextDagPrice.yml
new file mode 100644
index 0000000000..963ca91054
--- /dev/null
+++ b/doc/api/models/schemas/NextDagPrice.yml
@@ -0,0 +1,13 @@
+type: object
+description: Information about the next DAG price and when it will take effect
+properties:
+  price:
+    type: integer
+    format: int64
+    minimum: 0
+    description: The next DAG price in datum/USD format
+  asOfEpoch:
+    $ref: './EpochProgress.yml'
+required:
+  - price
+  - asOfEpoch
diff --git a/doc/api/models/schemas/RewardsInfo.yml b/doc/api/models/schemas/RewardsInfo.yml
new file mode 100644
index 0000000000..e1e40e94d0
--- /dev/null
+++ b/doc/api/models/schemas/RewardsInfo.yml
@@ -0,0 +1,46 @@
+type: object
+description: Information about rewards configuration on the network for calculating APY
+properties:
+  epochsPerYear:
+    type: integer
+    format: int64
+    description: Number of epochs per year (based on current GS epoch configuration)
+  currentDagPrice:
+    type: integer
+    format: int64
+    minimum: 0
+    description: Current DAG price in datum/USD format
+  nextDagPrice:
+    $ref: './NextDagPrice.yml'
+    description: Information about the next DAG price and when it will take effect
+  totalDelegatedAmount:
+    type: integer
+    format: int64
+    description: Total amount delegated across all active delegated stakes, including accrued rewards
+  latestAverageRewardPerDag:
+    type: integer
+    format: int64
+    minimum: 0
+    description: Average reward rate per DAG per epoch in datums
+  totalDagAmount:
+    type: integer
+    format: int64
+    description: Current total supply of all DAG balances in datums
+  totalRewardsPerLatestEpoch:
+    type: integer
+    format: int64
+    minimum: 0
+    description: Total incremental rewards added in the latest epoch in datums
+  totalRewardsPerYearEstimate:
+    type: integer
+    format: int64
+    description: Total reward APY per DAG in datums (latestAverageRewardPerDag * epochsPerYear)
+required:
+  - epochsPerYear
+  - currentDagPrice
+  - nextDagPrice
+  - totalDelegatedAmount
+  - latestAverageRewardPerDag
+  - totalDagAmount
+  - totalRewardsPerLatestEpoch
+  - totalRewardsPerYearEstimate
diff --git a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
index 6c523dd949..0ec6d9b73e 100644
--- a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
+++ b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
@@ -5,25 +5,33 @@ import cats.effect.Async
 import cats.syntax.all._
 
 import scala.collection.immutable.SortedMap
+import scala.math.BigDecimal.RoundingMode
 
 import io.constellationnetwork.dag.l0.domain.delegatedStake.{CreateDelegatedStakeOutput, DelegatedStakeOutput, WithdrawDelegatedStakeOutput}
+import io.constellationnetwork.env.AppEnvironment
 import io.constellationnetwork.ext.http4s.AddressVar
 import io.constellationnetwork.kernel._
+import io.constellationnetwork.node.shared.config.types.{DelegatedRewardsConfig, EmissionConfigEntry}
 import io.constellationnetwork.node.shared.domain.delegatedStake.UpdateDelegatedStakeValidator
 import io.constellationnetwork.node.shared.domain.node.NodeStorage
 import io.constellationnetwork.node.shared.domain.snapshot.storage.SnapshotStorage
+import io.constellationnetwork.node.shared.infrastructure.snapshot.DelegatedRewardsDistributor
 import io.constellationnetwork.routes.internal._
+import io.constellationnetwork.schema.ID.Id
 import io.constellationnetwork.schema._
 import io.constellationnetwork.schema.address.Address
 import io.constellationnetwork.schema.balance.Amount
 import io.constellationnetwork.schema.delegatedStake._
 import io.constellationnetwork.schema.epoch.EpochProgress
+import io.constellationnetwork.schema.node.UpdateNodeParameters
 import io.constellationnetwork.schema.peer.PeerId
 import io.constellationnetwork.security.Hasher
 import io.constellationnetwork.security.signature.Signed
+import io.constellationnetwork.utils.DecimalUtils
+import io.constellationnetwork.utils.DecimalUtils.syntax._
 
 import eu.timepit.refined.auto._
-import eu.timepit.refined.types.numeric.NonNegLong
+import eu.timepit.refined.types.numeric.{NonNegLong, PosLong}
 import io.circe.shapes._
 import org.http4s.HttpRoutes
 import org.http4s.circe.CirceEntityCodec.circeEntityEncoder
@@ -32,12 +40,15 @@ import org.typelevel.log4cats.slf4j.Slf4jLogger
 import shapeless._
 import shapeless.syntax.singleton._
 
+import AmountOps._
+
 final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
   mkCell: DelegatedStakeOutput => Cell[F, StackF, _, Either[CellError, Ω], _],
   validator: UpdateDelegatedStakeValidator[F],
   snapshotStorage: SnapshotStorage[F, GlobalIncrementalSnapshot, GlobalSnapshotInfo],
   nodeStorage: NodeStorage[F],
-  withdrawalTimeLimit: EpochProgress
+  withdrawalTimeLimit: EpochProgress,
+  delegatedRewardsDistributor: DelegatedRewardsDistributor[F]
 ) extends Http4sDsl[F]
     with PublicRoutes[F] {
 
@@ -140,6 +151,178 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emissionConfig <- delegatedRewardsDistributor.getEmissionConfig
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emissionConfig))
+      nextPrice <- getNextDagPrice(emissionConfig, lastSnapshot)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emissionConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emissionConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommissionValue = nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        val nodeCommission = BigDecimal(nodeCommissionValue)
+
+        val delegatePortion = if (nodeCommission >= 1.0) BigDecimal(0.0) else BigDecimal(1.0) - nodeCommission
+
+        val rewardsSum = rewards.values.map(_.value.value).sum
+        val rewardsBigDecimal = BigDecimal(rewardsSum)
+
+        if (delegatePortion == BigDecimal(0.0)) acc
+        else
+          acc + (rewardsBigDecimal / delegatePortion)
+            .setScale(0, RoundingMode.HALF_UP)
+            .longValue
+    }
+
+    delegateRewards
+      .foldLeft(0L)(calcFullReward)
+      .pure[F]
+      .flatMap(toAmount)
+  }
+
+  private def calculateAverageReward(latestRewards: Amount, totalStakedAmount: Amount): F[Amount] =
+    if (totalStakedAmount.value.value === 0) Amount.empty.pure[F]
+    else
+      toAmount(
+        (BigDecimal(latestRewards.value.value) / BigDecimal(totalStakedAmount.value.value))
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+      )
+
+  private def calculateAverageRewardOverAYear(avgReward: Amount, epochsPerYear: PosLong): F[Amount] =
+    toAmount(
+      (BigDecimal(avgReward.value.value) * BigDecimal(epochsPerYear.value))
+        .setScale(0, RoundingMode.HALF_UP)
+        .longValue
+    )
+
+  private def getCurrentDagPrice(emConfig: EmissionConfigEntry): Long = {
+    val dagPrices = emConfig.dagPrices
+    if (dagPrices.isEmpty) 0L
+    else {
+      val currentPrice = dagPrices.values.headOption.getOrElse(dagPrices.head._2)
+      (currentPrice.toBigDecimal * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+    }
+  }
+
+  private def getNextDagPrice(emConfig: EmissionConfigEntry, lastSnapshot: GlobalIncrementalSnapshot): F[NextDagPrice] = {
+    val dagPrices = emConfig.dagPrices
+
+    if (dagPrices.isEmpty) {
+      PosLong
+        .from(1L)
+        .fold(
+          err => Async[F].raiseError(new IllegalArgumentException(s"Failed to create positive epoch: $err")),
+          posLong =>
+            NextDagPrice(
+              price = Amount.empty,
+              asOfEpoch = EpochProgress(posLong)
+            ).pure[F]
+        )
+    } else {
+      val sortedPrices = dagPrices.toList.sortBy(_._1.value.value)
+
+      val priceValue = (sortedPrices.head._2.toBigDecimal * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+
+      val defaultPriceF: F[NextDagPrice] =
+        if (priceValue <= 0) {
+          NextDagPrice(
+            price = Amount.empty,
+            asOfEpoch = sortedPrices.head._1
+          ).pure[F]
+        } else {
+          PosLong
+            .from(priceValue)
+            .fold(
+              err => Async[F].raiseError(new IllegalArgumentException(s"Failed to create positive price: $err")),
+              posLong =>
+                NextDagPrice(
+                  price = Amount(posLong),
+                  asOfEpoch = sortedPrices.head._1
+                ).pure[F]
+            )
+        }
+
+      val currentEpochProgress = lastSnapshot.epochProgress
+
+      val maybeNext = sortedPrices.find {
+        case (epoch, _) => epoch.value.value > currentEpochProgress.value.value
+      }
+
+      maybeNext match {
+        case Some((epoch, price)) =>
+          toAmount((price.toBigDecimal * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue).map { amt =>
+            NextDagPrice(price = amt, asOfEpoch = epoch)
+          }
+        case None => defaultPriceF
+      }
+    }
+  }
+
   protected val public: HttpRoutes[F] = HttpRoutes.of[F] {
     case req @ POST -> Root =>
       snapshotStorage.head.flatMap {
@@ -200,5 +383,17 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
           Ok(getLastReference(address, info))
         case None => ServiceUnavailable()
       }
+
+    case GET -> Root / "rewards-info" =>
+      snapshotStorage.head.flatMap {
+        case Some((snapshot, info)) =>
+          getRewardsInfo(snapshot, info)
+            .flatMap(Ok(_))
+            .handleErrorWith { error =>
+              logger.error(error)(s"Error getting rewards info: ${error.getMessage}") >>
+                InternalServerError(s"Failed to get rewards info: ${error.getMessage}")
+            }
+        case None => ServiceUnavailable()
+      }
   }
 }
diff --git a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/GlobalDelegatedRewardsDistributor.scala b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/GlobalDelegatedRewardsDistributor.scala
index 7fd036b0b8..51550aa8d4 100644
--- a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/GlobalDelegatedRewardsDistributor.scala
+++ b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/GlobalDelegatedRewardsDistributor.scala
@@ -2,6 +2,7 @@ package io.constellationnetwork.dag.l0.infrastructure.rewards
 
 import java.math.MathContext
 
+import cats.Applicative
 import cats.effect.{Async, Sync}
 import cats.syntax.all._
 
@@ -13,9 +14,11 @@ import io.constellationnetwork.node.shared.config.types._
 import io.constellationnetwork.node.shared.domain.delegatedStake.UpdateDelegatedStakeAcceptanceResult
 import io.constellationnetwork.node.shared.infrastructure.consensus.trigger.{ConsensusTrigger, EventTrigger, TimeTrigger}
 import io.constellationnetwork.node.shared.infrastructure.snapshot._
+import io.constellationnetwork.schema.AmountOps._
 import io.constellationnetwork.schema.ID.Id
 import io.constellationnetwork.schema._
 import io.constellationnetwork.schema.address.Address
+import io.constellationnetwork.schema.balance.Balance._
 import io.constellationnetwork.schema.balance.{Amount, Balance}
 import io.constellationnetwork.schema.delegatedStake.{DelegatedStakeRecord, UpdateDelegatedStake}
 import io.constellationnetwork.schema.epoch.EpochProgress
@@ -26,6 +29,7 @@ import io.constellationnetwork.security._
 import io.constellationnetwork.security.hash.Hash
 import io.constellationnetwork.security.signature.Signed
 import io.constellationnetwork.syntax.sortedCollection.sortedMapSyntax
+import io.constellationnetwork.utils.DecimalUtils
 
 import eu.timepit.refined.auto.autoUnwrap
 import eu.timepit.refined.types.all.{NonNegLong, PosLong}
@@ -39,15 +43,30 @@ object GlobalDelegatedRewardsDistributor {
   ): DelegatedRewardsDistributor[F] = new DelegatedRewardsDistributor[F] {
 
     // Define a high precision MathContext for consistent calculations
-    val mc: MathContext = new java.math.MathContext(24, java.math.RoundingMode.HALF_UP)
+    private val mc: MathContext = new java.math.MathContext(24, java.math.RoundingMode.HALF_UP)
 
-    /** Calculate the variable amount of rewards to mint for this epoch based on the config and epoch progress.
+    /** Return emission configuration applied by this rewards distributor
       */
-    def calculateVariableInflation(epochProgress: EpochProgress): F[Amount] = for {
-      emConfig <- delegatedRewardsConfig.emissionConfig
+    def getEmissionConfig: F[EmissionConfigEntry] =
+      delegatedRewardsConfig.emissionConfig
         .get(environment)
         .pure[F]
         .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+    def getDistributionProgram(epochProgress: EpochProgress): F[ProgramsDistributionConfig] =
+      delegatedRewardsConfig.percentDistribution
+        .get(environment)
+        .pure[F]
+        .flatMap(
+          Async[F]
+            .fromOption(_, new RuntimeException(s"Could not retrieve program distribution config for env: $environment"))
+        )
+        .map(f => f(epochProgress))
+
+    /** Calculate the variable amount of rewards to mint for this epoch based on the config and epoch progress.
+      */
+    def calculateVariableInflation(epochProgress: EpochProgress): F[Amount] = for {
+      emConfig <- getEmissionConfig
       result <- calculateEmissionRewards(epochProgress, emConfig)
     } yield result
 
@@ -77,19 +96,8 @@ object GlobalDelegatedRewardsDistributor {
         case TimeTrigger =>
           for {
             emitFromFunction <- calculateVariableInflation(epochProgress)
-            pctConfig <- delegatedRewardsConfig.percentDistribution
-              .get(environment)
-              .pure[F]
-              .flatMap(
-                Async[F]
-                  .fromOption(_, new RuntimeException(s"Could not retrieve program distribution config for env: $environment"))
-              )
-              .map(f => f(epochProgress))
-            epochSPerYear <- delegatedRewardsConfig.emissionConfig
-              .get(environment)
-              .map(_.epochsPerYear.value)
-              .pure[F]
-              .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+            pctConfig <- getDistributionProgram(epochProgress)
+            epochSPerYear <- getEmissionConfig.map(_.epochsPerYear.value)
             (reservedRewards, facilitatorRewardPool, delegatorRewardPool) <- calculateEmissionDistribution(
               lastSnapshotContext.activeDelegatedStakes.getOrElse(SortedMap.empty),
               emitFromFunction,
@@ -177,12 +185,12 @@ object GlobalDelegatedRewardsDistributor {
       val totalSupply = BigDecimal(emConfig.totalSupply.value, mc)
 
       if (emConfig.dagPrices.values.isEmpty) {
-        Slf4jLogger.getLogger[F].error("Empty DAG price configuration").as(Amount(NonNegLong.unsafeFrom(0L)))
+        Slf4jLogger.getLogger[F].error("Empty DAG price configuration").as(Amount.empty)
       } else {
         val dagPrices = emConfig.dagPrices
         val initialPrice = dagPrices.head._2.toBigDecimal
         val currentPrice = getCurrentDagPrice(epochProgress, dagPrices).toBigDecimal
-        val yearDiff = BigDecimal(epochProgress.value.value - transitionEpoch.toLong, mc) / epochsPerYear
+        val yearDiff = DecimalUtils.safeDivide(BigDecimal(epochProgress.value.value - transitionEpoch.toLong, mc), epochsPerYear)
 
         for {
           // Current year
@@ -214,18 +222,8 @@ object GlobalDelegatedRewardsDistributor {
           annualEmissionValue = totalSupply * annualInflationRate
 
           // Per epoch emission
-          perEpochEmissionValue = annualEmissionValue / epochsPerYear
-
-          // Convert to Amount with consistent rounding
-          emissionLong = perEpochEmissionValue.setScale(0, RoundingMode.HALF_UP).toLong
-
-          amount <- NonNegLong
-            .from(emissionLong)
-            .pure[F]
-            .map(_.leftMap(new IllegalArgumentException(_)))
-            .flatMap(Async[F].fromEither(_))
-            .map(Amount(_))
-
+          perEpochEmissionValue = DecimalUtils.safeDivide(annualEmissionValue, epochsPerYear)
+          amount <- perEpochEmissionValue.roundedHalfUp(0).toAmount[F]
         } yield amount
       }
     }
@@ -237,7 +235,7 @@ object GlobalDelegatedRewardsDistributor {
       dagPrices.filter { case (epoch, _) => epoch.value <= epochProgress.value }
         .maxByOption(_._1.value.value)
         .map(_._2)
-        .getOrElse(dagPrices.head._2) // Default to initial price if no match found
+        .getOrElse(dagPrices.head._2)
 
     private def getStakedAmount(stakeRecord: DelegatedStakeRecord): Long =
       stakeRecord.event.value.amount.value.value + stakeRecord.rewards.value
@@ -325,15 +323,9 @@ object GlobalDelegatedRewardsDistributor {
                           nodePortionOfTotalStake *
                           delegatorPortionOfNodeStake
 
-                        val rewardLong = rewardBD.setScale(0, RoundingMode.HALF_UP).toLong
-
-                        NonNegLong
-                          .from(rewardLong)
-                          .pure[F]
-                          .map(_.leftMap(new IllegalArgumentException(_)))
-                          .flatMap(Async[F].fromEither(_))
-                          .map(Amount(_))
-                          .map(rewardAmount => nodeId.toPeerId -> (address -> rewardAmount))
+                        rewardBD.roundedHalfUp(0).toAmount[F].map { rewardAmount =>
+                          nodeId.toPeerId -> (address -> rewardAmount)
+                        }
                     }
                   } yield addressRewards
               }
@@ -350,77 +342,74 @@ object GlobalDelegatedRewardsDistributor {
       lastSnapshotContext: GlobalSnapshotInfo,
       acceptedCreates: SortedMap[Address, List[(Signed[UpdateDelegatedStake.Create], SnapshotOrdinal)]] = SortedMap.empty
     ): F[SortedSet[RewardTransaction]] = {
-      val facilitatorCount = BigDecimal(facilitators.size, mc)
-      val perValidatorStaticReward =
-        if (facilitators.isEmpty) BigDecimal(0, mc)
-        else facilitatorRewardPool / facilitatorCount
-
-      val staticRewardsList = facilitators.flatMap {
-        case (addr, _) =>
-          val staticReward = perValidatorStaticReward.setScale(0, RoundingMode.HALF_UP).toLong
-          if (staticReward > 0)
-            Some(
-              RewardTransaction(
-                addr,
-                TransactionAmount(PosLong.unsafeFrom(staticReward))
-              )
-            )
-          else None
+
+      val staticRewardsF = {
+        val facilitatorCount = BigDecimal(facilitators.size, mc)
+        val perValidatorStaticReward =
+          if (facilitators.isEmpty) BigDecimal(0, mc)
+          else facilitatorRewardPool / facilitatorCount
+
+        facilitators
+          .traverse[F, Option[RewardTransaction]] {
+            case (addr, _) =>
+              val staticRewardBD = perValidatorStaticReward.roundedHalfUp(0)
+              if (staticRewardBD <= 0) Option.empty[RewardTransaction].pure[F]
+              else
+                staticRewardBD
+                  .toTransactionAmount[F]
+                  .map(RewardTransaction(addr, _).some)
+          }
+          .map(_.flatten)
       }
 
       val activeDelegatedStakes =
         lastSnapshotContext.activeDelegatedStakes.getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
 
-      if (activeDelegatedStakes.isEmpty || (delegatorRewardPool === BigDecimal(0) && facilitatorRewardPool === BigDecimal(0)))
-        SortedSet.from(staticRewardsList).pure[F]
-      else {
-        val modifiedStakes = DelegatedRewardsDistributor.identifyModifiedStakes(activeDelegatedStakes, acceptedCreates)
-        val filteredActiveDelegatedStakes = DelegatedRewardsDistributor.filterOutModifiedStakes(activeDelegatedStakes, modifiedStakes)
-        val nodeStakes = filteredActiveDelegatedStakes.values.flatten
-          .groupBy(_.event.value.nodeId.toId)
-          .view
-          .mapValues(stakes => stakes.map(s => BigDecimal(getStakedAmount(s), mc)).sum)
-          .toMap
-
-        val facilitatorStakes = facilitators.map {
-          case (_, id) =>
-            (id, nodeStakes.getOrElse(id.toId, BigDecimal(0, mc)))
-        }
-
-        val totalFacilitatorStake = facilitatorStakes.map(_._2).sum
+      if (activeDelegatedStakes.isEmpty || (delegatorRewardPool === BigDecimal(0) && facilitatorRewardPool === BigDecimal(0))) {
+        staticRewardsF.map(staticRewards => SortedSet.from(staticRewards))
+      } else {
+        for {
+          staticRewards <- staticRewardsF
+          modifiedStakes = DelegatedRewardsDistributor.identifyModifiedStakes(activeDelegatedStakes, acceptedCreates)
+          filteredActiveDelegatedStakes = DelegatedRewardsDistributor.filterOutModifiedStakes(activeDelegatedStakes, modifiedStakes)
+          nodeStakes = filteredActiveDelegatedStakes.values.flatten
+            .groupBy(_.event.value.nodeId.toId)
+            .view
+            .mapValues(stakes => stakes.map(s => BigDecimal(getStakedAmount(s), mc)).sum)
+            .toMap
+
+          facilitatorStakes = facilitators.map {
+            case (_, id) =>
+              (id, nodeStakes.getOrElse(id.toId, BigDecimal(0, mc)))
+          }
 
-        val dynamicRewardsList =
-          if (totalFacilitatorStake <= 0) List.empty
-          else {
-            facilitatorStakes.flatMap {
-              case (nodeId, stakeAmount) =>
-                if (stakeAmount <= 0) None
-                else {
-                  nodeParametersMap.get(nodeId.toId).flatMap {
-                    case (params, _) =>
-                      // RewardFraction is stored as a value from 0 to 100,000,000 representing 0% to 100%
-                      val operatorCommission = BigDecimal(params.value.delegatedStakeRewardParameters.rewardFraction, mc) /
-                        BigDecimal(100_000_000, mc)
-
-                      val stakeRatio = stakeAmount / totalFacilitatorStake
-                      val dynamicRewardBD = delegatorRewardPool * stakeRatio * operatorCommission
-                      val dynamicReward = dynamicRewardBD.setScale(0, RoundingMode.HALF_UP).toLong
-
-                      if (dynamicReward > 0)
-                        Some(
-                          RewardTransaction(
-                            params.value.source,
-                            TransactionAmount(PosLong.unsafeFrom(dynamicReward))
-                          )
-                        )
-                      else None
-                  }
+          totalFacilitatorStake = facilitatorStakes.map(_._2).sum
+
+          dynamicRewards <- facilitatorStakes.flatMap {
+            case (nodeId, stakeAmount) =>
+              if (stakeAmount <= 0) None
+              else {
+                nodeParametersMap.get(nodeId.toId).flatMap {
+                  case (params, _) =>
+                    // RewardFraction is stored as a value from 0 to 100,000,000 representing 0% to 100%
+                    val operatorCommission = BigDecimal(params.value.delegatedStakeRewardParameters.rewardFraction, mc) /
+                      BigDecimal(100_000_000, mc)
+
+                    val stakeRatio = stakeAmount / totalFacilitatorStake
+                    val dynamicRewardBD = delegatorRewardPool * stakeRatio * operatorCommission
+                    val roundedDynamicReward = dynamicRewardBD.roundedHalfUp(0)
+                    if (roundedDynamicReward <= 0) None
+                    else
+                      roundedDynamicReward
+                        .toTransactionAmount[F]
+                        .map(RewardTransaction(params.value.source, _))
+                        .some
                 }
-            }
-          }
+              }
+          }.sequence
 
-        // Combine static and dynamic rewards
-        SortedSet.from(staticRewardsList ++ dynamicRewardsList).pure[F]
+          allRewards = SortedSet.from(staticRewards ++ dynamicRewards)
+        } yield allRewards
       }
     }
 
@@ -429,17 +418,12 @@ object GlobalDelegatedRewardsDistributor {
     ): F[SortedSet[RewardTransaction]] =
       reservedRewards.traverse {
         case (addr, amt) =>
-          PosLong
-            .from(amt.setScale(0, RoundingMode.HALF_UP).toLong)
-            .leftMap(new IllegalArgumentException(_))
-            .pure[F]
-            .flatMap(Async[F].fromEither)
-            .map { posLongAmt =>
-              RewardTransaction(
-                addr,
-                TransactionAmount(posLongAmt)
-              )
-            }
+          amt.roundedHalfUp(0).toTransactionAmount[F].map { amount =>
+            RewardTransaction(
+              addr,
+              TransactionAmount(amount.value)
+            )
+          }
       }.map(SortedSet.from(_))
 
     private def calculateWithdrawalRewardTransactions(
@@ -447,20 +431,11 @@ object GlobalDelegatedRewardsDistributor {
     ): F[SortedSet[RewardTransaction]] =
       withdrawingBalances.toList.filter { case (_, amount) => amount.value.value > 0 }.traverse {
         case (address, amount) =>
-          PosLong
-            .from(amount.value.value)
-            .pure[F]
-            .map(_.leftMap(new IllegalArgumentException(_)))
-            .flatMap(Async[F].fromEither(_))
-            .map(TransactionAmount(_))
-            .map(txAmt =>
-              RewardTransaction(
-                address,
-                txAmt
-              )
-            )
-      }
-        .map(SortedSet.from(_))
+          BigDecimal(amount.value.value)
+            .roundedHalfUp(0)
+            .toTransactionAmount[F]
+            .map(RewardTransaction(address, _))
+      }.map(SortedSet.from(_))
 
     private def applyDistribution(
       lastSnapshotContext: GlobalSnapshotInfo,
diff --git a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/RewardsService.scala b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/RewardsService.scala
new file mode 100644
index 0000000000..1cd2aa1ac7
--- /dev/null
+++ b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/infrastructure/rewards/RewardsService.scala
@@ -0,0 +1,11 @@
+package io.constellationnetwork.dag.l0.infrastructure.rewards
+
+import io.constellationnetwork.dag.l0.infrastructure.snapshot.event.GlobalSnapshotEvent
+import io.constellationnetwork.node.shared.domain.rewards.Rewards
+import io.constellationnetwork.node.shared.infrastructure.snapshot.DelegatedRewardsDistributor
+import io.constellationnetwork.schema.{GlobalIncrementalSnapshot, GlobalSnapshotStateProof}
+
+case class RewardsService[F[_]](
+  classicRewards: Rewards[F, GlobalSnapshotStateProof, GlobalIncrementalSnapshot, GlobalSnapshotEvent],
+  delegatedRewards: DelegatedRewardsDistributor[F]
+)
diff --git a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/HttpApi.scala b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/HttpApi.scala
index 946e4cd763..6f0d66622d 100644
--- a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/HttpApi.scala
+++ b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/HttpApi.scala
@@ -3,6 +3,7 @@ package io.constellationnetwork.dag.l0.modules
 import java.security.PrivateKey
 
 import cats.effect.Async
+import cats.syntax.flatMap._
 import cats.syntax.option._
 import cats.syntax.semigroupk._
 
@@ -162,7 +163,8 @@ sealed abstract class HttpApi[F[_]: Async: SecurityProvider: HasherSelector: Met
         sharedValidators.updateDelegatedStakeValidator,
         storages.globalSnapshot,
         storages.node,
-        delegatedStakingWithdrawalTimeLimit
+        delegatedStakingWithdrawalTimeLimit,
+        services.rewards.delegatedRewards
       )
     }
   private val nodeCollateralsRoutes = HasherSelector[F].withCurrent { implicit hasher =>
diff --git a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/Services.scala b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/Services.scala
index 9039a74f63..d89506e873 100644
--- a/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/Services.scala
+++ b/modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/Services.scala
@@ -21,7 +21,7 @@ import io.constellationnetwork.dag.l0.infrastructure.trust.TrustStorageUpdater
 import io.constellationnetwork.json.JsonSerializer
 import io.constellationnetwork.kryo.KryoSerializer
 import io.constellationnetwork.node.shared.cli.CliMethod
-import io.constellationnetwork.node.shared.config.types.SharedConfig
+import io.constellationnetwork.node.shared.config.types.{DelegatedRewardsConfig, SharedConfig}
 import io.constellationnetwork.node.shared.domain.cluster.services.{Cluster, Session}
 import io.constellationnetwork.node.shared.domain.collateral.Collateral
 import io.constellationnetwork.node.shared.domain.gossip.Gossip
@@ -32,6 +32,7 @@ import io.constellationnetwork.node.shared.domain.snapshot.services.AddressServi
 import io.constellationnetwork.node.shared.infrastructure.collateral.Collateral
 import io.constellationnetwork.node.shared.infrastructure.metrics.Metrics
 import io.constellationnetwork.node.shared.infrastructure.node.RestartService
+import io.constellationnetwork.node.shared.infrastructure.snapshot.DelegatedRewardsDistributor
 import io.constellationnetwork.node.shared.infrastructure.snapshot.services.AddressService
 import io.constellationnetwork.node.shared.modules.{SharedServices, SharedValidators}
 import io.constellationnetwork.schema.address.Address
@@ -79,6 +80,11 @@ object Services {
           .pure[F]
       }
 
+      rewardsService = RewardsService(
+        classicRewards,
+        delegatorRewards
+      )
+
       consensus <- HasherSelector[F].withCurrent { implicit hs =>
         GlobalSnapshotConsensus
           .make[F, R](
@@ -133,7 +139,8 @@ object Services {
         collateral = collateralService,
         stateChannel = stateChannelService,
         trustStorageUpdater = trustUpdaterService,
-        restart = sharedServices.restart
+        restart = sharedServices.restart,
+        rewards = rewardsService
       ) {}
 }
 
@@ -147,5 +154,6 @@ sealed abstract class Services[F[_], R <: CliMethod] private (
   val collateral: Collateral[F],
   val stateChannel: StateChannelService[F],
   val trustStorageUpdater: TrustStorageUpdater[F],
-  val restart: RestartService[F, R]
+  val restart: RestartService[F, R],
+  val rewards: RewardsService[F]
 )
diff --git a/modules/dag-l0/src/test/scala/io/constellationnetwork/dag/l0/infrastructure/snapshot/GlobalSnapshotConsensusFunctionsSuite.scala b/modules/dag-l0/src/test/scala/io/constellationnetwork/dag/l0/infrastructure/snapshot/GlobalSnapshotConsensusFunctionsSuite.scala
index 72621f7e4f..8e311f61e0 100644
--- a/modules/dag-l0/src/test/scala/io/constellationnetwork/dag/l0/infrastructure/snapshot/GlobalSnapshotConsensusFunctionsSuite.scala
+++ b/modules/dag-l0/src/test/scala/io/constellationnetwork/dag/l0/infrastructure/snapshot/GlobalSnapshotConsensusFunctionsSuite.scala
@@ -266,11 +266,14 @@ object GlobalSnapshotConsensusFunctionsSuite extends MutableIOSuite with Checker
         Amount.empty
       )
         .pure[F]
+
+    def getEmissionConfig: GlobalSnapshotConsensusFunctionsSuite.F[EmissionConfigEntry] =
+      delegatedRewardsConfigProvider.getConfig().emissionConfig.head._2.pure[F]
   }
 
   val delegatedRewardsConfigProvider: DelegatedRewardsConfigProvider = new DelegatedRewardsConfigProvider {
-    def getConfig(): io.constellationnetwork.node.shared.config.types.DelegatedRewardsConfig =
-      io.constellationnetwork.node.shared.config.types.DelegatedRewardsConfig(
+    def getConfig(): DelegatedRewardsConfig =
+      DelegatedRewardsConfig(
         flatInflationRate = io.constellationnetwork.schema.NonNegFraction.unsafeFrom(0, 100),
         emissionConfig = Map.empty,
         percentDistribution = Map.empty
diff --git a/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/DelegatedRewardsDistributor.scala b/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/DelegatedRewardsDistributor.scala
index 80b76518b6..85de073743 100644
--- a/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/DelegatedRewardsDistributor.scala
+++ b/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/DelegatedRewardsDistributor.scala
@@ -8,7 +8,8 @@ import cats.syntax.all._
 import scala.collection.immutable.{SortedMap, SortedSet, TreeSet}
 
 import io.constellationnetwork.currency.dataApplication.DataCalculatedState
-import io.constellationnetwork.node.shared.config.types.{DelegatedRewardsConfig, SharedConfig}
+import io.constellationnetwork.env.AppEnvironment
+import io.constellationnetwork.node.shared.config.types._
 import io.constellationnetwork.node.shared.domain.delegatedStake.UpdateDelegatedStakeAcceptanceResult
 import io.constellationnetwork.node.shared.infrastructure.consensus.trigger.{ConsensusTrigger, EventTrigger, TimeTrigger}
 import io.constellationnetwork.schema.ID.Id
@@ -46,6 +47,8 @@ case class PartitionedStakeUpdates(
 
 trait DelegatedRewardsDistributor[F[_]] {
 
+  def getEmissionConfig: F[EmissionConfigEntry]
+
   def calculateVariableInflation(epochProgress: EpochProgress): F[Amount]
 
   def distribute(
diff --git a/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/GlobalSnapshotContextFunctions.scala b/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/GlobalSnapshotContextFunctions.scala
index 2431e7b1e7..2b977780dd 100644
--- a/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/GlobalSnapshotContextFunctions.scala
+++ b/modules/node-shared/src/main/scala/io/constellationnetwork/node/shared/infrastructure/snapshot/GlobalSnapshotContextFunctions.scala
@@ -35,6 +35,7 @@ import eu.timepit.refined.types.all.NonNegLong
 abstract class GlobalSnapshotContextFunctions[F[_]] extends SnapshotContextFunctions[F, GlobalIncrementalSnapshot, GlobalSnapshotInfo]
 
 object GlobalSnapshotContextFunctions {
+
   def make[F[_]: Async: Parallel: HasherSelector: SecurityProvider](
     snapshotAcceptanceManager: GlobalSnapshotAcceptanceManager[F],
     updateDelegatedStakeAcceptanceManager: UpdateDelegatedStakeAcceptanceManager[F],
diff --git a/modules/shared/src/main/scala/io/constellationnetwork/schema/AmountOps.scala b/modules/shared/src/main/scala/io/constellationnetwork/schema/AmountOps.scala
new file mode 100644
index 0000000000..7c8f2da22a
--- /dev/null
+++ b/modules/shared/src/main/scala/io/constellationnetwork/schema/AmountOps.scala
@@ -0,0 +1,137 @@
+package io.constellationnetwork.schema
+
+import cats.syntax.all._
+import cats.{Applicative, MonadThrow}
+
+import scala.math.BigDecimal.RoundingMode
+
+import io.constellationnetwork.schema.balance.Amount
+import io.constellationnetwork.schema.transaction.TransactionAmount
+import io.constellationnetwork.utils.DecimalUtils
+
+import eu.timepit.refined.types.numeric.{NonNegLong, PosLong}
+
+/** Extension methods for Amount to provide more functional operations.
+  */
+object AmountOps {
+
+  /** Extension methods for Amount.
+    */
+  implicit class AmountOps(val amount: Amount) {
+
+    /** Safely divides this amount by another amount.
+      *
+      * @param divisor
+      *   The amount to divide by
+      * @param defaultValue
+      *   The default value to return if divisor is zero
+      * @return
+      *   The result of division, or defaultValue if divisor is zero
+      */
+    def safeDivideBy(divisor: Amount, defaultValue: BigDecimal = BigDecimal(0)): BigDecimal =
+      if (divisor.value.value == 0) defaultValue
+      else BigDecimal(amount.value.value) / BigDecimal(divisor.value.value)
+
+    /** Converts this amount to a BigDecimal.
+      *
+      * @return
+      *   The amount as a BigDecimal
+      */
+    def toBigDecimal: BigDecimal = BigDecimal(amount.value.value)
+
+    /** Multiplies this amount by a scalar value.
+      *
+      * @param scalar
+      *   The scalar value to multiply by
+      * @return
+      *   The product as a BigDecimal
+      */
+    def times(scalar: BigDecimal): BigDecimal = toBigDecimal * scalar
+
+    /** Rounds this amount to the specified number of decimal places.
+      *
+      * @param scale
+      *   The number of decimal places
+      * @param mode
+      *   The rounding mode (defaults to HALF_UP)
+      * @return
+      *   The rounded value as a BigDecimal
+      */
+    def roundedHalfUp(scale: Int, mode: BigDecimal.RoundingMode.Value = RoundingMode.HALF_UP): BigDecimal =
+      toBigDecimal.setScale(scale, mode)
+  }
+
+  /** Extension methods for BigDecimal to convert to Amount and provide additional utility methods.
+    */
+  implicit class BigDecimalOps(val value: BigDecimal) extends AnyVal {
+
+    /** Converts this BigDecimal to an Amount.
+      *
+      * @param F
+      *   The monad with error handling
+      * @return
+      *   The value as an Amount wrapped in F
+      */
+    def toAmount[F[_]: MonadThrow]: F[Amount] =
+      if (value <= 0) Amount.empty.pure[F]
+      else
+        MonadThrow[F].fromEither(
+          NonNegLong
+            .from(value.setScale(0, RoundingMode.HALF_UP).longValue)
+            .bimap(
+              err => new IllegalArgumentException(s"Failed to create non-negative amount: $err"),
+              nonNegLong => Amount(nonNegLong)
+            )
+        )
+
+    /** Converts this BigDecimal to a TransactionAmount.
+      *
+      * @param F
+      *   The monad with error handling
+      * @return
+      *   The value as a TransactionAmount wrapped in F
+      */
+    def toTransactionAmount[F[_]: MonadThrow]: F[TransactionAmount] =
+      if (value <= 0) MonadThrow[F].raiseError(new IllegalArgumentException("Transaction amount must be positive"))
+      else
+        MonadThrow[F].fromEither(
+          PosLong
+            .from(value.setScale(0, RoundingMode.HALF_UP).longValue)
+            .bimap(
+              err => new IllegalArgumentException(s"Failed to create positive transaction amount: $err"),
+              posLong => TransactionAmount(posLong)
+            )
+        )
+
+    /** Converts this BigDecimal to datum format.
+      *
+      * @return
+      *   The value in datum format
+      */
+    def toDatumFormat: Long = DecimalUtils.toDatumFormat(value)
+
+    /** Safely divides this BigDecimal by another.
+      *
+      * @param divisor
+      *   The divisor
+      * @param defaultValue
+      *   The default value to return if divisor is zero
+      * @return
+      *   The result of division, or defaultValue if divisor is zero
+      */
+    def safeDivideBy(divisor: BigDecimal, defaultValue: BigDecimal = BigDecimal(0)): BigDecimal =
+      DecimalUtils.safeDivide(value, divisor, defaultValue)
+
+    /** Rounds this BigDecimal to the specified number of decimal places.
+      *
+      * @param scale
+      *   The number of decimal places
+      * @param mode
+      *   The rounding mode (defaults to HALF_UP)
+      * @return
+      *   The rounded value
+      */
+    def roundedHalfUp(scale: Int, mode: BigDecimal.RoundingMode.Value = RoundingMode.HALF_UP): BigDecimal =
+      DecimalUtils.round(value, scale, mode)
+  }
+}
diff --git a/modules/shared/src/main/scala/io/constellationnetwork/schema/delegatedStake.scala b/modules/shared/src/main/scala/io/constellationnetwork/schema/delegatedStake.scala
index 9fcf678b13..f2334e4d49 100644
--- a/modules/shared/src/main/scala/io/constellationnetwork/schema/delegatedStake.scala
+++ b/modules/shared/src/main/scala/io/constellationnetwork/schema/delegatedStake.scala
@@ -25,7 +25,7 @@ import derevo.circe.magnolia.{decoder, encoder}
 import derevo.derive
 import eu.timepit.refined.auto.{autoRefineV, _}
 import eu.timepit.refined.cats._
-import eu.timepit.refined.types.numeric.NonNegLong
+import eu.timepit.refined.types.numeric.{NonNegLong, PosLong}
 import io.estatico.newtype.macros.newtype
 
 object delegatedStake {
@@ -144,4 +144,22 @@ object delegatedStake {
   sealed trait DelegatedStakeError extends NoStackTrace
   case class MissingDelegatedStaking(message: String) extends DelegatedStakeError
   case class MissingTokenLock(message: String) extends DelegatedStakeError
+
+  @derive(encoder, decoder, eqv, show)
+  case class NextDagPrice(
+    price: Amount,
+    asOfEpoch: EpochProgress
+  )
+
+  @derive(encoder, decoder, eqv, show)
+  case class RewardsInfo(
+    epochsPerYear: PosLong,
+    currentDagPrice: Amount,
+    nextDagPrice: NextDagPrice,
+    totalDelegatedAmount: Amount,
+    latestAverageRewardPerDag: Amount,
+    totalDagAmount: Amount,
+    totalRewardPerEpoch: Amount,
+    totalRewardsPerYearEstimate: Amount
+  )
 }
diff --git a/modules/shared/src/main/scala/io/constellationnetwork/utils/DecimalUtils.scala b/modules/shared/src/main/scala/io/constellationnetwork/utils/DecimalUtils.scala
new file mode 100644
index 0000000000..fe30b00a8f
--- /dev/null
+++ b/modules/shared/src/main/scala/io/constellationnetwork/utils/DecimalUtils.scala
@@ -0,0 +1,113 @@
+package io.constellationnetwork.utils
+
+import scala.math.BigDecimal.RoundingMode
+
+/** Utility class for decimal operations and conversions. Provides consistent handling of decimal values across the codebase.
+  */
+object DecimalUtils {
+
+  /** Conversion factor from USD to datum format. 1 USD = 100,000,000 datums (8 decimal places)
+    */
+  val DATUM_USD: Long = 100000000L
+
+  /** Converts a BigDecimal value to datum format (long value).
+    *
+    * @param value
+    *   The BigDecimal value to convert
+    * @return
+    *   The value in datum format (multiplied by DATUM_USD)
+    */
+  def toDatumFormat(value: BigDecimal): Long =
+    (value * DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+
+  /** Converts a datum format value to BigDecimal.
+    *
+    * @param value
+    *   The datum format value to convert
+    * @return
+    *   The value as BigDecimal (divided by DATUM_USD)
+    */
+  def fromDatumFormat(value: Long): BigDecimal =
+    BigDecimal(value) / DATUM_USD
+
+  /** Performs safe division, handling division by zero gracefully.
+    *
+    * @param numerator
+    *   The numerator
+    * @param denominator
+    *   The denominator
+    * @param defaultValue
+    *   The default value to return if denominator is zero
+    * @return
+    *   The result of division, or defaultValue if denominator is zero
+    */
+  def safeDivide(numerator: BigDecimal, denominator: BigDecimal, defaultValue: BigDecimal = BigDecimal(0)): BigDecimal =
+    if (denominator == 0) defaultValue else numerator / denominator
+
+  /** Rounds a BigDecimal value to the specified number of decimal places.
+    *
+    * @param value
+    *   The value to round
+    * @param scale
+    *   The number of decimal places
+    * @param mode
+    *   The rounding mode (defaults to HALF_UP)
+    * @return
+    *   The rounded value
+    */
+  def round(value: BigDecimal, scale: Int, mode: BigDecimal.RoundingMode.Value = RoundingMode.HALF_UP): BigDecimal =
+    value.setScale(scale, mode)
+
+  /** Syntax extensions for BigDecimal to provide more functional operations.
+    */
+  object syntax {
+
+    /** Extension methods for BigDecimal.
+      */
+    implicit class BigDecimalOps(val value: BigDecimal) extends AnyVal {
+
+      /** Converts this BigDecimal to datum format.
+        *
+        * @return
+        *   The value in datum format
+        */
+      def toDatumFormat: Long = DecimalUtils.toDatumFormat(value)
+
+      /** Safely divides this BigDecimal by another.
+        *
+        * @param divisor
+        *   The divisor
+        * @param defaultValue
+        *   The default value to return if divisor is zero
+        * @return
+        *   The result of division, or defaultValue if divisor is zero
+        */
+      def safeDivideBy(divisor: BigDecimal, defaultValue: BigDecimal = BigDecimal(0)): BigDecimal =
+        DecimalUtils.safeDivide(value, divisor, defaultValue)
+
+      /** Rounds this BigDecimal to the specified number of decimal places.
+        *
+        * @param scale
+        *   The number of decimal places
+        * @param mode
+        *   The rounding mode (defaults to HALF_UP)
+        * @return
+        *   The rounded value
+        */
+      def roundedHalfUp(scale: Int, mode: BigDecimal.RoundingMode.Value = RoundingMode.HALF_UP): BigDecimal =
+        DecimalUtils.round(value, scale, mode)
+    }
+
+    /** Extension methods for Long to convert from datum format.
+      */
+    implicit class LongOps(val value: Long) extends AnyVal {
+
+      /** Converts this Long from datum format to BigDecimal.
+        *
+        * @return
+        *   The value as BigDecimal
+        */
+      def fromDatumFormat: BigDecimal = DecimalUtils.fromDatumFormat(value)
+    }
+  }
+}
diff --git a/modules/shared/src/test/scala/io/constellationnetwork/schema/AmountOpsSuite.scala b/modules/shared/src/test/scala/io/constellationnetwork/schema/AmountOpsSuite.scala
new file mode 100644
index 0000000000..c970b05e31
--- /dev/null
+++ b/modules/shared/src/test/scala/io/constellationnetwork/schema/AmountOpsSuite.scala
@@ -0,0 +1,117 @@
+package io.constellationnetwork.schema
+
+import cats.effect.IO
+import cats.syntax.applicative._
+
+import io.constellationnetwork.schema.AmountOps.{AmountOps => AmountOpsClass, BigDecimalOps}
+import io.constellationnetwork.schema.balance.Amount
+
+import eu.timepit.refined.types.numeric.NonNegLong
+import org.scalacheck.Gen
+import weaver.SimpleIOSuite
+import weaver.scalacheck.Checkers
+
+object AmountOpsSuite extends SimpleIOSuite with Checkers {
+
+  // Use smaller values to avoid overflow when adding two large longs
+  val nonNegativeLongGen: Gen[Long] = Gen.chooseNum(0L, 1000000L)
+  val positiveAmountGen: Gen[Amount] = nonNegativeLongGen.map(l => Amount(NonNegLong.unsafeFrom(l)))
+  val anyDecimalGen: Gen[BigDecimal] = Gen.chooseNum(-1000.0, 1000.0).map(BigDecimal(_))
+  val positiveDecimalGen: Gen[BigDecimal] = Gen.posNum[Double].map(BigDecimal(_))
+  val scaleGen: Gen[Int] = Gen.chooseNum(0, 10)
+
+  test("plus should add two amounts") {
+    // Use smaller values to avoid overflow
+    val smallPositiveAmountGen = Gen.chooseNum(0L, 1000L).map(l => Amount(NonNegLong.unsafeFrom(l)))
+    val gen = for {
+      a1 <- smallPositiveAmountGen
+      a2 <- smallPositiveAmountGen
+    } yield (a1, a2)
+
+    forall(gen) { pair =>
+      val (a1, a2) = pair
+      for {
+        result <- IO.fromEither(a1.plus(a2))
+        expected = Amount(NonNegLong.unsafeFrom(a1.value.value + a2.value.value))
+      } yield expect.eql(expected, result)
+    }
+  }
+
+  test("safeDivideBy should handle division by zero") {
+    forall(positiveAmountGen) { amount =>
+      for {
+        zeroAmount <- Amount.empty.pure[IO]
+        result = amount.safeDivideBy(zeroAmount)
+      } yield expect.eql(BigDecimal(0), result)
+    }
+  }
+
+  test("safeDivideBy should perform normal division when divisor is not zero") {
+    // Use a fixed non-empty amount for the divisor to avoid discarding too many values
+    val nonEmptyAmount = Amount(NonNegLong.unsafeFrom(100L))
+
+    forall(positiveAmountGen) { a1 =>
+      for {
+        result <- new AmountOpsClass(a1).safeDivideBy(nonEmptyAmount).pure[IO]
+        expected = BigDecimal(a1.value.value) / BigDecimal(nonEmptyAmount.value.value)
+      } yield expect.eql(expected, result)
+    }
+  }
+
+  test("toBigDecimal should convert amount to BigDecimal") {
+    forall(positiveAmountGen) { amount =>
+      for {
+        result <- new AmountOpsClass(amount).toBigDecimal.pure[IO]
+        expected = BigDecimal(amount.value.value)
+      } yield expect.eql(expected, result)
+    }
+  }
+
+  test("times should multiply amount by scalar") {
+    val gen = for {
+      amount <- positiveAmountGen
+      scalar <- anyDecimalGen
+    } yield (amount, scalar)
+
+    forall(gen) { pair =>
+      val (amount, scalar) = pair
+      for {
+        result <- new AmountOpsClass(amount).times(scalar).pure[IO]
+        expected = BigDecimal(amount.value.value) * scalar
+      } yield expect.eql(expected, result)
+    }
+  }
+
+  test("rounded should round to the specified scale") {
+    val gen = for {
+      amount <- positiveAmountGen
+      scale <- scaleGen
+    } yield (amount, scale)
+
+    forall(gen) { pair =>
+      val (amount, scale) = pair
+      for {
+        result <- new AmountOpsClass(amount).roundedHalfUp(scale).pure[IO]
+        expected = BigDecimal(amount.value.value).setScale(scale, scala.math.BigDecimal.RoundingMode.HALF_UP)
+      } yield expect.eql(expected, result)
+    }
+  }
+
+  test("toAmount should convert positive BigDecimal to Amount") {
+    forall(positiveDecimalGen) { value =>
+      for {
+        result <- new BigDecimalOps(value).toAmount[IO]
+        expected = Amount(NonNegLong.unsafeFrom(value.setScale(0, scala.math.BigDecimal.RoundingMode.HALF_UP).longValue))
+      } yield expect.eql(expected, result)
+    }
+  }
+
+  test("toAmount should return empty Amount for non-positive BigDecimal") {
+    // Use a fixed non-positive value to avoid discarding too many values
+    val nonPositiveValue = BigDecimal(-1.0)
+
+    for {
+      result <- new BigDecimalOps(nonPositiveValue).toAmount[IO]
+    } yield expect.eql(Amount.empty, result)
+  }
+}
diff --git a/modules/shared/src/test/scala/io/constellationnetwork/utils/DecimalUtilsSuite.scala b/modules/shared/src/test/scala/io/constellationnetwork/utils/DecimalUtilsSuite.scala
new file mode 100644
index 0000000000..d51e0c1b49
--- /dev/null
+++ b/modules/shared/src/test/scala/io/constellationnetwork/utils/DecimalUtilsSuite.scala
@@ -0,0 +1,165 @@
+package io.constellationnetwork.utils
+
+import cats.effect.IO
+import cats.syntax.all._
+
+import scala.math.BigDecimal.RoundingMode
+
+import org.scalacheck.Gen
+import weaver.scalacheck.Checkers
+import weaver.{Expectations, SimpleIOSuite}
+
+object DecimalUtilsSuite extends SimpleIOSuite with Checkers {
+
+  // Generators for test data
+  val nonNegativeDecimalGen: Gen[BigDecimal] = Gen.choose(0.0, 1000000.0).map(BigDecimal(_))
+  val zeroDecimalGen: Gen[BigDecimal] = Gen.const(BigDecimal(0))
+  val positiveDecimalGen: Gen[BigDecimal] = Gen.choose(0.000001, 1000000.0).map(BigDecimal(_))
+  val negativeDecimalGen: Gen[BigDecimal] = Gen.choose(-1000000.0, -0.000001).map(BigDecimal(_))
+  val anyDecimalGen: Gen[BigDecimal] = Gen.choose(-1000000.0, 1000000.0).map(BigDecimal(_))
+  val smallDecimalGen: Gen[BigDecimal] = Gen.choose(0.0000001, 0.01).map(BigDecimal(_))
+  val largeDecimalGen: Gen[BigDecimal] = Gen.choose(1000000.0, 10000000.0).map(BigDecimal(_))
+  val scaleGen: Gen[Int] = Gen.choose(0, 10)
+  val nonZeroDecimalGen: Gen[BigDecimal] = anyDecimalGen.suchThat(_ != 0)
+  val datumLongGen: Gen[Long] = Gen.choose(1L, Long.MaxValue / 100) // Avoid overflow
+
+  def testWithSample[A](genSample: Option[A])(testFn: A => Expectations): IO[Expectations] =
+    genSample.fold(IO.pure(failure("Failed to generate test data")))(a => IO.pure(testFn(a)))
+
+  def expectGen[A](gen: Gen[A])(f: A => Expectations): IO[Expectations] =
+    gen.sample.traverse(a => IO.pure(f(a))).map(_.getOrElse(failure("Failed to generate test data")))
+
+  test("toDatumFormat should convert zero correctly") {
+    expect.eql(0L, DecimalUtils.toDatumFormat(BigDecimal(0))).pure[IO]
+  }
+
+  test("toDatumFormat should convert positive BigDecimal to datum format") {
+    expectGen(positiveDecimalGen) { value =>
+      val result = DecimalUtils.toDatumFormat(value)
+      val expected = (value * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+      expect.eql(expected, result)
+    }
+  }
+
+  test("toDatumFormat should convert negative BigDecimal to datum format") {
+    expectGen(negativeDecimalGen) { value =>
+      val result = DecimalUtils.toDatumFormat(value)
+      val expected = (value * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+      expect.eql(expected, result)
+    }
+  }
+
+  test("toDatumFormat should handle very small decimals") {
+    expectGen(smallDecimalGen) { value =>
+      val result = DecimalUtils.toDatumFormat(value)
+      val expected = (value * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+      expect.eql(expected, result)
+    }
+  }
+
+  test("toDatumFormat should handle large values safely") {
+    val safeMax = (Long.MaxValue / DecimalUtils.DATUM_USD.toDouble) * 0.9
+    val safeGen = Gen.choose(1.0, safeMax).map(BigDecimal(_))
+
+    expectGen(safeGen) { value =>
+      val result = DecimalUtils.toDatumFormat(value)
+      val expected = (value * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+      expect.eql(expected, result)
+    }
+  }
+
+  test("fromDatumFormat should convert zero correctly") {
+    expect.eql(BigDecimal(0), DecimalUtils.fromDatumFormat(0L)).pure[IO]
+  }
+
+  test("fromDatumFormat should convert positive datum format to BigDecimal") {
+    expectGen(Gen.posNum[Long]) { value =>
+      val result = DecimalUtils.fromDatumFormat(value)
+      val expected = BigDecimal(value) / DecimalUtils.DATUM_USD
+      expect.eql(expected, result)
+    }
+  }
+
+  test("fromDatumFormat should convert negative datum format to BigDecimal") {
+    expectGen(Gen.negNum[Long]) { value =>
+      val result = DecimalUtils.fromDatumFormat(value)
+      val expected = BigDecimal(value) / DecimalUtils.DATUM_USD
+      expect.eql(expected, result)
+    }
+  }
+
+  test("toDatumFormat and fromDatumFormat should be inverse operations for positive values") {
+    expectGen(positiveDecimalGen) { value =>
+      // Use precision that's less than the maximum precision of DATUM_USD
+      val scaledValue = value.setScale(6, RoundingMode.HALF_UP)
+      val datum = DecimalUtils.toDatumFormat(scaledValue)
+      val roundTrip = DecimalUtils.fromDatumFormat(datum)
+
+      // Due to potential rounding during conversion, we need to check within a small epsilon
+      // The epsilon is relative to the scale of DATUM_USD (8 decimal places)
+      val epsilon = BigDecimal(1) / (DecimalUtils.DATUM_USD / 2)
+      expect((scaledValue - roundTrip).abs <= epsilon)
+    }
+  }
+
+  test("toDatumFormat and fromDatumFormat should be inverse operations for negative values") {
+    expectGen(negativeDecimalGen) { value =>
+      val scaledValue = value.setScale(6, RoundingMode.HALF_UP)
+      val datum = DecimalUtils.toDatumFormat(scaledValue)
+      val roundTrip = DecimalUtils.fromDatumFormat(datum)
+      val epsilon = BigDecimal(1) / (DecimalUtils.DATUM_USD / 2)
+      expect((scaledValue - roundTrip).abs <= epsilon)
+    }
+  }
+
+  test("toDatumFormat should handle the maximum representable value without overflow") {
+    val maxSafeValue = BigDecimal(Long.MaxValue) / DecimalUtils.DATUM_USD
+    // Use a value slightly less than max to avoid overflow
+    val testValue = maxSafeValue * 0.9
+
+    // This should not throw an exception
+    expect(DecimalUtils.toDatumFormat(testValue) > 0).pure[IO]
+  }
+
+  test("toDatumFormat should handle maximum precision") {
+    // Test with a value that uses all 8 decimal places
+    val value = BigDecimal("0.00000001") // 8 decimal places
+    val result = DecimalUtils.toDatumFormat(value)
+    IO.pure(expect.eql(1L, result)) // Should convert to 1 datum
+  }
+
+  test("fromDatumFormat should preserve precision") {
+    val datumValue = 1L
+    val result = DecimalUtils.fromDatumFormat(datumValue)
+    val expected = BigDecimal("0.00000001") // 8 decimal places
+    IO.pure(expect.eql(expected, result))
+  }
+
+  test("safeDivide should perform normal division when denominator is not zero") {
+    val gen = for {
+      numerator <- anyDecimalGen
+      denominator <- nonZeroDecimalGen
+    } yield (numerator, denominator)
+
+    expectGen(gen) {
+      case (numerator, denominator) =>
+        val result = DecimalUtils.safeDivide(numerator, denominator)
+        val expected = numerator / denominator
+        expect.eql(expected, result)
+    }
+  }
+
+  test("round should round to the specified scale") {
+    val gen = for {
+      value <- anyDecimalGen
+      scale <- scaleGen
+    } yield (value, scale)
+
+    expectGen(gen) {
+      case (value, scale) =>
+        val result = DecimalUtils.round(value, scale)
+        val expected = value.setScale(scale, RoundingMode.HALF_UP)
+        expect.eql(expected, result)
+    }
+  }
+}


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

Added a new rewards-info endpoint to provide network APY calculation data and DAG price information, with comprehensive reward configuration details.

- Added `/delegated-stakes/rewards-info` endpoint returning epochs per year, current/next DAG prices, total delegated amounts, and reward rates
- Implemented NextDagPrice schema to track future price changes with price and asOfEpoch fields
- Added RewardsInfo schema with detailed APY calculation metrics including total supply, rewards per epoch, and yearly estimates
- Modified GlobalDelegatedRewardsDistributor to handle empty DAG price configurations using Amount.empty
- Added RewardsService to Services class to encapsulate classic and delegated rewards functionality

<sub>💡 (2/5) Greptile learns from your feedback when you react with 👍/👎!</sub>

<sub>8 file(s) reviewed, 7 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=tessellation_1143)</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-13T18:42:14Z
user: login: ryle-goehausen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T22:41:11Z
user: login: ryle-goehausen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T05:30:09Z
user: login: scasplte2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:26:24Z
user: login: greptile-apps[bot]
body: 
## PR Summary
(updates since last review)


Added test coverage and error handling improvements for the rewards-info endpoint, focusing on delegated staking functionality and transaction validation.

- Added third validator node testing in `delegated-staking.js` to verify multi-node reward distribution
- Improved error handling in `lib.js` with retries and better error messages for network operations
- Added verification that other stakes remain unchanged during updates/withdrawals in test scripts
- Reduced transaction sleep time from 120s to 30s in `currency.js` for more efficient testing
- Added proper error handling for division operations and collection access in `DelegatedStakesRoutes.scala`

<sub>16 file(s) reviewed, 2 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=tessellation_1143)</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-05-15T15:28:08Z
user: login: scasplte2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:39:53Z
user: login: scasplte2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T15:42:53Z
user: login: scasplte2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T21:14:12Z
user: login: scasplte2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T21:17:15Z
user: login: ryle-goehausen
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T20:07:08Z

Review comments:
diff_hunk: @@ -0,0 +1,12 @@
+type: object
+description: Information about the next DAG price and when it will take effect
+properties:
+  price:
+    type: integer
+    format: int64
path: doc/api/models/schemas/NextDagPrice.yml
user: login: greptile-apps[bot]
body: style: Consider adding minimum value constraint (&gt;= 0) since DAG price cannot be negative

```suggestion
    type: integer
    format: int64
    minimum: 0
```
created_at: 2025-05-13T18:41:00Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+type: object
+description: Information about rewards configuration on the network for calculating APY
+properties:
+  epochsPerYear:
+    type: integer
+    format: int64
+    description: Number of epochs per year (based on current GS epoch configuration)
+  currentDagPrice:
+    type: integer
+    format: int64
+    description: Current DAG price (in the same format as used internally in configuration)
path: doc/api/models/schemas/RewardsInfo.yml
user: login: greptile-apps[bot]
body: style: Consider adding minimum value constraint (&gt;= 0) since DAG price cannot be negative

```suggestion
    type: integer
    format: int64
    minimum: 0
    description: Current DAG price (in the same format as used internally in configuration)
```
created_at: 2025-05-13T18:41:00Z
author_association: NONE
diff_hunk: @@ -0,0 +1,43 @@
+type: object
+description: Information about rewards configuration on the network for calculating APY
+properties:
+  epochsPerYear:
+    type: integer
+    format: int64
+    description: Number of epochs per year (based on current GS epoch configuration)
+  currentDagPrice:
+    type: integer
+    format: int64
+    description: Current DAG price (in the same format as used internally in configuration)
+  nextDagPrice:
+    $ref: './NextDagPrice.yml'
+    description: Information about the next DAG price and when it will take effect
+  totalDelegatedAmount:
+    type: integer
+    format: int64
+    description: Total amount delegated across all active delegated stakes, including accrued rewards
+  latestAverageRewardPerDag:
+    type: integer
+    format: int64
+    description: Average reward rate per DAG per epoch in datums
path: doc/api/models/schemas/RewardsInfo.yml
user: login: greptile-apps[bot]
body: style: Consider adding minimum value constraint (&gt;= 0) for reward rate

```suggestion
    type: integer
    format: int64
    minimum: 0
    description: Average reward rate per DAG per epoch in datums
```
created_at: 2025-05-13T18:41:00Z
author_association: NONE
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommission = BigDecimal(
+          nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        )
+
+        val delegatePortion = 1 - nodeCommission
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: greptile-apps[bot]
body: logic: potential division by zero if nodeCommission is 1.0

```suggestion
        val delegatePortion = if (nodeCommission >= 1.0) 0.0 else 1.0 - nodeCommission
```
created_at: 2025-05-13T18:41:08Z
author_association: NONE
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommission = BigDecimal(
+          nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        )
+
+        val delegatePortion = 1 - nodeCommission
+
+        acc + (BigDecimal(rewards.values.map(_.value.value).sum) / delegatePortion)
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+    }
+
+    delegateRewards
+      .foldLeft(0L)(calcFullReward)
+      .pure[F]
+      .flatMap(toAmount)
+  }
+
+  private def calculateAverageReward(latestRewards: Amount, totalStakedAmount: Amount): F[Amount] =
+    if (totalStakedAmount.value.value === 0) Amount.empty.pure[F]
+    else
+      toAmount(
+        (BigDecimal(latestRewards.value.value) / BigDecimal(totalStakedAmount.value.value))
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+      )
+
+  private def calculateAverageRewardOverAYear(avgReward: Amount, epochsPerYear: PosLong): F[Amount] =
+    toAmount(
+      (BigDecimal(avgReward.value.value) * BigDecimal(epochsPerYear.value))
+        .setScale(0, RoundingMode.HALF_UP)
+        .longValue
+    )
+
+  private def getCurrentDagPrice(emConfig: EmissionConfigEntry): Long = {
+    val dagPrices = emConfig.dagPrices
+    val currentPrice = dagPrices.values.headOption.getOrElse(dagPrices.head._2)
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: greptile-apps[bot]
body: logic: dagPrices.head._2 access could throw if dagPrices is empty
created_at: 2025-05-13T18:41:08Z
author_association: NONE
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommission = BigDecimal(
+          nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        )
+
+        val delegatePortion = 1 - nodeCommission
+
+        acc + (BigDecimal(rewards.values.map(_.value.value).sum) / delegatePortion)
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+    }
+
+    delegateRewards
+      .foldLeft(0L)(calcFullReward)
+      .pure[F]
+      .flatMap(toAmount)
+  }
+
+  private def calculateAverageReward(latestRewards: Amount, totalStakedAmount: Amount): F[Amount] =
+    if (totalStakedAmount.value.value === 0) Amount.empty.pure[F]
+    else
+      toAmount(
+        (BigDecimal(latestRewards.value.value) / BigDecimal(totalStakedAmount.value.value))
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+      )
+
+  private def calculateAverageRewardOverAYear(avgReward: Amount, epochsPerYear: PosLong): F[Amount] =
+    toAmount(
+      (BigDecimal(avgReward.value.value) * BigDecimal(epochsPerYear.value))
+        .setScale(0, RoundingMode.HALF_UP)
+        .longValue
+    )
+
+  private def getCurrentDagPrice(emConfig: EmissionConfigEntry): Long = {
+    val dagPrices = emConfig.dagPrices
+    val currentPrice = dagPrices.values.headOption.getOrElse(dagPrices.head._2)
+    (currentPrice.toBigDecimal * 100000000).longValue // Convert to datum format
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: greptile-apps[bot]
body: style: magic number 100000000 should be defined as a constant with a descriptive name
created_at: 2025-05-13T18:41:09Z
author_association: NONE
diff_hunk: @@ -122,6 +123,12 @@ object Services {
         )
       getOrdinal = storages.globalSnapshot.headSnapshot.map(_.map(_.ordinal))
       trustUpdaterService = TrustStorageUpdater.make(getOrdinal, sharedServices.gossip, storages.trust)
+
+      rewardsService = RewardsService(
+        classicRewards,
+        delegatorRewards,
+        DefaultDelegatedRewardsConfigProvider.getConfig()
+      )
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/Services.scala
user: login: greptile-apps[bot]
body: logic: Potential issue: DefaultDelegatedRewardsConfigProvider.getConfig() is called twice - once here and once in line 78. Consider reusing the config instance to ensure consistency.
created_at: 2025-05-13T18:41:37Z
author_association: NONE
diff_hunk: @@ -0,0 +1,12 @@
+type: object
+description: Information about the next DAG price and when it will take effect
+properties:
+  price:
+    type: integer
+    format: int64
+    description: The next DAG price (in the same format as used internally in configuration)
path: doc/api/models/schemas/NextDagPrice.yml
user: login: ryle-goehausen
body: Since this is user facing API docs, I would explicitly describe the format. I think we decided on datum/USD? 
created_at: 2025-05-13T22:13:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+type: object
+description: Information about rewards configuration on the network for calculating APY
+properties:
+  epochsPerYear:
+    type: integer
+    format: int64
+    description: Number of epochs per year (based on current GS epoch configuration)
+  currentDagPrice:
+    type: integer
+    format: int64
+    description: Current DAG price (in the same format as used internally in configuration)
path: doc/api/models/schemas/RewardsInfo.yml
user: login: ryle-goehausen
body: Same here
created_at: 2025-05-13T22:14:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+type: object
+description: Information about rewards configuration on the network for calculating APY
+properties:
+  epochsPerYear:
+    type: integer
+    format: int64
+    description: Number of epochs per year (based on current GS epoch configuration)
+  currentDagPrice:
+    type: integer
+    format: int64
+    description: Current DAG price (in the same format as used internally in configuration)
+  nextDagPrice:
+    $ref: './NextDagPrice.yml'
+    description: Information about the next DAG price and when it will take effect
+  totalDelegatedAmount:
+    type: integer
+    format: int64
+    description: Total amount delegated across all active delegated stakes, including accrued rewards
+  latestAverageRewardPerDag:
+    type: integer
+    format: int64
+    description: Average reward rate per DAG per epoch in datums
+  totalDagAmount:
+    type: integer
+    format: int64
+    description: Current total supply of all DAG balances in datums
+  totalRewardPerEpoch:
path: doc/api/models/schemas/RewardsInfo.yml
user: login: ryle-goehausen
body: Realizing now this might not be the most descriptive name (but it's same as in ticket.) Maybe a better name here would be lastTotalIncrementalRewards or something like that? Or we leave it as is, and communicate it as a "rate" of change. Not sure actually.
created_at: 2025-05-13T22:30:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: ryle-goehausen
body: Maybe worth a longer discussion at some point on reducing the load on this API by pre-calculating this, but since we're doing this everywhere else probably not worth implementing here.
created_at: 2025-05-13T22:35:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: ryle-goehausen
body: I don't think this method should be a part of this class, for instance, it's missing balances from AllowSpends. It makes way more sense to declare a method on top of the state to sum the balances.
created_at: 2025-05-13T22:37:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommission = BigDecimal(
+          nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        )
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: ryle-goehausen
body: Similar issue here as above, where this method (or conversion,) should be associated with the class itself, such that we get back a decimal related to the class instance (and can therefore see how it was originally declared.

I'm also wondering if this should just be wrapped in the other fraction type which does the conversion there. It doesn't feel great having decimal logic encapsulated in places like this.
created_at: 2025-05-13T22:39:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommission = BigDecimal(
+          nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        )
+
+        val delegatePortion = 1 - nodeCommission
+
+        acc + (BigDecimal(rewards.values.map(_.value.value).sum) / delegatePortion)
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+    }
+
+    delegateRewards
+      .foldLeft(0L)(calcFullReward)
+      .pure[F]
+      .flatMap(toAmount)
+  }
+
+  private def calculateAverageReward(latestRewards: Amount, totalStakedAmount: Amount): F[Amount] =
+    if (totalStakedAmount.value.value === 0) Amount.empty.pure[F]
+    else
+      toAmount(
+        (BigDecimal(latestRewards.value.value) / BigDecimal(totalStakedAmount.value.value))
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: ryle-goehausen
body: Similar thing, it'd be great if we can hide the decimal interactions behind an API we control so it's more strictly verifiable.
created_at: 2025-05-13T22:40:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,5 +345,17 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
           Ok(getLastReference(address, info))
         case None => ServiceUnavailable()
       }
+
+    case GET -> Root / "rewards-info" =>
+      snapshotStorage.head.flatMap {
+        case Some((snapshot, info)) =>
+          getRewardsInfo(snapshot, info)
+            .flatMap(Ok(_))
+            .handleErrorWith { error =>
+              logger.error(error)(s"Error getting rewards info: ${error.getMessage}") >>
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: ryle-goehausen
body: Maybe over-reaching here, but it'd be nice to track failures like this in a metric, so we don't have to scan logs.
created_at: 2025-05-13T22:40:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,12 @@
+type: object
+description: Information about the next DAG price and when it will take effect
+properties:
+  price:
+    type: integer
+    format: int64
+    description: The next DAG price (in the same format as used internally in configuration)
path: doc/api/models/schemas/NextDagPrice.yml
user: login: ryle-goehausen
body: This is now changed to DAG/USD but as a fraction, we need the doc here to correspond to whatever fractional basis format that we're planning to return here (Or whatever it's presently represented as.)
created_at: 2025-05-15T05:30:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -122,6 +123,12 @@ object Services {
         )
       getOrdinal = storages.globalSnapshot.headSnapshot.map(_.map(_.ordinal))
       trustUpdaterService = TrustStorageUpdater.make(getOrdinal, sharedServices.gossip, storages.trust)
+
+      rewardsService = RewardsService(
+        classicRewards,
+        delegatorRewards,
+        DefaultDelegatedRewardsConfigProvider.getConfig()
+      )
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/modules/Services.scala
user: login: scasplte2
body: refactored approach
created_at: 2025-05-15T15:26:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -490,6 +490,35 @@ paths:
               $ref: '../../models/headers/X-Session-Token.yml'
             Request-Signature:
               $ref: '../../models/headers/Request-Signature.yml'
+            
+  /delegated-stakes/rewards-info:
+    get:
+      tags:
+        - Delegated stakes
+      description: Get rewards configuration information
path: doc/api/dag/l0/public.yml
user: login: greptile-apps[bot]
body: style: Description could be more detailed about what configuration information is returned
created_at: 2025-05-15T15:26:25Z
author_association: NONE
diff_hunk: @@ -140,6 +150,177 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emissionConfig <- delegatedRewardsDistributor.getEmissionConfig
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emissionConfig))
+      nextPrice <- getNextDagPrice(emissionConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emissionConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emissionConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
+    ).mapN(Tuple4.apply)
+  }
+
+  private def getLatestDelegateRewardTotal(snapshot: GlobalIncrementalSnapshot, info: GlobalSnapshotInfo): F[Amount] = {
+    val delegateRewards = snapshot.delegateRewards
+      .getOrElse(SortedMap.empty[PeerId, Map[Address, Amount]])
+
+    val nodeParams = info.updateNodeParameters
+      .getOrElse(SortedMap.empty[ID.Id, (Signed[UpdateNodeParameters], SnapshotOrdinal)])
+
+    val calcFullReward: (Long, (PeerId, Map[Address, Amount])) => Long = {
+      case (acc, (peerId, rewards)) =>
+        val nodeCommissionValue = nodeParams.get(peerId.toId).map(_._1.delegatedStakeRewardParameters.reward).getOrElse(0.0)
+        val nodeCommission = BigDecimal(nodeCommissionValue)
+
+        val delegatePortion = if (nodeCommission >= 1.0) BigDecimal(0.0) else BigDecimal(1.0) - nodeCommission
+
+        val rewardsSum = rewards.values.map(_.value.value).sum
+        val rewardsBigDecimal = BigDecimal(rewardsSum)
+
+        if (delegatePortion == BigDecimal(0.0)) acc
+        else
+          acc + (rewardsBigDecimal / delegatePortion)
+            .setScale(0, RoundingMode.HALF_UP)
+            .longValue
+    }
+
+    delegateRewards
+      .foldLeft(0L)(calcFullReward)
+      .pure[F]
+      .flatMap(toAmount)
+  }
+
+  private def calculateAverageReward(latestRewards: Amount, totalStakedAmount: Amount): F[Amount] =
+    if (totalStakedAmount.value.value === 0) Amount.empty.pure[F]
+    else
+      toAmount(
+        (BigDecimal(latestRewards.value.value) / BigDecimal(totalStakedAmount.value.value))
+          .setScale(0, RoundingMode.HALF_UP)
+          .longValue
+      )
+
+  private def calculateAverageRewardOverAYear(avgReward: Amount, epochsPerYear: PosLong): F[Amount] =
+    toAmount(
+      (BigDecimal(avgReward.value.value) * BigDecimal(epochsPerYear.value))
+        .setScale(0, RoundingMode.HALF_UP)
+        .longValue
+    )
+
+  private def getCurrentDagPrice(emConfig: EmissionConfigEntry): Long = {
+    val dagPrices = emConfig.dagPrices
+    if (dagPrices.isEmpty) 0L
+    else {
+      val currentPrice = dagPrices.values.headOption.getOrElse(dagPrices.head._2)
+      (currentPrice.toBigDecimal * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+    }
+  }
+
+  private def getNextDagPrice(emConfig: EmissionConfigEntry): F[NextDagPrice] = {
+    val dagPrices = emConfig.dagPrices
+
+    if (dagPrices.isEmpty) {
+      PosLong
+        .from(1L)
+        .fold(
+          err => Async[F].raiseError(new IllegalArgumentException(s"Failed to create positive epoch: $err")),
+          posLong =>
+            NextDagPrice(
+              price = Amount.empty,
+              asOfEpoch = EpochProgress(posLong)
+            ).pure[F]
+        )
+    } else {
+      val sortedPrices = dagPrices.toList.sortBy(_._1.value.value)
+
+      val priceValue = (sortedPrices.head._2.toBigDecimal * DecimalUtils.DATUM_USD).setScale(0, RoundingMode.HALF_UP).longValue
+
+      val defaultPriceF: F[NextDagPrice] =
+        if (priceValue <= 0) {
+          NextDagPrice(
+            price = Amount.empty,
+            asOfEpoch = sortedPrices.head._1
+          ).pure[F]
+        } else {
+          PosLong
+            .from(priceValue)
+            .fold(
+              err => Async[F].raiseError(new IllegalArgumentException(s"Failed to create positive price: $err")),
+              posLong =>
+                NextDagPrice(
+                  price = Amount(posLong),
+                  asOfEpoch = sortedPrices.head._1
+                ).pure[F]
+            )
+        }
+
+      val maybeNext = sortedPrices.dropWhile {
+        case (epoch, _) =>
+          epoch.value.value <= sortedPrices.head._1.value.value
+      }.headOption
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: greptile-apps[bot]
body: logic: dropWhile condition will never match anything except the first element since it's comparing against itself (sortedPrices.head._1.value.value)
created_at: 2025-05-15T15:26:59Z
author_association: NONE
diff_hunk: @@ -0,0 +1,12 @@
+type: object
+description: Information about the next DAG price and when it will take effect
+properties:
+  price:
+    type: integer
+    format: int64
+    description: The next DAG price (in the same format as used internally in configuration)
path: doc/api/models/schemas/NextDagPrice.yml
user: login: scasplte2
body: this has been updated to datum/usd but if we want to support the more rigorous Price typing that was discussed on the call, then perhaps we can incorporate those changes here once the types are defined.
created_at: 2025-05-15T15:39:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+type: object
+description: Information about rewards configuration on the network for calculating APY
+properties:
+  epochsPerYear:
+    type: integer
+    format: int64
+    description: Number of epochs per year (based on current GS epoch configuration)
+  currentDagPrice:
+    type: integer
+    format: int64
+    description: Current DAG price (in the same format as used internally in configuration)
path: doc/api/models/schemas/RewardsInfo.yml
user: login: scasplte2
body: updated to datum/usd with the assumption of the minimum supported price of 1 datum/usd. As above, I think we could revisit this with the updated types
created_at: 2025-05-15T15:42:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -200,5 +345,17 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
           Ok(getLastReference(address, info))
         case None => ServiceUnavailable()
       }
+
+    case GET -> Root / "rewards-info" =>
+      snapshotStorage.head.flatMap {
+        case Some((snapshot, info)) =>
+          getRewardsInfo(snapshot, info)
+            .flatMap(Ok(_))
+            .handleErrorWith { error =>
+              logger.error(error)(s"Error getting rewards info: ${error.getMessage}") >>
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: scasplte2
body: I agree that metrics in ieu of logging will be preferreed
created_at: 2025-05-16T21:14:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,6 +147,144 @@ final case class DelegatedStakesRoutes[F[_]: Async: Hasher](
       .traverse { case DelegatedStakeRecord(delegatedStaking, _, _) => DelegatedStakeReference.of(delegatedStaking) }
       .map(_.getOrElse(DelegatedStakeReference.empty))
 
+  private def toAmount(value: Long): F[Amount] =
+    if (value == 0L) Amount.empty.pure[F]
+    else
+      PosLong
+        .from(value)
+        .pure[F]
+        .map(_.leftMap(new IllegalArgumentException(_)))
+        .flatMap(Async[F].fromEither(_))
+        .map(Amount(_))
+
+  private def getRewardsInfo(
+    lastSnapshot: GlobalIncrementalSnapshot,
+    lastSnapshotInfo: GlobalSnapshotInfo
+  ): F[RewardsInfo] =
+    for {
+      emConfig <- delegatedRewardsConfig.emissionConfig
+        .get(environment)
+        .pure[F]
+        .flatMap(Async[F].fromOption(_, new RuntimeException(s"Could not retrieve emission config for env: $environment")))
+
+      (_, _, totalDelegateStake, currentTotalSupply) <- processDelegations(lastSnapshotInfo)
+      latestDelegateRewardsNoCommission <- getLatestDelegateRewardTotal(lastSnapshot, lastSnapshotInfo)
+
+      currentPrice <- toAmount(getCurrentDagPrice(emConfig))
+      nextPrice <- getNextDagPrice(emConfig)
+
+      avgRewardAmount <- calculateAverageReward(latestDelegateRewardsNoCommission, totalDelegateStake)
+      totalRewardsPerYear <- calculateAverageRewardOverAYear(avgRewardAmount, emConfig.epochsPerYear)
+
+    } yield
+      RewardsInfo(
+        epochsPerYear = emConfig.epochsPerYear,
+        currentDagPrice = currentPrice,
+        nextDagPrice = nextPrice,
+        totalDelegatedAmount = totalDelegateStake,
+        latestAverageRewardPerDag = avgRewardAmount,
+        totalDagAmount = currentTotalSupply,
+        totalRewardPerEpoch = latestDelegateRewardsNoCommission,
+        totalRewardsPerYearEstimate = totalRewardsPerYear
+      )
+
+  private def processDelegations(info: GlobalSnapshotInfo): F[(Amount, Amount, Amount, Amount)] = {
+    val activeDelegatedStakes = info.activeDelegatedStakes
+      .getOrElse(SortedMap.empty[Address, List[DelegatedStakeRecord]])
+
+    val pendingWithdrawals = info.delegatedStakesWithdrawals
+      .getOrElse(SortedMap.empty[Address, List[PendingDelegatedStakeWithdrawal]])
+
+    val totalSpendableSupply = info.balances.values.map(_.value.value).sum
+    val totalPendingSupply = pendingWithdrawals.values.flatten.map(_.rewards.value.value).sum
+
+    val (totalStakeLocked, totalActiveRewards) = activeDelegatedStakes.values.flatten.foldLeft((0L, 0L)) {
+      case ((stakeAcc, rewardsAcc), record) =>
+        val stakeAmount = record.event.value.amount.value.value
+        val rewardsAmount = record.rewards.value
+        (stakeAcc + stakeAmount, rewardsAcc + rewardsAmount)
+    }
+
+    (
+      toAmount(totalStakeLocked),
+      toAmount(totalActiveRewards),
+      toAmount(totalStakeLocked + totalActiveRewards), // totalDelegateStake
+      toAmount(totalSpendableSupply + totalPendingSupply + totalActiveRewards) // currentTotalSupply
path: modules/dag-l0/src/main/scala/io/constellationnetwork/dag/l0/http/routes/DelegatedStakesRoutes.scala
user: login: scasplte2
body: I can see this being useful somewhere else but it is not clear where we ight introduce this or how it could be useful in other sscenarios (in practice, in theory I agree we should reuse instead of repeat)
created_at: 2025-05-16T21:17:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-13T18:41:12Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "eea602bfff2321b855eddab9d52572b4d886823f"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-13T18:41:39Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "eea602bfff2321b855eddab9d52572b4d886823f"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-14T21:26:39Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "6152eb76e8d7401475cbea2775333b644cd837d3"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-15T15:25:09Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "6152eb76e8d7401475cbea2775333b644cd837d3"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: scasplte2
created_at: 2025-05-15T15:25:10Z
body: @greptileai please review again
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-16T20:23:39Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "6152eb76e8d7401475cbea2775333b644cd837d3"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T21:07:44Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "6152eb76e8d7401475cbea2775333b644cd837d3"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-16T21:35:12Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "6152eb76e8d7401475cbea2775333b644cd837d3"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T12:30:50Z
body: #### Terraform Format and Style 🖌`success`
#### Terraform Initialization ⚙️`success`
#### Terraform Plan 📖`success`
#### Terraform Validation 🤖`success`

<details><summary>Show Plan</summary>

```

terraform
module.bucket.aws_s3_bucket.apidoc: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_public_access_block.apidoc_public_access: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_dag_node_owner.null_resource.openapi: Refreshing state... [id=8960330066915993723]
module.bucket.aws_s3_bucket_website_configuration.apidoc_website: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l0.null_resource.openapi: Refreshing state... [id=3852364900351614681]
module.openapi_dag_node_public_l0.null_resource.openapi: Refreshing state... [id=1032322096694497247]
module.openapi_currency_node_public_l1_data.null_resource.openapi: Refreshing state... [id=3712149455435820928]
module.openapi_dag_node_public_l1.null_resource.openapi_static: Refreshing state... [id=3221763807190760223]
module.openapi_currency_node_public_l0.null_resource.openapi_static: Refreshing state... [id=7464736124404180355]
module.openapi_currency_node_public_l1.null_resource.openapi_static: Refreshing state... [id=8817818484422920738]
module.openapi_currency_node_owner.null_resource.openapi: Refreshing state... [id=5196494804680077150]
module.openapi_dag_node_owner.null_resource.openapi_static: Refreshing state... [id=8743539175090766498]
module.openapi_dag_node_public_l1.null_resource.openapi: Refreshing state... [id=8430299915080520849]
module.openapi_dag_node_public_l0.null_resource.openapi_static: Refreshing state... [id=6979997329551285806]
module.openapi_currency_node_owner.null_resource.openapi_static: Refreshing state... [id=4854377841430251531]
module.openapi_currency_node_public_l1_data.null_resource.openapi_static: Refreshing state... [id=4205831403476034193]
module.bucket.aws_s3_bucket_ownership_controls.apidoc_ownership: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.bucket.aws_s3_bucket_policy.apidoc_policy: Refreshing state... [id=apidoc-dev.constellationnetwork.io]
module.openapi_currency_node_public_l1.null_resource.openapi: Refreshing state... [id=3276962260901982590]
module.bucket.aws_s3_bucket_acl.apidoc_acl: Refreshing state... [id=apidoc-dev.constellationnetwork.io,public-read]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.openapi_dag_node_public_l0.null_resource.openapi must be replaced
-/+ resource "null_resource" "openapi" {
      ~ id       = "1032322096694497247" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "openapi_spec_sha1" = "60c2189b7500d11958a47eec702919eee96a6f8d" -> "6152eb76e8d7401475cbea2775333b644cd837d3"
            # (2 unchanged elements hidden)
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

```

</details>

*Pusher: @scasplte2, Action: `pull_request`*
author_association: NONE