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"
+}
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