diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/ConfigTransforms.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/ConfigTransforms.scala index c760d20745..09f61da17a 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/ConfigTransforms.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/ConfigTransforms.scala @@ -804,6 +804,11 @@ object ConfigTransforms { def withDevelopmentFundManager(fundManager: PartyId): ConfigTransform = updateAllSvAppFoundDsoConfigs_(c => c.copy(developmentFundManager = Some(fundManager))) + def withRewardConfig( + rewardConfig: InitialRewardConfig + ): ConfigTransform = + updateAllSvAppFoundDsoConfigs_(c => c.copy(initialRewardConfig = Some(rewardConfig))) + private def portTransform(bump: Int, c: AdminServerConfig): AdminServerConfig = c.copy(internalPort = c.internalPort.map(_ + bump)) diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala index 73250d955d..10ab4e68ec 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/SpliceConfig.scala @@ -536,6 +536,8 @@ object SpliceConfig { deriveReader[InitialAnsConfig] implicit val domainFeesConfigReader: ConfigReader[SynchronizerFeesConfig] = deriveReader[SynchronizerFeesConfig] + implicit val initialRewardConfigReader: ConfigReader[InitialRewardConfig] = + deriveReader[InitialRewardConfig] implicit val svOnboardingFoundDsoReader: ConfigReader[SvOnboardingConfig.FoundDso] = deriveReader[SvOnboardingConfig.FoundDso] implicit val svOnboardingJoinWithKeyReader: ConfigReader[SvOnboardingConfig.JoinWithKey] = @@ -995,6 +997,8 @@ object SpliceConfig { deriveWriter[InitialAnsConfig] implicit val domainFeesConfigWriter: ConfigWriter[SynchronizerFeesConfig] = deriveWriter[SynchronizerFeesConfig] + implicit val initialRewardConfigWriter: ConfigWriter[InitialRewardConfig] = + deriveWriter[InitialRewardConfig] implicit val svOnboardingFoundDsoWriter: ConfigWriter[SvOnboardingConfig.FoundDso] = deriveWriter[SvOnboardingConfig.FoundDso] implicit val svOnboardingJoinWithKeyWriter: ConfigWriter[SvOnboardingConfig.JoinWithKey] = diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala index 75158391ae..bbcfbaef33 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TrafficBasedRewardsTimeBasedIntegrationTest.scala @@ -13,10 +13,12 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.{ } import org.lfdecentralizedtrust.splice.console.WalletAppClientReference import org.lfdecentralizedtrust.splice.codegen.java.splice.testing.apps.tradingapp +import org.lfdecentralizedtrust.splice.config.ConfigTransforms import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{ ConfigurableApp, updateAutomationConfig, } +import org.lfdecentralizedtrust.splice.sv.config.InitialRewardConfig import org.lfdecentralizedtrust.splice.http.v0.definitions import definitions.DamlValueEncoding.members.CompactJson import definitions.GetRewardAccountingActivityTotalsResponse @@ -71,6 +73,15 @@ class TrafficBasedRewardsTimeBasedIntegrationTest _.withPausedTrigger[RewardComputationTrigger] )(config) ) + .addConfigTransform((_, config) => + ConfigTransforms.withRewardConfig( + InitialRewardConfig( + mintingVersion = TrafficBasedRewardsTimeBasedIntegrationTest.trafficBasedAppRewards, + appRewardCouponThreshold = + TrafficBasedRewardsTimeBasedIntegrationTest.appRewardCouponThreshold, + ) + )(config) + ) "App activity records are created for featured app parties" in { implicit env => val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) @@ -190,23 +201,36 @@ class TrafficBasedRewardsTimeBasedIntegrationTest assertNoAppActivity(event, "updateId1") } - clue("updateId2") { - val event = fetchEvent(updateId2, "updateId2") - assertTrafficSummary(event, "updateId2") - assertAppActivity(event, "updateId2", Set(venueParty), expectedRound = 5) - } - - clue("updateId3") { - val event = fetchEvent(updateId3, "updateId3") - assertTrafficSummary(event, "updateId3") - assertAppActivity(event, "updateId3", Set(venueParty, aliceParty), expectedRound = 6) - } + // Expected featured app providers per round — used for both event-level + // activity assertions and reward pipeline provider assertions. + val expectedProvidersByRound: Map[Long, Set[PartyId]] = Map( + 5L -> Set(venueParty), + 6L -> Set(venueParty, aliceParty), + 7L -> Set(aliceParty), + ) - clue("updateId4") { - val event = fetchEvent(updateId4, "updateId4") - assertTrafficSummary(event, "updateId4") - assertAppActivity(event, "updateId4", Set(aliceParty), expectedRound = 7) - } + // Capture per-round traffic costs for reward pipeline assertions. + // Each round has exactly one settlement in this test. + val trafficCostByRound: Map[Long, Long] = Map( + 5L -> clue("updateId2") { + val event = fetchEvent(updateId2, "updateId2") + assertTrafficSummary(event, "updateId2") + assertAppActivity(event, "updateId2", expectedProvidersByRound(5L), expectedRound = 5) + event.trafficSummary.value.totalTrafficCost + }, + 6L -> clue("updateId3") { + val event = fetchEvent(updateId3, "updateId3") + assertTrafficSummary(event, "updateId3") + assertAppActivity(event, "updateId3", expectedProvidersByRound(6L), expectedRound = 6) + event.trafficSummary.value.totalTrafficCost + }, + 7L -> clue("updateId4") { + val event = fetchEvent(updateId4, "updateId4") + assertTrafficSummary(event, "updateId4") + assertAppActivity(event, "updateId4", expectedProvidersByRound(7L), expectedRound = 7) + event.trafficSummary.value.totalTrafficCost + }, + ) clue("Alice-submitted create TransferInstruction has app activity for alice") { val event = fetchEvent(aliceCreateId, "aliceCreateId") @@ -240,6 +264,11 @@ class TrafficBasedRewardsTimeBasedIntegrationTest e.value } + val expectedProviders = expectedProvidersByRound.getOrElse( + earliest, + fail(s"No expected providers for earliest round $earliest"), + ) + clue("Verify activity totals for the computed round") { val totals = inside(sv1ScanBackend.getRewardAccountingActivityTotals(earliest)) { case GetRewardAccountingActivityTotalsResponse.members @@ -248,6 +277,13 @@ class TrafficBasedRewardsTimeBasedIntegrationTest } totals.roundNumber shouldBe earliest totals.activityRecordsCount should be > 0L + totals.activePartiesCount shouldBe expectedProviders.size.toLong + totals.totalAppActivityWeight should be > 0L + // The total weight must be at least as large as the traffic cost from the + // test's known settlement, since that settlement contributes activity records + // to the round (other background transactions may also contribute). + val roundTrafficCost = trafficCostByRound(earliest) + totals.totalAppActivityWeight should be >= roundTrafficCost } clue("Verify root hash is available") { @@ -258,12 +294,25 @@ class TrafficBasedRewardsTimeBasedIntegrationTest rootHash.rootHash should have length 64 // hex-encoded SHA-256 } - clue("Verify batch lookup for root hash returns batch contents") { + clue("Verify batch contains expected providers with non-zero amounts") { val rootHashHex = inside(sv1ScanBackend.getRewardAccountingRootHash(earliest)) { case GetRewardAccountingRootHashResponse.members.RewardAccountingRootHashOk(h) => h.rootHash } - sv1ScanBackend.getRewardAccountingBatch(earliest, rootHashHex) shouldBe defined + val batch = sv1ScanBackend.getRewardAccountingBatch(earliest, rootHashHex) + batch shouldBe defined + batch.value match { + case definitions.GetRewardAccountingBatchResponse.members + .RewardAccountingBatchOfMintingAllowances(allowances) => + val providers = allowances.mintingAllowances.map(_.provider).toSet + providers shouldBe expectedProviders.map(_.toProtoPrimitive) + allowances.mintingAllowances.foreach { ma => + BigDecimal(ma.amount) should be > BigDecimal(0) + } + case definitions.GetRewardAccountingBatchResponse.members + .RewardAccountingBatchOfBatches(batches) => + batches.childHashes should not be empty + } } clue("Verify response for non-existent data") { @@ -543,3 +592,13 @@ class TrafficBasedRewardsTimeBasedIntegrationTest new allocationv1.Allocation.ContractId(allocation.contractId.contractId) } } + +object TrafficBasedRewardsTimeBasedIntegrationTest { + + // Use traffic-based app rewards (CIP-0104), not on-ledger coupon counting. + val trafficBasedAppRewards = "RewardVersion_TrafficBasedAppRewards" + + // Set to zero so no rewards are filtered out in this test. + // In production this would be a small USD amount (e.g. 0.5). + val appRewardCouponThreshold = BigDecimal(0.0) +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceUtil.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceUtil.scala index e787165308..0b2719e263 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceUtil.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceUtil.scala @@ -367,6 +367,7 @@ object SpliceUtil { developmentFundManager: Option[PartyId] = None, initialExternalPartyConfigStateTickDuration: Option[NonNegativeFiniteDuration] = None, optValidatorFaucetCap: Option[BigDecimal] = None, + initialRewardConfig: Option[splice.amuletconfig.RewardConfig] = None, ): splice.amuletconfig.AmuletConfig[splice.amuletconfig.USD] = new splice.amuletconfig.AmuletConfig( // transferConfig @@ -395,7 +396,7 @@ object SpliceUtil { initialExternalPartyConfigStateTickDuration .map(t => new RelTime(TimeUnit.NANOSECONDS.toMicros(t.duration.toNanos))) .toJava, - Optional.empty(), // rewardConfig + initialRewardConfig.toJava, ) def defaultAnsConfig( diff --git a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/StoreTestBase.scala b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/StoreTestBase.scala index 090557663c..6749848417 100644 --- a/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/StoreTestBase.scala +++ b/apps/common/src/test/scala/org/lfdecentralizedtrust/splice/store/StoreTestBase.scala @@ -34,6 +34,9 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.{ schedule as scheduleCodegen, validatorlicense as validatorLicenseCodegen, } +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.{ + rewardaccountingv2 as rewardAccountingCodegen +} import org.lfdecentralizedtrust.splice.environment.{BaseLedgerConnection, DarResource, DarResources} import org.lfdecentralizedtrust.splice.environment.ledger.api.{ ActiveContract, @@ -331,6 +334,25 @@ abstract class StoreTestBase ) } + protected def calculateRewardsV2( + dso: PartyId, + round: Long, + dryRun: Boolean = true, + ) = { + val template = new rewardAccountingCodegen.CalculateRewardsV2( + dso.toProtoPrimitive, + new Round(round), + Instant.now().truncatedTo(ChronoUnit.MICROS), + new RelTime(600_000_000L), + dryRun, + ) + contract( + rewardAccountingCodegen.CalculateRewardsV2.TEMPLATE_ID_WITH_PACKAGE_ID, + new rewardAccountingCodegen.CalculateRewardsV2.ContractId(nextCid()), + template, + ) + } + protected def amulet( owner: PartyId, amount: BigDecimal, diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala index 152ecb40f6..1c4f5dbc58 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/ScanApp.scala @@ -391,6 +391,7 @@ class ScanApp( config.parameters.defaultLimit, ) automation.registerRewardsReferenceStoreIngestion(rewardsStore) + automation.registerRewardComputationTrigger(rewardsStore) Some(rewardsStore) } else None verdictAutomation = new ScanVerdictAutomationService( diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTrigger.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTrigger.scala index e09fbe23c8..3a5428e241 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTrigger.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTrigger.scala @@ -11,13 +11,19 @@ import org.lfdecentralizedtrust.splice.automation.{ TaskSuccess, TriggerContext, } +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 import org.lfdecentralizedtrust.splice.scan.metrics.RewardComputationMetrics import org.lfdecentralizedtrust.splice.scan.rewards.RewardComputationInputs -import org.lfdecentralizedtrust.splice.scan.store.{AppActivityStore, ScanAppRewardsStore} +import org.lfdecentralizedtrust.splice.scan.store.{ + AppActivityStore, + ScanAppRewardsStore, + ScanRewardsReferenceStore, +} import org.lfdecentralizedtrust.splice.store.UpdateHistory import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, SyncCloseable} import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.tracing.TraceContext +import io.grpc.Status import io.opentelemetry.api.trace.Tracer import scala.concurrent.{ExecutionContext, Future} @@ -28,12 +34,11 @@ import scala.concurrent.{ExecutionContext, Future} * 1. Aggregate activity totals from app activity records * 2. Compute reward totals (CC minting allowances with threshold filtering) * 3. Build the Merkle tree of batched reward hashes - * - * TODO(#4383): use ScanRewardsReferenceStore for synchronization */ class RewardComputationTrigger( appRewardsStore: ScanAppRewardsStore, appActivityStore: AppActivityStore, + rewardsReferenceStore: ScanRewardsReferenceStore, updateHistory: UpdateHistory, override protected val context: TriggerContext, )(implicit @@ -56,20 +61,26 @@ class RewardComputationTrigger( Future.successful(Seq.empty) } else for { + // List active CalculateRewardsV2 contracts, ascending by round + // and filter out + // - rounds where the rewards are already computed + // - rounds with incomplete activity + candidates <- rewardsReferenceStore.listActiveCalculateRewardsV2() + roundToContract = candidates.map(c => c.payload.round.number.toLong -> c.contractId).toMap + candidateRounds = roundToContract.keys.toSeq.sorted + computedRounds <- appRewardsStore.roundsWithComputedRewards(candidateRounds) + afterComputedFilter = candidateRounds.filterNot(computedRounds.contains) earliestCompleteO <- appActivityStore.earliestRoundWithCompleteAppActivity() latestCompleteO <- appActivityStore.latestRoundWithCompleteAppActivity() - latestComputedO <- appRewardsStore.lookupLatestRoundWithRewardComputation() + eligible = (earliestCompleteO, latestCompleteO) match { + case (Some(earliest), Some(latest)) => + afterComputedFilter.filter(r => r >= earliest && r <= latest) + case _ => Seq.empty[Long] + } - // TODO(#4383): obtain inputs and batchSize from the appropriate Contracts - inputs <- Future.successful(RewardComputationTrigger.placeholderInputs) - batchSize <- Future.successful(RewardComputationTrigger.placeholderBatchSize) - } yield RewardComputationTrigger.nextTask( - earliestCompleteO, - latestCompleteO, - latestComputedO, - batchSize, - inputs, - ) + // Look up OpenMiningRound for each eligible round and extract inputs. + tasks <- Future.traverse(eligible)(r => buildTask(r, roundToContract(r))) + } yield tasks } override protected def completeTask( @@ -88,12 +99,43 @@ class RewardComputationTrigger( ) } + private def buildTask( + roundNumber: Long, + contractId: CalculateRewardsV2.ContractId, + )(implicit tc: TraceContext): Future[RewardComputationTrigger.Task] = + rewardsReferenceStore.lookupOpenMiningRoundByNumber(roundNumber).map { + case None => + throw Status.INTERNAL + .withDescription( + s"Round $roundNumber has a CalculateRewardsV2 contract and complete activity " + + s"but its OpenMiningRound is not in the rewards reference store." + ) + .asRuntimeException() + case Some(contract) => + val (inputs, batchSize) = + RewardComputationInputs.fromOpenMiningRound(contract.payload).getOrElse { + throw Status.INTERNAL + .withDescription( + s"Round $roundNumber has a CalculateRewardsV2 contract but its " + + s"OpenMiningRound is missing rewardConfig or trafficPrice." + ) + .asRuntimeException() + } + RewardComputationTrigger.Task(roundNumber, batchSize, inputs, contractId) + } + override protected def isStaleTask( task: RewardComputationTrigger.Task )(implicit tc: TraceContext): Future[Boolean] = - appRewardsStore - .lookupLatestRoundWithRewardComputation() - .map(_.exists(_ >= task.roundNumber)) + rewardsReferenceStore.multiDomainAcsStore + .lookupContractById(CalculateRewardsV2.COMPANION)(task.calculateRewardsId) + .flatMap { + case None => Future.successful(true) + case Some(_) => + appRewardsStore + .roundsWithComputedRewards(Seq(task.roundNumber)) + .map(_.nonEmpty) + } override def closeAsync(): Seq[AsyncOrSyncCloseable] = super.closeAsync() :+ @@ -106,53 +148,13 @@ object RewardComputationTrigger { roundNumber: Long, batchSize: Int, inputs: RewardComputationInputs, + calculateRewardsId: CalculateRewardsV2.ContractId, ) extends PrettyPrinting { + import com.digitalasset.canton.participant.pretty.Implicits.prettyContractId override def pretty: Pretty[this.type] = - prettyOfClass(param("roundNumber", _.roundNumber)) - } - - /** Compute the next round to process, given the bounds of complete activity data - * and the latest round for which rewards have already been computed. - * Returns at most one task. - * - * TODO(#4570): Support parallel execution - */ - def nextTask( - earliestCompleteO: Option[Long], - latestCompleteO: Option[Long], - latestComputedO: Option[Long], - batchSize: Int, - inputs: RewardComputationInputs, - ): Seq[Task] = - (earliestCompleteO, latestCompleteO) match { - case (Some(earliestComplete), Some(latestComplete)) => - val start = math.max(earliestComplete, latestComputedO.fold(0L)(_ + 1)) - if (start <= latestComplete) - Seq(Task(start, batchSize, inputs)) - else Seq.empty - case _ => Seq.empty - } - - // TODO(#4383): Remove this once it is obtained from the appropriate Contract - private[scan] val placeholderBatchSize: Int = 100 - - // TODO(#4383): Remove this once the values are obtained from the appropriate Contract - // These placeholder values are from MainNet DSO config: - // - // (Round 89782: Checked in RewardComputationInputsTest). - private[scan] val placeholderInputs: RewardComputationInputs = { - import RewardComputationInputs.{fromBigDecimal as n} - val tickDurationMicros: Long = 600L * 1000000L - RewardComputationInputs( - amuletToIssuePerYear = n(BigDecimal("10000000000")), - appRewardPercentage = n(BigDecimal("0.62")), - featuredAppRewardCap = n(BigDecimal("1.5")), - unfeaturedAppRewardCap = n(BigDecimal("0.6")), - developmentFundPercentage = n(BigDecimal("0.05")), - tickDurationMicros = tickDurationMicros, - amuletPrice = n(BigDecimal("0.14877")), - trafficPrice = n(BigDecimal("60")), - appRewardCouponThreshold = n(BigDecimal("0.5")), - ) + prettyOfClass( + param("roundNumber", _.roundNumber), + param("calculateRewardsId", _.calculateRewardsId), + ) } } diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala index 05aeca4f02..3275afc876 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/automation/ScanAutomationService.scala @@ -80,17 +80,21 @@ class ScanAutomationService( registerTrigger( new ScanBackfillAggregatesTrigger(store, triggerContext, initialRound) ) - for { - appRewardsStore <- appRewardsStoreO - appActivityStore <- appActivityStoreO - } registerTrigger( - new RewardComputationTrigger( - appRewardsStore, - appActivityStore, - updateHistory, - triggerContext, + def registerRewardComputationTrigger( + rewardsReferenceStore: ScanRewardsReferenceStore + ): Unit = + for { + appRewardsStore <- appRewardsStoreO + appActivityStore <- appActivityStoreO + } registerTrigger( + new RewardComputationTrigger( + appRewardsStore, + appActivityStore, + rewardsReferenceStore, + updateHistory, + triggerContext, + ) ) - ) registerUpdateHistoryIngestion(updateHistory) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputs.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputs.scala index 9f013d72cf..ebbdf86e84 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputs.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputs.scala @@ -5,6 +5,8 @@ package org.lfdecentralizedtrust.splice.scan.rewards import com.digitalasset.daml.lf.data.Numeric import com.digitalasset.daml.lf.data.{assertRight as damlRight} +import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound +import scala.jdk.OptionConverters.RichOptional /** Derived parameters passed to computeRewardTotals. * @@ -94,7 +96,8 @@ final case class RewardComputationInputs( object RewardComputationInputs { private val scale: Numeric.Scale = Numeric.Scale.assertFromInt(10) - private[rewards] val zero: Numeric = fromLong(0L) + private[scan] val zero: Numeric = fromLong(0L) + private val defaultDevelopmentFundPercentage: BigDecimal = BigDecimal("0.05") private[rewards] def fromLong(x: Long): Numeric = damlRight(Numeric.fromLong(scale, x)) @@ -102,6 +105,35 @@ object RewardComputationInputs { private[scan] def fromBigDecimal(x: BigDecimal): Numeric = Numeric.assertFromBigDecimal(scale, x) + /** Build RewardComputationInputs and batchSize from an OpenMiningRound contract. + * + * Returns `None` for pre-CIP-104 rounds where `trafficPrice` or `rewardConfig` is absent. + */ + def fromOpenMiningRound( + round: OpenMiningRound + ): Option[(RewardComputationInputs, Int)] = + for { + rewardConfig <- round.rewardConfig.toScala + trafficPrice <- round.trafficPrice.toScala + } yield { + val issuance = round.issuanceConfig + val devFundPct: BigDecimal = issuance.optDevelopmentFundPercentage.toScala + .fold(defaultDevelopmentFundPercentage)(BigDecimal(_)) + + val inputs = RewardComputationInputs( + amuletToIssuePerYear = fromBigDecimal(issuance.amuletToIssuePerYear), + appRewardPercentage = fromBigDecimal(issuance.appRewardPercentage), + featuredAppRewardCap = fromBigDecimal(issuance.featuredAppRewardCap), + unfeaturedAppRewardCap = fromBigDecimal(issuance.unfeaturedAppRewardCap), + developmentFundPercentage = fromBigDecimal(devFundPct), + tickDurationMicros = round.tickDuration.microseconds, + amuletPrice = fromBigDecimal(round.amuletPrice), + trafficPrice = fromBigDecimal(trafficPrice), + appRewardCouponThreshold = fromBigDecimal(rewardConfig.appRewardCouponThreshold), + ) + (inputs, rewardConfig.batchSize.toInt) + } + private def div(a: Numeric, b: Numeric): Numeric = damlRight(Numeric.divide(scale, a, b)) diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala index cc6c3e9f12..17388f65cb 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/CachingScanRewardsReferenceStore.scala @@ -8,6 +8,7 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.tracing.TraceContext import com.github.blemale.scaffeine.Scaffeine +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound import org.lfdecentralizedtrust.splice.store.{Limit, MultiDomainAcsStore, SynchronizerStore} import org.lfdecentralizedtrust.splice.util.Contract @@ -71,6 +72,11 @@ class CachingScanRewardsReferenceStore private[splice] ( ): Future[Option[Contract[OpenMiningRound.ContractId, OpenMiningRound]]] = store.lookupOpenMiningRoundByNumber(roundNumber) + override def listActiveCalculateRewardsV2(limit: Limit = defaultLimit)(implicit + tc: TraceContext + ): Future[Seq[Contract[CalculateRewardsV2.ContractId, CalculateRewardsV2]]] = + store.listActiveCalculateRewardsV2(limit) + override val storeName: String = store.storeName override def defaultLimit: Limit = store.defaultLimit override lazy val acsContractFilter = store.acsContractFilter diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanAppRewardsStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanAppRewardsStore.scala index 39b2252905..51bfc7b69a 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanAppRewardsStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanAppRewardsStore.scala @@ -14,12 +14,12 @@ import scala.concurrent.Future */ trait ScanAppRewardsStore { - /** Returns the latest round for which reward computation has completed - * (i.e. a root hash exists). None if no rounds have been computed. + /** Returns the subset of the given round numbers for which reward + * computation has already completed (i.e. a root hash exists). */ - def lookupLatestRoundWithRewardComputation()(implicit + def roundsWithComputedRewards(rounds: Seq[Long])(implicit tc: TraceContext - ): Future[Option[Long]] + ): Future[Set[Long]] /** Runs the full reward computation pipeline for a single round: * aggregation, CC conversion, and Merkle tree hashing. diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala index 72490db82d..1c725e90f5 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala @@ -11,6 +11,7 @@ import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.topology.{ParticipantId, PartyId, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext import org.lfdecentralizedtrust.splice.codegen.java.splice +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.environment.RetryProvider @@ -78,6 +79,12 @@ trait ScanRewardsReferenceStore extends AppStore { tc: TraceContext ): Future[Option[Contract[OpenMiningRound.ContractId, OpenMiningRound]]] + /** List active CalculateRewardsV2 contracts, sorted by round number ascending. + */ + def listActiveCalculateRewardsV2(limit: Limit = defaultLimit)(implicit + tc: TraceContext + ): Future[Seq[Contract[CalculateRewardsV2.ContractId, CalculateRewardsV2]]] + override lazy val acsContractFilter: MultiDomainAcsStore.ContractFilter[ ScanRewardsReferenceStoreRowData, AcsInterfaceViewRowData.NoInterfacesIngested, @@ -156,6 +163,14 @@ object ScanRewardsReferenceStore { mkFilter(splice.dsorules.DsoRules.COMPANION)(co => co.payload.dso == dso) { contract => ScanRewardsReferenceStoreRowData(contract = contract) }, + mkFilter(splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION)(co => + co.payload.dso == dso + ) { contract => + ScanRewardsReferenceStoreRowData( + contract = contract, + round = Some(contract.payload.round.number), + ) + }, ), interfaceFilters = Map.empty, synchronizerFilter = Some(key.synchronizerId), diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala index 0155d5e3ac..4302e8f78b 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbAppActivityRecordStore.scala @@ -235,7 +235,7 @@ class DbAppActivityRecordStore( throw Status.FAILED_PRECONDITION .withDescription( s"Incomplete app activity for round $roundNumber: " + - s"round ${roundNumber - 1} exists=$hasPrev, round ${roundNumber + 1} exists=$hasNext" + s"prior round exists=$hasPrev, later round exists=$hasNext" ) .asRuntimeException() } yield (), diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanAppRewardsStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanAppRewardsStore.scala index 9752ad76bf..3ba86f00eb 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanAppRewardsStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanAppRewardsStore.scala @@ -610,19 +610,19 @@ class DbScanAppRewardsStore( // -- Aggregation ------------------------------------------------------------ - /** Returns the latest round for which reward computation has completed - * (i.e. a root hash exists). None if no rounds have been computed. - */ - def lookupLatestRoundWithRewardComputation()(implicit + override def roundsWithComputedRewards(rounds: Seq[Long])(implicit tc: TraceContext - ): Future[Option[Long]] = { - - runQuerySingle( - sql"""select max(round_number) from #${Tables.appRewardRootHashes} - where history_id = $historyId - """.as[Option[Long]].headOption.map(_.flatten), - "appRewards.lookupLatestRoundWithRewardComputation", - ) + ): Future[Set[Long]] = { + if (rounds.isEmpty) Future.successful(Set.empty) + else { + runQuery( + (sql"""select round_number from #${Tables.appRewardRootHashes} + where history_id = $historyId + and """ ++ inClause("round_number", rounds)).toActionBuilder + .as[Long], + "appRewards.roundsWithComputedRewards", + ).map(_.toSet) + } } /** Runs the full reward computation pipeline for a single round in a single diff --git a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala index 712cb5ace4..c4a2f4b162 100644 --- a/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala +++ b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/db/DbScanRewardsReferenceStore.scala @@ -11,6 +11,7 @@ import com.digitalasset.canton.topology.{ParticipantId, SynchronizerId} import com.digitalasset.canton.tracing.TraceContext import org.lfdecentralizedtrust.splice.codegen.java.splice import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.FeaturedAppRight +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.rewardaccountingv2.CalculateRewardsV2 import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.DsoRules import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound import org.lfdecentralizedtrust.splice.config.IngestionConfig @@ -56,7 +57,7 @@ class DbScanRewardsReferenceStore( acsTableName = ScanRewardsReferenceTables.acsTableName, interfaceViewsTableNameOpt = None, acsStoreDescriptor = StoreDescriptor( - version = 2, + version = 3, name = "DbScanRewardsReferenceStore", party = key.dsoParty, participant = participantId, @@ -215,4 +216,13 @@ class DbScanRewardsReferenceStore( ) } yield result.headOption.map(contractFromRow(OpenMiningRound.COMPANION)(_)) } + + override def listActiveCalculateRewardsV2(limit: Limit = defaultLimit)(implicit + tc: TraceContext + ): Future[Seq[Contract[CalculateRewardsV2.ContractId, CalculateRewardsV2]]] = + waitUntilInitialized.flatMap { _ => + multiDomainAcsStore + .listContracts(CalculateRewardsV2.COMPANION, limit) + .map(_.map(_.contract).sortBy(_.payload.round.number)) + } } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTriggerTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTriggerTest.scala deleted file mode 100644 index b69a6e38e6..0000000000 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/automation/RewardComputationTriggerTest.scala +++ /dev/null @@ -1,78 +0,0 @@ -package org.lfdecentralizedtrust.splice.scan.automation - -import com.digitalasset.canton.BaseTest -import org.lfdecentralizedtrust.splice.scan.rewards.RewardComputationInputs -import org.lfdecentralizedtrust.splice.scan.rewards.RewardComputationInputs.{fromBigDecimal as n} -import org.scalatest.wordspec.AnyWordSpec - -class RewardComputationTriggerTest extends AnyWordSpec with BaseTest { - - import RewardComputationTrigger.nextTask - import RewardComputationTriggerTest.* - - private def next( - earliest: Option[Long], - latest: Option[Long], - computed: Option[Long], - ) = nextTask(earliest, latest, computed, testBatchSize, testInputs) - - "RewardComputationTrigger.nextTask" should { - - "return empty when no complete activity data exists" in { - next(noRound, noRound, noRound) shouldBe empty - next(round5, noRound, noRound) shouldBe empty - next(noRound, round10, noRound) shouldBe empty - } - - "return the earliest complete round when nothing has been computed" in { - next(round3, round10, noRound) shouldBe Seq(task(3)) - } - - "return the round after the latest computed" in { - next(round3, round10, round5) shouldBe Seq(task(6)) - } - - "return earliest complete when it is ahead of latest computed" in { - next(round8, round10, round5) shouldBe Seq(task(8)) - } - - "return empty when all complete rounds have been computed" in { - next(round3, round10, round10) shouldBe empty - next(round3, round10, round15) shouldBe empty - } - - "return single task when earliest equals latest complete" in { - next(round5, round5, noRound) shouldBe Seq(task(5)) - next(round5, round5, round4) shouldBe Seq(task(5)) - next(round5, round5, round5) shouldBe empty - } - } -} - -object RewardComputationTriggerTest { - val noRound: Option[Long] = None - val round3: Option[Long] = Some(3) - val round4: Option[Long] = Some(4) - val round5: Option[Long] = Some(5) - val round8: Option[Long] = Some(8) - val round10: Option[Long] = Some(10) - val round15: Option[Long] = Some(15) - - // Simple fake values — this test only checks round selection logic, - // not reward computation correctness. - val testBatchSize: Int = 10 - val testInputs: RewardComputationInputs = RewardComputationInputs( - amuletToIssuePerYear = n(BigDecimal("1000")), - appRewardPercentage = n(BigDecimal("0.5")), - featuredAppRewardCap = n(BigDecimal("100")), - unfeaturedAppRewardCap = n(BigDecimal("0.6")), - developmentFundPercentage = n(BigDecimal("0")), - tickDurationMicros = 600L * 1000000L, - amuletPrice = n(BigDecimal("1")), - trafficPrice = n(BigDecimal("1")), - appRewardCouponThreshold = n(BigDecimal("0.5")), - ) - - def task(roundNumber: Long): RewardComputationTrigger.Task = - RewardComputationTrigger.Task(roundNumber, testBatchSize, testInputs) -} diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputsTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputsTest.scala index fab14bfeb9..ec98eb9b2f 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputsTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/rewards/RewardComputationInputsTest.scala @@ -4,13 +4,45 @@ package org.lfdecentralizedtrust.splice.scan.rewards import com.digitalasset.canton.BaseTest -import org.lfdecentralizedtrust.splice.util.BigDecimalMatchers +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletconfig.RewardConfig +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletconfig.RewardVersion.REWARDVERSION_TRAFFICBASEDAPPREWARDS +import org.lfdecentralizedtrust.splice.codegen.java.splice.issuance.IssuanceConfig +import org.lfdecentralizedtrust.splice.codegen.java.splice.round.OpenMiningRound +import org.lfdecentralizedtrust.splice.codegen.java.splice.types.Round +import org.lfdecentralizedtrust.splice.codegen.java.da.time.types.RelTime +import org.lfdecentralizedtrust.splice.util.{BigDecimalMatchers, SpliceUtil} import org.scalatest.wordspec.AnyWordSpec +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Optional + class RewardComputationInputsTest extends AnyWordSpec with BaseTest with BigDecimalMatchers { import RewardComputationInputsTest.* + "RewardComputationInputs.fromOpenMiningRound" should { + + "extract all fields from an OpenMiningRound with CIP-104 config" in { + RewardComputationInputs.fromOpenMiningRound(mainNetRound) shouldBe Some((mainNet, 100)) + } + + "use default developmentFundPercentage when optDevelopmentFundPercentage is None" in { + val round = mkOpenMiningRound(optDevelopmentFundPercentage = Optional.empty()) + RewardComputationInputs.fromOpenMiningRound(round).map(_._1) shouldBe Some(mainNet) + } + + "return None when rewardConfig is None" in { + val round = mkOpenMiningRound(rewardConfig = Optional.empty()) + RewardComputationInputs.fromOpenMiningRound(round) shouldBe None + } + + "return None when trafficPrice is None" in { + val round = mkOpenMiningRound(trafficPrice = Optional.empty()) + RewardComputationInputs.fromOpenMiningRound(round) shouldBe None + } + } + "RewardComputationInputs.deriveIssuanceParams" should { cases.foreach { tc => @@ -38,6 +70,47 @@ object RewardComputationInputsTest { import RewardComputationInputs.{fromBigDecimal as n} + private val defaultRewardConfig = new RewardConfig( + REWARDVERSION_TRAFFICBASEDAPPREWARDS, + Optional.empty(), + 100L, + new RelTime(36L * 3600L * 1000000L), + SpliceUtil.damlDecimal(0.5), + ) + + private def mkOpenMiningRound( + trafficPrice: Optional[java.math.BigDecimal] = Optional.of(SpliceUtil.damlDecimal(60.0)), + rewardConfig: Optional[RewardConfig] = Optional.of(defaultRewardConfig), + optDevelopmentFundPercentage: Optional[java.math.BigDecimal] = + Optional.of(SpliceUtil.damlDecimal(0.05)), + ): OpenMiningRound = { + val now = Instant.now().truncatedTo(ChronoUnit.MICROS) + new OpenMiningRound( + "dso-party", + new Round(42L), + SpliceUtil.damlDecimal(0.14877), + now, + now.plusSeconds(600), + new RelTime(600L * 1000000L), + SpliceUtil.defaultTransferConfig(10, SpliceUtil.damlDecimal(1.0)), + new IssuanceConfig( + SpliceUtil.damlDecimal(10000000000.0), + SpliceUtil.damlDecimal(0.0), + SpliceUtil.damlDecimal(0.62), + SpliceUtil.damlDecimal(0.0), + SpliceUtil.damlDecimal(1.5), + SpliceUtil.damlDecimal(0.6), + Optional.empty(), + optDevelopmentFundPercentage, + ), + new RelTime(600L * 1000000L), + trafficPrice, + rewardConfig, + ) + } + + private val mainNetRound: OpenMiningRound = mkOpenMiningRound() + // 600s tick → 52560 rounds/year private val tickDurationMicros: Long = 600L * 1000000L private val microsPerYear: Long = 365L * 24 * 3600 * 1000000L diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala index 36217f1f96..d7cfdff471 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbScanAppRewardsStoreTest.scala @@ -5,7 +5,6 @@ import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.resource.DbStorage import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo -import org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTrigger import org.lfdecentralizedtrust.splice.scan.rewards.{RewardComputationInputs, RewardIssuanceParams} import org.lfdecentralizedtrust.splice.scan.store.db.{ DbAppActivityRecordStore, @@ -432,7 +431,7 @@ class DbScanAppRewardsStoreTest result <- store.aggregateActivityTotals(roundNumber).failed } yield { result.getMessage should include("Incomplete app activity") - result.getMessage should include(s"round ${roundNumber - 1} exists=false") + result.getMessage should include("prior round exists=false") } } @@ -445,59 +444,48 @@ class DbScanAppRewardsStoreTest result <- store.aggregateActivityTotals(roundNumber).failed } yield { result.getMessage should include("Incomplete app activity") - result.getMessage should include(s"round ${roundNumber + 1} exists=false") + result.getMessage should include("later round exists=false") } } - // -- lookupLatestRoundWithRewardComputation ------ + // -- roundsWithComputedRewards ------ - "lookupLatestRoundWithRewardComputation returns None when no root hashes" in { + "roundsWithComputedRewards returns empty set for empty input" in { for { - (store, historyId) <- newStore() - result <- store.lookupLatestRoundWithRewardComputation() + (store, _) <- newStore() + result <- store.roundsWithComputedRewards(Seq.empty) } yield { - result shouldBe None + result shouldBe Set.empty } } - "lookupLatestRoundWithRewardComputation returns latest round with root hash" in { + "roundsWithComputedRewards returns correct subset" in { for { (store, historyId) <- newStore() _ <- store.insertAppRewardRootHashes( Seq( - AppRewardRootHashT( - historyId = historyId, - roundNumber = 10L, - rootHash = RewardHash(Array[Byte](1, 2, 3, 4)), - ), - AppRewardRootHashT( - historyId = historyId, - roundNumber = 20L, - rootHash = RewardHash(Array[Byte](5, 6, 7, 8)), - ), + AppRewardRootHashT(historyId, 10L, RewardHash(Array[Byte](1, 2, 3, 4))), + AppRewardRootHashT(historyId, 20L, RewardHash(Array[Byte](5, 6, 7, 8))), + AppRewardRootHashT(historyId, 30L, RewardHash(Array[Byte](9, 10, 11, 12))), ) ) - result <- store.lookupLatestRoundWithRewardComputation() + result <- store.roundsWithComputedRewards(Seq(10L, 15L, 20L, 25L)) } yield { - result.value shouldBe 20L + result shouldBe Set(10L, 20L) } } - "lookupLatestRoundWithRewardComputation returns single round" in { + "roundsWithComputedRewards returns empty set when no matches" in { for { (store, historyId) <- newStore() _ <- store.insertAppRewardRootHashes( Seq( - AppRewardRootHashT( - historyId = historyId, - roundNumber = 5L, - rootHash = RewardHash(Array[Byte](1, 2, 3, 4)), - ) + AppRewardRootHashT(historyId, 10L, RewardHash(Array[Byte](1, 2, 3, 4))) ) ) - result <- store.lookupLatestRoundWithRewardComputation() + result <- store.roundsWithComputedRewards(Seq(20L, 30L)) } yield { - result.value shouldBe 5L + result shouldBe Set.empty } } @@ -526,10 +514,13 @@ class DbScanAppRewardsStoreTest Seq("bob::provider"), Seq(500000L), ) + zeroThresholdInputs = testInputs.copy( + appRewardCouponThreshold = RewardComputationInputs.zero + ) summary <- store.computeAndStoreRewards( roundNumber, batchSize = 100, - RewardComputationTrigger.placeholderInputs, + zeroThresholdInputs, ) } yield { summary.activePartiesCount shouldBe 2L @@ -546,7 +537,7 @@ class DbScanAppRewardsStoreTest summary <- store.computeAndStoreRewards( roundNumber, batchSize = 100, - RewardComputationTrigger.placeholderInputs, + testInputs, ) } yield { summary.activePartiesCount shouldBe 0L @@ -809,9 +800,9 @@ class DbScanAppRewardsStoreTest "computeRewardHashes — root hash exists after multi-level aggregation" in { for { (store, _, _) <- setupAndComputeHashes(partyCount = 5, batchSize = 2) - latestRound <- store.lookupLatestRoundWithRewardComputation() + computed <- store.roundsWithComputedRewards(Seq(roundNumber)) } yield { - latestRound.value shouldBe roundNumber + computed shouldBe Set(roundNumber) } } @@ -1199,7 +1190,7 @@ object DbScanAppRewardsStoreTest { tickDurationMicros = tickDurationMicros, amuletPrice = n(BigDecimal("1.0")), trafficPrice = n(BigDecimal("1.0")), - appRewardCouponThreshold = n(BigDecimal("0.5")), + appRewardCouponThreshold = n(BigDecimal("0.0")), ) } diff --git a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala index 0e0cfc90a6..3fd9c6df45 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/store/db/DbScanRewardsReferenceStoreTest.scala @@ -25,7 +25,7 @@ import org.lfdecentralizedtrust.splice.environment.{DarResources, RetryProvider} import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo import org.lfdecentralizedtrust.splice.scan.store.ScanRewardsReferenceStore import org.lfdecentralizedtrust.splice.scan.store.db.DbScanRewardsReferenceStore -import org.lfdecentralizedtrust.splice.store.{HardLimit, Limit, StoreTestBase, TcsStore} +import org.lfdecentralizedtrust.splice.store.{HardLimit, Limit, PageLimit, StoreTestBase, TcsStore} import org.lfdecentralizedtrust.splice.util.{ResourceTemplateDecoder, TemplateJsonDecoder} import slick.jdbc.JdbcProfile @@ -280,6 +280,53 @@ class DbScanRewardsReferenceStoreTest } yield succeed } + "listActiveCalculateRewardsV2 returns active contracts sorted by round" in { + val store = mkStore() + val cr5 = calculateRewardsV2(dsoParty, round = 5) + .copy(createdAt = CantonTimestamp.ofEpochSecond(100).toInstant) + val cr3 = calculateRewardsV2(dsoParty, round = 3) + .copy(createdAt = CantonTimestamp.ofEpochSecond(200).toInstant) + val cr7 = calculateRewardsV2(dsoParty, round = 7) + .copy(createdAt = CantonTimestamp.ofEpochSecond(300).toInstant) + for { + _ <- initWithAcs()(store.multiDomainAcsStore) + _ <- sync1.create(cr5, recordTime = CantonTimestamp.ofEpochSecond(100).toInstant)( + store.multiDomainAcsStore + ) + _ <- sync1.create(cr3, recordTime = CantonTimestamp.ofEpochSecond(200).toInstant)( + store.multiDomainAcsStore + ) + _ <- sync1.create(cr7, recordTime = CantonTimestamp.ofEpochSecond(300).toInstant)( + store.multiDomainAcsStore + ) + + // All three active, sorted by round ascending + all <- store.listActiveCalculateRewardsV2() + _ = all.map(_.payload.round.number) shouldBe Seq(3L, 5L, 7L) + + // Limit respected + limited <- store.listActiveCalculateRewardsV2(PageLimit.tryCreate(2)) + _ = limited.map(_.payload.round.number) shouldBe Seq(3L, 5L) + + // Archive round 3 — no longer returned + _ <- sync1.archive(cr3, recordTime = CantonTimestamp.ofEpochSecond(400).toInstant)( + store.multiDomainAcsStore + ) + afterArchive <- store.listActiveCalculateRewardsV2() + _ = afterArchive.map(_.payload.round.number) shouldBe Seq(5L, 7L) + + // Empty when all archived + _ <- sync1.archive(cr5, recordTime = CantonTimestamp.ofEpochSecond(500).toInstant)( + store.multiDomainAcsStore + ) + _ <- sync1.archive(cr7, recordTime = CantonTimestamp.ofEpochSecond(600).toInstant)( + store.multiDomainAcsStore + ) + allArchived <- store.listActiveCalculateRewardsV2() + _ = allArchived shouldBe Seq.empty + } yield succeed + } + "lookupActiveOpenMiningRounds" in { val store = mkStore() // Timeline (ingestion start = 250, earliest archived_at): diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala index bf1a3ed58e..2b2bd9c273 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala @@ -114,6 +114,7 @@ object SvOnboardingConfig { developmentFundManager: Option[PartyId] = None, initialExternalPartyConfigStateTickDuration: Option[NonNegativeFiniteDuration] = None, optValidatorFaucetCap: Option[BigDecimal] = None, + initialRewardConfig: Option[InitialRewardConfig] = None, ) extends SvOnboardingConfig case class JoinWithKey( @@ -237,6 +238,35 @@ object SvOnboardingConfig { ) extends SvOnboardingConfig } +final case class InitialRewardConfig( + mintingVersion: String = "RewardVersion_FeaturedAppMarkers", + dryRunVersion: Option[String] = None, + batchSize: Long = 100, + rewardCouponTimeToLiveMicros: Long = 36L * 60 * 60 * 1000000, // 36 hours + appRewardCouponThreshold: BigDecimal = BigDecimal("0.5"), +) { + def toRewardConfig: splice.amuletconfig.RewardConfig = { + def parseVersion(s: String): splice.amuletconfig.RewardVersion = s match { + case "RewardVersion_FeaturedAppMarkers" => + splice.amuletconfig.RewardVersion.REWARDVERSION_FEATUREDAPPMARKERS + case "RewardVersion_TrafficBasedAppRewards" => + splice.amuletconfig.RewardVersion.REWARDVERSION_TRAFFICBASEDAPPREWARDS + case other => throw new IllegalArgumentException(s"Unknown RewardVersion: $other") + } + new splice.amuletconfig.RewardConfig( + parseVersion(mintingVersion), + dryRunVersion + .map(parseVersion) + .fold(java.util.Optional.empty[splice.amuletconfig.RewardVersion]())(java.util.Optional.of), + batchSize, + new org.lfdecentralizedtrust.splice.codegen.java.da.time.types.RelTime( + rewardCouponTimeToLiveMicros + ), + appRewardCouponThreshold.bigDecimal, + ) + } +} + final case class InitialAnsConfig( renewalDuration: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofDays(30), entryLifetime: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofDays(90), diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala index baf56bd110..855b857ac1 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/onboarding/sv1/SV1Initializer.scala @@ -686,6 +686,7 @@ class SV1Initializer( initialExternalPartyConfigStateTickDuration = sv1Config.initialExternalPartyConfigStateTickDuration, optValidatorFaucetCap = sv1Config.optValidatorFaucetCap, + initialRewardConfig = sv1Config.initialRewardConfig.map(_.toRewardConfig), ) for { sv1SynchronizerNodes <- SvUtil.getSV1SynchronizerNodeConfig( diff --git a/test-full-class-names-non-integration.log b/test-full-class-names-non-integration.log index 47ae38486f..e1b47217ae 100644 --- a/test-full-class-names-non-integration.log +++ b/test-full-class-names-non-integration.log @@ -17,7 +17,6 @@ org.lfdecentralizedtrust.splice.scan.admin.http.GeneratedScanRouteDropNullsTest org.lfdecentralizedtrust.splice.scan.admin.http.ScanHttpEncodingsTest org.lfdecentralizedtrust.splice.scan.admin.http.UpdateHistoryOmitNullStringComplianceTest org.lfdecentralizedtrust.splice.scan.automation.AcsSnapshotTriggerTest -org.lfdecentralizedtrust.splice.scan.automation.RewardComputationTriggerTest org.lfdecentralizedtrust.splice.scan.automation.ScanVerdictIngestionServiceTest org.lfdecentralizedtrust.splice.scan.config.ScanStorageConfigTest org.lfdecentralizedtrust.splice.scan.rewards.AppActivityComputationTest