Skip to content

Commit 21f3483

Browse files
CIP-82 (Scala): Add MergeUnclaimedDevelopmentFundCouponsTrigger (#3522)
PR reviewed in #3522 CI run #3522
1 parent 144423f commit 21f3483

File tree

14 files changed

+289
-12
lines changed

14 files changed

+289
-12
lines changed

apps/app/src/main/scala/org/lfdecentralizedtrust/splice/config/ConfigTransforms.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,9 @@ object ConfigTransforms {
718718
}
719719
}
720720

721+
def withDevelopmentFundPercentage(percentage: BigDecimal): ConfigTransform =
722+
updateAllSvAppFoundDsoConfigs_(c => c.copy(developmentFundPercentage = Some(percentage)))
723+
721724
private def portTransform(bump: Int, c: AdminServerConfig): AdminServerConfig =
722725
c.copy(internalPort = c.internalPort.map(_ + bump))
723726

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package org.lfdecentralizedtrust.splice.integration.tests
2+
3+
import com.digitalasset.canton.config.NonNegativeFiniteDuration
4+
import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.UnclaimedDevelopmentFundCoupon
5+
import org.lfdecentralizedtrust.splice.config.ConfigTransforms
6+
import org.lfdecentralizedtrust.splice.config.ConfigTransforms.{
7+
ConfigurableApp,
8+
updateAutomationConfig,
9+
}
10+
import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition
11+
import org.lfdecentralizedtrust.splice.sv.automation.delegatebased.AdvanceOpenMiningRoundTrigger
12+
import org.lfdecentralizedtrust.splice.util.{TriggerTestUtil, WalletTestUtil}
13+
14+
@org.lfdecentralizedtrust.splice.util.scalatesttags.SpliceDsoGovernance_0_1_21
15+
class DevelopmentFundCouponIntegrationTest
16+
extends SvIntegrationTestBase
17+
with TriggerTestUtil
18+
with WalletTestUtil {
19+
20+
private val threshold = 3
21+
22+
override def environmentDefinition
23+
: org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition =
24+
EnvironmentDefinition
25+
.simpleTopology1Sv(this.getClass.getSimpleName)
26+
.addConfigTransforms((_, config) =>
27+
updateAutomationConfig(ConfigurableApp.Sv)(
28+
_.withPausedTrigger[AdvanceOpenMiningRoundTrigger]
29+
)(config)
30+
)
31+
.addConfigTransform((_, config) =>
32+
ConfigTransforms.updateInitialTickDuration(NonNegativeFiniteDuration.ofMillis(500))(config)
33+
)
34+
.addConfigTransforms((_, config) =>
35+
ConfigTransforms.updateAllSvAppConfigs_(
36+
_.copy(
37+
unclaimedDevelopmentFundCouponsThreshold = threshold
38+
)
39+
)(config)
40+
)
41+
.addConfigTransform((_, config) =>
42+
ConfigTransforms.withDevelopmentFundPercentage(0.05)(config)
43+
)
44+
45+
"UnclaimedDevelopmentFundCoupons are merged" in { implicit env =>
46+
val (_, couponAmount) = actAndCheck(
47+
"Advance 5 rounds", {
48+
Range(0, 5).foreach(_ => advanceRoundsByOneTickViaAutomation())
49+
},
50+
)(
51+
"5 UnclaimedDevelopmentFundCoupons are created, and the trigger does not merge the coupons, " +
52+
"as it only acts when the number of coupons is ≥ 2 × threshold",
53+
_ => {
54+
val coupons = sv1Backend.participantClient.ledger_api_extensions.acs
55+
.filterJava(UnclaimedDevelopmentFundCoupon.COMPANION)(dsoParty)
56+
coupons.size shouldBe 5
57+
coupons.head.data.amount
58+
},
59+
)
60+
61+
actAndCheck(
62+
"Advance one round to create one more UnclaimedDevelopmentFundCoupon, reaching 2 × threshold coupons",
63+
advanceRoundsByOneTickViaAutomation(),
64+
)(
65+
"The MergeUnclaimedDevelopmentFundCouponsTrigger is triggered and merges the smallest three coupons (threshold), " +
66+
"while keeping the remaining coupons unchanged.",
67+
_ => {
68+
sv1Backend.participantClient.ledger_api_extensions.acs
69+
.filterJava(UnclaimedDevelopmentFundCoupon.COMPANION)(dsoParty)
70+
.map(_.data.amount)
71+
.sorted shouldBe Seq(
72+
couponAmount,
73+
couponAmount,
74+
couponAmount,
75+
couponAmount.multiply(new java.math.BigDecimal(3)),
76+
)
77+
},
78+
)
79+
80+
actAndCheck(
81+
"Advance two rounds to create two more UnclaimedDevelopmentFundCoupon, " +
82+
"reaching 2 × threshold coupons and triggering a second merge",
83+
Range(0, 2).foreach(_ => advanceRoundsByOneTickViaAutomation()),
84+
)(
85+
"The MergeUnclaimedDevelopmentFundCouponsTrigger merges the `threshold` smallest coupons",
86+
_ => {
87+
sv1Backend.participantClient.ledger_api_extensions.acs
88+
.filterJava(UnclaimedDevelopmentFundCoupon.COMPANION)(dsoParty)
89+
.map(_.data.amount)
90+
.sorted shouldBe Seq(
91+
couponAmount,
92+
couponAmount,
93+
couponAmount.multiply(new java.math.BigDecimal(3)),
94+
couponAmount.multiply(new java.math.BigDecimal(3)),
95+
)
96+
},
97+
)
98+
}
99+
}

apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DisabledWalletTimeBasedIntegrationTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class DisabledWalletTimeBasedIntegrationTest
6868
val expectedMinAmount =
6969
BigDecimal(
7070
computeSvRewardInRound0(
71-
defaultIssuanceCurve.initialValue,
71+
defaultIssuanceCurve().initialValue,
7272
defaultTickDuration,
7373
dsoSize = 1,
7474
)

apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/ScanWithGradualStartsTimeBasedIntegrationTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ class ScanWithGradualStartsTimeBasedIntegrationTest
143143
val svRewardPerRound =
144144
BigDecimal(
145145
computeSvRewardInRound0(
146-
defaultIssuanceCurve.initialValue,
146+
defaultIssuanceCurve().initialValue,
147147
defaultTickDuration,
148148
dsoSize = 2,
149149
)

apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/SvTimeBasedRewardCouponIntegrationTest.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ class SvTimeBasedRewardCouponIntegrationTest
170170
}
171171

172172
val eachSvGetInRound0 =
173-
computeSvRewardInRound0(defaultIssuanceCurve.initialValue, defaultTickDuration, svs.size)
173+
computeSvRewardInRound0(defaultIssuanceCurve().initialValue, defaultTickDuration, svs.size)
174174
val sv1Party = sv1Backend.getDsoInfo().svParty
175175
val aliceValidatorParty = aliceValidatorBackend.getValidatorPartyId()
176176
val expectedAliceAmount = eachSvGetInRound0.multiply(new java.math.BigDecimal("0.3333"))

apps/common/src/main/scala/org/lfdecentralizedtrust/splice/util/SpliceUtil.scala

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ object SpliceUtil {
207207
amuletsToIssuePerYear: Double,
208208
validatorPercentage: Double,
209209
appPercentage: Double,
210+
developmentFundPercentage: Option[BigDecimal] = None,
210211
): splice.issuance.IssuanceConfig = new IssuanceConfig(
211212
damlDecimal(amuletsToIssuePerYear),
212213
damlDecimal(validatorPercentage),
@@ -225,19 +226,27 @@ object SpliceUtil {
225226
Some(damlDecimal(2.85)).toJava,
226227

227228
// developmentFundPercentage
228-
Optional.empty(),
229+
developmentFundPercentage.map(damlDecimal).toJava,
229230
)
230231

231232
private def hours(h: Long): RelTime = new RelTime(TimeUnit.HOURS.toMicros(h))
232233

233-
val defaultIssuanceCurve: splice.schedule.Schedule[RelTime, IssuanceConfig] =
234+
def defaultIssuanceCurve(
235+
developmentFundPercentage: Option[BigDecimal] = None
236+
): splice.schedule.Schedule[RelTime, IssuanceConfig] =
234237
new Schedule(
235-
issuanceConfig(40e9, 0.05, 0.15),
238+
issuanceConfig(40e9, 0.05, 0.15, developmentFundPercentage),
236239
Seq(
237-
new Tuple2(hours(365 * 12), issuanceConfig(20e9, 0.12, 0.4)),
238-
new Tuple2(hours(3 * 365 * 12), issuanceConfig(10e9, 0.18, 0.62)),
239-
new Tuple2(hours(5 * 365 * 24), issuanceConfig(5e9, 0.21, 0.69)),
240-
new Tuple2(hours(10 * 365 * 24), issuanceConfig(2.5e9, 0.20, 0.75)),
240+
new Tuple2(hours(365 * 12), issuanceConfig(20e9, 0.12, 0.4, developmentFundPercentage)),
241+
new Tuple2(
242+
hours(3 * 365 * 12),
243+
issuanceConfig(10e9, 0.18, 0.62, developmentFundPercentage),
244+
),
245+
new Tuple2(hours(5 * 365 * 24), issuanceConfig(5e9, 0.21, 0.69, developmentFundPercentage)),
246+
new Tuple2(
247+
hours(10 * 365 * 24),
248+
issuanceConfig(2.5e9, 0.20, 0.75, developmentFundPercentage),
249+
),
241250
).asJava,
242251
)
243252

@@ -368,13 +377,14 @@ object SpliceUtil {
368377
transferPreapprovalFee: Option[BigDecimal] = None,
369378
featuredAppActivityMarkerAmount: Option[BigDecimal] = None,
370379
nextSynchronizerId: Option[SynchronizerId] = None,
380+
developmentFundPercentage: Option[BigDecimal] = None,
371381
): splice.amuletconfig.AmuletConfig[splice.amuletconfig.USD] =
372382
new splice.amuletconfig.AmuletConfig(
373383
// transferConfig
374384
defaultTransferConfig(initialMaxNumInputs, holdingFee, zeroTransferFees = zeroTransferFees),
375385

376386
// issuance curve
377-
defaultIssuanceCurve,
387+
defaultIssuanceCurve(developmentFundPercentage),
378388

379389
// global domain config
380390
defaultDecentralizedSynchronizerConfig(
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.lfdecentralizedtrust.splice.util.scalatesttags;
2+
3+
import org.scalatest.TagAnnotation;
4+
5+
import java.lang.annotation.ElementType;
6+
import java.lang.annotation.Retention;
7+
import java.lang.annotation.RetentionPolicy;
8+
import java.lang.annotation.Target;
9+
10+
// Don't run this test when testing against splice-dso-governance < 0.1.21
11+
@TagAnnotation
12+
@Retention(RetentionPolicy.RUNTIME)
13+
@Target({ElementType.METHOD, ElementType.TYPE})
14+
public @interface SpliceDsoGovernance_0_1_21 {}

apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/automation/DsoDelegateBasedAutomationService.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ class DsoDelegateBasedAutomationService(
116116
svTaskContext,
117117
)
118118
)
119+
registerTrigger(
120+
new MergeUnclaimedDevelopmentFundCouponsTrigger(config, triggerContext, svTaskContext)
121+
)
119122
}
120123

121124
}
@@ -149,5 +152,6 @@ object DsoDelegateBasedAutomationService extends AutomationServiceCompanion {
149152
aTrigger[AllocateUnallocatedUnclaimedActivityRecordTrigger],
150153
aTrigger[ExpiredUnallocatedUnclaimedActivityRecordTrigger],
151154
aTrigger[ExpiredUnclaimedActivityRecordTrigger],
155+
aTrigger[MergeUnclaimedDevelopmentFundCouponsTrigger],
152156
)
153157
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package org.lfdecentralizedtrust.splice.sv.automation.delegatebased
5+
6+
import org.lfdecentralizedtrust.splice.automation.{
7+
PollingParallelTaskExecutionTrigger,
8+
TaskOutcome,
9+
TaskSuccess,
10+
TriggerContext,
11+
}
12+
import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.UnclaimedDevelopmentFundCoupon
13+
import org.lfdecentralizedtrust.splice.codegen.java.splice.dsorules.DsoRules_MergeUnclaimedDevelopmentFundCoupons
14+
import org.lfdecentralizedtrust.splice.store.PageLimit
15+
import org.lfdecentralizedtrust.splice.util.Contract
16+
import org.lfdecentralizedtrust.splice.util.PrettyInstances.*
17+
import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting}
18+
import com.digitalasset.canton.tracing.TraceContext
19+
import io.opentelemetry.api.trace.Tracer
20+
import org.apache.pekko.stream.Materializer
21+
import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules_MergeUnclaimedDevelopmentFundCoupons
22+
import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority
23+
import org.lfdecentralizedtrust.splice.sv.config.SvAppBackendConfig
24+
25+
import scala.concurrent.{ExecutionContext, Future}
26+
import scala.jdk.CollectionConverters.*
27+
28+
class MergeUnclaimedDevelopmentFundCouponsTrigger(
29+
svConfig: SvAppBackendConfig,
30+
override protected val context: TriggerContext,
31+
override protected val svTaskContext: SvTaskBasedTrigger.Context,
32+
)(implicit
33+
override val ec: ExecutionContext,
34+
mat: Materializer,
35+
tracer: Tracer,
36+
) extends PollingParallelTaskExecutionTrigger[MergeUnclaimedDevelopmentFundCouponsTask]
37+
with SvTaskBasedTrigger[MergeUnclaimedDevelopmentFundCouponsTask] {
38+
39+
private val store = svTaskContext.dsoStore
40+
41+
protected def retrieveTasks()(implicit
42+
tc: TraceContext
43+
): Future[Seq[MergeUnclaimedDevelopmentFundCouponsTask]] = {
44+
val threshold = svConfig.unclaimedDevelopmentFundCouponsThreshold
45+
val limit = PageLimit.tryCreate(2 * threshold)
46+
store.listUnclaimedDevelopmentFundCoupons(limit).map { unclaimedDevelopmentFundCoupons =>
47+
if (unclaimedDevelopmentFundCoupons.length >= 2 * threshold) {
48+
Seq(
49+
MergeUnclaimedDevelopmentFundCouponsTask(
50+
// Merge the `threshold` smallest coupons (by amount) to keep larger coupons stable and
51+
// reduce contention with externally prepared transactions referencing contract-ids.
52+
unclaimedDevelopmentFundCoupons.sortBy(_.payload.amount).take(threshold)
53+
)
54+
)
55+
} else {
56+
Seq()
57+
}
58+
}
59+
}
60+
61+
protected def isStaleTask(
62+
unclaimedDevelopmentFundCouponsTask: MergeUnclaimedDevelopmentFundCouponsTask
63+
)(implicit tc: TraceContext): Future[Boolean] = store.multiDomainAcsStore.containsArchived(
64+
unclaimedDevelopmentFundCouponsTask.contractsToMerge.map(_.contractId)
65+
)
66+
67+
override def completeTaskAsDsoDelegate(
68+
unclaimedDevelopmentFundCouponsTask: MergeUnclaimedDevelopmentFundCouponsTask,
69+
controller: String,
70+
)(implicit tc: TraceContext): Future[TaskOutcome] = {
71+
for {
72+
dsoRules <- store.getDsoRules()
73+
amuletRules <- store.getAmuletRules()
74+
choiceArg = new AmuletRules_MergeUnclaimedDevelopmentFundCoupons(
75+
unclaimedDevelopmentFundCouponsTask.contractsToMerge
76+
.map(_.contractId)
77+
.asJava
78+
)
79+
arg = new DsoRules_MergeUnclaimedDevelopmentFundCoupons(
80+
amuletRules.contractId,
81+
choiceArg,
82+
controller,
83+
)
84+
cmd = dsoRules.exercise(_.exerciseDsoRules_MergeUnclaimedDevelopmentFundCoupons(arg))
85+
res <- for {
86+
outcome <- svTaskContext
87+
.connection(SpliceLedgerConnectionPriority.Low)
88+
.submit(
89+
Seq(store.key.svParty),
90+
Seq(store.key.dsoParty),
91+
cmd,
92+
)
93+
.noDedup
94+
.yieldResult()
95+
} yield Some(outcome)
96+
} yield {
97+
res
98+
.map(cid => {
99+
TaskSuccess(
100+
s"Merged unclaimed development fund coupons into contract ${cid.exerciseResult.result.unclaimedDevelopmentFundCouponCid.contractId}"
101+
)
102+
})
103+
.getOrElse(TaskSuccess(s"Not enough unclaimed development fund coupons to merge"))
104+
}
105+
}
106+
}
107+
108+
case class MergeUnclaimedDevelopmentFundCouponsTask(
109+
contractsToMerge: Seq[
110+
Contract[UnclaimedDevelopmentFundCoupon.ContractId, UnclaimedDevelopmentFundCoupon]
111+
]
112+
) extends PrettyPrinting {
113+
override def pretty: Pretty[this.type] =
114+
prettyOfClass(param("contractsToMerge", _.contractsToMerge))
115+
}

apps/sv/src/main/scala/org/lfdecentralizedtrust/splice/sv/config/SvAppConfig.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ object SvOnboardingConfig {
102102
initialFeaturedAppActivityMarkerAmount: Option[BigDecimal] = Some(BigDecimal(1.0)),
103103
voteCooldownTime: Option[NonNegativeFiniteDuration] = None,
104104
initialRound: Long = 0L,
105+
developmentFundPercentage: Option[BigDecimal] = None,
105106
) extends SvOnboardingConfig
106107

107108
case class JoinWithKey(
@@ -363,6 +364,8 @@ case class SvAppBackendConfig(
363364
// If true, we check that topology on mediator and sequencer is the same after
364365
// a migration. This can be a useful assertion but is very slow so should not be enabled on clusters with large topology state.
365366
validateTopologyAfterMigration: Boolean = false,
367+
// The threshold above which unclaimed development fund coupons will be merged.
368+
unclaimedDevelopmentFundCouponsThreshold: Int = 10,
366369
) extends SpliceBackendConfig {
367370

368371
def shouldSkipSynchronizerInitialization =

0 commit comments

Comments
 (0)