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 100ceb6066..48bd50078a 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 @@ -806,6 +806,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 317aa84eec..3bfa4e0769 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] = @@ -999,6 +1001,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/AppUpgradeIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala index ef7de46f4c..e857d1d03d 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AppUpgradeIntegrationTest.scala @@ -265,6 +265,7 @@ class AppUpgradeIntegrationTest amuletConfig.featuredAppActivityMarkerAmount, amuletConfig.optDevelopmentFundManager, amuletConfig.externalPartyConfigStateTickDuration, + amuletConfig.rewardConfig, ) val upgradeAction = new ARC_AmuletRules( new CRARC_SetConfig( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala index 9d8b73cc7a..dc32263e18 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/BootstrapPackageConfigIntegrationTest.scala @@ -233,6 +233,7 @@ class BootstrapPackageConfigIntegrationTest amuletConfig.featuredAppActivityMarkerAmount, amuletConfig.optDevelopmentFundManager, amuletConfig.externalPartyConfigStateTickDuration, + amuletConfig.rewardConfig, ) val upgradeAction = new ARC_AmuletRules( @@ -382,6 +383,7 @@ class BootstrapPackageConfigIntegrationTest amuletConfig.featuredAppActivityMarkerAmount, amuletConfig.optDevelopmentFundManager, amuletConfig.externalPartyConfigStateTickDuration, + amuletConfig.rewardConfig, ) val upgradeAction = new ARC_AmuletRules( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala index dae2872816..d2fab3a049 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala @@ -444,6 +444,7 @@ class DevelopmentFundFrontendTimeBasedIntegrationTest existingConfig.featuredAppActivityMarkerAmount, Optional.of(newDfm.toProtoPrimitive), existingConfig.externalPartyConfigStateTickDuration, + existingConfig.rewardConfig, ) val action = new ARC_AmuletRules( diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvReconcileSynchronizerConfigIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvReconcileSynchronizerConfigIntegrationTest.scala index 37181e5699..b5c9bca0a1 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvReconcileSynchronizerConfigIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvReconcileSynchronizerConfigIntegrationTest.scala @@ -143,6 +143,7 @@ class SvReconcileSynchronizerConfigIntegrationTest extends SvIntegrationTestBase amuletConfig.featuredAppActivityMarkerAmount, amuletConfig.optDevelopmentFundManager, amuletConfig.externalPartyConfigStateTickDuration, + amuletConfig.rewardConfig, ) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala index 841065adc3..d30ddbb0a6 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvStateManagementIntegrationTest.scala @@ -498,6 +498,7 @@ class SvStateManagementIntegrationTest extends SvIntegrationTestBase with Trigge initialConfig.featuredAppActivityMarkerAmount, initialConfig.optDevelopmentFundManager, initialConfig.externalPartyConfigStateTickDuration, + initialConfig.rewardConfig, ) val (_, voteRequestCid) = actAndCheck( 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 5012764b52..f440733aee 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 @@ -9,10 +9,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 org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition @@ -62,6 +64,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) @@ -167,23 +178,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 + }, + ) // -- Reward pipeline endpoint checks -------------------------------------- // ScanAggregationTrigger runs unpaused throughout the test and has already @@ -203,10 +227,22 @@ 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 = sv1ScanBackend.getRewardAccountingActivityTotals(earliest) totals.value.roundNumber shouldBe earliest totals.value.activityRecordsCount should be > 0L + totals.value.activePartiesCount shouldBe expectedProviders.size.toLong + totals.value.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.value.totalAppActivityWeight should be >= roundTrafficCost } clue("Verify root hash is available") { @@ -216,9 +252,22 @@ class TrafficBasedRewardsTimeBasedIntegrationTest rootHash.value.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 = sv1ScanBackend.getRewardAccountingRootHash(earliest).value.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 404 for non-existent data") { @@ -395,3 +444,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/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala index 40a27c304b..c9d8a82301 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/UnsupportedPackageVettingIntegrationTest.scala @@ -172,6 +172,7 @@ class UnsupportedPackageVettingIntegrationTest currentConfig.featuredAppActivityMarkerAmount, currentConfig.optDevelopmentFundManager, currentConfig.externalPartyConfigStateTickDuration, + currentConfig.rewardConfig, ) setAmuletConfig(Seq((None, newAmuletConfig, currentConfig))) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/AmuletConfigUtil.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/AmuletConfigUtil.scala index afa70c6662..9634c73da2 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/AmuletConfigUtil.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/util/AmuletConfigUtil.scala @@ -63,6 +63,7 @@ trait AmuletConfigUtil extends TestCommon { existingAmuletConfig.featuredAppActivityMarkerAmount, existingAmuletConfig.optDevelopmentFundManager, existingAmuletConfig.externalPartyConfigStateTickDuration, + existingAmuletConfig.rewardConfig, ) } diff --git a/apps/common/frontend-test-handlers/src/mocks/helpers/amulet-config-helper.ts b/apps/common/frontend-test-handlers/src/mocks/helpers/amulet-config-helper.ts index ac94e2256b..92f8a341d6 100644 --- a/apps/common/frontend-test-handlers/src/mocks/helpers/amulet-config-helper.ts +++ b/apps/common/frontend-test-handlers/src/mocks/helpers/amulet-config-helper.ts @@ -227,6 +227,13 @@ export function getAmuletRulesConfig( featuredAppActivityMarkerAmount: null, optDevelopmentFundManager: null, externalPartyConfigStateTickDuration: null, + rewardConfig: { + mintingVersion: 'RewardVersion_FeaturedAppMarkers', + dryRunVersion: null, + batchSize: '100', + rewardCouponTimeToLive: { microseconds: '129600000000' }, + appRewardCouponThreshold: '0.5', + }, }; } @@ -394,6 +401,17 @@ export function getExpectedAmuletRulesConfigDiffsHTML( "validatorLifecycle": "0.1.2", "wallet": "0.1.8", "walletPayments": "0.1.8" +}
  • rewardConfig
    {
    +  "mintingVersion": "RewardVersion_FeaturedAppMarkers",
    +  "dryRunVersion": null,
    +  "batchSize": "100",
    +  "rewardCouponTimeToLive": {
    +    "microseconds": "129600000000"
    +  },
    +  "appRewardCouponThreshold": "0.5"
     }
  • tickDuration
    new RelTime(TimeUnit.NANOSECONDS.toMicros(t.duration.toNanos))) .toJava, + 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 a7f4a27b1a..090557663c 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 @@ -282,6 +282,8 @@ abstract class StoreTestBase SpliceUtil.defaultTransferConfig(10, holdingFee), SpliceUtil.issuanceConfig(10.0, 10.0, 10.0), new RelTime(1_000_000), + Optional.empty(), // trafficPrice + Optional.empty(), // rewardConfig ) contract( diff --git a/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResources.scala b/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResources.scala index b9fb96c7de..0013237ef0 100644 --- a/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResources.scala +++ b/apps/dar-resources-generator/src/main/scala/org/lfdecentralizedtrust/splice/darutils/DarResources.scala @@ -83,6 +83,7 @@ object DarResources { lazy val amulet_0_1_16 = DarResource("splice-amulet-0.1.16.dar") lazy val amulet_0_1_17 = DarResource("splice-amulet-0.1.17.dar") lazy val amulet_0_1_18 = DarResource("splice-amulet-0.1.18.dar") + lazy val amulet_0_1_19 = DarResource("splice-amulet-0.1.19.dar") lazy val amulet_current = DarResource("splice-amulet-current.dar") lazy val amulet = PackageResource( amulet_current, @@ -107,6 +108,7 @@ object DarResources { amulet_0_1_16, amulet_0_1_17, amulet_0_1_18, + amulet_0_1_19, ), ) @@ -135,6 +137,7 @@ object DarResources { lazy val dsoGovernance_0_1_22 = DarResource("splice-dso-governance-0.1.22.dar") lazy val dsoGovernance_0_1_23 = DarResource("splice-dso-governance-0.1.23.dar") lazy val dsoGovernance_0_1_24 = DarResource("splice-dso-governance-0.1.24.dar") + lazy val dsoGovernance_0_1_25 = DarResource("splice-dso-governance-0.1.25.dar") lazy val dsoGovernance_current = DarResource("splice-dso-governance-current.dar") lazy val dsoGovernance = PackageResource( dsoGovernance_current, @@ -165,6 +168,7 @@ object DarResources { dsoGovernance_0_1_22, dsoGovernance_0_1_23, dsoGovernance_0_1_24, + dsoGovernance_0_1_25, ), ) @@ -188,6 +192,7 @@ object DarResources { lazy val amuletNameService_0_1_17 = DarResource("splice-amulet-name-service-0.1.17.dar") lazy val amuletNameService_0_1_18 = DarResource("splice-amulet-name-service-0.1.18.dar") lazy val amuletNameService_0_1_19 = DarResource("splice-amulet-name-service-0.1.19.dar") + lazy val amuletNameService_0_1_20 = DarResource("splice-amulet-name-service-0.1.20.dar") lazy val amuletNameService_current = DarResource("splice-amulet-name-service-current.dar") lazy val amuletNameService = PackageResource( amuletNameService_current, @@ -213,6 +218,7 @@ object DarResources { amuletNameService_0_1_17, amuletNameService_0_1_18, amuletNameService_0_1_19, + amuletNameService_0_1_20, ), ) @@ -236,6 +242,7 @@ object DarResources { lazy val splitwell_0_1_17 = DarResource("splitwell-0.1.17.dar") lazy val splitwell_0_1_18 = DarResource("splitwell-0.1.18.dar") lazy val splitwell_0_1_19 = DarResource("splitwell-0.1.19.dar") + lazy val splitwell_0_1_20 = DarResource("splitwell-0.1.20.dar") lazy val splitwell_current = DarResource("splitwell-current.dar") lazy val splitwell = PackageResource( splitwell_current, @@ -261,6 +268,7 @@ object DarResources { splitwell_0_1_17, splitwell_0_1_18, splitwell_0_1_19, + splitwell_0_1_20, ), ) @@ -284,6 +292,7 @@ object DarResources { lazy val wallet_0_1_17 = DarResource("splice-wallet-0.1.17.dar") lazy val wallet_0_1_18 = DarResource("splice-wallet-0.1.18.dar") lazy val wallet_0_1_19 = DarResource("splice-wallet-0.1.19.dar") + lazy val wallet_0_1_20 = DarResource("splice-wallet-0.1.20.dar") lazy val wallet_current = DarResource("splice-wallet-current.dar") lazy val wallet = PackageResource( wallet_current, @@ -309,6 +318,7 @@ object DarResources { wallet_0_1_17, wallet_0_1_18, wallet_0_1_19, + wallet_0_1_20, ), ) @@ -331,6 +341,7 @@ object DarResources { lazy val walletPayments_0_1_16 = DarResource("splice-wallet-payments-0.1.16.dar") lazy val walletPayments_0_1_17 = DarResource("splice-wallet-payments-0.1.17.dar") lazy val walletPayments_0_1_18 = DarResource("splice-wallet-payments-0.1.18.dar") + lazy val walletPayments_0_1_19 = DarResource("splice-wallet-payments-0.1.19.dar") lazy val walletPayments_current = DarResource("splice-wallet-payments-current.dar") lazy val walletPayments = PackageResource( walletPayments_current, @@ -355,6 +366,7 @@ object DarResources { walletPayments_0_1_16, walletPayments_0_1_17, walletPayments_0_1_18, + walletPayments_0_1_19, ), ) diff --git a/apps/package-lock.json b/apps/package-lock.json index b2ecf66159..2bacf985f1 100644 --- a/apps/package-lock.json +++ b/apps/package-lock.json @@ -26,13 +26,13 @@ "wallet/external-openapi-ts-client" ], "dependencies": { - "@daml.js/ans": "file:common/frontend/daml.js/splice-amulet-name-service-0.1.19", - "@daml.js/splice-amulet": "file:common/frontend/daml.js/splice-amulet-0.1.18", - "@daml.js/splice-dso-governance": "file:common/frontend/daml.js/splice-dso-governance-0.1.24", + "@daml.js/ans": "file:common/frontend/daml.js/splice-amulet-name-service-0.1.20", + "@daml.js/splice-amulet": "file:common/frontend/daml.js/splice-amulet-0.1.19", + "@daml.js/splice-dso-governance": "file:common/frontend/daml.js/splice-dso-governance-0.1.25", "@daml.js/splice-validator-lifecycle": "file:common/frontend/daml.js/splice-validator-lifecycle-0.1.6", - "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.19", - "@daml.js/splice-wallet-payments": "file:common/frontend/daml.js/splice-wallet-payments-0.1.18", - "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.19", + "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.20", + "@daml.js/splice-wallet-payments": "file:common/frontend/daml.js/splice-wallet-payments-0.1.19", + "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.20", "xunit-viewer": "^10.6.1" } }, @@ -517,6 +517,26 @@ "common/frontend/daml.js/splice-amulet-0.1.18": { "name": "@daml.js/splice-amulet-0.1.18", "version": "0.0.0", + "extraneous": true, + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Set-Types-1.0.0": "file:../daml-stdlib-DA-Set-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-api-featured-app-v1-1.0.0": "file:../splice-api-featured-app-v1-1.0.0", + "@daml.js/splice-api-featured-app-v2-1.0.0": "file:../splice-api-featured-app-v2-1.0.0", + "@daml.js/splice-api-token-allocation-instruction-v1-1.0.0": "file:../splice-api-token-allocation-instruction-v1-1.0.0", + "@daml.js/splice-api-token-allocation-v1-1.0.0": "file:../splice-api-token-allocation-v1-1.0.0", + "@daml.js/splice-api-token-holding-v1-1.0.0": "file:../splice-api-token-holding-v1-1.0.0", + "@daml.js/splice-api-token-metadata-v1-1.0.0": "file:../splice-api-token-metadata-v1-1.0.0", + "@daml.js/splice-api-token-transfer-instruction-v1-1.0.0": "file:../splice-api-token-transfer-instruction-v1-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "common/frontend/daml.js/splice-amulet-0.1.19": { + "name": "@daml.js/splice-amulet-0.1.19", + "version": "0.0.0", "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -676,6 +696,7 @@ "common/frontend/daml.js/splice-amulet-name-service-0.1.19": { "name": "@daml.js/splice-amulet-name-service-0.1.19", "version": "0.0.0", + "extraneous": true, "license": "UNLICENSED", "dependencies": { "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", @@ -686,6 +707,19 @@ "@mojotech/json-type-validation": "^3.1.0" } }, + "common/frontend/daml.js/splice-amulet-name-service-0.1.20": { + "name": "@daml.js/splice-amulet-name-service-0.1.20", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-amulet-0.1.19": "file:../splice-amulet-0.1.19", + "@daml.js/splice-api-featured-app-v1-1.0.0": "file:../splice-api-featured-app-v1-1.0.0", + "@daml.js/splice-wallet-payments-0.1.19": "file:../splice-wallet-payments-0.1.19", + "@mojotech/json-type-validation": "^3.1.0" + } + }, "common/frontend/daml.js/splice-amulet-name-service-0.1.9": { "name": "@daml.js/splice-amulet-name-service-0.1.9", "version": "0.0.0", @@ -882,6 +916,7 @@ "common/frontend/daml.js/splice-dso-governance-0.1.24": { "name": "@daml.js/splice-dso-governance-0.1.24", "version": "0.0.0", + "extraneous": true, "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -897,16 +932,15 @@ "common/frontend/daml.js/splice-dso-governance-0.1.25": { "name": "@daml.js/splice-dso-governance-0.1.25", "version": "0.0.0", - "extraneous": true, "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", "@daml.js/daml-stdlib-DA-Set-Types-1.0.0": "file:../daml-stdlib-DA-Set-Types-1.0.0", "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", - "@daml.js/splice-amulet-0.1.18": "file:../splice-amulet-0.1.18", - "@daml.js/splice-amulet-name-service-0.1.19": "file:../splice-amulet-name-service-0.1.19", - "@daml.js/splice-wallet-payments-0.1.18": "file:../splice-wallet-payments-0.1.18", + "@daml.js/splice-amulet-0.1.19": "file:../splice-amulet-0.1.19", + "@daml.js/splice-amulet-name-service-0.1.20": "file:../splice-amulet-name-service-0.1.20", + "@daml.js/splice-wallet-payments-0.1.19": "file:../splice-wallet-payments-0.1.19", "@mojotech/json-type-validation": "^3.1.0" } }, @@ -1105,6 +1139,7 @@ "common/frontend/daml.js/splice-wallet-0.1.19": { "name": "@daml.js/splice-wallet-0.1.19", "version": "0.0.0", + "extraneous": true, "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -1118,6 +1153,22 @@ "@mojotech/json-type-validation": "^3.1.0" } }, + "common/frontend/daml.js/splice-wallet-0.1.20": { + "name": "@daml.js/splice-wallet-0.1.20", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-amulet-0.1.19": "file:../splice-amulet-0.1.19", + "@daml.js/splice-api-token-allocation-instruction-v1-1.0.0": "file:../splice-api-token-allocation-instruction-v1-1.0.0", + "@daml.js/splice-api-token-allocation-v1-1.0.0": "file:../splice-api-token-allocation-v1-1.0.0", + "@daml.js/splice-api-token-transfer-instruction-v1-1.0.0": "file:../splice-api-token-transfer-instruction-v1-1.0.0", + "@daml.js/splice-wallet-payments-0.1.19": "file:../splice-wallet-payments-0.1.19", + "@mojotech/json-type-validation": "^3.1.0" + } + }, "common/frontend/daml.js/splice-wallet-0.1.9": { "name": "@daml.js/splice-wallet-0.1.9", "version": "0.0.0", @@ -1242,6 +1293,7 @@ "common/frontend/daml.js/splice-wallet-payments-0.1.18": { "name": "@daml.js/splice-wallet-payments-0.1.18", "version": "0.0.0", + "extraneous": true, "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -1251,6 +1303,18 @@ "@mojotech/json-type-validation": "^3.1.0" } }, + "common/frontend/daml.js/splice-wallet-payments-0.1.19": { + "name": "@daml.js/splice-wallet-payments-0.1.19", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-amulet-0.1.19": "file:../splice-amulet-0.1.19", + "@mojotech/json-type-validation": "^3.1.0" + } + }, "common/frontend/daml.js/splice-wallet-payments-0.1.9": { "name": "@daml.js/splice-wallet-payments-0.1.9", "version": "0.0.0", @@ -1393,6 +1457,7 @@ "common/frontend/daml.js/splitwell-0.1.19": { "name": "@daml.js/splitwell-0.1.19", "version": "0.0.0", + "extraneous": true, "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -1403,6 +1468,19 @@ "@mojotech/json-type-validation": "^3.1.0" } }, + "common/frontend/daml.js/splitwell-0.1.20": { + "name": "@daml.js/splitwell-0.1.20", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-amulet-0.1.19": "file:../splice-amulet-0.1.19", + "@daml.js/splice-wallet-payments-0.1.19": "file:../splice-wallet-payments-0.1.19", + "@mojotech/json-type-validation": "^3.1.0" + } + }, "common/frontend/daml.js/splitwell-0.1.9": { "name": "@daml.js/splitwell-0.1.9", "version": "0.0.0", @@ -1806,7 +1884,7 @@ } }, "node_modules/@daml.js/ans": { - "resolved": "common/frontend/daml.js/splice-amulet-name-service-0.1.19", + "resolved": "common/frontend/daml.js/splice-amulet-name-service-0.1.20", "link": true }, "node_modules/@daml.js/daml-prim-DA-Types-1.0.0": { @@ -1826,15 +1904,15 @@ "link": true }, "node_modules/@daml.js/splice-amulet": { - "resolved": "common/frontend/daml.js/splice-amulet-0.1.18", + "resolved": "common/frontend/daml.js/splice-amulet-0.1.19", "link": true }, - "node_modules/@daml.js/splice-amulet-0.1.18": { - "resolved": "common/frontend/daml.js/splice-amulet-0.1.18", + "node_modules/@daml.js/splice-amulet-0.1.19": { + "resolved": "common/frontend/daml.js/splice-amulet-0.1.19", "link": true }, - "node_modules/@daml.js/splice-amulet-name-service-0.1.19": { - "resolved": "common/frontend/daml.js/splice-amulet-name-service-0.1.19", + "node_modules/@daml.js/splice-amulet-name-service-0.1.20": { + "resolved": "common/frontend/daml.js/splice-amulet-name-service-0.1.20", "link": true }, "node_modules/@daml.js/splice-api-featured-app-v1-1.0.0": { @@ -1878,7 +1956,7 @@ "link": true }, "node_modules/@daml.js/splice-dso-governance": { - "resolved": "common/frontend/daml.js/splice-dso-governance-0.1.24", + "resolved": "common/frontend/daml.js/splice-dso-governance-0.1.25", "link": true }, "node_modules/@daml.js/splice-validator-lifecycle": { @@ -1886,19 +1964,19 @@ "link": true }, "node_modules/@daml.js/splice-wallet": { - "resolved": "common/frontend/daml.js/splice-wallet-0.1.19", + "resolved": "common/frontend/daml.js/splice-wallet-0.1.20", "link": true }, "node_modules/@daml.js/splice-wallet-payments": { - "resolved": "common/frontend/daml.js/splice-wallet-payments-0.1.18", + "resolved": "common/frontend/daml.js/splice-wallet-payments-0.1.19", "link": true }, - "node_modules/@daml.js/splice-wallet-payments-0.1.18": { - "resolved": "common/frontend/daml.js/splice-wallet-payments-0.1.18", + "node_modules/@daml.js/splice-wallet-payments-0.1.19": { + "resolved": "common/frontend/daml.js/splice-wallet-payments-0.1.19", "link": true }, "node_modules/@daml.js/splitwell": { - "resolved": "common/frontend/daml.js/splitwell-0.1.19", + "resolved": "common/frontend/daml.js/splitwell-0.1.20", "link": true }, "node_modules/@daml/ledger": { diff --git a/apps/package.json b/apps/package.json index fb68a7d573..7b86d7f274 100644 --- a/apps/package.json +++ b/apps/package.json @@ -21,13 +21,13 @@ "wallet/external-openapi-ts-client" ], "dependencies": { - "@daml.js/ans": "file:common/frontend/daml.js/splice-amulet-name-service-0.1.19", - "@daml.js/splice-amulet": "file:common/frontend/daml.js/splice-amulet-0.1.18", - "@daml.js/splice-dso-governance": "file:common/frontend/daml.js/splice-dso-governance-0.1.24", + "@daml.js/ans": "file:common/frontend/daml.js/splice-amulet-name-service-0.1.20", + "@daml.js/splice-amulet": "file:common/frontend/daml.js/splice-amulet-0.1.19", + "@daml.js/splice-dso-governance": "file:common/frontend/daml.js/splice-dso-governance-0.1.25", "@daml.js/splice-validator-lifecycle": "file:common/frontend/daml.js/splice-validator-lifecycle-0.1.6", - "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.19", - "@daml.js/splice-wallet-payments": "file:common/frontend/daml.js/splice-wallet-payments-0.1.18", - "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.19", + "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.20", + "@daml.js/splice-wallet-payments": "file:common/frontend/daml.js/splice-wallet-payments-0.1.19", + "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.20", "xunit-viewer": "^10.6.1" } } diff --git a/apps/scan/frontend/src/__tests__/mocks/data.ts b/apps/scan/frontend/src/__tests__/mocks/data.ts index 56b142f7cc..d11cd445e1 100644 --- a/apps/scan/frontend/src/__tests__/mocks/data.ts +++ b/apps/scan/frontend/src/__tests__/mocks/data.ts @@ -177,6 +177,7 @@ export function amuletRules(zeroTransferFees: boolean): any { featuredAppActivityMarkerAmount: null, optDevelopmentFundManager: null, externalPartyConfigStateTickDuration: null, + rewardConfig: null, }, futureValues: [], }, 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 0c4c6268f0..0798a30db1 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 @@ -386,6 +386,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..23386aee42 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 @@ -13,7 +13,11 @@ import org.lfdecentralizedtrust.splice.automation.{ } 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} @@ -28,12 +32,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 @@ -59,17 +62,44 @@ class RewardComputationTrigger( earliestCompleteO <- appActivityStore.earliestRoundWithCompleteAppActivity() latestCompleteO <- appActivityStore.latestRoundWithCompleteAppActivity() latestComputedO <- appRewardsStore.lookupLatestRoundWithRewardComputation() - - // 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, - ) + // While no rewards have been computed yet, skip pre-CIP-104 rounds + // by finding the earliest round with rewardConfig set. + earliestComputableO <- latestComputedO match { + case Some(_) => Future.successful(earliestCompleteO) + case None => earliestRoundWithRewardConfig(earliestCompleteO, latestCompleteO) + } + candidateRoundO = RewardComputationTrigger.nextRound( + earliestComputableO, + latestCompleteO, + latestComputedO, + ) + // Returns Seq.empty (no task created) when data is unavailable. + // This skips the round for this poll cycle — the trigger will poll + // again on its next interval. Unlike a failing task, this does not + // consume one of the trigger's limited task retries (which, once + // exhausted, cause the task to be abandoned with a log warning). + task <- candidateRoundO match { + case None => Future.successful(Seq.empty) + case Some(roundNumber) => + rewardsReferenceStore.lookupOpenMiningRoundByNumber(roundNumber).map { + case None => + logger.debug( + s"OpenMiningRound for round $roundNumber not yet ingested, waiting." + ) + Seq.empty + case Some(contract) => + RewardComputationInputs.fromOpenMiningRound(contract.payload) match { + case None => + logger.debug( + s"Round $roundNumber missing rewardConfig or trafficPrice, skipping." + ) + Seq.empty + case Some((inputs, batchSize)) => + Seq(RewardComputationTrigger.Task(roundNumber, batchSize, inputs)) + } + } + } + } yield task } override protected def completeTask( @@ -95,6 +125,49 @@ class RewardComputationTrigger( .lookupLatestRoundWithRewardComputation() .map(_.exists(_ >= task.roundNumber)) + /** Gate + linear search for the earliest round with rewardConfig. + * First checks that the latest complete round has rewardConfig (i.e. CIP-104 + * is active). If not, returns None to skip entirely. If yes, searches backward + * from latestComplete to find the earliest contiguous round with rewardConfig. + * + * Called each poll cycle until a reward is successfully computed. + * Before CIP-104 activates, only the gate check runs (one indexed lookup + * on the latest complete round), so repeated polling is cheap. + */ + private def earliestRoundWithRewardConfig( + earliestCompleteO: Option[Long], + latestCompleteO: Option[Long], + )(implicit tc: TraceContext): Future[Option[Long]] = + (earliestCompleteO, latestCompleteO) match { + case (Some(from), Some(to)) => + rewardsReferenceStore.lookupOpenMiningRoundByNumber(to).flatMap { + case Some(c) if c.payload.rewardConfig.isPresent => + searchBackward(from, to).map { result => + logger.debug(s"Earliest round with rewardConfig: $result") + Some(result) + } + case _ => + logger.debug( + "CIP-104 not yet active (latest complete round has no rewardConfig), skipping." + ) + Future.successful(None) + } + case _ => Future.successful(None) + } + + private def searchBackward( + from: Long, + candidate: Long, + )(implicit tc: TraceContext): Future[Long] = + if (candidate <= from) Future.successful(candidate) + else + rewardsReferenceStore.lookupOpenMiningRoundByNumber(candidate - 1).flatMap { + case Some(c) if c.payload.rewardConfig.isPresent => + searchBackward(from, candidate - 1) + case _ => + Future.successful(candidate) + } + override def closeAsync(): Seq[AsyncOrSyncCloseable] = super.closeAsync() :+ SyncCloseable("RewardComputationMetrics", rewardMetrics.close()) @@ -113,46 +186,20 @@ object RewardComputationTrigger { /** 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( + def nextRound( earliestCompleteO: Option[Long], latestCompleteO: Option[Long], latestComputedO: Option[Long], - batchSize: Int, - inputs: RewardComputationInputs, - ): Seq[Task] = + ): Option[Long] = (earliestCompleteO, latestCompleteO) match { - case (Some(earliestComplete), Some(latestComplete)) => + case (Some(earliestComplete), Some(latestComplete)) if earliestComplete <= latestComplete => val start = math.max(earliestComplete, latestComputedO.fold(0L)(_ + 1)) - if (start <= latestComplete) - Seq(Task(start, batchSize, inputs)) - else Seq.empty - case _ => Seq.empty + if (start <= latestComplete) Some(start) + else None + case _ => None } - // 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")), - ) - } } 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 b0239fc708..b5d4e4d8d9 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,7 +8,9 @@ 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.round.OpenMiningRound import org.lfdecentralizedtrust.splice.store.{Limit, MultiDomainAcsStore, SynchronizerStore} +import org.lfdecentralizedtrust.splice.util.Contract import scala.concurrent.{ExecutionContext, Future} @@ -50,6 +52,13 @@ class CachingScanRewardsReferenceStore private[splice] ( )(implicit tc: TraceContext): Future[Set[String]] = featuredAppPartiesCache.get(asOf) + override def lookupOpenMiningRoundByNumber( + roundNumber: Long + )(implicit + tc: TraceContext + ): Future[Option[Contract[OpenMiningRound.ContractId, OpenMiningRound]]] = + store.lookupOpenMiningRoundByNumber(roundNumber) + 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/ScanRewardsReferenceStore.scala b/apps/scan/src/main/scala/org/lfdecentralizedtrust/splice/scan/store/ScanRewardsReferenceStore.scala index b55ed62aa8..b816c1a04b 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,13 +11,14 @@ 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.round.OpenMiningRound import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo import org.lfdecentralizedtrust.splice.scan.store.db.ScanRewardsReferenceTables.ScanRewardsReferenceStoreRowData import org.lfdecentralizedtrust.splice.store.{AppStore, Limit, MultiDomainAcsStore} import org.lfdecentralizedtrust.splice.store.db.AcsInterfaceViewRowData -import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder +import org.lfdecentralizedtrust.splice.util.{Contract, TemplateJsonDecoder} import scala.concurrent.{ExecutionContext, Future} @@ -60,6 +61,16 @@ trait ScanRewardsReferenceStore extends AppStore { asOf: CantonTimestamp )(implicit tc: TraceContext): Future[Set[String]] + /** Look up an OpenMiningRound contract by its round number. + * Checks both the active ACS table and the archive table, + * since the round may have already been closed by the time the trigger runs. + */ + def lookupOpenMiningRoundByNumber( + roundNumber: Long + )(implicit + tc: TraceContext + ): Future[Option[Contract[OpenMiningRound.ContractId, OpenMiningRound]]] + override lazy val acsContractFilter: MultiDomainAcsStore.ContractFilter[ ScanRewardsReferenceStoreRowData, AcsInterfaceViewRowData.NoInterfacesIngested, 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 284755027d..687f14a2fb 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 @@ -100,7 +100,7 @@ class DbAppActivityRecordStore( select 1 from #${Tables.appActivityRecords} a join #${Tables.verdicts} v on a.verdict_row_id = v.row_id - where a.round_number = sub.min_round + 1 + where a.round_number > sub.min_round and v.history_id = $historyId ) """.as[Option[Long]].headOption.map(_.flatten), @@ -109,10 +109,10 @@ class DbAppActivityRecordStore( } /** Find the latest round with complete app activity. - * A round is complete when ingestion has moved past it, i.e., the next + * A round is complete when ingestion has moved past it, i.e., a later * round also has records. We return max_round - 1 because max_round * itself may still be receiving records. - * Returns None if fewer than two consecutive rounds have been ingested. + * Returns None if fewer than two distinct rounds have been ingested. */ def latestRoundWithCompleteAppActivity()(implicit tc: TraceContext @@ -130,7 +130,7 @@ class DbAppActivityRecordStore( select 1 from #${Tables.appActivityRecords} a join #${Tables.verdicts} v on a.verdict_row_id = v.row_id - where a.round_number = sub.max_round - 1 + where a.round_number < sub.max_round and v.history_id = $historyId ) """.as[Option[Long]].headOption.map(_.flatten), @@ -138,9 +138,9 @@ class DbAppActivityRecordStore( ) } - /** Assert that activity records exist for rounds roundNumber - 1 and - * roundNumber + 1, proving ingestion completeness for roundNumber. - * Round N-1 proves ingestion was running before N; round N+1 proves + /** Assert that activity records exist for a round before and a round after + * roundNumber, proving ingestion completeness for roundNumber. + * A prior round proves ingestion was running before N; a later round proves * ingestion has moved past N, so all of N's records have been ingested. */ def assertCompleteActivity(roundNumber: Long)(implicit @@ -152,13 +152,13 @@ class DbAppActivityRecordStore( hasPrev <- sql"""select exists( select 1 from #${Tables.appActivityRecords} a join #${Tables.verdicts} v on a.verdict_row_id = v.row_id - where a.round_number = ${roundNumber - 1} + where a.round_number < $roundNumber and v.history_id = $historyId )""".as[Boolean].head hasNext <- sql"""select exists( select 1 from #${Tables.appActivityRecords} a join #${Tables.verdicts} v on a.verdict_row_id = v.row_id - where a.round_number = ${roundNumber + 1} + where a.round_number > $roundNumber and v.history_id = $historyId )""".as[Boolean].head _ = if (!hasPrev || !hasNext) 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 2cd8be9831..a7de128f63 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 @@ -18,11 +18,21 @@ import org.lfdecentralizedtrust.splice.scan.store.ScanRewardsReferenceStore import org.lfdecentralizedtrust.splice.store.{Limit, TcsStore} import org.lfdecentralizedtrust.splice.store.db.{ AcsArchiveConfig, + AcsQueries, + AcsTables, DbAppStore, DbTcsStore, StoreDescriptor, } -import org.lfdecentralizedtrust.splice.util.{ContractWithState, TemplateJsonDecoder} +import org.lfdecentralizedtrust.splice.store.db.AcsQueries.SelectFromAcsTableResult +import org.lfdecentralizedtrust.splice.util.{ + Contract, + ContractWithState, + PackageQualifiedName, + TemplateJsonDecoder, +} +import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton import scala.concurrent.{ExecutionContext, Future} @@ -62,7 +72,9 @@ class DbScanRewardsReferenceStore( ) ), ) - with ScanRewardsReferenceStore { + with ScanRewardsReferenceStore + with AcsTables + with AcsQueries { override def waitUntilInitialized: Future[Unit] = multiDomainAcsStore.waitUntilAcsIngested() @@ -137,4 +149,47 @@ class DbScanRewardsReferenceStore( tc: TraceContext ): Future[Seq[ContractWithState[OpenMiningRound.ContractId, OpenMiningRound]]] = tcsStore.listAllContractsAsOf(OpenMiningRound.COMPANION, asOf) + + override def lookupOpenMiningRoundByNumber( + roundNumber: Long + )(implicit + tc: TraceContext + ): Future[Option[Contract[OpenMiningRound.ContractId, OpenMiningRound]]] = + waitUntilInitialized.flatMap { _ => + lookupOpenMiningRoundByNumberQuery(roundNumber) + } + + private def lookupOpenMiningRoundByNumberQuery( + roundNumber: Long + )(implicit + tc: TraceContext + ): Future[Option[Contract[OpenMiningRound.ContractId, OpenMiningRound]]] = { + val storeId = multiDomainAcsStore.acsStoreId + val migrationId = multiDomainAcsStore.domainMigrationId + val pqn = PackageQualifiedName.fromJavaCodegenCompanion(OpenMiningRound.COMPANION) + val columns = SelectFromAcsTableResult.sqlColumnsCommaSeparated() + val query = + sql"""( + select #$columns + from #${ScanRewardsReferenceTables.acsTableName} acs + where acs.store_id = $storeId + and acs.migration_id = $migrationId + and acs.package_name = ${pqn.packageName} + and acs.template_id_qualified_name = ${pqn.qualifiedName} + and acs.round = $roundNumber + ) union all ( + select #$columns + from #${ScanRewardsReferenceTables.archiveTableName} acs + where acs.store_id = $storeId + and acs.migration_id = $migrationId + and acs.package_name = ${pqn.packageName} + and acs.template_id_qualified_name = ${pqn.qualifiedName} + and acs.round = $roundNumber + ) limit 1""".as[SelectFromAcsTableResult] + for { + result <- futureUnlessShutdownToFuture( + storage.query(query, "lookupOpenMiningRoundByNumber") + ) + } yield result.headOption.map(contractFromRow(OpenMiningRound.COMPANION)(_)) + } } 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 index b69a6e38e6..bb50fc92ee 100644 --- 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 @@ -1,50 +1,47 @@ 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 RewardComputationTrigger.nextRound import RewardComputationTriggerTest.* - private def next( - earliest: Option[Long], - latest: Option[Long], - computed: Option[Long], - ) = nextTask(earliest, latest, computed, testBatchSize, testInputs) + "RewardComputationTrigger.nextRound" should { - "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 None when no complete activity data exists" in { + nextRound(noRound, noRound, noRound) shouldBe None + nextRound(round5, noRound, noRound) shouldBe None + nextRound(noRound, round10, noRound) shouldBe None } "return the earliest complete round when nothing has been computed" in { - next(round3, round10, noRound) shouldBe Seq(task(3)) + nextRound(round3, round10, noRound) shouldBe Some(3L) } "return the round after the latest computed" in { - next(round3, round10, round5) shouldBe Seq(task(6)) + nextRound(round3, round10, round5) shouldBe Some(6L) } "return earliest complete when it is ahead of latest computed" in { - next(round8, round10, round5) shouldBe Seq(task(8)) + nextRound(round8, round10, round5) shouldBe Some(8L) + } + + "return None when all complete rounds have been computed" in { + nextRound(round3, round10, round10) shouldBe None + nextRound(round3, round10, round15) shouldBe None } - "return empty when all complete rounds have been computed" in { - next(round3, round10, round10) shouldBe empty - next(round3, round10, round15) shouldBe empty + "return round when earliest equals latest complete" in { + nextRound(round5, round5, noRound) shouldBe Some(5L) + nextRound(round5, round5, round4) shouldBe Some(5L) + nextRound(round5, round5, round5) shouldBe None } - "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 + "return None when earliest complete exceeds latest complete" in { + nextRound(round10, round5, noRound) shouldBe None + nextRound(round10, round5, round3) shouldBe None } } } @@ -57,22 +54,4 @@ object RewardComputationTriggerTest { 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/DbAppActivityRecordStoreTest.scala b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala index 906f938b60..6a0fbe6476 100644 --- a/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala +++ b/apps/scan/src/test/scala/org/lfdecentralizedtrust/splice/scan/store/DbAppActivityRecordStoreTest.scala @@ -319,12 +319,14 @@ class DbAppActivityRecordStoreTest } } - "return None when rounds are not consecutive" in { + // #5186: round with zero activity between non-zero rounds should not block progress + "treat a zero-activity round between non-zero rounds as complete" in { for { (store, historyId) <- newStore() baseTs = CantonTimestamp.now() rowId1 <- insertVerdictRow(historyId, baseTs, "update-gap-10") rowId2 <- insertVerdictRow(historyId, baseTs.plusSeconds(1L), "update-gap-12") + // Round 10 and 12 have activity, round 11 has zero activity (no records) _ <- store.insertAppActivityRecords( Seq( mkRecord(rowId1, 10L, Seq("app1::provider"), Seq(100L)), @@ -333,8 +335,10 @@ class DbAppActivityRecordStoreTest ) result <- store.earliestRoundWithCompleteAppActivity() } yield { - // No round has a prior round with records (11 is missing) - result shouldBe None + // Round 11 has zero activity but is bounded by 10 and 12, so 11 is complete. + // Round 12 is also complete (activity exists for a prior round: 10). + // Earliest complete should be 11. + result.value shouldBe 11L } } @@ -447,12 +451,14 @@ class DbAppActivityRecordStoreTest } } - "return None when rounds are not consecutive" in { + // #5186: round with zero activity between non-zero rounds should not block progress + "treat a zero-activity round between non-zero rounds as complete" in { for { (store, historyId) <- newStore() baseTs = CantonTimestamp.now() rowId1 <- insertVerdictRow(historyId, baseTs, "update-gap-10") rowId2 <- insertVerdictRow(historyId, baseTs.plusSeconds(1L), "update-gap-12") + // Round 10 and 12 have activity, round 11 has zero activity (no records) _ <- store.insertAppActivityRecords( Seq( mkRecord(rowId1, 10L, Seq("app1::provider"), Seq(100L)), @@ -461,7 +467,10 @@ class DbAppActivityRecordStoreTest ) result <- store.latestRoundWithCompleteAppActivity() } yield { - result shouldBe None + // Round 12 is the max round. Round 11 has zero activity but is bounded + // by rounds with data, so it is complete. Latest complete should be 11 + // (12 cannot be confirmed complete without seeing round 13). + result.value shouldBe 11L } } 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 d25282ac4d..005762f545 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, @@ -526,10 +525,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 +548,7 @@ class DbScanAppRewardsStoreTest summary <- store.computeAndStoreRewards( roundNumber, batchSize = 100, - RewardComputationTrigger.placeholderInputs, + testInputs, ) } yield { summary.activePartiesCount shouldBe 0L 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 2677c7d890..7901ada14e 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 @@ -180,6 +180,48 @@ class DbScanRewardsReferenceStoreTest } yield succeed } + "lookupOpenMiningRoundByNumber returns the correct contract" in { + val store = mkStore() + val omr3 = openMiningRound(dsoParty, round = 3, amuletPrice = 1.0) + .copy(createdAt = CantonTimestamp.ofEpochSecond(100).toInstant) + val omr4 = openMiningRound(dsoParty, round = 4, amuletPrice = 1.5) + .copy(createdAt = CantonTimestamp.ofEpochSecond(200).toInstant) + val omr5 = openMiningRound(dsoParty, round = 5, amuletPrice = 2.0) + .copy(createdAt = CantonTimestamp.ofEpochSecond(300).toInstant) + for { + _ <- initWithAcs()(store.multiDomainAcsStore) + _ <- sync1.create(omr3, recordTime = CantonTimestamp.ofEpochSecond(100).toInstant)( + store.multiDomainAcsStore + ) + _ <- sync1.create(omr4, recordTime = CantonTimestamp.ofEpochSecond(200).toInstant)( + store.multiDomainAcsStore + ) + _ <- sync1.create(omr5, recordTime = CantonTimestamp.ofEpochSecond(300).toInstant)( + store.multiDomainAcsStore + ) + // Archive round 3 — it should still be found in the archive table + _ <- sync1.archive(omr3, recordTime = CantonTimestamp.ofEpochSecond(350).toInstant)( + store.multiDomainAcsStore + ) + + // Round 3: archived — found via archive table + result3 <- store.lookupOpenMiningRoundByNumber(3) + _ = result3 shouldBe Some(omr3) + + // Round 4: still active — found via active table + result4 <- store.lookupOpenMiningRoundByNumber(4) + _ = result4 shouldBe Some(omr4) + + // Round 5: still active + result5 <- store.lookupOpenMiningRoundByNumber(5) + _ = result5 shouldBe Some(omr5) + + // Round 99: never existed + resultMissing <- store.lookupOpenMiningRoundByNumber(99) + _ = resultMissing shouldBe None + } yield succeed + } + "lookupActiveOpenMiningRounds" in { val store = mkStore() // Timeline (ingestion start = 250, earliest archived_at): diff --git a/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts b/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts index b03af7d36a..a0bea6e3d9 100644 --- a/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts +++ b/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts @@ -218,6 +218,36 @@ describe('buildAmuletRulesConfigFromChanges', () => { currentValue: '0.1.1', newValue: '0.2.0', }, + { + fieldName: 'rewardConfigMintingVersion', + label: 'Reward config: Minting version', + currentValue: 'RewardVersion_FeaturedAppMarkers', + newValue: 'RewardVersion_TrafficBasedAppRewards', + }, + { + fieldName: 'rewardConfigDryRunVersion', + label: 'Reward config: Dry-run version', + currentValue: '', + newValue: 'RewardVersion_TrafficBasedAppRewards', + }, + { + fieldName: 'rewardConfigBatchSize', + label: 'Reward config: Batch size', + currentValue: '100', + newValue: '200', + }, + { + fieldName: 'rewardConfigRewardCouponTimeToLive', + label: 'Reward config: Reward coupon time to live (microseconds)', + currentValue: '129600000000', + newValue: '259200000000', + }, + { + fieldName: 'rewardConfigAppRewardCouponThreshold', + label: 'Reward config: App reward coupon threshold ($)', + currentValue: '0.5', + newValue: '1.0', + }, ]; const result = buildAmuletRulesConfigFromChanges(changes); @@ -264,6 +294,14 @@ describe('buildAmuletRulesConfigFromChanges', () => { expect(result.packageConfig.validatorLifecycle).toBe('0.2.0'); expect(result.packageConfig.wallet).toBe('0.2.0'); expect(result.packageConfig.walletPayments).toBe('0.2.0'); + + expect(result.rewardConfig).toEqual({ + mintingVersion: 'RewardVersion_TrafficBasedAppRewards', + dryRunVersion: 'RewardVersion_TrafficBasedAppRewards', + batchSize: '200', + rewardCouponTimeToLive: { microseconds: '259200000000' }, + appRewardCouponThreshold: '1.0', + }); }); test('should handle multiple transfer fee steps', () => { diff --git a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts index 5597780364..ac14e26031 100644 --- a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts @@ -1,7 +1,11 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 import { Optional } from '@daml/types'; -import { AmuletConfig, PackageConfig } from '@daml.js/splice-amulet/lib/Splice/AmuletConfig'; +import { + AmuletConfig, + PackageConfig, + RewardConfig, +} from '@daml.js/splice-amulet/lib/Splice/AmuletConfig'; import { Tuple2 } from '@daml.js/daml-prim-DA-Types-1.0.0/lib/DA/Types'; import { Set as DamlSet } from '@daml.js/daml-stdlib-DA-Set-Types-1.0.0/lib/DA/Set/Types'; import { RelTime } from '@daml.js/daml-stdlib-DA-Time-Types-1.0.0/lib/DA/Time/Types'; @@ -105,6 +109,8 @@ export function buildAmuletConfigChanges( ), ...buildPackageConfigChanges(before?.packageConfig, after?.packageConfig), + + ...buildRewardConfigChanges(before?.rewardConfig, after?.rewardConfig), ] as ConfigChange[]; return showAllFields ? changes : changes.filter(c => c.currentValue !== c.newValue); @@ -308,6 +314,44 @@ function buildIssuanceCurveChanges( return [...initialValues, ...futureValues]; } +function buildRewardConfigChanges( + before: RewardConfig | null | undefined, + after: RewardConfig | null | undefined +) { + return [ + { + fieldName: 'rewardConfigMintingVersion', + label: 'Reward config: Minting version', + currentValue: before?.mintingVersion || '', + newValue: after?.mintingVersion || '', + }, + { + fieldName: 'rewardConfigDryRunVersion', + label: 'Reward config: Dry-run version', + currentValue: before?.dryRunVersion || '', + newValue: after?.dryRunVersion || '', + }, + { + fieldName: 'rewardConfigBatchSize', + label: 'Reward config: Batch size', + currentValue: before?.batchSize || '', + newValue: after?.batchSize || '', + }, + { + fieldName: 'rewardConfigRewardCouponTimeToLive', + label: 'Reward config: Reward coupon time to live (microseconds)', + currentValue: before?.rewardCouponTimeToLive.microseconds || '', + newValue: after?.rewardCouponTimeToLive.microseconds || '', + }, + { + fieldName: 'rewardConfigAppRewardCouponThreshold', + label: 'Reward config: App reward coupon threshold ($)', + currentValue: before?.appRewardCouponThreshold || '', + newValue: after?.appRewardCouponThreshold || '', + }, + ] as ConfigChange[]; +} + function buildDecentralizedSynchronizerChanges( before: AmuletDecentralizedSynchronizerConfig | undefined, after: AmuletDecentralizedSynchronizerConfig | undefined diff --git a/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts b/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts index ce9a8ab1d6..ab43f8a833 100644 --- a/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts @@ -1,7 +1,7 @@ // Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { AmuletConfig } from '@daml.js/splice-amulet/lib/Splice/AmuletConfig'; +import { AmuletConfig, RewardVersion } from '@daml.js/splice-amulet/lib/Splice/AmuletConfig'; import { Tuple2 } from '@daml.js/daml-prim-DA-Types-1.0.0/lib/DA/Types'; import * as damlTypes from '@daml/types'; import { RelTime } from '@daml.js/daml-stdlib-DA-Time-Types-1.0.0/lib/DA/Time/Types'; @@ -85,6 +85,7 @@ export function buildAmuletRulesConfigFromChanges( 'issuanceCurveInitialValueOptDevelopmentFundPercentage' ); const externalPartyConfigStateTickDuration = getValue('externalPartyConfigStateTickDuration'); + const rewardConfigMintingVersion = getValue('rewardConfigMintingVersion'); const amuletConfig: AmuletConfig<'USD'> = { tickDuration: { microseconds: getValue('tickDuration') }, transferPreapprovalFee: transferPreapprovalFee === '' ? null : transferPreapprovalFee, @@ -147,6 +148,22 @@ export function buildAmuletRulesConfigFromChanges( wallet: getValue('packageConfigWallet'), walletPayments: getValue('packageConfigWalletPayments'), }, + + rewardConfig: + rewardConfigMintingVersion === '' + ? null + : { + mintingVersion: getValue('rewardConfigMintingVersion') as RewardVersion, + dryRunVersion: + getValue('rewardConfigDryRunVersion') === '' + ? null + : (getValue('rewardConfigDryRunVersion') as RewardVersion), + batchSize: getValue('rewardConfigBatchSize'), + rewardCouponTimeToLive: { + microseconds: getValue('rewardConfigRewardCouponTimeToLive'), + }, + appRewardCouponThreshold: getValue('rewardConfigAppRewardCouponThreshold'), + }, }; return amuletConfig; 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 c264692a82..00e8459148 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( @@ -243,6 +244,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 bd40943881..4c0d362742 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 @@ -725,6 +725,7 @@ class SV1Initializer( initialExternalPartyConfigStateTickDuration = sv1Config.initialExternalPartyConfigStateTickDuration, optValidatorFaucetCap = sv1Config.optValidatorFaucetCap, + initialRewardConfig = sv1Config.initialRewardConfig.map(_.toRewardConfig), ) sv1SynchronizerNodes <- SvUtil.getSV1SynchronizerNodeConfig( synchronizerNodeService.nodes.current.cometbftNode, diff --git a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala index 1b932b4bd2..5fed332d0c 100644 --- a/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala +++ b/apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/store/SvDsoStore.scala @@ -1265,6 +1265,35 @@ object SvDsoStore { rewardWeight = Some(contract.payload.weight), ) }, + mkFilter(splice.amulet.RewardCouponV2.COMPANION)(co => co.payload.dso == dso) { contract => + DsoAcsStoreRowData( + contract, + rewardRound = Some(contract.payload.round.number), + rewardParty = Some( + PartyId.tryFromProtoPrimitive( + contract.payload.beneficiary.orElse(contract.payload.provider) + ) + ), + rewardAmount = Some(contract.payload.amount), + contractExpiresAt = Some(Timestamp.assertFromInstant(contract.payload.expiresAt)), + ) + }, + mkFilter(splice.amulet.rewardaccountingv2.CalculateRewardsV2.COMPANION)(co => + co.payload.dso == dso + ) { contract => + DsoAcsStoreRowData( + contract, + rewardRound = Some(contract.payload.round.number), + ) + }, + mkFilter(splice.amulet.rewardaccountingv2.ProcessRewardsV2.COMPANION)(co => + co.payload.dso == dso + ) { contract => + DsoAcsStoreRowData( + contract, + rewardRound = Some(contract.payload.round.number), + ) + }, mkFilter(splice.round.OpenMiningRound.COMPANION)(co => co.payload.dso == dso) { contract => DsoAcsStoreRowData( contract, diff --git a/build.sbt b/build.sbt index 35b5c1ac2e..a9779b1d47 100644 --- a/build.sbt +++ b/build.sbt @@ -111,6 +111,7 @@ lazy val root: Project = (project in file(".")) `splice-dso-governance-test-daml`, `splice-validator-lifecycle-daml`, `splice-validator-lifecycle-test-daml`, + `splice-api-featured-app-v1-daml`, `splice-api-token-metadata-v1-daml`, `splice-api-token-holding-v1-daml`, `splice-api-token-transfer-instruction-v1-daml`, @@ -652,7 +653,7 @@ lazy val `splice-util-daml` = BuildCommon.damlSettings ) -lazy val `splice-featured-app-api-v1-daml` = +lazy val `splice-api-featured-app-v1-daml` = project .in(file("daml/splice-api-featured-app-v1")) .enablePlugins(DamlPlugin) @@ -661,9 +662,16 @@ lazy val `splice-featured-app-api-v1-daml` = Compile / damlPrebuiltDar := Some( (LocalRootProject / baseDirectory).value / "daml" / "dars" / "splice-api-featured-app-v1-1.0.0.dar" ), + // Exclude header check for FeaturedAppRightV1.daml and its generated docs as it exists on main without a header, + // and adding one would change its DAR hash, cascading through all dependent packages. + Compile / headerSources ~= { + _.filterNot(f => + f.getName == "FeaturedAppRightV1.daml" || f.getName == "Splice-Api-FeaturedAppRightV1.rst" + ) + }, ) -lazy val `splice-featured-app-api-v2-daml` = +lazy val `splice-api-featured-app-v2-daml` = project .in(file("daml/splice-api-featured-app-v2")) .enablePlugins(DamlPlugin) @@ -688,8 +696,8 @@ lazy val `splice-amulet-daml` = (`splice-api-token-allocation-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-request-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-instruction-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v2-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value ++ + (`splice-api-featured-app-v2-daml` / Compile / damlBuild).value, ) lazy val `splice-amulet-test-daml` = @@ -792,8 +800,8 @@ lazy val `splice-util-featured-app-proxies-daml` = (`splice-api-token-transfer-instruction-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-allocation-instruction-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v2-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value ++ + (`splice-api-featured-app-v2-daml` / Compile / damlBuild).value, ) lazy val `splice-util-token-standard-wallet-daml` = @@ -806,8 +814,8 @@ lazy val `splice-util-token-standard-wallet-daml` = (`splice-api-token-holding-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-metadata-v1-daml` / Compile / damlBuild).value ++ (`splice-api-token-transfer-instruction-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v2-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value ++ + (`splice-api-featured-app-v2-daml` / Compile / damlBuild).value, ) lazy val `splice-util-featured-app-proxies-test-daml` = @@ -841,8 +849,8 @@ lazy val `splice-util-batched-markers-daml` = .settings( BuildCommon.damlSettings, Compile / damlDependencies := - (`splice-featured-app-api-v1-daml` / Compile / damlBuild).value ++ - (`splice-featured-app-api-v2-daml` / Compile / damlBuild).value, + (`splice-api-featured-app-v1-daml` / Compile / damlBuild).value ++ + (`splice-api-featured-app-v2-daml` / Compile / damlBuild).value, ) lazy val `splice-util-batched-markers-test-daml` = @@ -932,8 +940,8 @@ lazy val `apps-common` = `splice-api-token-allocation-instruction-v1-daml`, `splice-token-test-dummy-holding-daml`, `splice-token-test-trading-app-daml`, - `splice-featured-app-api-v1-daml`, - `splice-featured-app-api-v2-daml`, + `splice-api-featured-app-v1-daml`, + `splice-api-featured-app-v2-daml`, `splice-util-batched-markers-daml`, ) .enablePlugins(BuildInfoPlugin) @@ -2004,8 +2012,8 @@ lazy val `apps-dar-resources-generator` = `splice-api-token-allocation-instruction-v1-daml`, `splice-token-test-dummy-holding-daml`, `splice-token-test-trading-app-daml`, - `splice-featured-app-api-v1-daml`, - `splice-featured-app-api-v2-daml`, + `splice-api-featured-app-v1-daml`, + `splice-api-featured-app-v2-daml`, `splice-util-batched-markers-daml`, ) .settings( diff --git a/daml/dars.lock b/daml/dars.lock index b5a4149581..095690e80e 100644 --- a/daml/dars.lock +++ b/daml/dars.lock @@ -9,6 +9,7 @@ splice-amulet 0.1.15 67fac2f853bce8dbf0b9817bb5ba7c59f10e8120b7c808696f7010e5f0c splice-amulet 0.1.16 c208d7ead1e4e9b610fc2054d0bf00716144ad444011bce0b02dcd6cd0cb8a23 splice-amulet 0.1.17 6c5802f86709a0ad4784af81f0bab40f3070b2f58128d8843da1e1784c147802 splice-amulet 0.1.18 e1353a59f1b3d471fd4e700eb134e7f2ab4e8e24a71c0d7d7a580fddf3239674 +splice-amulet 0.1.19 63b48b37ce4f64884133eefb3c17ffdacc3bf80396e3a4b95fdec741a58c3eed splice-amulet 0.1.2 1446ffdf23326cef2de97923df96618eb615792bea36cf1431f03639448f1645 splice-amulet 0.1.3 0d89016d5a90eb8bced48bbac99e81c57781b3a36094b8d48b8e4389851e19af splice-amulet 0.1.4 a36ef8888fb44caae13d96341ce1fabd84fc9e2e7b209bbc3caabb48b6be1668 @@ -30,6 +31,7 @@ splice-amulet-name-service 0.1.17 bcc80dce253c7b89efd9b263be5260a9609f8cb1fb5ea6 splice-amulet-name-service 0.1.18 64232089d6dc6ae1eabcebcbe5e8b1aa8f413e9c57e8986d2bca883cc306fde2 splice-amulet-name-service 0.1.19 6a1df284761ef320a08fe468efa22918a2a86065fd2536376ae5ad52703dc380 splice-amulet-name-service 0.1.2 711a2974d65e6ebd149704da75f3f71234798687ab895b92f066c865dbdeeabb +splice-amulet-name-service 0.1.20 0cde9b7ebc0ea8ba06bbe484e30a78b07e686df2044dca7950676d7320f10411 splice-amulet-name-service 0.1.3 beb4b85f3f0cf36dfb93fc917d3ac218ee5d41b6e70604720cb228d85e168ee0 splice-amulet-name-service 0.1.4 053c7f4c2a77312e7d465a4fa7dc8cb298754ad12c0c987a7c401bd724e65efc splice-amulet-name-service 0.1.5 6188c8b5f612278f988fc95c11e9742993ad3ac6ad0809f9af06ee9d366dc4a8 @@ -37,8 +39,8 @@ splice-amulet-name-service 0.1.6 a208aab2c4a248ab2eff352bd382f8b3bbadc92464123db splice-amulet-name-service 0.1.7 ba7806d9b2d593eac74a050161c54ae1325d170bf175cb66a9c1e5e5ffb88c3d splice-amulet-name-service 0.1.8 efeb3f9b2b92e55fac4ec2d6164f95407a01477240c7465e576df4e310f54bd3 splice-amulet-name-service 0.1.9 f1b5915ad45ded616f43f83c735b7ee158b5eb58abe758a721e50eee19b3e531 -splice-amulet-name-service-test 0.1.22 2efd2324bf29d3f44abb3bddc64080e95e1f2d6992cbb658297a5fea645fc8fd -splice-amulet-test 0.1.21 f7d715f8b87bbaf8d8e6e42a673fa63e4f1f6cf66220361bbd02ad96f93110a8 +splice-amulet-name-service-test 0.1.23 b6fa2d3007ff0298e2889f2cab9ee1d0f02f187db3ed139f37b65cadc36bc57e +splice-amulet-test 0.1.22 de4be9668174d8855fe2404abb65bccbb835c18114ec232839da9e6f370d3820 splice-api-featured-app-v1 1.0.0 7804375fe5e4c6d5afe067bd314c42fe0b7d005a1300019c73154dd939da4dda splice-api-featured-app-v2 1.0.0 dd22e3e168a8c7fd0313171922dabf1f7a3b131bd9bfc9ff98e606f8c57707ea splice-api-token-allocation-instruction-v1 1.0.0 275064aacfe99cea72ee0c80563936129563776f67415ef9f13e4297eecbc520 @@ -68,6 +70,7 @@ splice-dso-governance 0.1.21 2d306cfe8cdb3daf2d21f84dfecc3e2f26a41504e58fe25cb7f splice-dso-governance 0.1.22 5c28530209b9ab37c5f187132cd826709bb18b0efe28411488ab750870414738 splice-dso-governance 0.1.23 0c94a036ac5168a1dee26b435838e062f0d2f47d6eac49303978228ae559edb9 splice-dso-governance 0.1.24 540bb6cdfeaf57c2dc518cb28fb669336cd55efe5bc198a059aea7b20efcbe4c +splice-dso-governance 0.1.25 d69188062eb02ac3f0c97ef5a58e02c70080072293fc093609cab48723e094e6 splice-dso-governance 0.1.3 b0ae3cc03e418790305a3c15f761fe495572de5827f8d322fb8b96996b783c13 splice-dso-governance 0.1.4 dc24fd18b4d151cd1e0ff6bfb7438bafb2f50fe076d0f16f50565e60b153a0be splice-dso-governance 0.1.5 9e3ca1d22ad495dfabf3d61acae3dc1a7718f527f02092280b58cf69edfdc84c @@ -75,8 +78,8 @@ splice-dso-governance 0.1.6 4e7653cfbf7ca249de4507aca9cd3b91060e5489042a522c589d splice-dso-governance 0.1.7 d406eba1132d464605f4dae3edf8cf5ecbbb34bd8edef0e047e7e526d328718c splice-dso-governance 0.1.8 1790a114f83d5f290261fae1e7e46fba75a861a3dd603c6b4ef6b67b49053948 splice-dso-governance 0.1.9 9ee83bfd872f91e659b8a8439c5b4eaf240bcf6f19698f884d7d7993ab48c401 -splice-dso-governance-test 0.1.29 7285cd4c1a87fc02229e2e4243462089583d95f3bd1cbc177604b1865351cf77 -splice-token-standard-test 1.0.12 fac627b26a2bfb0917776993f9944cfe05a27c6eeaf4f8389990e7a0ad3de60f +splice-dso-governance-test 0.1.30 9c41327184c94a7ae63be2e5ec372da53d95b5bf8ec88d8148af2da854be32bd +splice-token-standard-test 1.0.13 7d1fa6161633dc6998ad6e56653990e6d17fe0bab2a5f12a0500974a08fa0a41 splice-token-test-dummy-holding 0.0.1 1cd171c6c42ab46dc9cf12d80c6111369e00cea5cdf054924b4f26ce94b1ef5b splice-token-test-dummy-holding 0.0.2 4f40fb033ef3db89623642c1b494e846097fa32af138b3864a63aa15937a323d splice-token-test-trading-app 1.0.0 e5c9847d5a88d3b8d65436f01765fc5ba142cc58529692e2dacdd865d9939f71 @@ -88,15 +91,15 @@ splice-util 0.1.4 b7356fbb2cf8a3b22194d8c743c3c216d9c7527b257c8c38b257eb22942be3 splice-util 0.1.5 5a58024e2cc488ca9e0c952ec7ef41da3a1ed0a78ba23bacd819e5b30afb5546 splice-util-batched-markers 1.0.0 727c5e97457d3ff841680816eb70d55834827ef756bac8551cace5b961c9c1d2 splice-util-batched-markers 1.0.1 4d91a9b044e0e996e91ee9aac3442591ffc78f16da4ff5c6f55218ba667f6192 -splice-util-batched-markers-test 1.0.4 dae8f975d49aed7688c5aad9b3d84f04f3f24f8ff005a5776d01bf6b9c9b2c8c +splice-util-batched-markers-test 1.0.5 ceba7f61476cdf3eb25aad99fede37c7b587e2d051097ffbf6815a7b427af8dd splice-util-featured-app-proxies 1.0.0 48e0c4fe4ea05e3b740404ebe37004ddd741efbdcd665c1c3199a5d6d9d944d7 splice-util-featured-app-proxies 1.1.0 81dd5a9e5c02d0de03208522a895fb85eeb12fbea4aca7c4ad0ad106f3b0bfce splice-util-featured-app-proxies 1.2.0 653c48879064332d34af5008bdfd8e349493460e67e62b85e8e7e3392831c842 splice-util-featured-app-proxies 1.2.1 06bab917848ef275317c2539b75c23b94e03ceb55b4a1346936f7832084cd7a6 splice-util-featured-app-proxies 1.2.2 2889c094cf9678b2b666221934ea56ab169a31b257450845bd53217a8cdfe44f -splice-util-featured-app-proxies-test 1.0.10 fa5333ad8ac2872d621e399780ec9faad431b2e0a7b1e8be4db27d058840a535 +splice-util-featured-app-proxies-test 1.0.11 986bf26fc73fb602ee3b09f9e4192274ef12ade87c61d22bdf25936d61781e1f splice-util-token-standard-wallet 1.0.0 1da198cb7968fa478cfa12aba9fdf128a63a8af6ab284ea6be238cf92a3733ac -splice-util-token-standard-wallet-test 1.0.5 33d3f9239573e51cd1817269a92b58073cacf89c53cdff3d1b26755b21332469 +splice-util-token-standard-wallet-test 1.0.6 4ad1c2011d8d53385ff336cd0e6eeb119d3f336d393396dbaec35b687eb331c4 splice-validator-lifecycle 0.1.0 cef96fac957362f1fc097120bd13686cac7f84fbc8053afa994a1f9214d9570c splice-validator-lifecycle 0.1.1 1ddf05c96002914593c929848b786f34c753fb0be07717d1786be177a564aada splice-validator-lifecycle 0.1.2 57e2f15f9755db1f00e51c52c319294264a21ad71c6bc1e7cd70db4b164c0aaa @@ -118,6 +121,7 @@ splice-wallet 0.1.17 176c2924cd7aa12bc81ffd1a8d6cfaf46e70378f653eb5f19f2d6b9599c splice-wallet 0.1.18 94d88246f69d8a4b69333d1f993e3280deaca19b70511ea7687f01e4328a34a4 splice-wallet 0.1.19 5aec5297c656e1b83e1645eb64076a1f65f2d8a90c2660ed55e7f41840d9deac splice-wallet 0.1.2 c162e08a4ec0428bfa870b6d9040989e575c74199c3a80558c62e03196dd5146 +splice-wallet 0.1.20 618eccebf41f9797f5e12c63d26cb028489975f19eaaee3d01a56912449b0c4c splice-wallet 0.1.3 2c35bb4f5084ea66db59717d21750bfd64c43147ef5fd5166615092d592a6917 splice-wallet 0.1.4 141dad2d33b6410b8e1c35a0c4f8f76cb691e4d9a4410ce89f33f373855317e1 splice-wallet 0.1.5 614b525a50c624062d851ce7df5bdb90ddfa0d6871c486cb6e2c7b694bfbce59 @@ -136,6 +140,7 @@ splice-wallet-payments 0.1.15 f80fae7a9de9431854372a66c3ca78675f77b2f54ede65abdc splice-wallet-payments 0.1.16 45e7ac4601186747e2c4d2fd7e54a15e5752eee56d6cf767eb62141b7a10c0a8 splice-wallet-payments 0.1.17 94bba10a5b3fef448ccd28669359af3b09442a1d1bd6cdbb52c401d7d10075bc splice-wallet-payments 0.1.18 f43669b775dd54fb74cf3fe3ff9bc9cedd397b5f2c0071cf70c318203dc06195 +splice-wallet-payments 0.1.19 44478251b273209d60bce47f207747f820ac2a3fe0ff87fc188135de1bc70109 splice-wallet-payments 0.1.2 775f5eb9c0249509adda5eb3ea4ee31bb953601168c18880df6f2ff09ec4298a splice-wallet-payments 0.1.3 b953b3729c81a55e598a364be7d0c0574750df3de12a7a1b53a300f217cb5c5c splice-wallet-payments 0.1.4 12177f54873c1094ea169874ad0d7838383fd137f302d16356e93f28dfbc0fcc @@ -144,7 +149,7 @@ splice-wallet-payments 0.1.6 6124379528eeb6fa17ecdab15577c29abb33d0c0d34dc5f2680 splice-wallet-payments 0.1.7 4e3e0d9cdadf80f4bf8f3cd3660d5287c084c9a29f23c901aabce597d72fd467 splice-wallet-payments 0.1.8 e48ea337ee3335c8bb3206a2501ce947ac1a7bdb1825cee8f28bad64f5a7bc4b splice-wallet-payments 0.1.9 7f4e081ad96f2ccded0c053b0cf5ddddae1139dfc3bb89cefcf77ea70f2cecb7 -splice-wallet-test 0.1.22 b8b7c47df383d7218c8b0975e4296400010c54acb2e42e1b31ea03422deddfa8 +splice-wallet-test 0.1.23 34c3a31a9e8c60662739a9f699ccdbd8d5c93e64c155855b7edfe5ddd6701bdc splitwell 0.1.0 075c76de553ab88383a7c69de134afa82aacfdf8ea8fcfe8852c4b199c3b2669 splitwell 0.1.1 ccb1a0215053062202052e1a052f9214da3fdae5253a6d43e2e155ff4f57fe75 splitwell 0.1.10 d42676a366f7ca7a2409974dd3054aa4d83ab29baa3b2086ad021407b0a1a295 @@ -158,6 +163,7 @@ splitwell 0.1.17 a631654e66ef31017bf3c9cb4ab2429157d5e5f948f1b6b15a38f0ec7c0cd36 splitwell 0.1.18 4694a5545800c7b98cdd7e7349c98f037931bb91574a76715d52da9c647c4081 splitwell 0.1.19 e819494b6781fc3fd5acb9f749cd11078b30f4c85272ef857d07541069e915e0 splitwell 0.1.2 778edd2c228c6b68198d4d033885b2d0dae7daaee55d7df3edd9dfdf1f10fbd0 +splitwell 0.1.20 25835c6004bf0b7e788cdd6b925d69c4d57be33d86e1f4f0cd0b60076068b042 splitwell 0.1.3 7cde068cde689584f86a2499689d5cb165264d96496721e24ac6fb909f770a58 splitwell 0.1.4 85557b86cd4f330f093915db1ea26eac5092de6b5ddae0690146f6059c89419b splitwell 0.1.5 a68e78774a7be655f5744c8ae0ac8b46d55ef6d1e7661bc27b9296154d56ac74 @@ -165,4 +171,4 @@ splitwell 0.1.6 872da0dd7986fd768930f85d6a7310a94a0ef924e7fbb7bb7a4e149f2b5feb74 splitwell 0.1.7 841d1c9c86b5c8f3a39059459ecd8febedf7703e18f117300bb0ebf4423db096 splitwell 0.1.8 63b8153a08ceb4bf40d807acc5712372c3eac548c266be4d5e92470b4f655515 splitwell 0.1.9 b6267905698d2798b9ef171e27d49fb88e052ec0ec0e0675a3a1b275c7d037d4 -splitwell-test 0.1.22 32bb24d2e914ede96f46b035498cd0f32be2cd8c59072f007a31cc168167a0c9 \ No newline at end of file +splitwell-test 0.1.23 781e502852284a6ee23252ec7fbbee4832145bc661f28415281ea36c8860fe95 \ No newline at end of file diff --git a/daml/dars/splice-amulet-0.1.19.dar b/daml/dars/splice-amulet-0.1.19.dar new file mode 100644 index 0000000000..58b164f920 Binary files /dev/null and b/daml/dars/splice-amulet-0.1.19.dar differ diff --git a/daml/dars/splice-amulet-name-service-0.1.20.dar b/daml/dars/splice-amulet-name-service-0.1.20.dar new file mode 100644 index 0000000000..579eca915d Binary files /dev/null and b/daml/dars/splice-amulet-name-service-0.1.20.dar differ diff --git a/daml/dars/splice-dso-governance-0.1.25.dar b/daml/dars/splice-dso-governance-0.1.25.dar new file mode 100644 index 0000000000..02bc87caa2 Binary files /dev/null and b/daml/dars/splice-dso-governance-0.1.25.dar differ diff --git a/daml/dars/splice-wallet-0.1.20.dar b/daml/dars/splice-wallet-0.1.20.dar new file mode 100644 index 0000000000..8a011dd0af Binary files /dev/null and b/daml/dars/splice-wallet-0.1.20.dar differ diff --git a/daml/dars/splice-wallet-payments-0.1.19.dar b/daml/dars/splice-wallet-payments-0.1.19.dar new file mode 100644 index 0000000000..c920f105a2 Binary files /dev/null and b/daml/dars/splice-wallet-payments-0.1.19.dar differ diff --git a/daml/dars/splitwell-0.1.20.dar b/daml/dars/splitwell-0.1.20.dar new file mode 100644 index 0000000000..82d5d8002c Binary files /dev/null and b/daml/dars/splitwell-0.1.20.dar differ diff --git a/daml/splice-amulet-name-service-test/daml.yaml b/daml/splice-amulet-name-service-test/daml.yaml index 6e11ea08b5..3fecefc352 100644 --- a/daml/splice-amulet-name-service-test/daml.yaml +++ b/daml/splice-amulet-name-service-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-amulet-name-service-test source: daml -version: 0.1.22 +version: 0.1.23 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-amulet-name-service/daml.yaml b/daml/splice-amulet-name-service/daml.yaml index ec605816b6..1bbfe7b567 100644 --- a/daml/splice-amulet-name-service/daml.yaml +++ b/daml/splice-amulet-name-service/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-amulet-name-service source: daml -version: 0.1.19 +version: 0.1.20 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-amulet-name-service/daml/Splice/Ans/AmuletConversionRateFeed.daml b/daml/splice-amulet-name-service/daml/Splice/Ans/AmuletConversionRateFeed.daml index 7a03e54a97..2993386a34 100644 --- a/daml/splice-amulet-name-service/daml/Splice/Ans/AmuletConversionRateFeed.daml +++ b/daml/splice-amulet-name-service/daml/Splice/Ans/AmuletConversionRateFeed.daml @@ -9,10 +9,12 @@ -- is a long-standing plan. module Splice.Ans.AmuletConversionRateFeed where +import DA.Action (when) import DA.Assert import DA.Foldable import DA.Time import Splice.AmuletRules +import Splice.AmuletConfig import Splice.Api.FeaturedAppRightV1 qualified as Api import Splice.Schedule import Splice.Types @@ -58,11 +60,12 @@ template AmuletConversionRateFeed -- always be slightly slower than the minimum rate limit. -- we add one extra microseconds so now + 5 minutes is allowed and the caller does not need to add the microsecond assertWithinDeadline "newNextUpdateAfter - 0.5 tickDuration" (newNextUpdateAfter `addRelTime` convertMicrosecondsToRelTime (- (convertRelTimeToMicroseconds currentConfig.tickDuration) / 2 + 1)) - forA_ markerContextO $ \markerContext -> do - _ <- fetchCheckedInterface ForOwner{dso, owner = publisher} markerContext.featuredAppRightCid - exercise markerContext.featuredAppRightCid Api.FeaturedAppRight_CreateActivityMarker - with - beneficiaries = markerContext.beneficiaries + when (useFeaturedAppMarkers $ fmap (.mintingVersion) currentConfig.rewardConfig) $ + forA_ markerContextO $ \markerContext -> do + _ <- fetchCheckedInterface ForOwner{dso, owner = publisher} markerContext.featuredAppRightCid + exercise markerContext.featuredAppRightCid Api.FeaturedAppRight_CreateActivityMarker + with + beneficiaries = markerContext.beneficiaries cid <- create this with amuletConversionRate nextUpdateAfter = Some newNextUpdateAfter diff --git a/daml/splice-amulet-test/daml.yaml b/daml/splice-amulet-test/daml.yaml index 587482008a..e1168f7444 100644 --- a/daml/splice-amulet-test/daml.yaml +++ b/daml/splice-amulet-test/daml.yaml @@ -6,7 +6,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-amulet-test source: daml -version: 0.1.21 +version: 0.1.22 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockAndAmuletExpiry.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockAndAmuletExpiry.daml index 71794ecc7e..deb500bda6 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/TestLockAndAmuletExpiry.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestLockAndAmuletExpiry.daml @@ -34,6 +34,7 @@ scaleAmuletConfig amuletPrice config = AmuletConfig with featuredAppActivityMarkerAmount = fmap (/ amuletPrice) config.featuredAppActivityMarkerAmount optDevelopmentFundManager = config.optDevelopmentFundManager externalPartyConfigStateTickDuration = config.externalPartyConfigStateTickDuration + rewardConfig = config.rewardConfig test : Script () test = script do diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/TestRewardAccountingV2.daml b/daml/splice-amulet-test/daml/Splice/Scripts/TestRewardAccountingV2.daml new file mode 100644 index 0000000000..d6f2105b7c --- /dev/null +++ b/daml/splice-amulet-test/daml/Splice/Scripts/TestRewardAccountingV2.daml @@ -0,0 +1,493 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Scripts.TestRewardAccountingV2 where + + +import DA.Action (void) +import DA.Assert +import DA.Foldable (forA_) +import DA.List +import DA.Map qualified as Map +import DA.Set as Set +import DA.Time + +import Daml.Script + +import Splice.Amulet +import Splice.Amulet.RewardAccountingV2 +import Splice.Amulet.CryptoHash qualified as CryptoHash +import Splice.AmuletConfig +import Splice.AmuletRules +import Splice.ExternalPartyConfigState +import Splice.Round +import Splice.Types + +import Splice.Scripts.Util + +import Splice.Testing.Registries.AmuletRegistry.Parameters (defaultAmuletConfig) +import Splice.Testing.TokenStandard.WalletClient as WalletClient + + +-- Reward accounting tests +--------------------------- + +-- | Shared function to test the creation and processing of reward batches in dry-run or production mode. +create_and_process_reward_batches : Bool -> Script (Script (), (AmuletApp, Party, Party, Party, Party)) +create_and_process_reward_batches dryRun = do + -- enable traffic based app rewards, which are the first use-case for reward accounting v2 + app <- setupAppWithConfig $ defaultAmuletConfig with + rewardConfig = Some $ RewardConfig with + mintingVersion = if dryRun then RewardVersion_FeaturedAppMarkers else RewardVersion_TrafficBasedAppRewards + dryRunVersion = if dryRun then Some RewardVersion_TrafficBasedAppRewards else None + batchSize = 100 + rewardCouponTimeToLive = hours 36 + appRewardCouponThreshold = 0.5 + + -- setup users + alice <- allocateParty "Alice" + bob <- allocateParty "Bob" + charlie <- allocateParty "Charlie" + dora <- allocateParty "Dora" + + setTime demoTime + + -- move the first round through issuance, which will also trigger the reward calculation for this round + if dryRun then runNextIssuance app + else runNextIssuanceWithTrafficBasedAppRewards app 1.2 + + -- setup demo data + let mintingAllowances1 = sortOn (.provider) + [ MintingAllowance alice 1000.0 + , MintingAllowance bob 2000.0 + ] + let mintingAllowances2 = sortOn (.provider) + [ MintingAllowance charlie 30.0 + , MintingAllowance dora 5.1 + ] + let b1 = BatchOfMintingAllowances mintingAllowances1 + let b2 = BatchOfMintingAllowances mintingAllowances2 + let rootBatch = BatchOfBatches [CryptoHash.hash b1, CryptoHash.hash b2] + let rootBatchHash = CryptoHash.hash rootBatch + let batchesWithHiding = [(b1, [bob]), (b2, [dora]), (rootBatch, [])] + + -- get the contract representing the pending calculation and confirmation of rewards for round 0 + [(calculateRewardsCid, _)] <- query @CalculateRewardsV2 app.dso + + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + + -- setup reward coupon creation workflow state + submit [app.dso] $ exerciseCmd amuletRulesCid + AmuletRules_StartProcessingRewardsV2 with + calculateRewardsCid + batchHash = rootBatchHash + + let processBatches = do + states <- query @ProcessRewardsV2 app.dso + forA_ states $ \(processRewardsCid, processRewards) -> do + let Some (b, badVettingState) = find (\(b, _) -> CryptoHash.hash b == processRewards.batchHash) batchesWithHiding + void $ submit app.dso $ exerciseCmd processRewardsCid + ProcessRewardsV2_ProcessBatch with + batch = b + providersWithWrongVettingState = Set.fromList badVettingState + + pure (processBatches, (app, alice, bob, charlie, dora)) + +-- | Test the full happy path of reward accounting v2 +test_reward_accounting_v2 : Script () +test_reward_accounting_v2 = do + (processBatches, (app, alice, bob, charlie, dora)) <- create_and_process_reward_batches False + + -- show that a non-dry run cannot be archived + processRewards <- query @ProcessRewardsV2 app.dso + calculateRewards <- query @CalculateRewardsV2 app.dso + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + + submitMustFail app.dso $ exerciseCmd amuletRulesCid AmuletRules_ArchiveDryRunRewardAccountingV2 with + processRewardsCids = map fst processRewards + calculateRewardsCids = map fst calculateRewards + + -- proceed with processing + processBatches -- expand root hash into batch hashes + processBatches -- expand follow-up batches into coupons + + -- no left-over processing contracts + [] <- query @CalculateRewardsV2 app.dso + [] <- query @ProcessRewardsV2 app.dso + + -- check created coupons + now <- getTime + let couponExpiryTime = now `addRelTime` hours 36 + let expectedAmounts = [(alice, 1000.0), (bob, 2000.0), (charlie, 30.0), (dora, 5.1)] + let expectedCoupons = do + (provider, amount) <- expectedAmounts + pure RewardCouponV2 with + dso = app.dso + provider + amount + round = Round 0 + expiresAt = couponExpiryTime + providerIsObserver = provider `notElem` [bob, dora] + beneficiary = None + + actualCoupons0 <- query @RewardCouponV2 app.dso + let actualCoupons = sortOn (.provider) $ fmap snd actualCoupons0 + actualCoupons === expectedCoupons + + -- make Bob and Dora observers of their coupons (simulates them changing their vetting state) + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + unobservableCoupons <- queryFilter @RewardCouponV2 app.dso (\c -> not c.providerIsObserver) + void $ submit app.dso $ exerciseCmd amuletRulesCid AmuletRules_UnhideRewardCouponsV2 with + rewardCouponCids = map fst unobservableCoupons + beneficiaries = map (._2.provider) unobservableCoupons + + couponsAfterUnhiding <- query @RewardCouponV2 app.dso + sortOn (.provider) (map snd couponsAfterUnhiding) === + map (\c -> c with providerIsObserver = True) expectedCoupons + + -- check that reward minting works + forA_ expectedAmounts $ \(beneficiary, amount) -> do + mintRewardsV2 app beneficiary + WalletClient.checkBalance beneficiary app.registry.instrumentId amount + + pure () + + +test_reward_accounting_v2_dry_run : Script () +test_reward_accounting_v2_dry_run = do + (processBatches, (app, _, _, _, _)) <- create_and_process_reward_batches True + + processBatches -- expand root hash into batch hashes + processBatches -- expand follow-up batches into coupons + + -- no left-over processing contracts + [] <- query @CalculateRewardsV2 app.dso + [] <- query @ProcessRewardsV2 app.dso + + -- check that no coupons were created + [] <- query @RewardCouponV2 app.dso + pure () + +test_reward_accounting_v2_skip_stuck_dry_run : Script () +test_reward_accounting_v2_skip_stuck_dry_run = do + (processBatches, (app, _, _, _, _)) <- create_and_process_reward_batches True + + processBatches -- expand root hash into batch hashes + -- pretend that follow up batches fail to process due to hash mismatches + + -- move to next round, so there's also a calculate rewards contract + runNextIssuance app + + -- archive the stuck state + processRewards <- query @ProcessRewardsV2 app.dso + calculateRewards <- query @CalculateRewardsV2 app.dso + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + + submit app.dso $ exerciseCmd amuletRulesCid AmuletRules_ArchiveDryRunRewardAccountingV2 with + processRewardsCids = map fst processRewards + calculateRewardsCids = map fst calculateRewards + + -- no left-over processing contracts + [] <- query @CalculateRewardsV2 app.dso + [] <- query @ProcessRewardsV2 app.dso + + -- check that no coupons were created + [] <- query @RewardCouponV2 app.dso + pure () + +-- | When not running in dry-run mode, the contracts tracking the processing cannot be archived +test_reward_accounting_v2_only_archive_dry_run : Script () +test_reward_accounting_v2_only_archive_dry_run = do + (processBatches, (app, _, _, _, _)) <- create_and_process_reward_batches False + + processBatches -- expand root hash into batch hashes + -- pretend that follow up batches fail to process due to hash mismatches + + -- move to next round, so there's also a calculate rewards contract + runNextIssuanceWithTrafficBasedAppRewards app 1.2 + + -- show that a non-dry run cannot be archived + processRewards <- query @ProcessRewardsV2 app.dso + calculateRewards <- query @CalculateRewardsV2 app.dso + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + + submitMustFail app.dso $ exerciseCmd amuletRulesCid AmuletRules_ArchiveDryRunRewardAccountingV2 with + processRewardsCids = map fst processRewards + calculateRewardsCids = [] + + submitMustFail app.dso $ exerciseCmd amuletRulesCid AmuletRules_ArchiveDryRunRewardAccountingV2 with + processRewardsCids = [] + calculateRewardsCids = map fst calculateRewards + + pure () + + +-- Reward minting +----------------- + +data SetupConfig = SetupConfig with + hideCoupon : Bool -- ^ Whether to hide alice's coupon + useTrafficBasedAppRewards : Bool -- ^ Whether to configure traffic-based rewards for minting + +setupAliceWithCoupon : Bool -> Script (AmuletApp, Party, AmuletUser) +setupAliceWithCoupon hideCoupon = setupAliceWithCoupon' $ SetupConfig with + hideCoupon + useTrafficBasedAppRewards = False + +setupAliceWithCoupon' : SetupConfig -> Script (AmuletApp, Party, AmuletUser) +setupAliceWithCoupon' config = do + app <- setupAppWithConfig $ defaultAmuletConfig with + rewardConfig = Some $ RewardConfig with + mintingVersion = + if config.useTrafficBasedAppRewards + then RewardVersion_TrafficBasedAppRewards else RewardVersion_FeaturedAppMarkers + dryRunVersion = None + batchSize = 100 + rewardCouponTimeToLive = hours 36 + appRewardCouponThreshold = 0.5 + setTime demoTime + aliceUser <- setupUserWithoutValidatorRight app "Alice" + let alice = aliceUser.primaryParty + + -- bare create a coupon + let coupon = RewardCouponV2 with + dso = app.dso + provider = alice + amount = 1000.0 + round = Round 0 + expiresAt = demoTime `addRelTime` hours 36 + providerIsObserver = not config.hideCoupon + beneficiary = None + submit app.dso $ createCmd coupon + pure (app, alice, aliceUser) + + +test_direct_mint : Script () +test_direct_mint = do + (app, alice, _) <- setupAliceWithCoupon False + + -- mint and check balance + mintRewardsV2 app alice + WalletClient.checkBalance alice app.registry.instrumentId 1000.0 + + +test_mint_of_hidden_coupon : Script () +test_mint_of_hidden_coupon = do + (app, alice, _) <- setupAliceWithCoupon True + + -- create transfer context + (openMiningRound, _) <- getLatestOpenRound app + let context = TransferContext with + openMiningRound + issuingMiningRounds = Map.empty + validatorRights = Map.empty + featuredAppRight = None + -- mint the coupons + coupons <- query @RewardCouponV2 app.dso + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + submitMulti [alice] [app.dso] $ exerciseCmd amuletRulesCid AmuletRules_Transfer with + transfer = Transfer with + sender = alice + provider = alice + inputs = map (InputRewardCouponV2 . fst) coupons + outputs = [] + beneficiaries = None -- no featured-app-marker beneficiaries + context + expectedDso = Some app.dso + + -- check balance + WalletClient.checkBalance alice app.registry.instrumentId 1000.0 + + +-- Claiming expired reward coupons +---------------------------------- + +test_claim_expired_coupons : Script () +test_claim_expired_coupons = do + (app, alice, _) <- setupAliceWithCoupon False + bob <- allocateParty "Bob" + charlie <- allocateParty "Charlie" + + -- create an extra coupon + let extraCoupon = RewardCouponV2 with + dso = app.dso + provider = alice + amount = 500.0 + round = Round 0 + expiresAt = demoTime `addRelTime` hours 48 + providerIsObserver = False + beneficiary = Some bob + submit app.dso $ createCmd extraCoupon + + -- show that expiry doesn't work before expiry time + coupons <- query @RewardCouponV2 app.dso + let rewardCouponCids = map fst coupons + length coupons === 2 + + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + + submitMustFail app.dso $ exerciseCmd amuletRulesCid AmuletRules_ClaimExpiredRewardsV2 with + rewardCouponCids + beneficiaries = [bob, alice] + + -- move time past expiry of the first coupon + passTime $ hours 48 + + -- beneficiaries are checked + submitMustFail app.dso $ exerciseCmd amuletRulesCid AmuletRules_ClaimExpiredRewardsV2 with + rewardCouponCids + beneficiaries = [] + + submitMustFail app.dso $ exerciseCmd amuletRulesCid AmuletRules_ClaimExpiredRewardsV2 with + rewardCouponCids + beneficiaries = [alice, bob, charlie] + + -- correct expiry works + submit app.dso $ exerciseCmd amuletRulesCid AmuletRules_ClaimExpiredRewardsV2 with + rewardCouponCids + beneficiaries = [bob, alice] + + -- no coupons left + [] <- query @RewardCouponV2 app.dso + + -- unclaimed reward was created + [(_, unclaimedReward)] <- query @UnclaimedReward app.dso + unclaimedReward === UnclaimedReward with + dso = app.dso + amount = 1500.0 + + pure () + + +-- test featured marker disablement +----------------------------------- + +test_markers_pre_traffic_based : Script () +test_markers_pre_traffic_based = do + (app, alice, aliceUser) <- setupAliceWithCoupon' $ SetupConfig with + hideCoupon = False + useTrafficBasedAppRewards = False + featureApp app aliceUser + bob <- allocateParty "Bob" + + -- make a transfer and check that the marker is created + pay app aliceUser bob 100.0 + [(_, marker)] <- query @FeaturedAppActivityMarker app.dso + marker === FeaturedAppActivityMarker with + dso = app.dso + provider = alice + beneficiary = alice + weight = 1.0 + + -- switch config and test that markers are properly archived + updateAmuletConfig app $ \config -> config with + rewardConfig = Some $ RewardConfig with + mintingVersion = RewardVersion_TrafficBasedAppRewards + dryRunVersion = None + batchSize = 100 + rewardCouponTimeToLive = hours 36 + appRewardCouponThreshold = 0.5 + + -- two issuance required to make the first round with the updated config active and open + runNextIssuance app + runNextIssuance app + + markers <- query @FeaturedAppActivityMarker app.dso + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + (openMiningRoundCid, _) <- getLatestOpenRound app + + submit app.dso $ exerciseCmd amuletRulesCid AmuletRules_ConvertFeaturedAppActivityMarkers with + markerCids = map fst markers + openMiningRoundCid + observers = None + + [] <- query @FeaturedAppActivityMarker app.dso + [] <- query @AppRewardCoupon app.dso + + pure () + + +test_no_markers_post_traffic_based : Script () +test_no_markers_post_traffic_based = do + (app, _, aliceUser) <- setupAliceWithCoupon' $ SetupConfig with + hideCoupon = False + useTrafficBasedAppRewards = True + featureApp app aliceUser + bob <- allocateParty "Bob" + + -- pay and check that there is no marker + pay app aliceUser bob 100.0 + [] <- query @FeaturedAppActivityMarker app.dso + + pure () + + +-- test switch to traffic-based rewards +--------------------------------------- + +test_switch_to_traffic_based_rewards : Script () +test_switch_to_traffic_based_rewards = do + app <- setupApp + + -- validate starting config + let checkAmuletRules expectedRewardConfig = do + [(_, amuletRules)] <- query @AmuletRules app.dso + amuletRules.configSchedule.initialValue.rewardConfig === expectedRewardConfig + let checkOpenRounds expectedRewardConfig = do + openRounds <- query @OpenMiningRound app.dso + length openRounds === 3 + forA_ openRounds $ \(_, openRound) -> + openRound.rewardConfig === expectedRewardConfig + let checkSummarizingRounds expectedRewardConfig = do + summarizingRounds <- query @SummarizingMiningRound app.dso + forA_ summarizingRounds $ \(_, summarizingRound) -> + summarizingRound.rewardConfig === expectedRewardConfig + let checkExternalPartyConfigs expectedVersion = do + externalPartyConfigs <- query @ExternalPartyConfigState app.dso + length externalPartyConfigs === 2 + forA_ externalPartyConfigs $ \(_, config) -> + config.rewardCalculationVersion === expectedVersion + + checkAmuletRules None + checkOpenRounds None + checkExternalPartyConfigs None + + -- switch config + let rewardConfig = Some $ RewardConfig with + mintingVersion = RewardVersion_TrafficBasedAppRewards + dryRunVersion = None + batchSize = 100 + rewardCouponTimeToLive = hours 36 + appRewardCouponThreshold = 0.5 + updateAmuletConfig app $ \config -> config with rewardConfig + + -- validate updated config + checkAmuletRules rewardConfig + checkOpenRounds None + checkExternalPartyConfigs None + + -- advance to next round to see that it picks up the config change + runNextIssuance app + (_, latestRound) <- getLatestActiveOpenRound app + latestRound.rewardConfig === rewardConfig + + -- SummarizingRounds are still on old config + checkSummarizingRounds None + + -- external party updates pick up the new config from the round + passTime (hours 24) + updateExternalPartyConfigState app + -- require two updates as each only processes one of the two states + passTime (hours 24) + updateExternalPartyConfigState app + + checkExternalPartyConfigs ((.mintingVersion) <$> rewardConfig) + + -- two more issuances and all open rounds use the new config + runNextIssuance app + runNextIssuance app + + checkOpenRounds rewardConfig + checkSummarizingRounds rewardConfig + + pure () diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/UnitTests/Amulet/CryptoHash.daml b/daml/splice-amulet-test/daml/Splice/Scripts/UnitTests/Amulet/CryptoHash.daml new file mode 100644 index 0000000000..095ffc2f3c --- /dev/null +++ b/daml/splice-amulet-test/daml/Splice/Scripts/UnitTests/Amulet/CryptoHash.daml @@ -0,0 +1,368 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Scripts.UnitTests.Amulet.CryptoHash where + +import Daml.Script +import DA.Assert +import Splice.Amulet.CryptoHash + +-------------------------------------------------------------------------------- +-- Record Types +-------------------------------------------------------------------------------- + +data RecV1 = RecV1 + with + a : Int + b : Text + deriving (Eq, Show) + +data RecV1' = RecV1' + with + a : Int + deriving (Eq, Show) + +data RecV2 = RecV2 + with + a : Int + b : Text + c : Optional Int + deriving (Eq, Show) + +data RecV3 = RecV3 + with + a : Int + b : Text + c : Optional Int + d : Optional Int + deriving (Eq, Show) + +instance Hashable RecV1 where + hash r = hashRecord [hash r.a , hash r.b] + +instance Hashable RecV1' where + hash r = hashRecord [hash r.a] + + +instance Hashable RecV2 where + hash r = + hashUpgradedRecord + [ hash r.a + , hash r.b ] + [ fmap hash r.c ] + +instance Hashable RecV3 where + hash r = + hashUpgradedRecord + [ hash r.a + , hash r.b + ] + [ fmap hash r.c + , fmap hash r.d + ] + + +-------------------------------------------------------------------------------- +-- Variant Payload Type (shared argument type) +-------------------------------------------------------------------------------- + +data Payload = Payload + with + x : Int + y : Text + deriving (Eq, Show) + + +-------------------------------------------------------------------------------- +-- Variant Types (same argument type via `with`) +-------------------------------------------------------------------------------- + +data VarV1 + = V1 with payload : Payload + | V2 with payload : Payload + deriving (Eq, Show) + +instance Hashable VarV1 where + hash v = + case v of + V1 with payload -> + hashVariant + "V1" + [ hash payload.x + , hash payload.y + ] + + V2 with payload -> + hashVariant + "V2" + [ hash payload.x + , hash payload.y + ] + + +data VarV2 + = V1V2 + with + payload : Payload + c : Optional Int + d : Optional Int + | V2V2 + with + payload : Payload + c : Optional Int + d : Optional Int + deriving (Eq, Show) + +instance Hashable VarV2 where + hash v = + case v of + V1V2 with payload; c; d -> + hashUpgradedVariant + "V1" + [ hash payload.x + , hash payload.y + ] + [ fmap hash c + , fmap hash d + ] + + V2V2 with payload; c; d -> + hashUpgradedVariant + "V2" + [ hash payload.x + , hash payload.y + ] + [ fmap hash c + , fmap hash d + ] + + +-------------------------------------------------------------------------------- +-- Structural Behavior Tests +-------------------------------------------------------------------------------- + +test_emptyListDifferent : Script () +test_emptyListDifferent = script do + hash ([] : [Int]) =/= hash ([1] : [Int]) + +test_listLengthMatters : Script () +test_listLengthMatters = script do + hash ([1,2] : [Int]) =/= hash ([1,2,3] : [Int]) + + +test_listStructureMatters : Script () +test_listStructureMatters = script do + hash ([[1],[2]] : [[Int]]) =/= hash ([1,2] : [Int]) + + +test_recordFieldCountMatters : Script () +test_recordFieldCountMatters = script do + let r = RecV1 with a = 1; b = "x" + let r' = RecV1' with a = 1 + hash r =/= hash r' + + +-------------------------------------------------------------------------------- +-- Variant Separation +-------------------------------------------------------------------------------- + +test_variantTagMatters : Script () +test_variantTagMatters = script do + let payload = Payload with x = 1; y = "x" + let v1 = V1 with payload + let v2 = V2 with payload + hash v1 =/= hash v2 + + +test_variantValueMatters : Script () +test_variantValueMatters = script do + let p1 = Payload with x = 1; y = "x" + let p2 = Payload with x = 2; y = "x" + let v1 = V1 with payload = p1 + let v2 = V1 with payload = p2 + hash v1 =/= hash v2 + + +-------------------------------------------------------------------------------- +-- Optional Semantics +-------------------------------------------------------------------------------- + +test_optionalNoneVsSome : Script () +test_optionalNoneVsSome = script do + hash (None : Optional Int) =/= hash (Some 1) + + +-------------------------------------------------------------------------------- +-- Record Upgrade Compatibility (Multiple Optionals) +-------------------------------------------------------------------------------- + +test_recordUpgrade_allUnset : Script () +test_recordUpgrade_allUnset = script do + let r1 = RecV1 with a = 1; b = "abc" + let r2 = + RecV3 + with + a = 1 + b = "abc" + c = None + d = None + hash r1 === hash r2 + + +test_recordUpgrade_firstSet : Script () +test_recordUpgrade_firstSet = script do + let r1 = RecV1 with a = 1; b = "abc" + let r2 = + RecV3 + with + a = 1 + b = "abc" + c = Some 1 + d = None + hash r1 =/= hash r2 + + +test_recordUpgrade_secondSet : Script () +test_recordUpgrade_secondSet = script do + let r1 = RecV1 with a = 1; b = "abc" + let r2 = + RecV3 + with + a = 1 + b = "abc" + c = None + d = Some 1 + hash r1 =/= hash r2 + + +test_recordUpgrade_bothSet : Script () +test_recordUpgrade_bothSet = script do + let r1 = RecV1 with a = 1; b = "abc" + let r2 = + RecV3 + with + a = 1 + b = "abc" + c = Some 1 + d = Some 1 + hash r1 =/= hash r2 + +test_recordUpgradeOrderOfOptionalsDoesNotMatter : Script () +test_recordUpgradeOrderOfOptionalsDoesNotMatter = script do + let r1' = RecV2 with + a = 1 + b = "abc" + c = Some 1 + let r1 = RecV3 with + a = 1 + b = "abc" + c = Some 1 + d = None + let r2 = RecV3 with + a = 1 + b = "abc" + c = None + d = Some 1 + hash r1 === hash r1' + hash r1 =/= hash r2 + + +-------------------------------------------------------------------------------- +-- Variant Upgrade Compatibility (Multiple Optionals) +-------------------------------------------------------------------------------- + +test_variantUpgrade_allUnset : Script () +test_variantUpgrade_allUnset = script do + let payload = Payload with x = 5; y = "x" + + let v1 = V1 with payload + + let v2 = + V1V2 + with + payload + c = None + d = None + + assertEq (hash v1) (hash v2) + + +test_variantUpgrade_firstSet : Script () +test_variantUpgrade_firstSet = script do + let payload = Payload with x = 5; y = "x" + + let v1 = V1 with payload + + let v2 = + V1V2 + with + payload + c = Some 9 + d = None + + hash v1 =/= hash v2 + + +test_variantUpgrade_secondSet : Script () +test_variantUpgrade_secondSet = script do + let payload = Payload with x = 5; y = "x" + + let v1 = V1 with payload + + let v2 = + V1V2 + with + payload + c = None + d = Some 1 + + hash v1 =/= hash v2 + + +test_variantUpgrade_bothSet : Script () +test_variantUpgrade_bothSet = script do + let payload = Payload with x = 5; y = "x" + + let v1 = V1 with payload + + let v2 = + V1V2 + with + payload + c = Some 1 + d = Some 1 + + hash v1 =/= hash v2 + + +test_variantUpgrade_constructorSeparationStillHolds : Script () +test_variantUpgrade_constructorSeparationStillHolds = script do + let payload = Payload with x = 5; y = "x" + + let v1 = V1V2 with payload; c = None; d = None + let v2 = V2V2 with payload; c = None; d = None + + hash v1 =/= hash v2 + + +-------------------------------------------------------------------------------- +-- Golden Structural Stability +-------------------------------------------------------------------------------- + +test_golden_recordEncoding : Script () +test_golden_recordEncoding = script do + let r = RecV1 with a = 1; b = "x" + + let expected = "e2878cf11d8a10aa17f359e1f61f711756fdbbc256bf541baec14c21b6888f6e" + + assertEq (hash r).value expected + + +test_golden_variantEncoding : Script () +test_golden_variantEncoding = script do + let payload = Payload with x = 1; y = "x" + let v = V1 with payload + + let expected = "ca314e0bdf0fc327940a89334ff6df58f234b395d36328af1a0cce2339227e5c" + + assertEq (hash v).value expected diff --git a/daml/splice-amulet-test/daml/Splice/Scripts/Util.daml b/daml/splice-amulet-test/daml/Splice/Scripts/Util.daml index 82a2c5f36e..b22fc277ab 100644 --- a/daml/splice-amulet-test/daml/Splice/Scripts/Util.daml +++ b/daml/splice-amulet-test/daml/Splice/Scripts/Util.daml @@ -47,8 +47,17 @@ import Splice.Util data AmuletApp = AmuletApp with dso : Party dsoUser : AmuletUser + registry : Registry.AmuletRegistry deriving (Eq, Ord, Show) +mkAmuletApp : Party -> AmuletUser -> AmuletApp +mkAmuletApp dso dsoUser = AmuletApp with + dso + dsoUser + registry = Registry.AmuletRegistry with + dso + instrumentId = amuletInstrumentId dso + demoTime : Time demoTime = time (DA.Date.date 2022 Jan 1) 0 0 0 @@ -57,21 +66,30 @@ demoTime = time (DA.Date.date 2022 Jan 1) 0 0 0 setupApp : Script AmuletApp setupApp = genericSetupApp "" +setupAppWithConfig : AmuletConfig Unit.USD -> Script AmuletApp +setupAppWithConfig config = genericSetupAppWithConfig config "" + -- | Setup the DSO party with a specific prefix and the contracts defining the Amulet app. genericSetupApp : Text -> Script AmuletApp -genericSetupApp dsoPrefix = do +genericSetupApp dsoPrefix = genericSetupAppWithConfig defaultAmuletConfig dsoPrefix + +-- | Setup the DSO party with a specific prefix and the contracts defining the Amulet app. +genericSetupAppWithConfig : AmuletConfig Unit.USD -> Text -> Script AmuletApp +genericSetupAppWithConfig config dsoPrefix = do -- use a time that is easy to reason about in script outputs setTime demoTime dso <- allocateParty (dsoPrefix <> "dso-party") dsoUser <- validateUserId (dsoPrefix <> "dummy-dso-user") _ <- createUser (User dsoUser (Some dso)) [] - let app = AmuletApp with dso, dsoUser = AmuletUser dsoUser dso + let app = mkAmuletApp dso (AmuletUser dsoUser dso) recordValidatorOf app app.dso app.dso _ <- submit dso $ createCmd AmuletRules with - configSchedule = defaultAmuletConfigSchedule + configSchedule = Schedule with + initialValue = config + futureValues = [] isDevNet = True contractStateSchemaVersion = None .. @@ -87,6 +105,7 @@ genericSetupApp dsoPrefix = do return app + -- Replacing AmuletConfig -------------------------- @@ -219,11 +238,17 @@ runNextIssuance : AmuletApp -> Script () runNextIssuance app = do runNextIssuanceInternal app 1.0 +-- | Run the next issuance with an off-ledger calculated app activity round total (in MB of traffic). +-- Used when the round uses traffic-based app rewards. +runNextIssuanceWithTrafficBasedAppRewards : AmuletApp -> Decimal -> Script () +runNextIssuanceWithTrafficBasedAppRewards app appActivityRoundTotal = do + Registry.runNextIssuance app.dso 1.0 (Some appActivityRoundTotal) + -- Only directly call this function if you know what you are doing. -- Due to effective-dating, the price you give here is not the amulet price that can be used immediately runNextIssuanceInternal : AmuletApp -> Decimal -> Script () runNextIssuanceInternal app amuletPrice = do - Registry.runNextIssuance app.dso amuletPrice + Registry.runNextIssuance app.dso amuletPrice None -- Set time so that at least one mining round is open and usable passTimeToRoundOpen : AmuletApp -> Script () @@ -312,7 +337,7 @@ pay : AmuletApp -> AmuletUser -> Party -> Decimal -> Script () pay app sender recipient amuletAmount = do payWithTransferFeeRatio app sender recipient amuletAmount 0.0 --- | A simple and slightly hacky-way to test payment: it just gathers all amulets from +-- | A simple and slightly hacky-way to test payment: it just gathers all amulets and RewarCouponV2s from -- the sender and transfers the desired amount off of them to the receiver. -- The left-over amount is kept. payWithTransferFeeRatio : AmuletApp -> AmuletUser -> Party -> Decimal -> Decimal -> Script () @@ -320,10 +345,11 @@ payWithTransferFeeRatio app sender recipient amuletAmount transferFeeRatio = do -- TODO(tech-debt): create payment pre-approval flow and use that here instead of submitMulti readAs <- getUserReadAs sender.userId amulets <- getAmuletInputs sender.primaryParty + rewardCoupons <- query @RewardCouponV2 sender.primaryParty let transfer = Transfer with sender = sender.primaryParty provider = sender.primaryParty - inputs = amulets + inputs = amulets ++ map (InputRewardCouponV2 . fst) rewardCoupons outputs = [ TransferOutput with receiver = recipient @@ -415,6 +441,9 @@ runAmuletDepositBots app = do svRewardCoupons <- queryFilter @SvRewardCoupon app.dso $ \c -> c.round `elem` issuingRoundNumbers && c.beneficiary == user + rewardCouponV2s <- queryFilter @RewardCouponV2 app.dso $ \c -> + fromOptional c.provider c.beneficiary == user + -- get all amulets of this user amulets <- getAmuletInputs user -- Need readAs rights for all hosted users to collect their validator rewards, @@ -431,6 +460,7 @@ runAmuletDepositBots app = do map (mkInputValidatorFaucetCoupon . fst) validatorFaucetCoupons ++ map (InputValidatorLivenessActivityRecord . fst) validatorLivenessActivityRecords ++ map (InputSvRewardCoupon . fst) svRewardCoupons ++ + map (InputRewardCouponV2 . fst) rewardCouponV2s ++ amulets outputs = [] beneficiaries = None -- no beneficiaries for self-transfer @@ -447,6 +477,28 @@ runAmuletDepositBots app = do require "Owners of amulets must be unique" (unique amuletOwners) pure () +mintRewardsV2 : AmuletApp -> Party -> Script () +mintRewardsV2 app beneficiary = do + -- create transfer context + (openMiningRound, _) <- getLatestOpenRound app + let context = TransferContext with + openMiningRound + issuingMiningRounds = Map.empty + validatorRights = Map.empty + featuredAppRight = None + -- query the coupons + coupons <- query @RewardCouponV2 beneficiary + void $ submitExerciseAmuletRulesByKey app [beneficiary] [] AmuletRules_Transfer with + transfer = Transfer with + sender = beneficiary + provider = beneficiary + inputs = map (InputRewardCouponV2 . fst) coupons + outputs = [] + beneficiaries = None -- no featured-app-marker beneficiaries + context + expectedDso = Some app.dso + + -- | Retrieve the list of all amulets that the given party can use as transfer inputs. getAmuletInputs : Party -> Script [TransferInput] getAmuletInputs sender = do @@ -624,6 +676,12 @@ setAmuletConfig app baseConfig newConfig = do newConfig baseConfig +updateAmuletConfig : AmuletApp -> (AmuletConfig Unit.USD -> AmuletConfig Unit.USD) -> Script () +updateAmuletConfig app f = do + baseConfig <- getAmuletConfig app + let newConfig = f baseConfig + setAmuletConfig app baseConfig newConfig + allocateDevelopmentFundCoupon : AmuletApp -> Party -> Party -> Decimal -> Time -> Text -> [ContractId UnclaimedDevelopmentFundCoupon] -> Script AmuletRules_AllocateDevelopmentFundCouponResult diff --git a/daml/splice-amulet/daml.yaml b/daml/splice-amulet/daml.yaml index 994b69cae5..9771c0f254 100644 --- a/daml/splice-amulet/daml.yaml +++ b/daml/splice-amulet/daml.yaml @@ -6,7 +6,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-amulet source: daml -version: 0.1.18 +version: 0.1.19 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-amulet/daml/Splice/Amulet.daml b/daml/splice-amulet/daml/Splice/Amulet.daml index 14481a9486..850a511461 100644 --- a/daml/splice-amulet/daml/Splice/Amulet.daml +++ b/daml/splice-amulet/daml/Splice/Amulet.daml @@ -9,7 +9,7 @@ import DA.Action (void) import DA.Assert import DA.Map as Map import DA.TextMap as TextMap -import DA.Optional (fromOptional) +import DA.Optional (fromOptional, optionalToList) import Splice.Api.Token.MetadataV1 qualified as Api.Token.MetadataV1 import Splice.Api.Token.HoldingV1 qualified as Api.Token.HoldingV1 @@ -267,38 +267,37 @@ template FeaturedAppRight with controller provider do return FeaturedAppRight_CancelResult - interface instance Splice.Api.FeaturedAppRightV1.FeaturedAppRight for FeaturedAppRight where view = Splice.Api.FeaturedAppRightV1.FeaturedAppRightView with dso, provider featuredAppRight_CreateActivityMarkerImpl _self arg = do - validateAppRewardBeneficiaries arg.beneficiaries - let groupedBeneficiaries = Map.fromListWithR (+) (map (\b -> (b.beneficiary, b.weight)) arg.beneficiaries) - cids <- forA (Map.toList groupedBeneficiaries) $ \(beneficiary, weight) -> - create FeaturedAppActivityMarker - with - dso - provider - beneficiary = beneficiary - weight = weight - pure (Splice.Api.FeaturedAppRightV1.FeaturedAppRight_CreateActivityMarkerResult $ map toInterfaceContractId cids) + validateAppRewardBeneficiaries arg.beneficiaries + let groupedBeneficiaries = Map.fromListWithR (+) (map (\b -> (b.beneficiary, b.weight)) arg.beneficiaries) + cids <- forA (Map.toList groupedBeneficiaries) $ \(beneficiary, weight) -> + create FeaturedAppActivityMarker + with + dso + provider + beneficiary = beneficiary + weight = weight + pure (Splice.Api.FeaturedAppRightV1.FeaturedAppRight_CreateActivityMarkerResult $ map toInterfaceContractId cids) interface instance Splice.Api.FeaturedAppRightV2.FeaturedAppRight for FeaturedAppRight where view = Splice.Api.FeaturedAppRightV2.FeaturedAppRightView with dso, provider featuredAppRight_CreateActivityMarkerImpl _self arg = do - validateAppRewardBeneficiariesV2 arg.beneficiaries - require "Weight >= 1.0" (fromOptional 1.0 arg.weight >= 1.0) - require "Weight <= 10000.0" (fromOptional 1.0 arg.weight <= 10000.0) - let groupedBeneficiaries = Map.fromListWithR (+) (map (\b -> (b.beneficiary, b.weight)) arg.beneficiaries) - cids <- forA (Map.toList groupedBeneficiaries) $ \(beneficiary, weight) -> - create FeaturedAppActivityMarker - with - dso - provider - beneficiary = beneficiary - weight = weight * fromOptional 1.0 arg.weight - pure (Splice.Api.FeaturedAppRightV2.FeaturedAppRight_CreateActivityMarkerResult $ map toInterfaceContractId cids) + validateAppRewardBeneficiariesV2 arg.beneficiaries + require "Weight >= 1.0" (fromOptional 1.0 arg.weight >= 1.0) + require "Weight <= 10000.0" (fromOptional 1.0 arg.weight <= 10000.0) + let groupedBeneficiaries = Map.fromListWithR (+) (map (\b -> (b.beneficiary, b.weight)) arg.beneficiaries) + cids <- forA (Map.toList groupedBeneficiaries) $ \(beneficiary, weight) -> + create FeaturedAppActivityMarker + with + dso + provider + beneficiary = beneficiary + weight = weight * fromOptional 1.0 arg.weight + pure (Splice.Api.FeaturedAppRightV2.FeaturedAppRight_CreateActivityMarkerResult $ map toInterfaceContractId cids) validateAppRewardBeneficiaries : [Splice.Api.FeaturedAppRightV1.AppRewardBeneficiary] -> Update () validateAppRewardBeneficiaries beneficiaries = do @@ -372,6 +371,32 @@ template AppRewardCoupon return AppRewardCoupon_DsoExpireResult with .. +-- | A coupon for receiving rewards, which is currently only used for +-- traffic-based app rewards. +-- +-- Designed to be flexible so we could extend it to include other kinds of rewards +-- that are based on on off-ledger calculations. +-- See Splice.Amulet.RewardAccountingV2 for details. +template RewardCouponV2 + with + dso : Party + provider : Party -- ^ The party that provided the service for whose activity the minting right was granted. + round : Round -- ^ The round in which the providers activity was recorded. + amount : Decimal -- ^ The amount of reward that can be minted for this coupon. Denominated in Amulet. + expiresAt : Time -- ^ Time-based expiry. Used to determine when the reward can no longer be minted. + providerIsObserver : Bool + -- ^ Whether the provider should be an observer, which they are unless + -- their vetting state at the time of coupon creation does not allow it. + -- DSO automation will then attempt to make the provider an observer when they + -- change their vetting state unless the coupon expired in the meantime. + beneficiary : Optional Party + -- ^ The party that can mint the reward for the activity by the provider. + -- If not set, this is the provider. + where + signatory dso + observer if providerIsObserver then [provider] ++ optionalToList beneficiary else [] + ensure amount > 0.0 + -- | A coupon for receiving validator rewards proportional to the usage fee paid by a user -- hosted by a validator operator. @@ -537,6 +562,9 @@ template UnclaimedActivityRecord pure UnclaimedActivityRecord_DsoExpireResult with unclaimedRewardCid +-- Support code +--------------- + requireAmuletExpiredForAllRounds : ContractId ExternalPartyConfigState -> ContractId ExternalPartyConfigState -> Amulet -> Update () requireAmuletExpiredForAllRounds externalPartyConfigState0Cid externalPartyConfigState1Cid amulet = do require "externalPartyConfigState0Cid /= externalPartyConfigState1Cid" (externalPartyConfigState0Cid /= externalPartyConfigState1Cid) @@ -546,7 +574,6 @@ requireAmuletExpiredForAllRounds externalPartyConfigState0Cid externalPartyConfi require "Amulet is expired" (isAmuletExpired round amulet.amount) - -- instances ------------ @@ -559,6 +586,12 @@ instance HasCheckedFetch LockedAmulet ForOwner where instance HasCheckedFetch AppRewardCoupon ForOwner where contractGroupId AppRewardCoupon{..} = ForOwner with dso; owner = fromOptional provider beneficiary +instance HasCheckedFetch RewardCouponV2 ForOwner where + contractGroupId RewardCouponV2{..} = ForOwner with dso; owner = fromOptional provider beneficiary + +instance HasCheckedFetch RewardCouponV2 ForDso where + contractGroupId RewardCouponV2{..} = ForDso with dso + instance HasCheckedFetch SvRewardCoupon ForOwner where contractGroupId SvRewardCoupon{..} = ForOwner with dso; owner = beneficiary diff --git a/daml/splice-amulet/daml/Splice/Amulet/CryptoHash.daml b/daml/splice-amulet/daml/Splice/Amulet/CryptoHash.daml new file mode 100644 index 0000000000..951b096c22 --- /dev/null +++ b/daml/splice-amulet/daml/Splice/Amulet/CryptoHash.daml @@ -0,0 +1,116 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Utilities to compute cryptographic hashes of Daml data structures. +-- We use this for computing compact commitments for reflecting off-ledger data +-- shared by the SV nodes on-ledger. +-- +-- Note that the hashes are based on viewing all scalar values as strings and taking +-- a structural view of Daml records; i.e., the hashes do not include a unique type +-- tag by default. Make sure to include a type tag using `hashVariant` if you +-- want to hash different data structures in the same scope. +module Splice.Amulet.CryptoHash + ( + Hash(..), + Hashable(..), + hashRecord, + hashUpgradedRecord, + hashVariant, + hashUpgradedVariant, + ) where + +import DA.Optional (isNone) +import DA.Text qualified as T + +data Hash = Hash with value : Text + deriving (Eq, Show) + +-- | Compute the hash of a record. +hashRecord : [Hash] -> Hash +hashRecord = hashListInternal . map (.value) + +-- | Compute the hash of an upgraded record so that it agrees with the old record hash +-- when ignoring trailing None fields. +hashUpgradedRecord : [Hash] -> [Optional Hash] -> Hash +hashUpgradedRecord oldFieldHashes newFieldHashes = + hashListInternal $ + [ h.value | h <- oldFieldHashes ] ++ + [ (hashOptionalInternal optField).value | optField <- dropTrailingNones newFieldHashes ] + +-- | Compute the hash of a variant. +hashVariant : Text -> [Hash] -> Hash +hashVariant tag fieldHashes = hashVariantInternal tag [ h.value | h <- fieldHashes ] + +-- | Compute the hash of an upgraded variant so that it agrees with the old variant hash +-- when ignoring trailing None fields. +hashUpgradedVariant : Text -> [Hash] -> [Optional Hash] -> Hash +hashUpgradedVariant tag oldFieldHashes newFieldHashes = + hashVariantInternal tag $ + [ h.value | h <- oldFieldHashes ] ++ + [ (hashOptionalInternal optField).value | optField <- dropTrailingNones newFieldHashes ] + +class Hashable a where + hash : a -> Hash + +-- | Identity instance for Hash, which is useful for hash types like [Hash]. +instance Hashable Hash where + hash h = h + +instance Hashable Int where + hash = hashText . show + +instance Hashable Decimal where + hash = hashText . show + +instance Hashable Party where + hash = hashText . partyToText + +instance Hashable Text where + hash = hashText + +instance Hashable a => Hashable (Optional a) where + hash = hashOptionalInternal . fmap hash + +instance Hashable a => Hashable [a] where + hash = hashList hash + + +-- internal helper functions +---------------------------- + +-- Design Note: we want these hashes to be easy to compute in many systems. +-- Therefore we essentially encode the data structure as an S-expression and hash that +-- one recursively. Concretely, we use the following rules: +-- +-- - hash scalars by hashing their string rendering +-- - hash lists by hashing the length and the element hashes using "|" as a separator +-- - hash records by hashing the list of field hashes +-- - hash variants by hashing the list of fields prefixed with tag for the variant constructor +-- +-- The length prefix on lists also serves as a tag to distinguish different tree structures. +-- We include the number of fields in the hash of a record, as the number of fields +-- can change as part of a Smart Contract Upgrades. +-- +-- Tags for variants must be unique within the scope where the hashes are used. + + +hashList : (a -> Hash) -> [a] -> Hash +hashList hashElem xs = hashListInternal [ (hashElem x).value | x <- xs ] + +hashText : Text -> Hash +hashText = Hash . T.sha256 + +hashListInternal : [Text] -> Hash +hashListInternal ts = Hash $ T.sha256 $ T.intercalate "|" (show (length ts) :: ts) + +hashVariantInternal : Text -> [Text] -> Hash +hashVariantInternal tag fieldValues = + Hash $ T.sha256 $ T.intercalate "|" (tag :: show (length fieldValues) :: fieldValues) + +-- we view optionals as lists of length 0 or 1 to simplify the encoding in other systems +hashOptionalInternal : Optional Hash -> Hash +hashOptionalInternal None = hashListInternal [] +hashOptionalInternal (Some h) = hashListInternal [h.value] + +dropTrailingNones : [Optional Hash] -> [Optional Hash] +dropTrailingNones = reverse . dropWhile isNone . reverse diff --git a/daml/splice-amulet/daml/Splice/Amulet/RewardAccountingV2.daml b/daml/splice-amulet/daml/Splice/Amulet/RewardAccountingV2.daml new file mode 100644 index 0000000000..53993578dd --- /dev/null +++ b/daml/splice-amulet/daml/Splice/Amulet/RewardAccountingV2.daml @@ -0,0 +1,129 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +-- | Templates and support code for reward accounting based on off-ledger calculations, as initially +-- described in CIP-104 for traffic-based app rewards. +-- +-- It is a Version 2 compared to the previous reward accounting mechanism that is based on tracking +-- all state on-ledger using App|Validator|SvRewardCoupon and IssuingMiningRound contracts. +-- +-- The design is flexible to allow for extending it to other kinds of rewards +-- that are based on off-ledger calculations, but the initial implementation is +-- specialized to traffic-based app rewards. +module Splice.Amulet.RewardAccountingV2 where + +import DA.Action (unless) +import DA.Foldable (forA_) +import DA.Set as Set +import DA.Time + +import Splice.Amulet.CryptoHash qualified as CryptoHash +import Splice.Amulet +import Splice.Types +import Splice.Util + +-- | State contract tracking the need to calculate and confirm the app reward amounts for the given round. +template CalculateRewardsV2 with + dso : Party + round : Round + rewardCouponTimeToLive : RelTime + -- ^ Time to live for reward coupons that are created. + -- + -- We store this as part of requesting the calculation of rewards to avoid reading + -- the AmuletConfig later again, which could lead to inconsistencies in case of config changes. + dryRun : Bool + -- ^ Whether to only simulate the confirmation without creating any RewardCouponV2 contracts. + where + signatory dso + +-- | A minting allowance for a service provider to mint Amulet. +data MintingAllowance = MintingAllowance with + provider : Party + amount : Decimal + deriving (Eq, Show) + +type MintingAllowances = [MintingAllowance] + +data Batch + = BatchOfBatches [CryptoHash.Hash] + | BatchOfMintingAllowances MintingAllowances + deriving (Eq, Show) + +data ProcessRewardsV2_ProcessBatchResult = ProcessRewardsV2_ProcessBatchResult {} + deriving (Eq, Show) + +-- | State contract tracking outstanding processing of rewards for a given round and batch hash. +template ProcessRewardsV2 with + dso : Party + round : Round + dryRun : Bool -- ^ Whether to only simulate the processing without creating any RewardCouponV2 contracts. + rewardCouponTimeToLive : RelTime + batchHash : CryptoHash.Hash + where + signatory dso + + choice ProcessRewardsV2_ProcessBatch : ProcessRewardsV2_ProcessBatchResult + with + batch : Batch + providersWithWrongVettingState : Set Party + -- ^ Service providers that do not have the correct vetting state for receiving rewards. + observer batchProviders providersWithWrongVettingState batch + controller dso + do + let actualHash = CryptoHash.hash batch + require "batch hash matches" (actualHash == batchHash) + case batch of + BatchOfBatches batchHashes -> do + forA_ batchHashes $ \newBatchHash -> + create ProcessRewardsV2 with + dso + round + rewardCouponTimeToLive + dryRun + batchHash = newBatchHash + BatchOfMintingAllowances mintingAllowances -> do + unless dryRun $ do + -- Coupon expiry is determined here based on the time of creation to ensure + -- providers are always given the full rewardCouponTimeToLive duration to redeem their coupons, + -- independent of how long the processing takes and how many batches there are. + now <- getTime + let expiresAt = now `addRelTime` rewardCouponTimeToLive + forA_ mintingAllowances $ \MintingAllowance{..} -> + create RewardCouponV2 with + dso + round + provider + amount + expiresAt + providerIsObserver = + not $ Set.member provider providersWithWrongVettingState + beneficiary = None + + -- intentionally not returning any information here to save computational overhead + return ProcessRewardsV2_ProcessBatchResult {} + +batchProviders : Set Party -> Batch -> [Party] +batchProviders ignoredProviders batch = case batch of + BatchOfBatches _ -> [] + BatchOfMintingAllowances mintingAllowances -> + [ provider | MintingAllowance{..} <- mintingAllowances, not (Set.member provider ignoredProviders) ] + + +-- instances + +instance CryptoHash.Hashable MintingAllowance where + hash MintingAllowance {provider, amount} = + CryptoHash.hashRecord [CryptoHash.hash provider , CryptoHash.hash amount] + +instance CryptoHash.Hashable Batch where + hash batch = case batch of + BatchOfBatches batchHashes -> + CryptoHash.hashVariant "BatchOfBatches" [CryptoHash.hash batchHashes] + BatchOfMintingAllowances mintingAllowances -> + CryptoHash.hashVariant "BatchOfMintingAllowances" [CryptoHash.hash mintingAllowances] + +instance HasCheckedFetch CalculateRewardsV2 ForDso where + contractGroupId CalculateRewardsV2 with .. = ForDso with .. + +instance HasCheckedFetch ProcessRewardsV2 ForDso where + contractGroupId ProcessRewardsV2 with .. = ForDso with .. diff --git a/daml/splice-amulet/daml/Splice/AmuletConfig.daml b/daml/splice-amulet/daml/Splice/AmuletConfig.daml index 76b80c1a4c..728915188c 100644 --- a/daml/splice-amulet/daml/Splice/AmuletConfig.daml +++ b/daml/splice-amulet/daml/Splice/AmuletConfig.daml @@ -47,6 +47,42 @@ data TransferConfigV2 unit = TransferConfigV2 with maxNumLockHolders : Int deriving (Eq, Show) + +-- Reward configuration +----------------------- + +-- | How rewards and their minting allowances are tracked and computed. +data RewardVersion + = RewardVersion_FeaturedAppMarkers + -- ^ Version of app rewards pre CIP-104 + | RewardVersion_TrafficBasedAppRewards + -- ^ Traffic-based app rewards as introduced in CIP-104 + deriving (Eq, Show) + +useTrafficBasedAppRewards : Optional RewardVersion -> Bool +useTrafficBasedAppRewards rv = case rv of + None -> False + Some RewardVersion_FeaturedAppMarkers -> False + Some RewardVersion_TrafficBasedAppRewards -> True + +useFeaturedAppMarkers : Optional RewardVersion -> Bool +useFeaturedAppMarkers = not . useTrafficBasedAppRewards + +-- | Configuration for how reward accounting and calculation should be performed. +data RewardConfig = RewardConfig with + mintingVersion : RewardVersion -- ^ What scheme to use for minting rewards. + dryRunVersion : Optional RewardVersion -- ^ What scheme to use for dry-running reward minting. If None, dry-runs are disabled. + batchSize : Int -- ^ Batch size to use for building the Merkle-tree over minting allowances. + rewardCouponTimeToLive : RelTime -- ^ Time to live for reward coupons, default 36h to support batching of collection across 12h with a 24h prepare-submission delay. + appRewardCouponThreshold : Decimal -- ^ Threshold for minting reward coupons, in USD. + -- This threshold is not enforced in daml, but it must be used while building the Merkle-tree + -- to ignore minting allowances lower than this value. + deriving (Eq, Show) + + +-- Full AmuletConfig +-------------------- + -- | Configuration includes TransferConfig, issuance curve and tickDuration -- -- See Splice.Scripts.Parameters for concrete values. @@ -65,6 +101,8 @@ data AmuletConfig unit = AmuletConfig with -- ^ Party authorized to manage and allocate minting rights from the Development Fund. externalPartyConfigStateTickDuration : Optional RelTime -- ^ Half the lifetime of an `ExternalPartyConfigState` contract and the overlap between two successive contracts. Default: 24h. + rewardConfig : Optional RewardConfig + -- ^ Configuration for the reward accounting and calculation. If None, then on-ledger reward tracking is used. deriving (Eq, Show) getExternalPartyConfigStateTickDuration : AmuletConfig a -> RelTime @@ -138,6 +176,9 @@ data PackageConfig = PackageConfig validPackageConfig : PackageConfig -> Bool validPackageConfig _ = True +instance Patchable RewardVersion where + patch = patchScalar + instance Patchable (AmuletConfig USD) where patch new base current = AmuletConfig with transferConfig = patch new.transferConfig base.transferConfig current.transferConfig @@ -149,6 +190,15 @@ instance Patchable (AmuletConfig USD) where featuredAppActivityMarkerAmount = patch new.featuredAppActivityMarkerAmount base.featuredAppActivityMarkerAmount current.featuredAppActivityMarkerAmount optDevelopmentFundManager = patch new.optDevelopmentFundManager base.optDevelopmentFundManager current.optDevelopmentFundManager externalPartyConfigStateTickDuration = patch new.externalPartyConfigStateTickDuration base.externalPartyConfigStateTickDuration current.externalPartyConfigStateTickDuration + rewardConfig = patch new.rewardConfig base.rewardConfig current.rewardConfig + +instance Patchable RewardConfig where + patch new base current = RewardConfig with + mintingVersion = patch new.mintingVersion base.mintingVersion current.mintingVersion + dryRunVersion = patch new.dryRunVersion base.dryRunVersion current.dryRunVersion + batchSize = patch new.batchSize base.batchSize current.batchSize + rewardCouponTimeToLive = patch new.rewardCouponTimeToLive base.rewardCouponTimeToLive current.rewardCouponTimeToLive + appRewardCouponThreshold = patch new.appRewardCouponThreshold base.appRewardCouponThreshold current.appRewardCouponThreshold instance Patchable (TransferConfig USD) where patch new base current = TransferConfig with diff --git a/daml/splice-amulet/daml/Splice/AmuletRules.daml b/daml/splice-amulet/daml/Splice/AmuletRules.daml index 34642561f4..245b50b594 100644 --- a/daml/splice-amulet/daml/Splice/AmuletRules.daml +++ b/daml/splice-amulet/daml/Splice/AmuletRules.daml @@ -10,10 +10,10 @@ import DA.Action (filterA, foldlA, when, unless, void) import DA.Assert import DA.Fail import DA.Foldable (forA_) -import DA.List (dedupSort, maximumOn) +import DA.List (sort, dedupSort, maximumOn) import DA.Map (Map) import qualified DA.Map as Map -import DA.Optional (fromOptional, isNone, optionalToList) +import DA.Optional (fromOptional, isNone, isSome, optionalToList) import DA.Set (Set) import qualified DA.Set as Set import DA.TextMap (TextMap) @@ -26,8 +26,14 @@ import Splice.Api.FeaturedAppRightV1 (AppRewardBeneficiary(..)) import Splice.Api.Token.MetadataV1 as Api.Token.MetadataV1 import Splice.Api.Token.HoldingV1 qualified as Api.Token.HoldingV1 import Splice.Amulet +import Splice.Amulet.CryptoHash qualified as CryptoHash +import Splice.Amulet.RewardAccountingV2 import Splice.Amulet.TokenApiUtils -import Splice.AmuletConfig (transferConfigToTransferConfigV2, AmuletConfig(..), TransferConfig(..), TransferConfigV2(..), validAmuletConfig, defaultTransferPreapprovalFee, getExternalPartyConfigStateTickDuration) +import Splice.AmuletConfig + ( transferConfigToTransferConfigV2, AmuletConfig(..), TransferConfig(..), TransferConfigV2(..), validAmuletConfig + , defaultTransferPreapprovalFee, getExternalPartyConfigStateTickDuration, useTrafficBasedAppRewards + , RewardVersion(..), useFeaturedAppMarkers + ) import qualified Splice.AmuletConfig as Unit import Splice.ExternalPartyConfigState import Splice.Schedule @@ -92,6 +98,16 @@ data AmuletRules_MiningRound_ArchiveResult = AmuletRules_MiningRound_ArchiveResu data AmuletRules_ClaimExpiredRewardsResult = AmuletRules_ClaimExpiredRewardsResult with unclaimedRewardCid : Optional (ContractId UnclaimedReward) +-- The following results are intentionally empty to save on storage cost incurred in Scan. +-- These choices are driven by automation, which reads its results indirectly via the update to their backing stores. +data AmuletRules_ClaimExpiredRewardsV2Result = AmuletRules_ClaimExpiredRewardsV2Result {} + +data AmuletRules_StartProcessingRewardsV2Result = AmuletRules_StartProcessingRewardsV2Result {} + +data AmuletRules_UnhideRewardCouponsV2Result = AmuletRules_UnhideRewardCouponsV2Result {} + +data AmuletRules_ArchiveDryRunRewardAccountingV2Result = AmuletRules_ArchiveDryRunRewardAccountingV2Result {} + data AmuletRules_MergeUnclaimedRewardsResult = AmuletRules_MergeUnclaimedRewardsResult with unclaimedRewardCid : ContractId UnclaimedReward @@ -373,6 +389,11 @@ template AmuletRules now <- getTime let configUsd = getValueAsOf now configSchedule let tickDuration = configUsd.tickDuration + let rewardConfig = configUsd.rewardConfig + let trafficPrice = + if isSome rewardConfig + then Some configUsd.decentralizedSynchronizer.fees.extraTrafficPrice + else None -- Keep OpenMiningRound downgradable to prior versions for as long as possible let nr0 = fromOptional 0 initialRound let nr1 = nr0 + 1 let nr2 = nr1 + 1 @@ -402,10 +423,13 @@ template AmuletRules oldest <- create OpenMiningRound with dso; round = Round nr0; amuletPrice; opensAt = opensAt0; targetClosesAt = targetClosesAt0; issuingFor = issuingFor0; transferConfigUsd ; tickDuration ; issuanceConfig = issuanceConfig0 + trafficPrice; rewardConfig newestOpen <- create OpenMiningRound with dso; round = Round nr1; amuletPrice; opensAt = opensAt1; targetClosesAt = targetClosesAt1; issuingFor = issuingFor1; transferConfigUsd ; tickDuration ; issuanceConfig = issuanceConfig1 + trafficPrice; rewardConfig last <- create OpenMiningRound with dso; round = Round nr2; amuletPrice; opensAt = opensAt2; targetClosesAt = targetClosesAt2; issuingFor = issuingFor2; transferConfigUsd ; tickDuration ; issuanceConfig = issuanceConfig2 + trafficPrice; rewardConfig exercise self AmuletRules_BootstrapExternalPartyConfigState with openMiningRoundTriple = OpenMiningRoundTriple with @@ -447,19 +471,40 @@ template AmuletRules require "latestRound is open" (latestRound.opensAt <= now) require "middle round has been open for >= 1 tick" (addRelTime middleRound.opensAt middleRound.tickDuration <= now) - -- archive and create the rounds + -- create the right state to start summarizing the round newSummarizingRound <- create SummarizingMiningRound with dso round = roundToArchive.round amuletPrice = roundToArchive.amuletPrice issuanceConfig = roundToArchive.issuanceConfig tickDuration = roundToArchive.tickDuration + trafficPrice = roundToArchive.trafficPrice + rewardConfig = roundToArchive.rewardConfig + + -- trigger off-ledger reward calculation if needed + forA_ roundToArchive.rewardConfig $ \rewardConfig -> do + when (useTrafficBasedAppRewards (Some rewardConfig.mintingVersion)) $ do + void $ create CalculateRewardsV2 with + dso + round = roundToArchive.round + rewardCouponTimeToLive = rewardConfig.rewardCouponTimeToLive + dryRun = False + + when (useTrafficBasedAppRewards rewardConfig.dryRunVersion) $ do + void $ create CalculateRewardsV2 with + dso + round = roundToArchive.round + rewardCouponTimeToLive = rewardConfig.rewardCouponTimeToLive + dryRun = True + + -- create the new open round newOpenRound <- do let configUsd = getValueAsOf now configSchedule tickDuration = configUsd.tickDuration -- round is in pre-fetchable state for at least 1 tick and can only open with 1-tick difference between latestRound's opensAt opensAt = addRelTime (max now latestRound.opensAt) tickDuration newOpenRoundIssuingFor = latestRound.issuingFor + latestRound.tickDuration + rewardConfig = configUsd.rewardConfig create OpenMiningRound with dso round = Round (latestRound.round.number + 1) @@ -473,6 +518,11 @@ template AmuletRules transferConfigUsd = configUsd.transferConfig issuanceConfig = getValueAsOf newOpenRoundIssuingFor configUsd.issuanceCurve tickDuration + rewardConfig + trafficPrice = + if isSome rewardConfig + then Some configUsd.decentralizedSynchronizer.fees.extraTrafficPrice + else None -- Keep OpenMiningRound downgradable to prior versions for as long as possible return AmuletRules_AdvanceOpenMiningRoundsResult with summarizingRoundCid = newSummarizingRound openRoundCid = newOpenRound @@ -612,6 +662,84 @@ template AmuletRules else Some <$> create UnclaimedReward with dso; amount return AmuletRules_ClaimExpiredRewardsResult with unclaimedRewardCid + -- batch claim of expired rewards that use time-based expiry + nonconsuming choice AmuletRules_ClaimExpiredRewardsV2 : AmuletRules_ClaimExpiredRewardsV2Result + with + rewardCouponCids : [ContractId RewardCouponV2] + beneficiaries : [Party] + observer beneficiaries + controller dso + do + require "at least one coupon" (not (null rewardCouponCids)) + + -- archive coupons + coupons <- forA rewardCouponCids $ \cid -> do + coupon <- fetchAndArchive (ForDso with dso) cid + assertDeadlineExceeded "coupon.expiresAt" coupon.expiresAt + return coupon + let actualBeneficiaries = [ fromOptional coupon.provider coupon.beneficiary | coupon <- coupons ] + require "beneficiaries match coupons" (sort beneficiaries == dedupSort actualBeneficiaries) + + -- create unclaimed reward for the total + let amount = sum [ coupon.amount | coupon <- coupons ] + when (amount > 0.0) $ + void $ create UnclaimedReward with dso; amount + return AmuletRules_ClaimExpiredRewardsV2Result {} + + nonconsuming choice AmuletRules_StartProcessingRewardsV2 : AmuletRules_StartProcessingRewardsV2Result + with + calculateRewardsCid : ContractId CalculateRewardsV2 + batchHash : CryptoHash.Hash + controller dso + do + calculateRewards <- fetchAndArchive (ForDso with dso) calculateRewardsCid + create ProcessRewardsV2 with + dso + round = calculateRewards.round + dryRun = calculateRewards.dryRun + rewardCouponTimeToLive = calculateRewards.rewardCouponTimeToLive + batchHash + + return AmuletRules_StartProcessingRewardsV2Result {} + + -- batch conversion of coupons not yet observable by their beneficiaries + nonconsuming choice AmuletRules_UnhideRewardCouponsV2 : AmuletRules_UnhideRewardCouponsV2Result + with + rewardCouponCids : [ContractId RewardCouponV2] + beneficiaries : [Party] + observer beneficiaries + controller dso + do + require "at least one coupon" (not (null rewardCouponCids)) + + -- unhide coupons + actualBeneficiaries <- forA rewardCouponCids $ \cid -> do + coupon <- fetchAndArchive (ForDso with dso) cid + require "provider is no observer on the coupon" (not (coupon.providerIsObserver)) + assertWithinDeadline "coupon.expiresAt" coupon.expiresAt + create coupon with providerIsObserver = True + pure coupon.provider + + -- check specified beneficiaries match actual beneficiaries of the coupons + require "beneficiaries match coupons" (dedupSort beneficiaries == dedupSort actualBeneficiaries) + + return AmuletRules_UnhideRewardCouponsV2Result {} + + -- Choice to cleanup the state of stuck dry-runs in case getting them unstuck is too expensive. + nonconsuming choice AmuletRules_ArchiveDryRunRewardAccountingV2 : AmuletRules_ArchiveDryRunRewardAccountingV2Result + with + calculateRewardsCids : [ContractId CalculateRewardsV2] + processRewardsCids : [ContractId ProcessRewardsV2] + controller dso + do + forA_ calculateRewardsCids $ \calculateRewardsCid -> do + state <- fetchAndArchive (ForDso with dso) calculateRewardsCid + require "CalculateRewardsV2 is in dry-run state" (state.dryRun) + forA_ processRewardsCids $ \processRewardsCid -> do + state <- fetchAndArchive (ForDso with dso) processRewardsCid + require "ProcessRewardsV2 is in dry-run state" (state.dryRun) + return AmuletRules_ArchiveDryRunRewardAccountingV2Result {} + -- Batch merge of unclaimed rewards nonconsuming choice AmuletRules_MergeUnclaimedRewards : AmuletRules_MergeUnclaimedRewardsResult with @@ -750,27 +878,34 @@ template AmuletRules observers : Optional [Party] -- ^ A list of choice observers. This is expected to be set to the union of all providers and beneficiaries to ensure that this creates only one view. observer fromOptional [] observers controller dso - do now <- getTime - markers <- mapA (fetchAndArchive (ForDso dso)) markerCids - let groupedMarkers = Map.fromListWithR (+) (map (\m -> ((m.provider, m.beneficiary), m.weight)) markers) - round <- fetchReferenceData (ForDso dso) openMiningRoundCid - require ("mining round is open: " <> show round) (round.opensAt <= now) - let configUsd = getValueAsOf now configSchedule - -- If the amount is not set or is <= 0 we just archive the marker contracts. - markerCids <- forA (optionalToList configUsd.featuredAppActivityMarkerAmount) $ \amountUsd -> do - let amountAmulet = amountUsd / round.amuletPrice - if amountAmulet > 0.0 - then - forA (Map.toList groupedMarkers) $ \((provider, beneficiary), weight) -> - create AppRewardCoupon with - dso - provider - beneficiary = Some beneficiary - featured = True - round = round.round - amount = amountAmulet * weight - else pure [] - pure AmuletRules_ConvertFeaturedAppActivityMarkersResult with appRewardCouponCids = concat markerCids + do + now <- getTime + markers <- mapA (fetchAndArchive (ForDso dso)) markerCids + let groupedMarkers = Map.fromListWithR (+) (map (\m -> ((m.provider, m.beneficiary), m.weight)) markers) + round <- fetchReferenceData (ForDso dso) openMiningRoundCid + require ("mining round is open: " <> show round) (round.opensAt <= now) + let configUsd = getValueAsOf now configSchedule + if useTrafficBasedAppRewards ((.mintingVersion) <$> round.rewardConfig) + then + -- Markers may still be created for rounds that use traffic-based app rewards + -- ==> just archive the markers. + pure AmuletRules_ConvertFeaturedAppActivityMarkersResult with appRewardCouponCids = [] + else do + -- If the amount is not set or is <= 0 we just archive the marker contracts. + markerCids <- forA (optionalToList configUsd.featuredAppActivityMarkerAmount) $ \amountUsd -> do + let amountAmulet = amountUsd / round.amuletPrice + if amountAmulet > 0.0 + then + forA (Map.toList groupedMarkers) $ \((provider, beneficiary), weight) -> + create AppRewardCoupon with + dso + provider + beneficiary = Some beneficiary + featured = True + round = round.round + amount = amountAmulet * weight + else pure [] + pure AmuletRules_ConvertFeaturedAppActivityMarkersResult with appRewardCouponCids = concat markerCids nonconsuming choice AmuletRules_Amulet_ExpireTransferInstructions : AmuletRules_Amulet_ExpireTransferInstructionsResult with @@ -821,8 +956,10 @@ template AmuletRules holdingFeesOpenRoundNumber = validatedRounds.oldestRound.round amuletPrice = validatedRounds.latestUsableRound.amuletPrice transferConfig = transferConfigToTransferConfigV2 validatedRounds.latestUsableRound.transferConfigUsd + rewardCalculationVersion = (.mintingVersion) <$> validatedRounds.latestUsableRound.rewardConfig pure AmuletRules_UpdateExternalPartyConfigStatesResult with .. + data OpenMiningRoundTriple = OpenMiningRoundTriple with round0Cid : ContractId OpenMiningRound @@ -848,6 +985,9 @@ validateOpenMiningRoundTriple dso roundTriple = do oldestRound = round0 latestUsableRound = maximumOn (.round.number) usableRounds +miningRoundTripleCids : OpenMiningRoundTriple -> [ContractId OpenMiningRound] +miningRoundTripleCids OpenMiningRoundTriple {..} = [round0Cid, round1Cid, round2Cid] + -- Transfer logic -- ============== @@ -1018,6 +1158,7 @@ data TransferContextSummaryV2 = TransferContextSummaryV2 with amuletPrice : Decimal issuingMiningRounds : Map Round IssuingMiningRound validatorRights : Map Party (ContractId ValidatorRight) + rewardCalculationVersion : Optional RewardVersion deriving (Eq, Show) data TransferInputsSummary = TransferInputsSummary with @@ -1077,6 +1218,7 @@ summarizeAndValidateContext context dso tf = do issuingMiningRounds = Map.fromList issuingMiningRounds validatorRights = Map.fromList validatorRights amuletPrice = openRound.amuletPrice + rewardCalculationVersion = (.mintingVersion) <$> openRound.rewardConfig summarizeAndValidateExternalPartyContext : ExternalPartyTransferContext -> Party -> Transfer -> Update TransferContextSummaryV2 summarizeAndValidateExternalPartyContext context dso tf = do @@ -1093,10 +1235,11 @@ summarizeAndValidateExternalPartyContext context dso tf = do config = scaleFees2 (1.0 / externalPartyConfigState.amuletPrice) $ externalPartyConfigState.transferConfig featuredAppProvider openRoundNumber = externalPartyConfigState.holdingFeesOpenRoundNumber - amuletPrice = externalPartyConfigState.amuletPrice -- no minting for long lived transfers issuingMiningRounds = Map.empty validatorRights = Map.empty + amuletPrice = externalPartyConfigState.amuletPrice + rewardCalculationVersion = externalPartyConfigState.rewardCalculationVersion getValidatorRight : TransferContextSummaryV2 -> Party -> Update (ContractId ValidatorRight) getValidatorRight csum user = @@ -1258,6 +1401,23 @@ summarizeAndConsumeInputs csum dso sender inps = do changeToHoldingFeesRate = s.changeToHoldingFeesRate totalDevelopmentFundAmount = (+ coupon.amount) <$> s.totalDevelopmentFundAmount + summarizeAndConsumeInput _round s (InputRewardCouponV2 couponCid) = do + coupon <- fetchAndArchive forOwner couponCid + assertWithinDeadline "RewardCouponV2.expiresAt" coupon.expiresAt + return TransferInputsSummary with + totalAmuletAmount = s.totalAmuletAmount + -- Note: in the current implementation RewardCouponV2 is only used for app rewards. + -- This may change in the future. As part of such a change we'll adjust the attribution done here. + totalAppRewardAmount = s.totalAppRewardAmount + coupon.amount + totalValidatorRewardAmount = s.totalValidatorRewardAmount + totalUnclaimedActivityRecordAmount = s.totalUnclaimedActivityRecordAmount + totalValidatorFaucetAmount = s.totalValidatorFaucetAmount + totalSvRewardAmount = s.totalSvRewardAmount + totalHoldingFees = s.totalHoldingFees + amountArchivedAsOfRoundZero = s.amountArchivedAsOfRoundZero + changeToHoldingFeesRate = s.changeToHoldingFeesRate + totalDevelopmentFundAmount = s.totalDevelopmentFundAmount + summarizeAndConsumeValidatorFaucetInput s couponCid = do coupon <- fetchAndArchive forOwner couponCid -- compute balance change @@ -1364,22 +1524,24 @@ summarizeTransfer sender openRoundNumber amuletPrice transferConfigAmulet inp pr amuletPrice issueRewards : RewardsIssuanceConfig -> TransferContextSummaryV2 -> Party -> Optional [AppRewardBeneficiary] -> Update () -issueRewards config csum provider beneficiaries = do - if config.issueAppRewards - then do - let beneficiaries' = fromOptional [AppRewardBeneficiary provider 1.0] beneficiaries - validateAppRewardBeneficiaries beneficiaries' - let groupedBeneficiaries = Map.fromListWithR (+) (map (\b -> (b.beneficiary, b.weight)) beneficiaries') - when featured $ - forA_ (Map.toList groupedBeneficiaries) $ \(beneficiary, weight) -> - void $ create FeaturedAppActivityMarker - with - dso = csum.dso - provider - beneficiary = beneficiary - weight = weight - else do - require "beneficiaries are unset if issueAppRewards is false" (optional True null beneficiaries) +issueRewards config csum provider beneficiaries + | useFeaturedAppMarkers csum.rewardCalculationVersion = do + if config.issueAppRewards + then do + let beneficiaries' = fromOptional [AppRewardBeneficiary provider 1.0] beneficiaries + validateAppRewardBeneficiaries beneficiaries' + let groupedBeneficiaries = Map.fromListWithR (+) (map (\b -> (b.beneficiary, b.weight)) beneficiaries') + when featured $ + forA_ (Map.toList groupedBeneficiaries) $ \(beneficiary, weight) -> + void $ create FeaturedAppActivityMarker + with + dso = csum.dso + provider + beneficiary = beneficiary + weight = weight + else do + require "beneficiaries are unset if issueAppRewards is false" (optional True null beneficiaries) + | otherwise = pure () where featured = Some provider == csum.featuredAppProvider @@ -1442,12 +1604,13 @@ validateBuyMemberTrafficInputs configUsd synchronizerId trafficAmount -- | Computing synchronizer fees computeSynchronizerFees : Party -> Party -> Int -> AmuletRules -> TransferContext -> Update (Decimal, Decimal) computeSynchronizerFees dso validator trafficAmount amuletRules context = do + contextMiningRound <- fetchPublicReferenceData (ForDso dso) context.openMiningRound (OpenMiningRound_Fetch validator) -- compute traffic cost in USD configUsd <- getValueAsOfLedgerTime amuletRules.configSchedule - let extraTrafficPrice = configUsd.decentralizedSynchronizer.fees.extraTrafficPrice + let extraTrafficPrice = + fromOptional (configUsd.decentralizedSynchronizer.fees.extraTrafficPrice) contextMiningRound.trafficPrice let trafficCostUsd = intToDecimal trafficAmount / 1e6 * extraTrafficPrice -- compute traffic cost in Amulet - contextMiningRound <- fetchPublicReferenceData (ForDso dso) context.openMiningRound (OpenMiningRound_Fetch validator) let trafficCostAmulet = trafficCostUsd / contextMiningRound.amuletPrice pure (trafficCostAmulet, trafficCostUsd) @@ -1553,6 +1716,7 @@ data TransferInput | InputValidatorLivenessActivityRecord (ContractId ValidatorLivenessActivityRecord) | InputUnclaimedActivityRecord (ContractId UnclaimedActivityRecord) | InputDevelopmentFundCoupon (ContractId DevelopmentFundCoupon) + | InputRewardCouponV2 (ContractId RewardCouponV2) deriving (Eq, Ord, Show) -- | Smart constructor for inputing validator faucet coupons into a transfer. @@ -1994,6 +2158,7 @@ bootstrapExternalPartyConfigState AmuletRules{..} openMiningRoundTriple = do holdingFeesOpenRoundNumber = validatedRounds.oldestRound.round amuletPrice = validatedRounds.latestUsableRound.amuletPrice transferConfig = transferConfig + rewardCalculationVersion = (.mintingVersion) <$> validatedRounds.latestUsableRound.rewardConfig configState1 = configState0 with targetArchiveAfter = configState0.targetArchiveAfter `addRelTime` getExternalPartyConfigStateTickDuration config create configState0 diff --git a/daml/splice-amulet/daml/Splice/ExternalPartyAmuletRules.daml b/daml/splice-amulet/daml/Splice/ExternalPartyAmuletRules.daml index 395b3e9d80..5a08c68008 100644 --- a/daml/splice-amulet/daml/Splice/ExternalPartyAmuletRules.daml +++ b/daml/splice-amulet/daml/Splice/ExternalPartyAmuletRules.daml @@ -21,7 +21,6 @@ import Splice.AmuletRules import Splice.Types import Splice.Util - data ExternalPartyAmuletRules_ExpireAmuletAllocationInput = ExternalPartyAmuletRules_ExpireAmuletAllocationInput with allocationCid : ContractId AmuletAllocation diff --git a/daml/splice-amulet/daml/Splice/ExternalPartyConfigState.daml b/daml/splice-amulet/daml/Splice/ExternalPartyConfigState.daml index 784c4cc213..02835994a3 100644 --- a/daml/splice-amulet/daml/Splice/ExternalPartyConfigState.daml +++ b/daml/splice-amulet/daml/Splice/ExternalPartyConfigState.daml @@ -21,6 +21,7 @@ template ExternalPartyConfigState amuletPrice : Decimal -- ^ Amulet price at the time the config state was created. transferConfig : TransferConfigV2 Unit.USD -- ^ Transfer config at the time the config state was created. targetArchiveAfter : Time -- ^ Lower bound on the time the contract gets archived, not enforced as a strict upper bound. + rewardCalculationVersion : Optional RewardVersion -- ^ The reward calculation version to use for transactions relying on this config state. where signatory dso diff --git a/daml/splice-amulet/daml/Splice/Round.daml b/daml/splice-amulet/daml/Splice/Round.daml index 20f985f54f..122cc27ff3 100644 --- a/daml/splice-amulet/daml/Splice/Round.daml +++ b/daml/splice-amulet/daml/Splice/Round.daml @@ -28,6 +28,12 @@ template OpenMiningRound transferConfigUsd : TransferConfig USD -- ^ Configuration determining the fees and limits in USD for Amulet transfers issuanceConfig : IssuanceConfig -- ^ Configuration for issuance of this round. tickDuration : RelTime -- ^ Duration of a tick, which is the duration of half a round. + trafficPrice : Optional Decimal + -- ^ Traffic price in $/MB at round start time. Used by the reward + -- calculation to translate traffic burn back to amulet. + rewardConfig : Optional RewardConfig + -- ^ Configuration for off-ledger reward calculation for this round. + -- If None, rewards are computed on-ledger using the pre-CIP-104 mechanism. where signatory dso ensure isDefinedRound round @@ -47,6 +53,8 @@ template SummarizingMiningRound amuletPrice : Decimal issuanceConfig : IssuanceConfig tickDuration : RelTime + trafficPrice : Optional Decimal + rewardConfig : Optional RewardConfig where signatory dso ensure isDefinedRound round diff --git a/daml/splice-dso-governance-test/daml.yaml b/daml/splice-dso-governance-test/daml.yaml index 32bb99906b..079a9028ff 100644 --- a/daml/splice-dso-governance-test/daml.yaml +++ b/daml/splice-dso-governance-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-dso-governance-test source: daml -version: 0.1.29 +version: 0.1.30 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestRewardAccountingV2.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestRewardAccountingV2.daml new file mode 100644 index 0000000000..837af8463d --- /dev/null +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestRewardAccountingV2.daml @@ -0,0 +1,215 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Scripts.DsoTestRewardAccountingV2 where + + +import DA.Action (void) +import DA.Assert +import DA.Foldable (forA_) +import DA.List +import DA.Set as Set +import DA.Time + +import Daml.Script + +import Splice.Amulet +import Splice.Amulet.RewardAccountingV2 +import Splice.Amulet.CryptoHash qualified as CryptoHash +import Splice.AmuletConfig +import Splice.AmuletRules +import Splice.DsoRules +import Splice.Types + +import Splice.Scripts.Util +import Splice.Scripts.DsoTestUtils + +import Splice.Testing.Registries.AmuletRegistry.Parameters (defaultAmuletConfig) + + +-- | Shared setup for reward accounting tests, which initiates the reward processing workflow and sets up demo data. +initiate_reward_processing : Bool -> Script (Script (), AmuletApp, (Party, Party, Party, Party, Party)) +initiate_reward_processing dryRun = do + -- enable traffic based app rewards, which are the first use-case for reward accounting v2 + let amuletConfig = defaultAmuletConfig with + rewardConfig = Some $ RewardConfig with + mintingVersion = if dryRun then RewardVersion_FeaturedAppMarkers else RewardVersion_TrafficBasedAppRewards + dryRunVersion = if dryRun then Some RewardVersion_TrafficBasedAppRewards else None + batchSize = 100 + rewardCouponTimeToLive = hours 36 + appRewardCouponThreshold = 0.5 + + (app, _, (sv1, _, _, _)) <- initDevNetWithAmuletConfig amuletConfig + + alice <- allocateParty "Alice" + bob <- allocateParty "Bob" + charlie <- allocateParty "Charlie" + dora <- allocateParty "Dora" + + setTime demoTime + + -- move the first round through issuance, which will also trigger the reward calculation for this round + if dryRun then runNextIssuanceD app 1.0 None + else runNextIssuanceD app 1.0 (Some 1.2) + + -- setup demo data + let mintingAllowances1 = sortOn (.provider) + [ MintingAllowance alice 1000.0 + , MintingAllowance bob 2000.0 + ] + let mintingAllowances2 = sortOn (.provider) + [ MintingAllowance charlie 30.0 + , MintingAllowance dora 5.1 + ] + let b1 = BatchOfMintingAllowances mintingAllowances1 + let b2 = BatchOfMintingAllowances mintingAllowances2 + let rootBatch = BatchOfBatches [CryptoHash.hash b1, CryptoHash.hash b2] + let rootBatchHash = CryptoHash.hash rootBatch + let batchesWithHiding = [(b1, [bob]), (b2, [dora]), (rootBatch, [])] + + -- get the contract representing the pending calculation and confirmation of rewards for round 0 + [(calculateRewardsCid, _)] <- query @CalculateRewardsV2 app.dso + + -- setup reward coupon creation workflow state + confirmAndExecutionAction app ARC_AmuletRules with + amuletRulesAction = CRARC_StartProcessingRewardsV2 + AmuletRules_StartProcessingRewardsV2 with + calculateRewardsCid + batchHash = rootBatchHash + + [(dsoRulesCid, _)] <- query @DsoRules app.dso + + let processBatches = do + states <- query @ProcessRewardsV2 app.dso + forA_ states $ \(processRewardsCid, processRewards) -> do + let Some (b, badVettingState) = find (\(b, _) -> CryptoHash.hash b == processRewards.batchHash) batchesWithHiding + void $ submitMulti [sv1] [app.dso] $ exerciseCmd dsoRulesCid DsoRules_ProcessRewardsV2_ProcessBatch with + sv = sv1 + processRewardsCid + choiceArg = ProcessRewardsV2_ProcessBatch with + batch = b + providersWithWrongVettingState = Set.fromList badVettingState + + pure (processBatches, app, (sv1, alice, bob, charlie, dora)) + + +-- | Test that reward accounting can be driven via DsoRules. +-- +-- We focus on the core logic only, as the extensive functional testing is done +-- directly on `AmuletRules` in `TestRewardAccountingV2`, and the choices in DsoRules +-- are pass-through choices to `AmuletRules` that don't have any additional logic. +test_reward_accounting_v2 : Script () +test_reward_accounting_v2 = do + (processBatches, app, (sv1, alice, bob, charlie, dora)) <- initiate_reward_processing False + + processBatches -- expand root hash into batch hashes + processBatches -- expand follow-up batches into coupons + + let couponExpiryTime = demoTime `addRelTime` hours 36 + let expectedAmounts = [(alice, 1000.0), (bob, 2000.0), (charlie, 30.0), (dora, 5.1)] + let expectedCoupons = do + (provider, amount) <- expectedAmounts + pure RewardCouponV2 with + dso = app.dso + provider + amount + round = Round 0 + expiresAt = couponExpiryTime + providerIsObserver = provider `notElem` [bob, dora] + beneficiary = None + + actualCoupons0 <- query @RewardCouponV2 app.dso + let actualCoupons = sortOn (.provider) $ fmap snd actualCoupons0 + actualCoupons === expectedCoupons + + -- make Bob and Dora observers of their coupons (simulates them changing their vetting state) + [(dsoRulesCid, _)] <- query @DsoRules app.dso + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + unobservableCoupons <- queryFilter @RewardCouponV2 app.dso (\c -> not c.providerIsObserver) + void $ submitMulti [sv1] [app.dso] $ exerciseCmd dsoRulesCid DsoRules_UnhideRewardCouponsV2 with + sv = sv1 + amuletRulesCid + choiceArg = AmuletRules_UnhideRewardCouponsV2 with + rewardCouponCids = map fst unobservableCoupons + beneficiaries = map (._2.provider) unobservableCoupons + + couponsAfterUnhiding <- query @RewardCouponV2 app.dso + sortOn (.provider) (map snd couponsAfterUnhiding) === + map (\c -> c with providerIsObserver = True) expectedCoupons + + pure () + + +-- Test that the choice forwarding to AmuletRules works correctly +----------------------------------------------------------------- + +test_ClaimExpiredRewardsV2 : Script () +test_ClaimExpiredRewardsV2 = do + (app, _, (sv1, _, _, _)) <- initDevNet + + alice <- allocateParty "Alice" + bob <- allocateParty "Bob" + + + -- create coupons + forA_ [ (alice, Some alice, 100.0), (alice, Some bob, 500.0) ] $ \(provider, beneficiary, amount) -> do + submit app.dso $ createCmd RewardCouponV2 with + dso = app.dso + provider + amount + round = Round 0 + expiresAt = demoTime `addRelTime` hours 48 + providerIsObserver = False + beneficiary + + -- expiry works + setTime $ demoTime `addRelTime` hours 48 + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + [(dsoRulesCid, _)] <- query @DsoRules app.dso + rewardCouponCids <- query @RewardCouponV2 app.dso + + submitMulti [sv1] [app.dso] $ exerciseCmd dsoRulesCid DsoRules_ClaimExpiredRewardsV2 with + amuletRulesCid + choiceArg = AmuletRules_ClaimExpiredRewardsV2 with + rewardCouponCids = map fst rewardCouponCids + beneficiaries = [bob, alice] + sv = sv1 + + -- no coupons left + [] <- query @RewardCouponV2 app.dso + + pure () + + +test_ArchiveDryRunRewardAccountingV2 : Script () +test_ArchiveDryRunRewardAccountingV2 = do + (processBatches, app, (sv1, _, _, _, _)) <- initiate_reward_processing True + + processBatches -- expand root hash into batch hashes + -- pretend that follow up batches fail to process due to hash mismatches + + -- move to next round, so there's also a calculate rewards contract + runNextIssuance app + + -- archive the stuck state + processRewards <- query @ProcessRewardsV2 app.dso + calculateRewards <- query @CalculateRewardsV2 app.dso + + [(amuletRulesCid, _)] <- query @AmuletRules app.dso + [(dsoRulesCid, _)] <- query @DsoRules app.dso + + void $ submitMulti [sv1] [app.dso] $ exerciseCmd dsoRulesCid DsoRules_ArchiveDryRunRewardAccountingV2 with + sv = sv1 + amuletRulesCid + choiceArg = AmuletRules_ArchiveDryRunRewardAccountingV2 with + processRewardsCids = map fst processRewards + calculateRewardsCids = map fst calculateRewards + + -- no left-over processing contracts + [] <- query @CalculateRewardsV2 app.dso + [] <- query @ProcessRewardsV2 app.dso + + -- check that no coupons were created + [] <- query @RewardCouponV2 app.dso + pure () + diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml index 16eab814c7..eab05aee69 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/DsoTestUtils.daml @@ -16,7 +16,7 @@ import Daml.Script import DA.Time import Splice.Amulet -import Splice.AmuletConfig (AmuletConfig(..), USD) +import Splice.AmuletConfig (AmuletConfig(..), USD, useTrafficBasedAppRewards) import Splice.AmuletRules import Splice.Issuance import Splice.Round @@ -132,11 +132,7 @@ initDecentralizedSynchronizerWithAmuletPrice isDevNet initialRound amuletPrice o DsoBootstrap_Bootstrap dsoUserId <- validateUserId "dso-user" - let app = AmuletApp with - dso - dsoUser = AmuletUser with - userId = dsoUserId - primaryParty = dso + let app = mkAmuletApp dso (AmuletUser dsoUserId dso) -- add more sv nodes forA_ (zip [sv2, sv3, sv4] ["sv2", "sv3", "sv4"]) $ \(svParty, svName) -> do @@ -177,7 +173,7 @@ generateUnclaimedReward app provider1 = do round = Round 0 featured = True - runNextIssuanceD app 1.0 + runNextIssuanceD app 1.0 None pure () @@ -207,8 +203,8 @@ onboardValidator app sponsor validatorName validator = do -- | Run the next issuance. -runNextIssuanceD : AmuletApp -> Decimal -> Script () -runNextIssuanceD app amuletPrice = do +runNextIssuanceD : AmuletApp -> Decimal -> Optional Decimal -> Script () +runNextIssuanceD app amuletPrice appActivityRoundTotal = do advanceToNextRoundChange app.dso -- expire rewards for closed rounds closedRounds <- query @ClosedMiningRound app.dso @@ -262,6 +258,8 @@ runNextIssuanceD app amuletPrice = do sv = Some (head (Map.keys dsoRules.svs)) let summarizingRoundCid = advanceResult.summarizingRound + Some summarizingRound <- queryContractId app.dso summarizingRoundCid + -- compute total burn appRewardCoupons <- queryFilter @AppRewardCoupon app.dso (\bc -> bc.round == roundToArchive.round) validatorRewardCoupons <- queryFilter @ValidatorRewardCoupon app.dso (\bc -> bc.round == roundToArchive.round) @@ -269,10 +267,27 @@ runNextIssuanceD app amuletPrice = do validatorLivenessActivityRecords <- queryFilter @ValidatorLivenessActivityRecord app.dso (\bc -> bc.round == roundToArchive.round) svRewardCoupons <- queryFilter @SvRewardCoupon app.dso (\bc -> bc.round == roundToArchive.round) + let trafficBasedRewards = useTrafficBasedAppRewards ((.mintingVersion) <$> summarizingRound.rewardConfig) + + -- compute app reward totals: for traffic-based rewards, convert activity (MB) to Amulet + -- via trafficPrice ($/MB) and amuletPrice ($/Amulet); otherwise use on-ledger coupons + (totalFeaturedCoupons, totalUnfeaturedCoupons) <- + if trafficBasedRewards then + case (appActivityRoundTotal, summarizingRound.trafficPrice) of + (Some activity, Some trafficPrice) -> + pure (activity * trafficPrice / summarizingRound.amuletPrice, 0.0) + (None, _) -> + fail "appActivityRoundTotal must be specified when using traffic-based app rewards" + (_, None) -> + fail "trafficPrice must be specified when using traffic-based app rewards" + else + pure (sum [ c.amount | (_, c) <- appRewardCoupons, c.featured] + , sum [ c.amount | (_, c) <- appRewardCoupons, not (c.featured)]) + let summary = OpenMiningRoundSummary with totalValidatorRewardCoupons = sum [ c.amount | (_, c) <- validatorRewardCoupons] - totalFeaturedAppRewardCoupons = sum [ c.amount | (_, c) <- appRewardCoupons, c.featured] - totalUnfeaturedAppRewardCoupons = sum [ c.amount | (_, c) <- appRewardCoupons, not (c.featured)] + totalFeaturedAppRewardCoupons = totalFeaturedCoupons + totalUnfeaturedAppRewardCoupons = totalUnfeaturedCoupons totalSvRewardWeight = sum [ c.weight | (_, c) <- svRewardCoupons] optTotalValidatorFaucetCoupons = Some (length validatorFaucetCoupons + length validatorLivenessActivityRecords) @@ -303,6 +318,19 @@ confirmAWC_MiningRound_Archive app = do confirmer = sv pure () +-- | Convenience function to confirm and execute an action requiring confirmation. +confirmAndExecutionAction : AmuletApp -> ActionRequiringConfirmation -> Script () +confirmAndExecutionAction app action = do + [(dsoRulesCid, rules)] <- query @DsoRules app.dso + forA_ (Map.keys rules.svs) $ \sv -> do + -- mallory does not act + unless ("mallory" `T.isPrefixOf` partyToText sv) $ do + submitMulti [sv] [app.dso] $ exerciseCmd dsoRulesCid DsoRules_ConfirmAction with + action + confirmer = sv + pure () + executeAllConfirmedActions app + executeAllConfirmedActions : AmuletApp -> Script () executeAllConfirmedActions app = do [(amuletRulesCid, _)] <- query @AmuletRules app.dso diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestDecentralizedAutomation.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestDecentralizedAutomation.daml index 45acadbc3e..d1390b87c6 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestDecentralizedAutomation.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestDecentralizedAutomation.daml @@ -85,7 +85,7 @@ testUnclaimedDevelopmentFundCouponsMerging = do (app, _, (sv1, _, _, _)) <- initDevNetWithAmuletConfig amuletConfig -- Mint 5 unclaimed development fund coupons - replicateA_ 5 $ runNextIssuanceD app 1.0 + replicateA_ 5 $ runNextIssuanceD app 1.0 None [(amuletRulesCid, _)] <- query @AmuletRules app.dso unclaimedDevelopmentFundCouponCids@(cid1 :: _) <- fmap fst <$> query @UnclaimedDevelopmentFundCoupon app.dso @@ -127,7 +127,7 @@ testDevelopmentFundCouponExpiry = do [(dsoRulesCid, _)] <- query @DsoRules app.dso -- Mint 1 unclaimed development fund coupon - runNextIssuanceD app 1.0 + runNextIssuanceD app 1.0 None [(unclaimedDevelopmentFundCouponCid, unclaimedDevelopmentFundCoupon)] <- query @UnclaimedDevelopmentFundCoupon app.dso -- Allocate a development fund coupon diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestFeaturedAppActivityMarkers.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestFeaturedAppActivityMarkers.daml index 1e6cd98ab2..d8336d1c59 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestFeaturedAppActivityMarkers.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestFeaturedAppActivityMarkers.daml @@ -134,8 +134,8 @@ testFeaturedAppActivityMarkers = do amount = 50.0 * defaultFeaturedAppActivityMarkerAmount / amuletPrice -- ensure that the round is in issuing phase - runNextIssuanceD app amuletPrice - runNextIssuanceD app amuletPrice + runNextIssuanceD app amuletPrice None + runNextIssuanceD app amuletPrice None -- advance until the opensAt advanceToNextRoundChange app.dso -- Check that coupons are archived, we don't check the detailed minting computations diff --git a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml index df550998c1..62744ad147 100644 --- a/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml +++ b/daml/splice-dso-governance-test/daml/Splice/Scripts/TestGovernance.daml @@ -718,7 +718,7 @@ testAmuletRulesTickDurationChange = do round2.tickDuration === initialConfig.tickDuration round3.tickDuration === initialConfig.tickDuration - runNextIssuanceD app 1.2 + runNextIssuanceD app 1.2 None -- check that tickDuraction has changed [(_, round1), (_, round2), (_, round3)] <- sortOn (._2.round) <$> query @OpenMiningRound dso @@ -1005,7 +1005,7 @@ testAmuletPriceVoting = do (app, dso, (sv1, sv2, sv3, sv4)) <- initMainNet -- run a normal issuance so time advances and the amulet price is set to 1.2 for everybody - runNextIssuanceD app 1.2 + runNextIssuanceD app 1.2 None -- have all SV's adjust their prices [(dsoRulesCid, _)] <- query @DsoRules dso diff --git a/daml/splice-dso-governance/daml.yaml b/daml/splice-dso-governance/daml.yaml index 68bfdc70e3..62d92e1092 100644 --- a/daml/splice-dso-governance/daml.yaml +++ b/daml/splice-dso-governance/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-dso-governance source: daml -version: 0.1.24 +version: 0.1.25 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-dso-governance/daml/Splice/DsoRules.daml b/daml/splice-dso-governance/daml/Splice/DsoRules.daml index 299dac9c96..77b9f91c7d 100644 --- a/daml/splice-dso-governance/daml/Splice/DsoRules.daml +++ b/daml/splice-dso-governance/daml/Splice/DsoRules.daml @@ -18,6 +18,7 @@ import qualified DA.Text as T import DA.Time import Splice.Amulet +import Splice.Amulet.RewardAccountingV2 qualified as RewardAccountingV2 import Splice.AmuletRules import Splice.ExternalPartyAmuletRules @@ -91,6 +92,8 @@ data AmuletRules_ActionRequiringConfirmation -- ^ **Deprecated, use CRARC_SetConfig instead**: Voted action to update a config schedule in the `AmuletRules`. | CRARC_SetConfig AmuletRules_SetConfig -- ^ Voted action to change the `AmuletConfig`. Not idempotent. + | CRARC_StartProcessingRewardsV2 AmuletRules_StartProcessingRewardsV2 + -- ^ Automated action to start the processing of rewards where the minting allowances were computed off-ledger. deriving (Eq, Show) data DsoRules_ActionRequiringConfirmation @@ -268,6 +271,21 @@ data DsoRules_ReceiveSvRewardCouponResult = DsoRules_ReceiveSvRewardCouponResult data DsoRules_ClaimExpiredRewardsResult = DsoRules_ClaimExpiredRewardsResult with unclaimedReward: Optional (ContractId UnclaimedReward) +data DsoRules_ClaimExpiredRewardsV2Result = DsoRules_ClaimExpiredRewardsV2Result with + result : AmuletRules_ClaimExpiredRewardsV2Result + +data DsoRules_StartProcessingRewardsV2Result = DsoRules_StartProcessingRewardsV2Result with + result : AmuletRules_StartProcessingRewardsV2Result + +data DsoRules_ProcessRewardsV2_ProcessBatchResult = DsoRules_ProcessRewardsV2_ProcessBatchResult with + result : RewardAccountingV2.ProcessRewardsV2_ProcessBatchResult + +data DsoRules_UnhideRewardCouponsV2Result = DsoRules_UnhideRewardCouponsV2Result with + result : AmuletRules_UnhideRewardCouponsV2Result + +data DsoRules_ArchiveDryRunRewardAccountingV2Result = DsoRules_ArchiveDryRunRewardAccountingV2Result with + result : AmuletRules_ArchiveDryRunRewardAccountingV2Result + data DsoRules_MergeUnclaimedRewardsResult = DsoRules_MergeUnclaimedRewardsResult with unclaimedReward: ContractId UnclaimedReward @@ -1419,6 +1437,56 @@ template DsoRules with return DsoRules_ClaimExpiredRewardsResult with unclaimedReward = unclaimedRewardCid + nonconsuming choice DsoRules_ClaimExpiredRewardsV2 : DsoRules_ClaimExpiredRewardsV2Result + with + amuletRulesCid : ContractId AmuletRules + choiceArg : AmuletRules_ClaimExpiredRewardsV2 + sv : Party + controller sv + do + _ <- getAndValidateSvParty this (Some sv) + result <- exercise amuletRulesCid choiceArg + return DsoRules_ClaimExpiredRewardsV2Result with + result + + nonconsuming choice DsoRules_ProcessRewardsV2_ProcessBatch : DsoRules_ProcessRewardsV2_ProcessBatchResult + with + processRewardsCid : ContractId RewardAccountingV2.ProcessRewardsV2 + choiceArg : RewardAccountingV2.ProcessRewardsV2_ProcessBatch + sv : Party + controller sv + do + _ <- getAndValidateSvParty this (Some sv) + result <- exercise processRewardsCid choiceArg + return DsoRules_ProcessRewardsV2_ProcessBatchResult with + result + + nonconsuming choice DsoRules_UnhideRewardCouponsV2 : DsoRules_UnhideRewardCouponsV2Result + with + amuletRulesCid : ContractId AmuletRules + choiceArg : AmuletRules_UnhideRewardCouponsV2 + sv : Party + controller sv + do + _ <- getAndValidateSvParty this (Some sv) + result <- exercise amuletRulesCid choiceArg + return DsoRules_UnhideRewardCouponsV2Result with + result + + -- Choice to cleanup the state of stuck dry-runs in case getting them unstuck is too expensive. + nonconsuming choice DsoRules_ArchiveDryRunRewardAccountingV2 : DsoRules_ArchiveDryRunRewardAccountingV2Result + with + amuletRulesCid : ContractId AmuletRules + choiceArg : AmuletRules_ArchiveDryRunRewardAccountingV2 + sv : Party + controller sv + do + _ <- getAndValidateSvParty this (Some sv) + result <- exercise amuletRulesCid choiceArg + return DsoRules_ArchiveDryRunRewardAccountingV2Result with + result + + -- Batch merge of unclaimed rewards nonconsuming choice DsoRules_MergeUnclaimedRewards : DsoRules_MergeUnclaimedRewardsResult with @@ -1707,6 +1775,7 @@ executeActionRequiringConfirmation dso dsoRulesCid amuletRulesCid act = case act CRARC_AddFutureAmuletConfigSchedule choiceArg -> void $ exercise amuletRulesCid choiceArg CRARC_RemoveFutureAmuletConfigSchedule choiceArg -> void $ exercise amuletRulesCid choiceArg CRARC_UpdateFutureAmuletConfigSchedule choiceArg -> void $ exercise amuletRulesCid choiceArg + CRARC_StartProcessingRewardsV2 choiceArg -> void $ exercise amuletRulesCid choiceArg ARC_DsoRules with .. -> do void $ fetchChecked (ForDso with dso) dsoRulesCid case dsoAction of @@ -1774,6 +1843,7 @@ actionRequiringConfirmationEffectiveAt action = CRARC_UpdateFutureAmuletConfigSchedule choiceArg -> Some choiceArg.scheduleItem._1 CRARC_MiningRound_Archive _ -> None CRARC_MiningRound_StartIssuing _ -> None + CRARC_StartProcessingRewardsV2 _ -> None ARC_DsoRules with .. -> None ARC_AnsEntryContext with .. -> None ExtActionRequiringConformation _dummyUnitField -> diff --git a/daml/splice-util-batched-markers-test/daml.yaml b/daml/splice-util-batched-markers-test/daml.yaml index 6704c5e1c2..849fd2c151 100644 --- a/daml/splice-util-batched-markers-test/daml.yaml +++ b/daml/splice-util-batched-markers-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-util-batched-markers-test source: daml -version: 1.0.4 +version: 1.0.5 dependencies: - daml-prim - daml-script diff --git a/daml/splice-util-featured-app-proxies-test/daml.yaml b/daml/splice-util-featured-app-proxies-test/daml.yaml index a24db3e93e..5982430781 100644 --- a/daml/splice-util-featured-app-proxies-test/daml.yaml +++ b/daml/splice-util-featured-app-proxies-test/daml.yaml @@ -11,7 +11,7 @@ description: | are normal .dar files and can be shared by copying the .dars. (TODO(#594): remove this limitation) source: daml -version: 1.0.10 +version: 1.0.11 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-util-token-standard-wallet-test/daml.yaml b/daml/splice-util-token-standard-wallet-test/daml.yaml index 4e44826e94..f706b41c19 100644 --- a/daml/splice-util-token-standard-wallet-test/daml.yaml +++ b/daml/splice-util-token-standard-wallet-test/daml.yaml @@ -4,7 +4,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-util-token-standard-wallet-test source: daml -version: 1.0.5 +version: 1.0.6 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-wallet-payments/daml.yaml b/daml/splice-wallet-payments/daml.yaml index e5fc5f503f..4862704853 100644 --- a/daml/splice-wallet-payments/daml.yaml +++ b/daml/splice-wallet-payments/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-wallet-payments source: daml -version: 0.1.18 +version: 0.1.19 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-wallet-test/daml.yaml b/daml/splice-wallet-test/daml.yaml index e6d8674710..ca5d8c7be7 100644 --- a/daml/splice-wallet-test/daml.yaml +++ b/daml/splice-wallet-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-wallet-test source: daml -version: 0.1.22 +version: 0.1.23 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml b/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml index 1e739ea8ab..f49fad10f4 100644 --- a/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml +++ b/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml @@ -83,6 +83,16 @@ testMintingDelegation = do expiresAt = now `addRelTime` days 60 reason = "Test development fund coupon" + let rewardCouponV2Amount = 42.0 + rewardCouponV2Cid <- submit app.dso $ createCmd RewardCouponV2 with + dso = app.dso + provider = provider2.primaryParty + amount = rewardCouponV2Amount + expiresAt = now `addRelTime` days 60 + round = openRound.round + providerIsObserver = False + beneficiary = Some beneficiary + -- Wait for rounds to advance so coupons can be minted runNextIssuance app runNextIssuance app @@ -106,7 +116,8 @@ testMintingDelegation = do InputAppRewardCoupon appRewardCouponCid, InputValidatorRewardCoupon validatorRewardCouponCid, InputUnclaimedActivityRecord unclaimedActivityRecordCid, - InputDevelopmentFundCoupon developmentFundCouponCid + InputDevelopmentFundCoupon developmentFundCouponCid, + InputRewardCouponV2 rewardCouponV2Cid ] context = PaymentTransferContext with context @@ -119,7 +130,7 @@ testMintingDelegation = do -- expected rewards based on issuance rates let expectedValidatorFaucetAmount = getIssuingMiningRoundIssuancePerValidatorFaucetCoupon issuingRound - let expectedAppReward = appRewardCouponAmount * issuingRound.issuancePerUnfeaturedAppRewardCoupon + let expectedAppReward = appRewardCouponAmount * issuingRound.issuancePerUnfeaturedAppRewardCoupon + rewardCouponV2Amount let expectedValidatorReward = validatorRewardCouponAmount * issuingRound.issuancePerValidatorRewardCoupon let expectedTotal = expectedValidatorFaucetAmount + expectedAppReward + expectedValidatorReward + unclaimedRewardCouponAmount + developmentFundCouponAmount diff --git a/daml/splice-wallet/daml.yaml b/daml/splice-wallet/daml.yaml index 90e8587b93..e8b0d7c501 100644 --- a/daml/splice-wallet/daml.yaml +++ b/daml/splice-wallet/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-wallet source: daml -version: 0.1.19 +version: 0.1.20 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splitwell-test/daml.yaml b/daml/splitwell-test/daml.yaml index 7ef619ce3c..405b595a0c 100644 --- a/daml/splitwell-test/daml.yaml +++ b/daml/splitwell-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splitwell-test source: daml -version: 0.1.22 +version: 0.1.23 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splitwell/daml.yaml b/daml/splitwell/daml.yaml index 212f158602..b38877da5d 100644 --- a/daml/splitwell/daml.yaml +++ b/daml/splitwell/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splitwell source: daml -version: 0.1.19 +version: 0.1.20 dependencies: - daml-prim - daml-stdlib diff --git a/docs/src/app_dev/daml_api/index.rst b/docs/src/app_dev/daml_api/index.rst index 9b7582fd42..84f8a1d3c5 100644 --- a/docs/src/app_dev/daml_api/index.rst +++ b/docs/src/app_dev/daml_api/index.rst @@ -29,6 +29,16 @@ Refer to the :ref:`Token Standard documentation section `. Featured App Activity Markers API (CIP-0047) -------------------------------------------- +.. important:: + + On networks where traffic-based app rewards as described in `CIP-0104 `__ are enabled, + the Featured App Activity Markers API will become irrelevant. + On such networks, the API is still supported, but no rewards can be earned using it. + We recommend apps to stop creating featured app activity markers once CIP-0104 is enabled. + + The documentation here is provided for apps that need to integrate with app rewards prior + to CIP-0104 being enabled to avoid unnecessary traffic costs. + * See the `text of the CIP-0047 `__ for its background on its design and its specification. diff --git a/docs/src/release_notes_upcoming.rst b/docs/src/release_notes_upcoming.rst index 352a723961..376c717777 100644 --- a/docs/src/release_notes_upcoming.rst +++ b/docs/src/release_notes_upcoming.rst @@ -74,3 +74,58 @@ - Wallet app - ``batchSize`` in ``TreasuryConfig`` is set to 1. + + .. important:: + + **Action recommended for validator operators:** upgrade to this release + before the SVs start testing traffic-based app rewards in dry-run mode + (see `SV Longterm Operations Schedule `__ for dates for the different networks). + Otherwise, CC transfers and reward collection will stop working for parties on your node until you upgrade. + + **Action recommended for app devs:** app's with Daml code that statically depends on ``splice-amulet`` + should recompile their Daml code + to link against the new version of ``splice-amulet`` listed below. Otherwise, code involving CC transfers + will stop working as both ``OpenMiningRound`` and ``AmuletRules`` include newly introduced config fields. + + Apps that build against the :ref:`token_standard` API are not required to change except for upgrading + their validator node. + + - Daml + + - Add ``RewardCouponV2`` to represent rewards available from traffic-based app rewards that are computed + by the SV apps off-ledger as described in `CIP 104 `__. + They are created in an efficient batched fashion once per-round for every party that is eligible for traffic-based app rewards. + + In contrast to the existing reward coupons, these new coupons are using time based expiry, + and can be minted by default up to 36h after their creation. Thereby allowing their beneficiaries + to batch the minting to save traffic costs. + + They can be minted like all other coupon types using one of the following methods: + + 1. Automated minting via the Splice Wallet backend that is part of the validator app, + which works for onboarded internal parties and for external parties with a :ref:`minting delegation `. + 2. Direct minting by constructing calls to ``AmuletRules_Transfer`` that uses them as + an transfer input. These calls can be made directly against the Ledger API, or indirectly + via custom Daml code deployed to the validator node. + + - Add a new field ``rewardConfig`` to the ``AmuletConfig`` for configuring whether rounds should use + traffic-based app rewards or on-ledger reward accounting, and whether traffic-based app reward coupon creation + should be simulated in a dry-run mode. See the + :ref:`RewardConfig ` + data type definition for the list reward configuration fields and their semantics. + + - Store the current ``rewardConfig`` and ``trafficPrice`` on every ``OpenMiningRound`` contract when creating it. + This information serves to synchronize the SV apps on the parameters to use for processing traffic-based app rewards. + + - Add ``CalculateRewardsV2`` and ``ProcessRewardsV2`` templates together with supporting code + to implement the creation of the new reward coupons based on the reward + values computed off-ledger by the SV apps. + + - Adjust the CC transfer implementation such that it stops creating featured app activity markers + when it runs against a round (or external party configuration state) where traffic-based app rewards + are enabled. + Due to the propagation delay of updating the external party configuration state in the ``splice-amulet`` code, + there will be a transition phase where token standard CC transfers still create featured app markers. + These will be automatically archived as soon as traffic-based app rewards are enabled. + Thus no double-issuance of rewards will occur. + diff --git a/project/BuildCommon.scala b/project/BuildCommon.scala index a0530d5626..109411a519 100644 --- a/project/BuildCommon.scala +++ b/project/BuildCommon.scala @@ -250,8 +250,8 @@ object BuildCommon { "splice-util-token-standard-wallet-test-daml/clean", "splice-util-batched-markers-daml/clean", "splice-util-batched-markers-test-daml/clean", - "splice-featured-app-api-v1-daml/clean", - "splice-featured-app-api-v2-daml/clean", + "splice-api-featured-app-v1-daml/clean", + "splice-api-featured-app-v2-daml/clean", ).map(";" + _).mkString(""), ) ++ addCommandAlias("splice-clean", "; clean-splice") ++ diff --git a/scripts/rename.sh b/scripts/rename.sh index ba23359fb9..46139572d9 100755 --- a/scripts/rename.sh +++ b/scripts/rename.sh @@ -1181,7 +1181,7 @@ function subcmd_no_illegal_daml_references() { done local illegal_patterns=( svc SVC Svc # to avoid conflict with PerSvContracts - '(? Decimal -> Script () -runNextIssuance dso amuletPrice = do +runNextIssuance : Party -> Decimal -> Optional Decimal -> Script () +runNextIssuance dso amuletPrice appActivityRoundTotal = do [(amuletRulesCid, _)] <- query @AmuletRules dso advanceToNextRoundChange dso -- expire rewards for closed rounds @@ -484,10 +485,29 @@ runNextIssuance dso amuletPrice = do validatorLivenessActivityRecords <- queryFilter @ValidatorLivenessActivityRecord dso (\bc -> bc.round == roundToArchive.round) svRewardCoupons <- queryFilter @SvRewardCoupon dso (\bc -> bc.round == roundToArchive.round) + Some summarizingRound <- queryContractId dso closingRoundCid + + let trafficBasedRewards = useTrafficBasedAppRewards ((.mintingVersion) <$> summarizingRound.rewardConfig) + + -- compute app reward totals: for traffic-based rewards, convert activity (MB) to Amulet + -- via trafficPrice ($/MB) and amuletPrice ($/Amulet); otherwise use on-ledger coupons + (totalFeaturedCoupons, totalUnfeaturedCoupons) <- + if trafficBasedRewards then + case (appActivityRoundTotal, summarizingRound.trafficPrice) of + (Some activity, Some trafficPrice) -> + pure (activity * trafficPrice / summarizingRound.amuletPrice, 0.0) + (None, _) -> + fail "appActivityRoundTotal must be specified when using traffic-based app rewards" + (_, None) -> + fail "trafficPrice must be specified when using traffic-based app rewards" + else + pure (sum [ c.amount | (_, c) <- appRewardCoupons, c.featured] + , sum [ c.amount | (_, c) <- appRewardCoupons, not (c.featured)]) + let summary = OpenMiningRoundSummary with totalValidatorRewardCoupons = sum [ c.amount | (_, c) <- validatorRewardCoupons] - totalFeaturedAppRewardCoupons = sum [ c.amount | (_, c) <- appRewardCoupons, c.featured] - totalUnfeaturedAppRewardCoupons = sum [ c.amount | (_, c) <- appRewardCoupons, not (c.featured)] + totalFeaturedAppRewardCoupons = totalFeaturedCoupons + totalUnfeaturedAppRewardCoupons = totalUnfeaturedCoupons totalSvRewardWeight = sum [ c.weight | (_, c) <- svRewardCoupons] optTotalValidatorFaucetCoupons = Some (length validatorFaucetCoupons + length validatorLivenessActivityRecords) diff --git a/token-standard/splice-token-standard-test/daml/Splice/Testing/Registries/AmuletRegistry/Parameters.daml b/token-standard/splice-token-standard-test/daml/Splice/Testing/Registries/AmuletRegistry/Parameters.daml index ee97d7eb8c..f52f5fcef5 100644 --- a/token-standard/splice-token-standard-test/daml/Splice/Testing/Registries/AmuletRegistry/Parameters.daml +++ b/token-standard/splice-token-standard-test/daml/Splice/Testing/Registries/AmuletRegistry/Parameters.daml @@ -91,6 +91,8 @@ defaultAmuletConfig = AmuletConfig with optDevelopmentFundManager = None + rewardConfig = None + -- | Default configuration schedule with single current amulet config defaultAmuletConfigSchedule : Schedule Time (AmuletConfig USD) defaultAmuletConfigSchedule = Schedule with @@ -164,5 +166,5 @@ defaultSynchronizerFeesConfig : SynchronizerFeesConfig defaultSynchronizerFeesConfig = SynchronizerFeesConfig with baseRateTrafficLimits = defaultBaseRateTrafficLimits minTopupAmount = 1_000_000 -- 1MB - extraTrafficPrice = 1.0 + extraTrafficPrice = 1.0 -- USD/MB readVsWriteScalingFactor = 4 -- charge 4 per 10,000, i.e., 0.04% of write cost for every read diff --git a/token-standard/splice-token-standard-test/daml/Splice/Tests/TestAmuletTokenTransfer.daml b/token-standard/splice-token-standard-test/daml/Splice/Tests/TestAmuletTokenTransfer.daml index cd907ee330..c145665aa3 100644 --- a/token-standard/splice-token-standard-test/daml/Splice/Tests/TestAmuletTokenTransfer.daml +++ b/token-standard/splice-token-standard-test/daml/Splice/Tests/TestAmuletTokenTransfer.daml @@ -192,9 +192,9 @@ test_happy_path_self = script do WalletClient.checkBalance bob registry.instrumentId 50.0 -- advance 3 times to make sure all previously active (even if not yet open) mining round contracts are closed - AmuletRegistry.runNextIssuance registry.dso 1.0 - AmuletRegistry.runNextIssuance registry.dso 1.0 - AmuletRegistry.runNextIssuance registry.dso 1.0 + AmuletRegistry.runNextIssuance registry.dso 1.0 None + AmuletRegistry.runNextIssuance registry.dso 1.0 None + AmuletRegistry.runNextIssuance registry.dso 1.0 None -- Trigger a self-transfer result <- submitWithDisclosures' bob enrichedChoice.disclosures $ exerciseCmd enrichedChoice.factoryCid enrichedChoice.arg